Tower: unpublish laundry_management — remove source from 19.0 branch

This commit is contained in:
2026-05-02 11:15:37 +00:00
parent 22162f9d47
commit 26ae0e14df
230 changed files with 0 additions and 17001 deletions

View File

@@ -1,52 +0,0 @@
/** @odoo-module
*
* ClosePosPopup patch — surface a READ-ONLY Laundry Settlements panel
* in the POS closing popup so cashiers and managers can see how much
* was collected via the laundry settle-dues flow during this session,
* grouped per journal.
*
* NON-CASH settlements create account.payment records (not pos.payment),
* so they don't naturally appear in the closing popup's per-method
* breakdown. CASH settlements DO appear there (via statement_line_ids
* in expected cash). This panel makes both visible side-by-side so the
* cashier can reconcile drawer vs. settlement collections.
*
* IMPORTANT: this is purely informational. It does NOT inject into the
* cash-counted/expected math, does NOT create accounting entries, and
* does NOT modify settlement amounts.
*/
import { patch } from "@web/core/utils/patch";
import { onWillStart, useState } from "@odoo/owl";
import { ClosePosPopup } from "@point_of_sale/app/components/popups/closing_popup/closing_popup";
patch(ClosePosPopup.prototype, {
setup() {
super.setup();
this.laundrySettlements = useState({
loaded: false,
total: 0.0,
count: 0,
by_journal: [],
});
onWillStart(async () => {
const sessionId = this.pos?.session?.id;
if (!sessionId) {
this.laundrySettlements.loaded = true;
return;
}
try {
const data = await this.pos.data.call(
"res.partner",
"get_session_settlements",
[sessionId]
);
if (data) {
Object.assign(this.laundrySettlements, data, { loaded: true });
}
} catch {
// Read-only panel — silent failure is fine.
this.laundrySettlements.loaded = true;
}
});
},
});

View File

@@ -1,35 +0,0 @@
/** @odoo-module
*
* ControlButtons patch — wires the two custom POS quick-action buttons
* declared in static/src/xml/control_buttons.xml to the PosStore methods
* that already exist in pos_store_patch.js:
*
* onClickSettleDues → pos.settleLaundryDues()
* onClickViewLaundryOrders → pos.viewLaundryOrders()
*
* Without this bridge, OWL throws
* "Invalid handler (expected a function, received: 'undefined')"
* at click time because the template references methods on the
* ControlButtons component that don't otherwise exist.
*
* Each handler closes the parent ControlButtons modal (props.close)
* after dispatching, mirroring core's behavior for its own buttons.
*/
import { patch } from "@web/core/utils/patch";
import { ControlButtons } from "@point_of_sale/app/screens/product_screen/control_buttons/control_buttons";
patch(ControlButtons.prototype, {
onClickSettleDues() {
this.pos.settleLaundryDues();
if (this.props.close) {
this.props.close();
}
},
onClickViewLaundryOrders() {
this.pos.viewLaundryOrders();
if (this.props.close) {
this.props.close();
}
},
});

View File

@@ -1,18 +0,0 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { ControlButtons } from "@point_of_sale/app/screens/product_screen/control_buttons/control_buttons";
patch(ControlButtons.prototype, {
onClickSettleDues() {
this.pos.settleLaundryDues();
if (this.props.close) {
this.props.close();
}
},
onClickViewLaundryOrders() {
this.pos.viewLaundryOrders();
if (this.props.close) {
this.props.close();
}
},
});

View File

@@ -1,72 +0,0 @@
/** @odoo-module
*
* LaundryContextStore — small reactive store, owned by PosStore, that
* holds the per-order laundry context (type / attributes / delivery)
* keyed by order uuid.
*
* Why a separate store?
* The POS related-models engine wraps records in a field-aware proxy.
* Writing custom properties (not declared in _load_pos_data_fields)
* directly on the order does NOT trigger OWL reactivity, so the
* context panel never re-renders after the popup confirms.
*
* Exposing the fields via _load_pos_data_fields is proven to crash
* POS at boot (`lines is undefined` in _computeAllPrices), so the
* only safe path is to keep these values OUTSIDE the engine, in a
* plain OWL reactive object that components can subscribe to.
*
* Contract:
* pos.laundryContext.get(uuid) → reactive object with keys:
* { type_id, attribute_ids,
* is_delivery, delivery_address,
* delivery_scheduled_at }
* pos.laundryContext.set(uuid, patch)
* pos.laundryContext.clear(uuid)
*
* Persistence is independent: the same values are mirrored on the
* order as primitives (set in pos_store_patch.js) and shipped to the
* backend via PosOrder.serializeForORM (see pos_order_patch.js).
*/
import { reactive } from "@odoo/owl";
function emptyContext() {
return {
type_id: false,
attribute_ids: [],
is_delivery: false,
delivery_address: false,
delivery_scheduled_at: false,
};
}
export class LaundryContextStore {
constructor() {
this.byUuid = reactive({});
}
get(uuid) {
if (!uuid) {
return emptyContext();
}
if (!this.byUuid[uuid]) {
this.byUuid[uuid] = emptyContext();
}
return this.byUuid[uuid];
}
set(uuid, patch) {
if (!uuid || !patch) {
return;
}
const cur = this.get(uuid);
for (const key of Object.keys(patch)) {
cur[key] = patch[key];
}
}
clear(uuid) {
if (uuid && this.byUuid[uuid]) {
delete this.byUuid[uuid];
}
}
}

View File

@@ -1,117 +0,0 @@
/** @odoo-module
*
* LaundryOrderContextPanel — premium card showing the order's
* legacy laundry context (type / attributes / delivery) with inline edit.
*
* Visible only when `pos.config.enable_laundry_order_type` is True.
* Reactive source: pos.laundryContext.get(order.uuid)
* (see laundry_context_store.js). Falls back to order primitives when
* the store has no entry yet (e.g. on order recall).
*/
import { Component } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/hooks/pos_hook";
export class LaundryOrderContextPanel extends Component {
static template = "laundry_management.LaundryOrderContextPanel";
static props = {};
setup() {
this.pos = usePos();
}
get order() {
return this.pos.getOrder();
}
get isFeatureEnabled() {
return !!this.pos.config?.enable_laundry_order_type;
}
/** Reactive context for the current order, with order-primitive fallback. */
get ctx() {
const order = this.order;
if (!order) return null;
const live = this.pos.laundryContext?.get(order.uuid);
if (live && (live.type_id || live.attribute_ids?.length || live.is_delivery)) {
return live;
}
return {
type_id: order.laundry_order_type_id || false,
attribute_ids: order.laundry_order_attribute_ids || [],
is_delivery: !!order.laundry_is_delivery,
delivery_address: order.laundry_delivery_address || "",
delivery_scheduled_at: order.laundry_delivery_scheduled_at || "",
};
}
get orderType() {
const id = this.ctx?.type_id;
if (!id) return null;
return this.pos.models["laundry.order.type"]?.get(id) || null;
}
get attributes() {
const ids = this.ctx?.attribute_ids || [];
const model = this.pos.models["laundry.order.attribute"];
if (!model) return [];
return ids.map((id) => model.get(id)).filter(Boolean);
}
get isDelivery() {
return !!this.ctx?.is_delivery;
}
get deliveryAddress() {
const a = this.ctx?.delivery_address || "";
if (!a) return "";
return a.length > 64 ? a.slice(0, 61) + "…" : a;
}
get deliveryScheduledAt() {
const v = this.ctx?.delivery_scheduled_at;
if (!v) return "";
try {
const d = new Date(v.replace(" ", "T"));
if (!isNaN(d.getTime())) {
return d.toLocaleString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
} catch (_e) {
// ignore — fall back to raw value
}
return String(v);
}
get hasContent() {
return !!(this.orderType || this.attributes.length || this.isDelivery);
}
get typeStyle() {
const c = this.orderType?.color;
return c ? `background-color:${c};color:#fff;` : "";
}
attrStyle(attr) {
const c = attr?.color;
return c ? `background-color:${c};color:#fff;` : "";
}
typeIcon(type) {
if (!type) return "fa-tag";
if (type.priority === "urgent") return "fa-bolt";
if (type.is_delivery) return "fa-truck";
return "fa-tag";
}
onClickEdit() {
if (!this.order) return;
// Bypasses the allow_change guard because the cashier explicitly
// clicked the panel's edit affordance.
this.pos.runLaundryOrderTypeFlow(this.order);
}
}

View File

@@ -1,47 +0,0 @@
/** @odoo-module
*
* Pricing hook (PREPARE-ONLY).
*
* Reads the per-type and per-attribute extra_price values for the
* current order. Returned values are NEVER applied to the order.
* This module exists so future pricing logic can plug into a single
* stable surface without touching order.lines / pricing / taxes today.
*
* Usage example (NOT wired anywhere):
* import { computeLaundryExtras } from "@laundry_management/js/laundry_pricing_hook";
* const { typeExtra, attributeExtras, total } = computeLaundryExtras(pos, order);
*/
export function computeLaundryExtras(pos, order) {
const empty = { typeExtra: 0, attributeExtras: [], total: 0 };
if (!pos || !order) return empty;
const typeId = order.laundry_order_type_id;
const attrIds = Array.isArray(order.laundry_order_attribute_ids)
? order.laundry_order_attribute_ids
: [];
let typeExtra = 0;
if (typeId) {
const type = pos.models["laundry.order.type"]?.get(typeId);
typeExtra = Number(type?.extra_price) || 0;
}
const attrModel = pos.models["laundry.order.attribute"];
const attributeExtras = [];
let attrTotal = 0;
for (const id of attrIds) {
const rec = attrModel?.get(id);
const price = Number(rec?.extra_price) || 0;
if (price) {
attributeExtras.push({ id, name: rec?.name || "", price });
attrTotal += price;
}
}
return {
typeExtra,
attributeExtras,
total: typeExtra + attrTotal,
};
}

View File

@@ -1,83 +0,0 @@
/** @odoo-module
*
* LaundryReceiptDetails — read-only receipt block.
* Reads from pos.laundryContext (live) for fresh prints, falls back
* to the order's persisted primitives for re-prints / backend prints.
*/
import { Component } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/hooks/pos_hook";
export class LaundryReceiptDetails extends Component {
static template = "laundry_management.LaundryReceiptDetails";
static props = {
order: { type: Object, optional: true },
};
setup() {
this.pos = usePos();
}
get order() {
return this.props.order;
}
/** Live context first, persisted primitives as fallback. */
get ctx() {
const order = this.order;
if (!order) return null;
const live = this.pos?.laundryContext?.get(order.uuid);
if (live && (live.type_id || live.attribute_ids?.length || live.is_delivery)) {
return live;
}
return {
type_id: order.laundry_order_type_id || false,
attribute_ids: order.laundry_order_attribute_ids || [],
is_delivery: !!order.laundry_is_delivery,
delivery_address: order.laundry_delivery_address || "",
delivery_scheduled_at: order.laundry_delivery_scheduled_at || "",
};
}
get orderType() {
const id = this.ctx?.type_id;
if (!id) return null;
return this.pos?.models?.["laundry.order.type"]?.get(id) || null;
}
get attributes() {
const ids = this.ctx?.attribute_ids || [];
const model = this.pos?.models?.["laundry.order.attribute"];
if (!model) return [];
return ids.map((id) => model.get(id)).filter(Boolean);
}
get isDelivery() {
return !!this.ctx?.is_delivery;
}
get deliveryAddress() {
return this.ctx?.delivery_address || "";
}
get deliveryScheduledAt() {
const v = this.ctx?.delivery_scheduled_at;
if (!v) return "";
try {
const d = new Date(v.replace(" ", "T"));
if (!isNaN(d.getTime())) {
return d.toLocaleString();
}
} catch (_e) {
// ignore
}
return String(v);
}
get isUrgent() {
return this.orderType?.priority === "urgent";
}
get hasContent() {
return !!(this.orderType || this.attributes.length || this.isDelivery);
}
}

View File

@@ -1,58 +0,0 @@
/** @odoo-module
*
* LaundrySettleBanner — prominent visual indicator that POS is currently
* locked into settle-due mode. Provides the explicit "Exit" affordance
* and toggles a document.body class so global CSS can dim/disable
* irrelevant POS chrome (non-active order tabs, "+" button) for the
* duration of settle mode.
*
* Reactive source: order.uiState.is_laundry_settle_due (seeded in
* pos_order_patch.js initState so OWL tracks future writes).
*/
import { Component, useEffect } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/hooks/pos_hook";
const BODY_CLASS = "pos-laundry-settle-active";
export class LaundrySettleBanner extends Component {
static template = "laundry_management.LaundrySettleBanner";
static props = {};
setup() {
this.pos = usePos();
useEffect(
(active) => {
if (active) {
document.body.classList.add(BODY_CLASS);
return () => document.body.classList.remove(BODY_CLASS);
}
document.body.classList.remove(BODY_CLASS);
return () => {};
},
() => [this.isActive]
);
}
get order() {
return this.pos.getOrder();
}
get isActive() {
return this.pos.isSettleDueOrder(this.order);
}
get partnerName() {
return this.order?.getPartner()?.name || "";
}
get amountLabel() {
const order = this.order;
if (!order) return "";
const total = order.priceIncl ?? 0;
return this.pos.env.utils.formatCurrency(total);
}
onClickExit() {
this.pos.exitSettleDueMode();
}
}

View File

@@ -1,47 +0,0 @@
/** @odoo-module
*
* LaundryWorkOrderThermal — receipt-style component for the POS printer.
*
* Rendered by `pos.printer.print(LaundryWorkOrderThermal, {data}, {webPrintFallback: true})`.
* The data payload comes from `laundry.order.pos_get_thermal_data` —
* a self-contained dict so this component never reads from the POS env.
*
* Styling lives in laundry_pos.scss under `.laundry-thermal`. The
* `webPrintFallback` option means this component also works when no
* hardware printer is configured: POS opens a print preview in a new
* tab using the same DOM.
*/
import { Component } from "@odoo/owl";
export class LaundryWorkOrderThermal extends Component {
static template = "laundry_management.LaundryWorkOrderThermal";
static props = {
data: { type: Object },
};
fmt(amount) {
return parseFloat(amount || 0).toFixed(2);
}
fmtDate(s) {
if (!s) return "";
try {
const d = new Date(s.replace(" ", "T") + "Z");
if (!isNaN(d.getTime())) return d.toLocaleString();
} catch (_e) { /* fall through */ }
return s;
}
get currency() {
const sym = this.props.data?.currency_symbol || "";
const pos = this.props.data?.currency_position || "after";
return { sym, pos };
}
money(amount) {
const v = this.fmt(amount);
const { sym, pos } = this.currency;
if (!sym) return v;
return pos === "before" ? `${sym} ${v}` : `${v} ${sym}`;
}
}

View File

@@ -1,21 +0,0 @@
/** @odoo-module
*
* Navbar patch — gate the POS-logo "register" click so cashiers can't
* silently abandon a settle-due order by clicking back to the home/
* register screen.
*
* The original onClickRegister is sync; we make ours async and the
* existing OWL bindings still work (they fire-and-forget).
*/
import { patch } from "@web/core/utils/patch";
import { Navbar } from "@point_of_sale/app/components/navbar/navbar";
patch(Navbar.prototype, {
async onClickRegister() {
const allowed = await this.pos.confirmExitSettleIfNeeded(null);
if (!allowed) {
return;
}
return super.onClickRegister();
},
});

View File

@@ -1,5 +0,0 @@
/** @odoo-module
*
* ISOLATION STUB — original at order_payment_validation.js.bak
* Disabled to bisect _computeAllPrices crash.
*/

View File

@@ -1,31 +0,0 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import OrderPaymentValidation from "@point_of_sale/app/utils/order_payment_validation";
import { ask } from "@point_of_sale/app/utils/make_awaitable_dialog";
patch(OrderPaymentValidation.prototype, {
async askBeforeValidation() {
const result = await super.askBeforeValidation();
if (result === false) {
return false;
}
// Only enforce customer for orders with laundry products
const hasLaundry = this.order.lines.some(
(line) => line.product_id?.product_tmpl_id?.is_laundry_service
);
if (hasLaundry && !this.order.getPartner()) {
const confirmed = await ask(this.pos.dialog, {
title: _t("Customer Required"),
body: _t(
"This order contains laundry items. Please select a customer before validating."
),
});
if (confirmed) {
await this.pos.selectPartner();
}
return false;
}
return true;
},
});

View File

@@ -1,14 +0,0 @@
/** @odoo-module
*
* Register LaundryReceiptDetails as a child component of OrderReceipt
* so the receipt template can render <LaundryReceiptDetails .../>.
*
* No prototype changes — additive component wiring only.
*/
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
import { LaundryReceiptDetails } from "@laundry_management/js/laundry_receipt_details";
OrderReceipt.components = {
...(OrderReceipt.components || {}),
LaundryReceiptDetails,
};

View File

@@ -1,34 +0,0 @@
/** @odoo-module
*
* OrderSummary integration:
* 1. Register the LaundryOrderContextPanel as a child component.
* 2. Lock numpad-driven mutations (qty / discount / price / remove)
* while the active order is in settle-due mode. The settlement
* amount is fixed and must not be touched from the order summary.
*/
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary";
import { LaundryOrderContextPanel } from "@laundry_management/js/laundry_order_context_panel";
import { LaundrySettleBanner } from "@laundry_management/js/laundry_settle_banner";
OrderSummary.components = {
...(OrderSummary.components || {}),
LaundryOrderContextPanel,
LaundrySettleBanner,
};
patch(OrderSummary.prototype, {
async updateSelectedOrderline(args) {
const order = this.pos.getOrder();
if (this.pos.isSettleDueOrder(order)) {
this.numberBuffer.reset();
this.pos.notification.add(
_t("Settlement amount is fixed. Cannot modify from the cart."),
{ type: "warning" }
);
return;
}
return super.updateSelectedOrderline(args);
},
});

View File

@@ -1,33 +0,0 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary";
patch(OrderSummary.prototype, {
get laundryTypeSummary() {
const order = this.currentOrder;
if (!order) {
return null;
}
const type = order.laundry_order_type_id;
const attrs = order.laundry_order_attribute_ids || [];
if (!type && (!attrs || attrs.length === 0)) {
return null;
}
return {
typeName: type?.name || "",
typeColor: type?.color || "",
attributes: attrs.map((a) => ({
id: a.id,
name: a.name,
color: a.color || "",
})),
isDelivery: !!order.laundry_is_delivery,
scheduledAt: order.laundry_delivery_scheduled_at || "",
canEdit: !!this.pos.config?.allow_change_laundry_order_type_before_payment,
};
},
onClickEditLaundryType() {
this.pos.editLaundryOrderType();
},
});

View File

@@ -1,28 +0,0 @@
/** @odoo-module
*
* OrderTabs patch — gate every tab-driven order switch and the "+" new
* order button through pos.confirmExitSettleIfNeeded so the cashier
* cannot silently leave a live settle-due order behind.
*
* Both methods stay async to align with their existing signatures.
*/
import { patch } from "@web/core/utils/patch";
import { OrderTabs } from "@point_of_sale/app/components/order_tabs/order_tabs";
patch(OrderTabs.prototype, {
async selectFloatingOrder(order) {
const allowed = await this.pos.confirmExitSettleIfNeeded(order);
if (!allowed) {
return;
}
return super.selectFloatingOrder(order);
},
async newFloatingOrder() {
const allowed = await this.pos.confirmExitSettleIfNeeded(null);
if (!allowed) {
return;
}
return super.newFloatingOrder();
},
});

View File

@@ -1,166 +0,0 @@
/** @odoo-module
*
* PaymentScreen patch — minimal laundry behaviors:
* 1. Settlement orders: hide pay-later methods, custom validateOrder
* that calls settle_laundry_dues_rpc and bypasses normal sync.
* 2. Legacy laundry orders: trigger order-type popup at payment time
* (NOT on add-line). Block payment if type is required and missing.
*
* STRICT CONTRACT:
* - Does NOT touch order.lines, pricing, taxes
* - Does NOT override PosOrder.setup
* - Selections stored as primitives only (handled in pos_store_patch.js)
* - For non-laundry orders, super.validateOrder runs unchanged.
*/
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen";
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { LaundrySettlementReceipt } from "@laundry_management/js/settlement_receipt";
function isSettlementOrder(order) {
return (
order &&
order.lines &&
order.lines.some(
(line) => line.product_id?.product_tmpl_id?.is_laundry_settlement
)
);
}
patch(PaymentScreen.prototype, {
setup() {
super.setup(...arguments);
// For settlement orders, hide Customer Account / pay-later methods.
if (isSettlementOrder(this.currentOrder)) {
this.payment_methods_from_config =
this.payment_methods_from_config.filter(
(pm) => !pm.split_transactions
);
}
},
/**
* Pre-payment gate for the legacy laundry order-type flow.
* - true → continue with payment
* - false → block payment
*
* Skipped for: feature disabled, settlement orders, non-laundry orders,
* and orders that already have a type. If required + missing, opens the
* popup; blocks if the user closes/cancels.
*/
async beforePaymentFlow(order) {
const pos = this.pos;
const config = pos.config;
if (!order || !config?.enable_laundry_order_type) {
return true;
}
if (isSettlementOrder(order)) {
return true;
}
if (!pos.orderHasLaundryServiceLine(order)) {
return true;
}
if (order.laundry_order_type_id) {
return true;
}
await pos.runLaundryOrderTypeFlow(order);
if (config.require_laundry_order_type && !order.laundry_order_type_id) {
pos.notification.add(
_t("Order type is required to proceed to payment."),
{ type: "warning" }
);
return false;
}
return true;
},
async validateOrder(isForceValidate) {
const order = this.currentOrder;
// ── Settlement validation — bypass normal POS sync ──
if (isSettlementOrder(order)) {
return this._validateSettlementOrder(order);
}
// ── Legacy laundry order-type gate ──
const allowed = await this.beforePaymentFlow(order);
if (!allowed) {
return false;
}
return super.validateOrder(isForceValidate);
},
async _validateSettlementOrder(order) {
const paymentLines = order.payment_ids
.filter((pl) => pl.getAmount() > 0)
.map((pl) => ({
pos_payment_method_id: pl.payment_method_id.id,
amount: pl.getAmount(),
}));
if (paymentLines.length === 0) {
this.notification.add(
_t("Please add at least one payment."),
{ type: "warning" }
);
return false;
}
const partnerId = order.partner_id?.id;
if (!partnerId) {
this.notification.add(_t("No customer selected."), {
type: "danger",
});
return false;
}
const sessionId = this.pos.session?.id || null;
let response;
try {
this.ui.block();
response = await this.pos.data.call(
"res.partner",
"settle_laundry_dues_rpc",
[partnerId, paymentLines, sessionId]
);
} catch (err) {
this.dialog.add(AlertDialog, {
title: _t("Settlement Failed"),
body:
err?.data?.message ||
err?.message ||
_t("Unknown error."),
});
return false;
} finally {
this.ui.unblock();
}
if (
order.payment_ids.some(
(pl) => pl.payment_method_id.is_cash_count && pl.getAmount() > 0
)
) {
this.pos.hardwareProxy.openCashbox();
}
this.dialog.add(LaundrySettlementReceipt, {
partnerName: order.partner_id.name,
settledTotal: response.settled_total || 0,
remainingDue: response.remaining_due || 0,
payments: response.payments || [],
settledOrders: response.settled_orders || [],
});
this.pos._lastSettlementMethodId =
paymentLines[0]?.pos_payment_method_id || null;
this.pos.removeOrder(order, false);
this.pos.addNewOrder();
this.pos.navigate("ProductScreen");
return true;
},
});

View File

@@ -1,115 +0,0 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen";
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { LaundrySettlementReceipt } from "@laundry_management/js/settlement_receipt";
/**
* Check whether a POS order is a settlement order (contains
* the special settlement product, NOT a laundry service).
*/
function isSettlementOrder(order) {
return (
order &&
order.lines &&
order.lines.some(
(line) => line.product_id?.product_tmpl_id?.is_laundry_settlement
)
);
}
patch(PaymentScreen.prototype, {
setup() {
super.setup(...arguments);
// For settlement orders, hide Customer Account / pay-later methods
if (isSettlementOrder(this.currentOrder)) {
this.payment_methods_from_config =
this.payment_methods_from_config.filter(
(pm) => !pm.split_transactions
);
}
},
async validateOrder(isForceValidate) {
const order = this.currentOrder;
if (!isSettlementOrder(order)) {
return super.validateOrder(isForceValidate);
}
// ── Settlement validation — bypass normal POS sync ──
const paymentLines = order.payment_ids
.filter((pl) => pl.getAmount() > 0)
.map((pl) => ({
pos_payment_method_id: pl.payment_method_id.id,
amount: pl.getAmount(),
}));
if (paymentLines.length === 0) {
this.notification.add(
_t("Please add at least one payment."),
{ type: "warning" }
);
return false;
}
const partnerId = order.partner_id?.id;
if (!partnerId) {
this.notification.add(_t("No customer selected."), {
type: "danger",
});
return false;
}
const sessionId = this.pos.session?.id || null;
let response;
try {
this.ui.block();
response = await this.pos.data.call(
"res.partner",
"settle_laundry_dues_rpc",
[partnerId, paymentLines, sessionId]
);
} catch (err) {
this.dialog.add(AlertDialog, {
title: _t("Settlement Failed"),
body:
err?.data?.message ||
err?.message ||
_t("Unknown error."),
});
return false;
} finally {
this.ui.unblock();
}
// Open cash drawer if cash was used
if (
order.payment_ids.some(
(pl) => pl.payment_method_id.is_cash_count && pl.getAmount() > 0
)
) {
this.pos.hardwareProxy.openCashbox();
}
// Show settlement receipt
this.dialog.add(LaundrySettlementReceipt, {
partnerName: order.partner_id.name,
settledTotal: response.settled_total || 0,
remainingDue: response.remaining_due || 0,
payments: response.payments || [],
settledOrders: response.settled_orders || [],
});
// Remember last-used method for next settlement
this.pos._lastSettlementMethodId =
paymentLines[0]?.pos_payment_method_id || null;
// Clean up: remove settlement order (never synced to backend)
this.pos.removeOrder(order, false);
this.pos.addNewOrder();
this.pos.navigate("ProductScreen");
return true;
},
});

View File

@@ -1,59 +0,0 @@
/** @odoo-module */
import { Component, useState, useRef, onMounted } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { _t } from "@web/core/l10n/translation";
export class LaundryDeliveryDetailsPopup extends Component {
static components = { Dialog };
static template = "laundry_management.LaundryDeliveryDetailsPopup";
static props = {
defaultAddress: { type: String, optional: true },
defaultScheduledAt: { type: String, optional: true },
requireAddress: { type: Boolean, optional: true },
requireScheduledTime: { type: Boolean, optional: true },
title: { type: String, optional: true },
getPayload: { type: Function },
close: { type: Function },
};
static defaultProps = {
defaultAddress: "",
defaultScheduledAt: "",
requireAddress: true,
requireScheduledTime: false,
title: _t("Delivery Details"),
};
setup() {
this.state = useState({
address: this.props.defaultAddress || "",
scheduledAt: this.props.defaultScheduledAt || "",
addressError: false,
timeError: false,
});
this.addressRef = useRef("addressInput");
onMounted(() => {
if (this.addressRef.el) {
this.addressRef.el.focus();
}
});
}
confirm() {
this.state.addressError =
this.props.requireAddress && !this.state.address.trim();
this.state.timeError =
this.props.requireScheduledTime && !this.state.scheduledAt;
if (this.state.addressError || this.state.timeError) {
return;
}
this.props.getPayload({
address: this.state.address.trim(),
scheduledAt: this.state.scheduledAt || false,
});
this.props.close();
}
cancel() {
this.props.close();
}
}

View File

@@ -1,51 +0,0 @@
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { _t } from "@web/core/l10n/translation";
export class LaundryOrderAttributePopup extends Component {
static components = { Dialog };
static template = "laundry_management.LaundryOrderAttributePopup";
static props = {
attributes: { type: Array },
preselectedIds: { type: Array, optional: true },
title: { type: String, optional: true },
getPayload: { type: Function },
close: { type: Function },
};
static defaultProps = {
preselectedIds: [],
title: _t("Select Attributes"),
};
setup() {
const initial = new Set(this.props.preselectedIds);
this.state = useState({
selected: initial,
});
}
toggle(attrId) {
if (this.state.selected.has(attrId)) {
this.state.selected.delete(attrId);
} else {
this.state.selected.add(attrId);
}
}
isSelected(attrId) {
return this.state.selected.has(attrId);
}
confirm() {
const ids = [...this.state.selected];
const chosen = this.props.attributes.filter((a) => ids.includes(a.id));
this.props.getPayload(chosen);
this.props.close();
}
skip() {
this.props.getPayload([]);
this.props.close();
}
}

View File

@@ -1,51 +0,0 @@
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { _t } from "@web/core/l10n/translation";
export class LaundryOrderTypePopup extends Component {
static components = { Dialog };
static template = "laundry_management.LaundryOrderTypePopup";
static props = {
types: { type: Array },
defaultTypeId: { type: [Number, Boolean], optional: true },
showIcons: { type: Boolean, optional: true },
allowSkip: { type: Boolean, optional: true },
title: { type: String, optional: true },
getPayload: { type: Function },
close: { type: Function },
};
static defaultProps = {
showIcons: true,
allowSkip: true,
title: _t("Select Order Type"),
};
setup() {
this.state = useState({
selectedId: this.props.defaultTypeId || false,
});
}
select(typeId) {
this.state.selectedId = typeId;
}
confirm() {
if (!this.state.selectedId && !this.props.allowSkip) {
return;
}
const chosen = this.props.types.find((t) => t.id === this.state.selectedId);
this.props.getPayload(chosen || null);
this.props.close();
}
skip() {
this.props.getPayload(null);
this.props.close();
}
cancel() {
this.props.close();
}
}

View File

@@ -1,133 +0,0 @@
/** @odoo-module
*
* PosOrder patch — laundry context fields.
*
* Two responsibilities:
* 1. setup(): seed primitive defaults on the reactive proxy so that
* OWL components (panel, receipt) which read these slots track
* future writes and re-render. Without seeding, assigning a fresh
* property after first render does NOT trigger reactivity.
* No fields are pulled from the loader — pure in-memory defaults.
*
* 2. serializeForORM(): inject the laundry slots into the sync_from_ui
* payload so the backend pos.order columns are populated, exactly
* like `general_customer_note`.
* - laundry_order_type_id → integer id (Many2one)
* - laundry_order_attribute_ids → [[6, 0, ids]] (Many2many "set")
* - laundry_is_delivery → boolean
* - laundry_delivery_address → string | false
* - laundry_delivery_scheduled_at → string | false
*
* NOTE: this patch does NOT touch order.lines, pricing, taxes, or any
* relational field exposed by _load_pos_data_fields.
*/
import { patch } from "@web/core/utils/patch";
import { PosOrder } from "@point_of_sale/app/models/pos_order";
function pickId(v) {
if (!v) return false;
if (typeof v === "number") return v;
if (typeof v === "object" && Number.isInteger(v.id)) return v.id;
return false;
}
function pickIds(arr) {
if (!Array.isArray(arr)) return [];
const out = [];
const seen = new Set();
for (const v of arr) {
const id = pickId(v);
if (id && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
}
function normalizeDateTime(value) {
if (!value || typeof value !== "string") return false;
let v = value;
// Remove timezone like +03:00 or -03:00
const tzMatch = v.match(/([+-]\d{2}:\d{2})$/);
if (tzMatch) {
v = v.replace(tzMatch[0], "");
}
// Remove Z
if (v.endsWith("Z")) {
v = v.slice(0, -1);
}
// Remove milliseconds
if (v.includes(".")) {
v = v.split(".")[0];
}
// Replace T with space
v = v.replace("T", " ");
// Ensure seconds exist
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(v)) {
v += ":00";
}
return v;
}
patch(PosOrder.prototype, {
setup(...args) {
super.setup(...args);
// Seed defaults on the reactive proxy. Assigning after first
// render only triggers OWL reactivity if the key already exists
// on the proxy at observation time.
if (this.laundry_order_type_id === undefined) {
this.laundry_order_type_id = false;
}
if (this.laundry_order_attribute_ids === undefined) {
this.laundry_order_attribute_ids = [];
}
if (this.laundry_is_delivery === undefined) {
this.laundry_is_delivery = false;
}
if (this.laundry_delivery_address === undefined) {
this.laundry_delivery_address = false;
}
if (this.laundry_delivery_scheduled_at === undefined) {
this.laundry_delivery_scheduled_at = false;
}
},
initState(...args) {
super.initState(...args);
// Reactive flag for POS Mode system: when true, the order is a
// dedicated settlement context — products cannot be added,
// quantities cannot be edited, line cannot be deleted.
// Seeded here so OWL reactivity tracks future writes.
if (this.uiState && !("is_laundry_settle_due" in this.uiState)) {
this.uiState.is_laundry_settle_due = false;
}
},
serializeForORM(opts = {}) {
const data = super.serializeForORM(opts);
const typeId = pickId(this.laundry_order_type_id);
data.laundry_order_type_id = typeId || false;
const attrIds = pickIds(this.laundry_order_attribute_ids);
data.laundry_order_attribute_ids = [[6, 0, attrIds]];
data.laundry_is_delivery = !!this.laundry_is_delivery;
data.laundry_delivery_address = this.laundry_delivery_address || false;
// ABSOLUTE GUARANTEE: nothing with a "T", ".000", or timezone reaches
// the backend. Run the full normalizer even if upstream already did;
// normalize(normalize(x)) === normalize(x), so this is a zero-cost net.
data.laundry_delivery_scheduled_at =
normalizeDateTime(this.laundry_delivery_scheduled_at) || false;
return data;
},
});

View File

@@ -1,83 +0,0 @@
/** @odoo-module
*
* PosOrder JS-side patch for the laundry order-type / attributes feature.
*
* Why this patch exists:
* The two relational laundry fields on `pos.order`
* - laundry_order_type_id (Many2one → laundry.order.type)
* - laundry_order_attribute_ids (Many2many → laundry.order.attribute)
* are NOT exposed via _load_pos_data_fields anymore. Including them was
* breaking the POS relational engine during order initialization
* (TypeError: lines is undefined → _computeAllPrices crash).
*
* So instead of round-tripping them through the relational engine, we:
* 1. Store them as plain JS attributes on the order instance
* (assigned by runLaundryOrderTypeFlow in pos_store_patch.js).
* 2. Inject them into the sync_from_ui payload via serializeForORM
* as standard ORM commands (integer id for M2O, [(6,0,ids)] for M2M).
* 3. The backend pos.order columns still exist; sync writes the values
* normally and _maybe_create_laundry_order propagates them onward.
*
* The 3 SCALAR laundry fields (laundry_is_delivery, ..._address,
* ..._scheduled_at) remain in _load_pos_data_fields and round-trip
* normally through the relational engine. We only set safe defaults
* for them here so consumers never hit `undefined`.
*
* Setup runs with (...args) so super gets the exact arguments the
* framework passed, and all writes happen AFTER super.setup so we never
* interfere with base initialization.
*/
import { patch } from "@web/core/utils/patch";
import { PosOrder } from "@point_of_sale/app/models/pos_order";
patch(PosOrder.prototype, {
setup(...args) {
super.setup(...args);
// Scalar field defaults (these ARE in _load_pos_data_fields).
if (this.laundry_is_delivery === undefined) {
this.laundry_is_delivery = false;
}
if (this.laundry_delivery_address === undefined) {
this.laundry_delivery_address = false;
}
if (this.laundry_delivery_scheduled_at === undefined) {
this.laundry_delivery_scheduled_at = false;
}
// Relational field shadows (NOT in _load_pos_data_fields — pure JS).
// Plain attributes only, never relational records, so the engine
// does not see them as relational writes.
if (this.laundry_order_type_id === undefined) {
this.laundry_order_type_id = false;
}
if (this.laundry_order_attribute_ids === undefined) {
this.laundry_order_attribute_ids = [];
}
},
serializeForORM(opts = {}) {
const data = super.serializeForORM(opts);
// Many2one → just the integer id (or false to clear).
const typeRec = this.laundry_order_type_id;
if (typeRec && typeof typeRec === "object" && typeRec.id) {
data.laundry_order_type_id = typeRec.id;
} else if (typeof typeRec === "number") {
data.laundry_order_type_id = typeRec;
} else {
data.laundry_order_type_id = false;
}
// Many2many → standard ORM "set" command [(6, 0, [ids])].
const attrs = Array.isArray(this.laundry_order_attribute_ids)
? this.laundry_order_attribute_ids
: [];
const attrIds = attrs
.map((a) => (a && typeof a === "object" ? a.id : a))
.filter((id) => Number.isInteger(id));
data.laundry_order_attribute_ids = [[6, 0, attrIds]];
return data;
},
});

View File

@@ -1,807 +0,0 @@
/** @odoo-module
*
* PosStore patch — laundry order type / attributes / delivery popup chain.
*
* Two storage paths kept in sync:
* 1. pos.laundryContext (OWL reactive) — drives panel + receipt re-render.
* 2. order.laundry_* primitives — drives serializeForORM payload.
*
* STRICT CONTRACT:
* - Does NOT override PosOrder.setup beyond seeding scalar defaults
* (see pos_order_patch.js)
* - Does NOT touch order.lines, pricing, or taxes
* - Does NOT push laundry fields through _load_pos_data_fields
*/
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { PosStore } from "@point_of_sale/app/services/pos_store";
import { ask, makeAwaitable } from "@point_of_sale/app/utils/make_awaitable_dialog";
import { LaundryContextStore } from "@laundry_management/js/laundry_context_store";
import { LaundryQuickCreatePartner } from "@laundry_management/js/quick_create_partner";
import { LaundryOrdersViewPopup } from "@laundry_management/js/view_laundry_orders";
import { LaundryOrderTypePopup } from "@laundry_management/js/popups/laundry_order_type_popup";
import { LaundryOrderAttributePopup } from "@laundry_management/js/popups/laundry_order_attribute_popup";
import { LaundryDeliveryDetailsPopup } from "@laundry_management/js/popups/laundry_delivery_details_popup";
function hasLaundryProduct(order) {
return order.lines.some(
(line) => line.product_id?.product_tmpl_id?.is_laundry_service
);
}
function isLaundryServiceLine(line) {
const tmpl = line.product_id?.product_tmpl_id;
return tmpl?.is_laundry_service && !tmpl?.is_laundry_settlement;
}
function toId(v) {
if (!v) return false;
if (typeof v === "number") return v;
if (typeof v === "object" && Number.isInteger(v.id)) return v.id;
return false;
}
function toIds(arr) {
if (!Array.isArray(arr)) return [];
const seen = new Set();
for (const v of arr) {
const id = toId(v);
if (id) seen.add(id);
}
return [...seen];
}
function normalizeDateTime(value) {
if (!value || typeof value !== "string") return false;
let v = value;
// Remove timezone like +03:00 or -03:00
const tzMatch = v.match(/([+-]\d{2}:\d{2})$/);
if (tzMatch) {
v = v.replace(tzMatch[0], "");
}
// Remove Z
if (v.endsWith("Z")) {
v = v.slice(0, -1);
}
// Remove milliseconds
if (v.includes(".")) {
v = v.split(".")[0];
}
// Replace T with space
v = v.replace("T", " ");
// Ensure seconds exist
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(v)) {
v += ":00";
}
return v;
}
patch(PosStore.prototype, {
setup(...args) {
const result = super.setup(...args);
if (!this.laundryContext) {
this.laundryContext = new LaundryContextStore();
}
if (this._settleDuesInFlight === undefined) {
this._settleDuesInFlight = false;
}
return result;
},
/**
* Defensive override for the core configurator helper.
*
* In Odoo 19 core, `doHaveConflictWith` accesses `value.id` and
* `selectedValues.map(v => v.id)` with no null-check. When the
* loaded POS data has even ONE incomplete PTAV reference (most
* commonly after a partial product re-seed, attribute rename, or
* orphan ir_model_data row), the configurator render throws:
*
* TypeError: can't access property "id", value is undefined
* at doHaveConflictWith
*
* The exception leaves the POS in an unrecoverable state mid-popup
* and the cashier can't add the laundry product at all.
*
* This guard filters out null/undefined entries and short-circuits
* to "no conflict" when `value` itself is malformed. It changes
* NO business behavior — exclusions still apply for valid PTAVs.
*/
doHaveConflictWith(value, selectedValues) {
if (!value || value.id === undefined) {
return false;
}
const safeSelected = Array.isArray(selectedValues)
? selectedValues.filter((v) => v && v.id !== undefined)
: [];
return super.doHaveConflictWith(value, safeSelected);
},
// ─── POS Mode (settle_due isolation) ──────────────────────────────────
isSettleDueOrder(order) {
return !!order?.uiState?.is_laundry_settle_due;
},
/** Reactive accessor for templates: "sale" | "settle_due". */
get currentLaundryMode() {
return this.isSettleDueOrder(this.getOrder()) ? "settle_due" : "sale";
},
_markSettleDue(order) {
if (order?.uiState) {
order.uiState.is_laundry_settle_due = true;
}
},
_clearSettleDue(order) {
if (order?.uiState) {
order.uiState.is_laundry_settle_due = false;
}
},
/**
* Cashier-initiated exit from settle mode.
* ALWAYS drops the settlement order AND creates a fresh clean sale
* order — never leaves the POS in a zero-order state.
*
* Confirmation tone differs based on what the cashier has staged:
* • No payment lines yet → light confirm ("Cancel settlement?").
* • Amount already entered → stronger confirm warning that the
* payment lines on this settle order will be discarded.
* Cleanup is the same in both cases — the settle order is removed
* locally so any payment lines on it go with it.
*/
async exitSettleDueMode() {
const order = this.getOrder();
if (!this.isSettleDueOrder(order)) {
return;
}
const hasPaymentLines = (order.payment_ids || []).some(
(p) => (p.amount || 0) > 0
);
const dialogProps = hasPaymentLines
? {
title: _t("Discard settlement payment?"),
body: _t(
"Settlement has payment lines entered. Leaving now "
+ "discards them. Continue?"
),
confirmLabel: _t("Discard & Exit"),
cancelLabel: _t("Stay"),
}
: {
title: _t("Cancel settlement and return to POS?"),
body: _t(
"No payment entered yet. The settlement will be "
+ "cancelled and you will return to the product screen."
),
confirmLabel: _t("Exit"),
cancelLabel: _t("Stay"),
};
const confirmed = await ask(this.env.services.dialog, dialogProps);
if (!confirmed) {
return;
}
this._clearSettleDue(order);
this.removeOrder(order, false);
// Always hand the cashier a clean new sale order — no stranded state.
this.addNewOrder();
this.navigate("ProductScreen");
},
/**
* Nuke every open order in the POS except an optional keeper. Used
* when entering settle-due mode to enforce single-order isolation.
*
* DATA-LOSS PROTECTION: if any of the to-be-removed orders still has
* lines, prompt ONCE before discarding. Returns true if the removal
* completed, false if the cashier cancelled.
*/
async _removeAllOpenOrders({ except = null } = {}) {
const keepUuid = except?.uuid || null;
const snapshot = this.getOpenOrders().slice();
const doomed = snapshot.filter((o) => o.uuid !== keepUuid);
if (!doomed.length) {
return true;
}
const hasLines = doomed.some((o) => (o.lines?.length || 0) > 0);
if (hasLines) {
const confirmed = await ask(this.env.services.dialog, {
title: _t("Discard Open Orders?"),
body: _t(
"Starting a settlement will discard %s open order(s) that still contain items. Continue?",
doomed.length
),
confirmLabel: _t("Continue"),
cancelLabel: _t("Cancel"),
});
if (!confirmed) {
return false;
}
}
for (const o of doomed) {
this._clearSettleDue(o);
this.removeOrder(o, false);
}
return true;
},
/**
* Navigation gate — every cashier action that would switch the active
* order context (tab click, "+" new order, navbar register click)
* must funnel through this helper FIRST.
*
* targetOrder = null → "create new order" intent
* targetOrder = some open order → "switch to that order" intent
*
* Returns true if the navigation should proceed, false if blocked.
* If the user confirms the exit, the active settle order is dropped
* here so callers can proceed without further cleanup.
*/
async confirmExitSettleIfNeeded(targetOrder) {
const current = this.getOrder();
if (!this.isSettleDueOrder(current)) {
return true;
}
// Switching to the same settle order itself is a no-op for safety.
if (targetOrder && targetOrder.uuid === current.uuid) {
return true;
}
const confirmed = await ask(this.env.services.dialog, {
title: _t("Exit Settle Dues Mode?"),
body: _t(
"You are settling dues for %s. Leaving will cancel the current settlement.",
current.getPartner()?.name || _t("a customer")
),
confirmLabel: _t("Exit"),
cancelLabel: _t("Cancel"),
});
if (!confirmed) {
return false;
}
this._clearSettleDue(current);
this.removeOrder(current, false);
return true;
},
/**
* Hard guard: while a settle-due order is active, the only line that
* can land on it is the settlement product itself. Any other product
* click is silently rejected with a notification.
*/
async addLineToCurrentOrder(vals, opts = {}, configure = true) {
const order = this.getOrder();
if (this.isSettleDueOrder(order)) {
const tmplVal = vals?.product_tmpl_id;
const tmpl = typeof tmplVal === "number"
? this.data.models["product.template"]?.get(tmplVal)
: tmplVal;
if (!tmpl?.is_laundry_settlement) {
this.notification.add(
_t("Cannot add products while settling dues. Exit settle mode first."),
{ type: "warning" }
);
return null;
}
}
// Laundry configuration now runs through the CORE Odoo configurator
// (ProductConfiguratorPopup). We don't intercept here — core's
// handleConfigurableProduct / openConfigurator fires naturally for
// any template whose attributes expose >1 value. We only enhance
// the popup's presentation via XML inheritance + scoped SCSS
// (see xml/product_configurator_popup.xml).
return super.addLineToCurrentOrder(vals, opts, configure);
},
/**
* HARD BLOCK: once a settle-due order is active, no new orders can
* be created. No confirm — just stop. Any path that tries to create
* a new order during settle mode is either a mistake or an escape
* route we haven't caught upstream; either way, block it.
*/
addNewOrder(data = {}) {
if (this.isSettleDueOrder(this.getOrder())) {
this.notification.add(
_t("Finish settlement first."),
{ type: "warning" }
);
return null;
}
return super.addNewOrder(data);
},
/**
* HARD CONTROL on order switching. If the active order is settle-due
* and the target is a different order:
* - ask confirmation
* - cancel → block the switch
* - confirm → drop the settle order cleanly, then proceed
*
* The function becomes async. Native sync callers (e.g. TicketScreen)
* do not await, but the user-facing UI gates (OrderTabs.selectFloating-
* Order, Navbar.onClickRegister) run confirmExitSettleIfNeeded first
* and clear settle mode before super.setOrder runs, so this patch is
* defense-in-depth for any escape route that bypasses those gates.
*/
async setOrder(order) {
const current = this.getOrder();
if (
current &&
this.isSettleDueOrder(current) &&
order &&
order.uuid !== current.uuid
) {
const confirmed = await ask(this.env.services.dialog, {
title: _t("Exit Settle Dues Mode?"),
body: _t("The current settlement will be cancelled."),
confirmLabel: _t("Exit"),
cancelLabel: _t("Cancel"),
});
if (!confirmed) {
return;
}
this._clearSettleDue(current);
this.removeOrder(current, false);
}
return super.setOrder(order);
},
/** Always release settle-state when an order is dropped (success OR exit). */
removeOrder(order, removeFromServer = true) {
if (order?.uiState) {
order.uiState.is_laundry_settle_due = false;
}
return super.removeOrder(order, removeFromServer);
},
/**
* Fail-safe navigation: while a settle-due order is active, the ONLY
* legitimate screen is PaymentScreen. Any other navigation target is
* rewritten to PaymentScreen so the cashier never lands on a screen
* that would corrupt the settlement context (no blank screen, no
* stranded products on the wrong order).
*
* BUT: when the cashier presses Back (PaymentScreen → ProductScreen),
* silently snapping them back used to feel like a trap. We now also
* SCHEDULE the existing exit-confirmation dialog on the next
* microtask. The cashier sees PaymentScreen for one frame, then a
* clear "Cancel settlement?" dialog. Confirming → settle order
* removed, fresh sale order, ProductScreen. Cancelling → still on
* PaymentScreen, can keep settling. No new buttons, no XML changes.
*
* Legit exit paths (exitSettleDueMode itself, removeOrder during
* payment success) clear the settle flag BEFORE calling navigate,
* so they pass through unaffected and never trigger the prompt.
*/
navigate(routeName, routeParams = {}) {
const current = this.getOrder();
if (
this.isSettleDueOrder(current) &&
routeName !== "PaymentScreen"
) {
// Schedule exit-confirmation but don't await — keep navigate
// sync so callers that don't await still work cleanly.
Promise.resolve().then(() => this.exitSettleDueMode());
return super.navigate("PaymentScreen", { orderUuid: current.uuid });
}
return super.navigate(routeName, routeParams);
},
/** Mirror a patch into BOTH the reactive store and the order primitives. */
_writeLaundryContext(order, patchObj) {
if (!order) {
return;
}
// Reactive store — drives panel + receipt re-render.
this.laundryContext.set(order.uuid, patchObj);
// Order primitives — drive serializeForORM payload.
if ("type_id" in patchObj) {
order.laundry_order_type_id = patchObj.type_id || false;
}
if ("attribute_ids" in patchObj) {
order.laundry_order_attribute_ids = Array.isArray(patchObj.attribute_ids)
? patchObj.attribute_ids.slice()
: [];
}
if ("is_delivery" in patchObj) {
order.laundry_is_delivery = !!patchObj.is_delivery;
}
if ("delivery_address" in patchObj) {
order.laundry_delivery_address = patchObj.delivery_address || false;
}
if ("delivery_scheduled_at" in patchObj) {
order.laundry_delivery_scheduled_at =
patchObj.delivery_scheduled_at || false;
}
},
async postSyncAllOrders(orders) {
await super.postSyncAllOrders(orders);
for (const order of orders) {
if (order.laundry_order_id) {
this.notification.add(_t("Laundry Order Created"), {
type: "success",
});
}
}
},
async pay() {
const currentOrder = this.getOrder();
if (!currentOrder) {
return;
}
// Settle-due orders already have a partner (gated in settleLaundryDues).
if (
!this.isSettleDueOrder(currentOrder) &&
hasLaundryProduct(currentOrder) &&
!currentOrder.getPartner()
) {
const confirmed = await ask(this.env.services.dialog, {
title: _t("Customer Required"),
body: _t(
"This order contains laundry items. Please select or create a customer."
),
});
if (confirmed) {
const partner = await this.selectPartner();
if (!partner) {
return;
}
} else {
return;
}
}
return super.pay();
},
async editPartner(partner) {
if (partner) {
return super.editPartner(partner);
}
const result = await makeAwaitable(this.dialog, LaundryQuickCreatePartner, {});
if (!result) {
return false;
}
const existing = await this.data.call(
"res.partner",
"laundry_find_by_phone",
[result.phone]
);
if (existing) {
const useExisting = await ask(this.env.services.dialog, {
title: _t("Customer Already Exists"),
body: _t(
'A customer "%s" already exists with this phone number. Use existing customer?',
existing.name
),
});
if (useExisting) {
const partners = await this.data.read("res.partner", [existing.id]);
return partners[0] || false;
}
return false;
}
const partnerId = await this.data.call(
"res.partner",
"laundry_quick_create",
[{ name: result.name, phone: result.phone, street: result.street || "" }]
);
const partners = await this.data.read("res.partner", [partnerId]);
return partners[0] || false;
},
async settleLaundryDues() {
// Re-entrancy guard — defends against double-clicks while we
// hit the server for dues / before the order is materialized.
if (this._settleDuesInFlight) {
return;
}
this._settleDuesInFlight = true;
try {
const currentOrder = this.getOrder();
const partner = currentOrder?.getPartner();
if (!partner) {
this.notification.add(_t("Please select a customer first."), {
type: "warning",
});
const selectedPartner = await this.selectPartner();
if (!selectedPartner) {
return;
}
this._settleDuesInFlight = false;
return this.settleLaundryDues();
}
// Reuse an existing settle-due order for the same partner
// instead of creating a duplicate one. Whether we reuse or
// create fresh, SINGLE-ORDER MODE must hold: every other
// open order is removed before we navigate.
const existing = this.getOpenOrders().find(
(o) =>
this.isSettleDueOrder(o) &&
o.getPartner()?.id === partner.id
);
if (existing) {
const ok = await this._removeAllOpenOrders({ except: existing });
if (!ok) {
return;
}
// Our patched setOrder short-circuits when target === current
// settle order; safe to call here.
this.setOrder(existing);
this.navigate("PaymentScreen", { orderUuid: existing.uuid });
return;
}
const dues = await this.data.call(
"res.partner",
"get_laundry_dues",
[partner.id]
);
if (!dues || dues.total_due <= 0) {
this.notification.add(
_t('"%s" has no outstanding laundry dues.', partner.name),
{ type: "info" }
);
return;
}
const settlementProduct = this.models["product.product"]
.getAll()
.find((p) => p.product_tmpl_id?.is_laundry_settlement);
if (!settlementProduct) {
this.notification.add(
_t("Settlement product not configured. Please check laundry settings."),
{ type: "danger" }
);
return;
}
// ─── SINGLE-ORDER MODE ─────────────────────────────────────
// Nuke every open order BEFORE creating the settle order.
// Data-loss protection lives inside _removeAllOpenOrders —
// it prompts once if any order has lines and returns false
// if the cashier cancels.
const ok = await this._removeAllOpenOrders();
if (!ok) {
return;
}
const order = this.addNewOrder();
// Mark BEFORE adding the settlement line so any concurrent
// attempt to add other products to this order is blocked.
// The settlement product itself is whitelisted in
// addLineToCurrentOrder.
this._markSettleDue(order);
order.setPartner(partner);
await this.addLineToCurrentOrder(
{
product_id: settlementProduct,
product_tmpl_id: settlementProduct.product_tmpl_id,
price_unit: dues.total_due,
qty: 1,
},
{},
false
);
this.navigate("PaymentScreen", {
orderUuid: order.uuid,
});
} finally {
this._settleDuesInFlight = false;
}
},
/** True iff order has at least one laundry-service line (excludes settlement). */
orderHasLaundryServiceLine(order) {
return !!order?.lines?.some(isLaundryServiceLine);
},
async runLaundryOrderTypeFlow(order) {
if (!order) {
return;
}
const config = this.config;
// Step 1 — Main type
const types = (this.models["laundry.order.type"]?.getAll() || [])
.slice()
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
if (!types.length) {
this.notification.add(
_t("No laundry order types configured. Ask a manager to add some."),
{ type: "warning" }
);
if (config.require_laundry_order_type) {
return;
}
}
const partner = order.getPartner();
const ctx = this.laundryContext.get(order.uuid);
const defaultTypeId =
toId(ctx.type_id) ||
toId(order.laundry_order_type_id) ||
toId(partner?.default_laundry_order_type_id) ||
toId(config.default_laundry_order_type_id) ||
false;
const chosenType = await makeAwaitable(this.dialog, LaundryOrderTypePopup, {
types: types,
defaultTypeId: defaultTypeId,
showIcons: !!config.show_order_type_icons,
allowSkip: !config.require_laundry_order_type,
title: _t("Select Order Type"),
});
if (chosenType === undefined) {
return; // user closed without confirming
}
this._writeLaundryContext(order, {
type_id: chosenType ? chosenType.id : false,
});
// Step 2 — Attributes
let chosenAttributes = [];
if (config.enable_laundry_attributes) {
const allAttrs = (this.models["laundry.order.attribute"]?.getAll() || [])
.slice()
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
if (allAttrs.length) {
const suggestedIds = toIds(chosenType?.attribute_ids);
const partnerDefaultIds = toIds(partner?.default_laundry_attribute_ids);
const preselected = [
...new Set([...suggestedIds, ...partnerDefaultIds]),
];
const result = await makeAwaitable(
this.dialog,
LaundryOrderAttributePopup,
{
attributes: allAttrs,
preselectedIds: preselected,
title: _t("Select Attributes (optional)"),
}
);
if (result !== undefined) {
chosenAttributes = result;
}
}
}
this._writeLaundryContext(order, {
attribute_ids: chosenAttributes.map((a) => a.id),
});
// Step 3 — Delivery details
const typeIsDelivery = !!chosenType?.is_delivery;
const attrIsDelivery = chosenAttributes.some((a) => a.is_delivery_related);
const isDelivery = typeIsDelivery || attrIsDelivery;
this._writeLaundryContext(order, { is_delivery: isDelivery });
if (isDelivery && config.require_delivery_details_if_needed) {
const requireAddress =
!!chosenType?.requires_address || attrIsDelivery;
const requireTime = !!chosenType?.requires_scheduled_time;
const result = await makeAwaitable(
this.dialog,
LaundryDeliveryDetailsPopup,
{
defaultAddress: ctx.delivery_address || "",
defaultScheduledAt: ctx.delivery_scheduled_at || "",
requireAddress: requireAddress,
requireScheduledTime: requireTime,
}
);
if (result) {
this._writeLaundryContext(order, {
delivery_address: result.address || false,
delivery_scheduled_at: normalizeDateTime(result.scheduledAt) || false,
});
}
} else if (!isDelivery) {
this._writeLaundryContext(order, {
delivery_address: false,
delivery_scheduled_at: false,
});
}
},
async editLaundryOrderType() {
const order = this.getOrder();
if (!order) {
return;
}
if (!this.config?.allow_change_laundry_order_type_before_payment) {
this.notification.add(_t("Changing order type is disabled."), {
type: "warning",
});
return;
}
await this.runLaundryOrderTypeFlow(order);
},
/**
* Entry point invoked by the laundry-orders popup's "Collect Payment"
* button. Reuses the existing settleLaundryDues flow end-to-end so
* the well-tested settle-due isolation, navigation guards, and
* accounting RPC are inherited verbatim — no parallel collection
* pipeline, no new payment logic, no accounting changes.
*
* Steps:
* 1. Resolve the partner from POS data.
* 2. Make sure the active POS order has that partner set so
* settleLaundryDues doesn't pop the partner-selection dialog
* again.
* 3. Hand off to settleLaundryDues — which:
* • drops every other open order (with a confirm if any
* carry lines) so the cashier is in a single-order state,
* • spins up the settle-due context order,
* • hides Customer Account / pay-later methods,
* • navigates to PaymentScreen,
* • on Validate calls settle_laundry_dues_rpc → cash bank
* statement OR account.payment + reconcile + FIFO
* distribution across the customer's open laundry orders
* (this order included).
*
* Idempotent: if a settle-due is already active for the same
* partner, settleLaundryDues reuses it instead of creating a
* duplicate.
*/
async collectLaundryOrderPayment(partnerId) {
if (!partnerId) {
this.notification.add(
_t("Cannot collect payment: customer not specified."),
{ type: "warning" }
);
return;
}
const partner = this.models["res.partner"].get(partnerId);
if (!partner) {
this.notification.add(
_t("Customer not loaded in POS data. Refresh POS and retry."),
{ type: "danger" }
);
return;
}
let currentOrder = this.getOrder();
if (!currentOrder) {
currentOrder = this.addNewOrder();
}
const cur = currentOrder?.getPartner();
if (!cur || cur.id !== partner.id) {
currentOrder?.setPartner(partner);
}
await this.settleLaundryDues();
},
async viewLaundryOrders() {
const currentOrder = this.getOrder();
let partner = currentOrder?.getPartner();
if (!partner) {
partner = await this.selectPartner();
if (!partner) {
return;
}
}
// Popup owns its own data lifecycle — fetches on mount + search,
// refetches the affected order after each workflow action.
this.dialog.add(LaundryOrdersViewPopup, {
partnerId: partner.id,
partnerName: partner.name,
// `mobile` is optional in some Odoo distributions — guard.
partnerPhone: partner.phone || partner.mobile || "",
});
},
});

View File

@@ -1,363 +0,0 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { PosStore } from "@point_of_sale/app/services/pos_store";
import { ask, makeAwaitable } from "@point_of_sale/app/utils/make_awaitable_dialog";
import { LaundryQuickCreatePartner } from "@laundry_management/js/quick_create_partner";
import { LaundryOrdersViewPopup } from "@laundry_management/js/view_laundry_orders";
import { LaundryOrderTypePopup } from "@laundry_management/js/popups/laundry_order_type_popup";
import { LaundryOrderAttributePopup } from "@laundry_management/js/popups/laundry_order_attribute_popup";
import { LaundryDeliveryDetailsPopup } from "@laundry_management/js/popups/laundry_delivery_details_popup";
/**
* Check whether a POS order contains at least one laundry-service product.
*/
function hasLaundryProduct(order) {
return order.lines.some(
(line) => line.product_id?.product_tmpl_id?.is_laundry_service
);
}
/** A line is a laundry SERVICE line — excludes settlement product. */
function isLaundryServiceLine(line) {
const tmpl = line.product_id?.product_tmpl_id;
return tmpl?.is_laundry_service && !tmpl?.is_laundry_settlement;
}
patch(PosStore.prototype, {
// -- Laundry notification after sync --
async postSyncAllOrders(orders) {
await super.postSyncAllOrders(orders);
for (const order of orders) {
if (order.laundry_order_id) {
this.notification.add(_t("Laundry Order Created"), {
type: "success",
});
}
}
},
// -- Force customer before payment screen (laundry orders only) --
async pay() {
const currentOrder = this.getOrder();
if (!currentOrder) {
return;
}
if (hasLaundryProduct(currentOrder) && !currentOrder.getPartner()) {
const confirmed = await ask(this.env.services.dialog, {
title: _t("Customer Required"),
body: _t(
"This order contains laundry items. Please select or create a customer."
),
});
if (confirmed) {
const partner = await this.selectPartner();
if (!partner) {
return;
}
} else {
return;
}
}
return super.pay();
},
// -- Quick create partner (name + phone + street, with duplicate check) --
async editPartner(partner) {
if (partner) {
return super.editPartner(partner);
}
// New partner: show lightweight popup
const result = await makeAwaitable(this.dialog, LaundryQuickCreatePartner, {});
if (!result) {
return false;
}
// Check for existing partner by phone
const existing = await this.data.call(
"res.partner",
"laundry_find_by_phone",
[result.phone]
);
if (existing) {
const useExisting = await ask(this.env.services.dialog, {
title: _t("Customer Already Exists"),
body: _t(
'A customer "%s" already exists with this phone number. Use existing customer?',
existing.name
),
});
if (useExisting) {
const partners = await this.data.read("res.partner", [existing.id]);
return partners[0] || false;
}
return false;
}
// Create new partner
const partnerId = await this.data.call(
"res.partner",
"laundry_quick_create",
[{ name: result.name, phone: result.phone, street: result.street || "" }]
);
const partners = await this.data.read("res.partner", [partnerId]);
return partners[0] || false;
},
// -- Settle laundry dues via native POS PaymentScreen --
async settleLaundryDues() {
const currentOrder = this.getOrder();
const partner = currentOrder?.getPartner();
if (!partner) {
this.notification.add(_t("Please select a customer first."), {
type: "warning",
});
const selectedPartner = await this.selectPartner();
if (!selectedPartner) {
return;
}
return this.settleLaundryDues();
}
// Fetch outstanding dues
const dues = await this.data.call(
"res.partner",
"get_laundry_dues",
[partner.id]
);
if (!dues || dues.total_due <= 0) {
this.notification.add(
_t('"%s" has no outstanding laundry dues.', partner.name),
{ type: "info" }
);
return;
}
// Find the settlement product (is_laundry_settlement = true)
const settlementProduct = this.models["product.product"]
.getAll()
.find((p) => p.product_tmpl_id?.is_laundry_settlement);
if (!settlementProduct) {
this.notification.add(
_t("Settlement product not configured. Please check laundry settings."),
{ type: "danger" }
);
return;
}
// Create a dedicated settlement order
const order = this.addNewOrder();
order.setPartner(partner);
// Add settlement product with total_due as price
await this.addLineToCurrentOrder(
{
product_id: settlementProduct,
product_tmpl_id: settlementProduct.product_tmpl_id,
price_unit: dues.total_due,
qty: 1,
},
{},
false
);
// Navigate to native POS PaymentScreen
this.navigate("PaymentScreen", {
orderUuid: order.uuid,
});
},
// ─────────────────────────────────────────────────────────────────
// Laundry order type popup chain
// ─────────────────────────────────────────────────────────────────
async addLineToCurrentOrder(vals, opts, configure) {
const result = await super.addLineToCurrentOrder(vals, opts, configure);
try {
await this.maybeAskLaundryOrderType(this.getOrder());
} catch (e) {
console.error("Laundry order-type prompt failed:", e);
}
return result;
},
/**
* Trigger the type/attributes/delivery popup chain when the FIRST
* laundry-service line is added to an order — and only when:
* - feature is enabled in pos.config
* - order type not already chosen
* - the order has at least one laundry-service line (excludes settlement)
* - ask-on-first-line is enabled
*/
async maybeAskLaundryOrderType(order) {
if (!order || !order.lines.length) {
return;
}
const config = this.config;
if (!config?.enable_laundry_order_type) {
return;
}
if (order.laundry_order_type_id) {
return;
}
if (config.ask_laundry_order_type_on_first_line === false) {
return;
}
const laundryLines = order.lines.filter(isLaundryServiceLine);
if (!laundryLines.length) {
return;
}
// Only trigger when this is the FIRST laundry line.
if (laundryLines.length > 1) {
return;
}
await this.runLaundryOrderTypeFlow(order);
},
/**
* The popup chain itself — also reusable from the inline edit button
* in the order summary.
*/
async runLaundryOrderTypeFlow(order) {
if (!order) {
return;
}
const config = this.config;
// Step 1 — Main type
const types = (this.models["laundry.order.type"]?.getAll() || [])
.slice()
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
if (!types.length) {
this.notification.add(
_t("No laundry order types configured. Ask a manager to add some."),
{ type: "warning" }
);
if (config.require_laundry_order_type) {
return;
}
}
const partner = order.getPartner();
const partnerDefaultType = partner?.default_laundry_order_type_id;
const defaultTypeId =
order.laundry_order_type_id?.id ||
(partnerDefaultType?.id ?? partnerDefaultType) ||
(config.default_laundry_order_type_id?.id ??
config.default_laundry_order_type_id) ||
false;
const chosenType = await makeAwaitable(this.dialog, LaundryOrderTypePopup, {
types: types,
defaultTypeId: defaultTypeId,
showIcons: !!config.show_order_type_icons,
allowSkip: !config.require_laundry_order_type,
title: _t("Select Order Type"),
});
if (chosenType === undefined) {
return; // user closed without confirming
}
order.laundry_order_type_id = chosenType
? this.models["laundry.order.type"].get(chosenType.id)
: false;
// Step 2 — Attributes (skip if disabled or nothing to show)
let chosenAttributes = [];
if (config.enable_laundry_attributes) {
const allAttrs = (this.models["laundry.order.attribute"]?.getAll() || [])
.slice()
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
if (allAttrs.length) {
const suggestedIds = chosenType?.attribute_ids
? chosenType.attribute_ids
.map((a) => (typeof a === "object" ? a.id : a))
: [];
const partnerDefaultIds = (partner?.default_laundry_attribute_ids || [])
.map((a) => (typeof a === "object" ? a.id : a));
const preselected = [
...new Set([...suggestedIds, ...partnerDefaultIds]),
];
const result = await makeAwaitable(
this.dialog,
LaundryOrderAttributePopup,
{
attributes: allAttrs,
preselectedIds: preselected,
title: _t("Select Attributes (optional)"),
}
);
if (result !== undefined) {
chosenAttributes = result;
}
}
}
order.laundry_order_attribute_ids = chosenAttributes.map((a) =>
this.models["laundry.order.attribute"].get(a.id)
);
// Step 3 — Delivery details (only when needed)
const typeIsDelivery = !!chosenType?.is_delivery;
const attrIsDelivery = chosenAttributes.some((a) => a.is_delivery_related);
const isDelivery = typeIsDelivery || attrIsDelivery;
order.laundry_is_delivery = isDelivery;
if (isDelivery && config.require_delivery_details_if_needed) {
const requireAddress =
!!chosenType?.requires_address ||
attrIsDelivery; // delivery attribute implies need for address
const requireTime = !!chosenType?.requires_scheduled_time;
const result = await makeAwaitable(
this.dialog,
LaundryDeliveryDetailsPopup,
{
defaultAddress: order.laundry_delivery_address || "",
defaultScheduledAt: order.laundry_delivery_scheduled_at || "",
requireAddress: requireAddress,
requireScheduledTime: requireTime,
}
);
if (result) {
order.laundry_delivery_address = result.address || false;
order.laundry_delivery_scheduled_at = result.scheduledAt || false;
}
} else if (!isDelivery) {
order.laundry_delivery_address = false;
order.laundry_delivery_scheduled_at = false;
}
},
/** Inline edit handler from the order-summary header. */
async editLaundryOrderType() {
const order = this.getOrder();
if (!order) {
return;
}
if (!this.config?.allow_change_laundry_order_type_before_payment) {
this.notification.add(_t("Changing order type is disabled."), {
type: "warning",
});
return;
}
await this.runLaundryOrderTypeFlow(order);
},
// -- View laundry orders for current customer --
async viewLaundryOrders() {
const currentOrder = this.getOrder();
let partner = currentOrder?.getPartner();
if (!partner) {
partner = await this.selectPartner();
if (!partner) {
return;
}
}
const orders = await this.data.call(
"res.partner",
"get_laundry_orders_for_pos",
[partner.id, 30]
);
this.dialog.add(LaundryOrdersViewPopup, {
partnerName: partner.name,
orders: orders || [],
});
},
});

View File

@@ -1,72 +0,0 @@
/** @odoo-module
*
* LaundryQuickCreatePartner — POS-only quick create.
*
* UX rules:
* - Phone is REQUIRED and the FIRST input (autofocused on mount).
* - Name is OPTIONAL. If empty on confirm, the backend
* `res.partner.laundry_quick_create` falls back to using the phone
* value as the partner name (single source of truth — JS does not
* duplicate that fallback).
* - Street is fully optional.
*
* All user-facing strings come from `_t()` getters so the active
* language switches the popup text cleanly. No mixed-language labels.
*/
import { Component, useState, useRef, onMounted } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { _t } from "@web/core/l10n/translation";
export class LaundryQuickCreatePartner extends Component {
static components = { Dialog };
static template = "laundry_management.QuickCreatePartner";
static props = {
getPayload: { type: Function },
close: { type: Function },
};
setup() {
this.state = useState({
phone: "",
name: "",
street: "",
phoneError: false,
});
this.phoneRef = useRef("phoneInput");
onMounted(() => {
if (this.phoneRef.el) this.phoneRef.el.focus();
});
}
// ── Translatable labels (computed each render so language change
// propagates without a remount) ─────────────────────────────────
get title() { return _t("Quick Create Customer"); }
get labelPhone() { return _t("Phone / Mobile"); }
get labelName() { return _t("Name"); }
get labelStreet() { return _t("Street"); }
get labelCreate() { return _t("Create"); }
get labelCancel() { return _t("Cancel"); }
get nameOptionalHint() { return _t("(optional — phone is used if empty)"); }
get placeholderPhone() { return _t("e.g. +966 50 123 4567"); }
get placeholderName() { return _t("Customer name"); }
get placeholderStreet() { return _t("Building, street, district…"); }
get errorPhoneRequired() { return _t("Phone is required."); }
confirm() {
const phone = (this.state.phone || "").trim();
if (!phone) {
this.state.phoneError = true;
return;
}
this.props.getPayload({
phone,
name: (this.state.name || "").trim(), // empty → backend falls back to phone
street: (this.state.street || "").trim(),
});
this.props.close();
}
cancel() {
this.props.close();
}
}

View File

@@ -1,3 +0,0 @@
/** @odoo-module */
// Settlement popup replaced by native POS PaymentScreen flow.
// See payment_screen_patch.js for the settlement validation logic.

View File

@@ -1,94 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
// Sort priority: cash first, then bank, then others
const JOURNAL_ORDER = { cash: 0, bank: 1 };
export class LaundrySettlementReceipt extends Component {
static components = { Dialog };
static template = "laundry_management.SettlementReceipt";
static props = {
partnerName: { type: String },
settledTotal: { type: Number },
remainingDue: { type: Number },
payments: { type: Array },
settledOrders: { type: Array },
close: { type: Function },
};
get dateTime() {
return new Date().toLocaleString();
}
fmt(value) {
return parseFloat(value || 0).toFixed(2);
}
/** Group payments by method name, merge duplicates, sort cash → bank → other. */
get groupedPayments() {
const map = {};
for (const p of this.props.payments) {
// Use method_name (from settlement_pos_pm_id) when available,
// fall back to journal_name for legacy payments without it.
const key = p.method_name || p.journal_name;
const jtype = p.journal_type || "other";
if (!map[key]) {
map[key] = { name: key, amount: 0, _type: jtype };
}
map[key].amount += p.amount;
}
return Object.values(map).sort(
(a, b) => (JOURNAL_ORDER[a._type] ?? 2) - (JOURNAL_ORDER[b._type] ?? 2)
);
}
/** Group settled orders by name, merge duplicates. */
get groupedOrders() {
const map = {};
for (const o of this.props.settledOrders) {
const key = o.name;
if (!map[key]) {
map[key] = { name: key, applied: 0, remaining_on_order: 0 };
}
map[key].applied += o.applied;
map[key].remaining_on_order = o.remaining_on_order;
}
return Object.values(map);
}
printReceipt() {
const receiptEl = document.querySelector(".settlement-receipt-content");
if (!receiptEl) return;
const printWindow = window.open("", "_blank", "width=300,height=600");
if (!printWindow) return;
printWindow.document.write(`
<html><head><title>Settlement Receipt</title>
<style>
body { font-family: monospace; font-size: 12px; width: 280px;
margin: 0 auto; padding: 10px; }
.text-center { text-align: center; }
.fw-bold { font-weight: bold; }
.fs-5 { font-size: 16px; }
.mt-2 { margin-top: 8px; }
.mb-1 { margin-bottom: 4px; }
.mb-2 { margin-bottom: 8px; }
.border-top { border-top: 1px dashed #000; padding-top: 6px; }
.border-bottom { border-bottom: 1px dashed #000; padding-bottom: 6px; }
.d-flex { display: flex; }
.justify-content-between { justify-content: space-between; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: 2px 0; }
.text-end { text-align: right; }
.text-muted { color: #666; }
.small { font-size: 11px; }
</style></head><body>
${receiptEl.innerHTML}
</body></html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
}
}

View File

@@ -1,26 +0,0 @@
/** @odoo-module
*
* TicketScreen patch — gate double-click-to-resume-order through the
* settle-due exit confirmation so cashiers can't silently abandon a
* live settle-due order from the ticket list.
*
* Single-click (onClickOrder) only updates the ticket-list selection
* and does NOT change the active POS order, so it's safe to leave
* untouched. Double-click calls pos.setOrder — our patched setOrder
* also enforces the gate, but doing it here too avoids the flicker
* of partially-switching before the async confirm resolves.
*/
import { patch } from "@web/core/utils/patch";
import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_screen";
patch(TicketScreen.prototype, {
async onDblClickOrder(order) {
if (!order?.finalized) {
const allowed = await this.pos.confirmExitSettleIfNeeded(order);
if (!allowed) {
return;
}
}
return super.onDblClickOrder(order);
},
});

View File

@@ -1,273 +0,0 @@
/** @odoo-module
*
* LaundryOrdersViewPopup — in-POS customer laundry orders manager.
*
* Owns its own data lifecycle: fetches on mount, refetches on search,
* refetches the single affected order after each workflow action.
*
* All writes go through the whitelisted `pos_action_*` RPCs on
* `laundry.order` — never touches line/price/customer fields, so
* Phase 3 locking remains authoritative and the popup cannot open a
* side channel around it.
*/
import { Component, useState, onWillStart, useRef, onMounted } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { _t } from "@web/core/l10n/translation";
import { usePos } from "@point_of_sale/app/hooks/pos_hook";
import { useService } from "@web/core/utils/hooks";
import { ask } from "@point_of_sale/app/utils/make_awaitable_dialog";
// Thermal receipt is intentionally NOT imported — the component is
// excluded from the asset bundle until individually re-validated.
// Print falls back to the standard PDF report (always available).
const WORKFLOW_BADGE = {
intake: { label: "Intake", klass: "badge-state-intake" },
processing: { label: "Processing", klass: "badge-state-processing"},
ready: { label: "Ready", klass: "badge-state-ready" },
delivered: { label: "Delivered", klass: "badge-state-delivered" },
cancelled: { label: "Cancelled", klass: "badge-state-cancelled" },
};
const PAYMENT_BADGE = {
paid: { label: "Paid", klass: "badge-payment-paid" },
deferred: { label: "Deferred", klass: "badge-payment-deferred" },
settled: { label: "Settled", klass: "badge-payment-settled" },
due: { label: "Due", klass: "badge-payment-due" },
};
export class LaundryOrdersViewPopup extends Component {
static components = { Dialog };
static template = "laundry_management.LaundryOrdersViewPopup";
static props = {
partnerId: { type: Number },
partnerName: { type: String },
partnerPhone: { type: String, optional: true },
close: { type: Function },
};
static defaultProps = {
partnerPhone: "",
};
setup() {
this.pos = usePos();
this.notification = useService("notification");
this.dialog = useService("dialog");
this.orm = this.pos.data; // shared ORM adapter
this.searchRef = useRef("searchInput");
this.state = useState({
orders: [],
loading: true,
error: null,
searchQuery: "",
busyOrderIds: {}, // { [id]: 'action_key' } while RPC in flight
});
onWillStart(async () => {
await this._fetch();
});
onMounted(() => {
if (this.searchRef.el) this.searchRef.el.focus();
});
}
// ─── Data ─────────────────────────────────────────────────────────
async _fetch() {
this.state.loading = true;
this.state.error = null;
try {
const rows = await this.orm.call(
"laundry.order",
"pos_search_customer_orders",
[],
{
partner_id: this.props.partnerId,
search_query: this.state.searchQuery || false,
limit: 20,
}
);
this.state.orders = rows || [];
} catch (err) {
this.state.error = this._humanizeError(err);
} finally {
this.state.loading = false;
}
}
_replaceOrder(updated) {
if (!updated?.id) return;
const idx = this.state.orders.findIndex((o) => o.id === updated.id);
if (idx >= 0) {
this.state.orders.splice(idx, 1, updated);
}
}
_humanizeError(err) {
return (
err?.data?.message ||
err?.message ||
_t("Could not contact the server. Please retry.")
);
}
// ─── Search ───────────────────────────────────────────────────────
onSearchInput(ev) {
this.state.searchQuery = ev.target.value || "";
}
async onSearchSubmit(ev) {
ev?.preventDefault?.();
await this._fetch();
}
async onClearSearch() {
this.state.searchQuery = "";
await this._fetch();
}
// ─── Workflow actions ─────────────────────────────────────────────
async _runAction(order, methodName, actionKey) {
if (this.state.busyOrderIds[order.id]) return;
this.state.busyOrderIds[order.id] = actionKey;
try {
const updated = await this.orm.call(
"laundry.order",
methodName,
[[order.id]]
);
this._replaceOrder(updated);
this.notification.add(
_t('Order %s updated.', updated?.name || order.name),
{ type: "success" }
);
} catch (err) {
this.notification.add(this._humanizeError(err), { type: "danger" });
} finally {
delete this.state.busyOrderIds[order.id];
}
}
onClickStartProcessing(order) {
return this._runAction(order, "pos_action_start_processing", "start_processing");
}
onClickMarkReady(order) {
return this._runAction(order, "pos_action_mark_ready", "mark_ready");
}
async onClickDeliver(order) {
const confirmed = await ask(this.dialog, {
title: _t("Deliver Order?"),
body: _t(
"Confirm delivery of %(name)s (%(items)s items, %(total)s).",
{
name: order.name,
items: order.item_count,
total: this._fmt(order.amount_total),
}
),
confirmLabel: _t("Deliver"),
cancelLabel: _t("Cancel"),
});
if (!confirmed) return;
return this._runAction(order, "pos_action_deliver", "deliver");
}
/**
* Hand off to the proven settle-due flow on the store. We close the
* popup BEFORE the navigation kicks off — keeping the modal mounted
* while routes change is the known cause of the white-screen race
* the user reported. Toast first so the cashier always sees feedback,
* even if the screen change is instantaneous.
*/
async onClickCollectPayment(order) {
this.notification.add(
_t("Redirecting to payment for %(name)s — %(amount)s due.", {
name: order.name,
amount: this._fmt(order.amount_due),
}),
{ type: "info" }
);
this.props.close();
try {
await this.pos.collectLaundryOrderPayment(this.props.partnerId);
} catch (err) {
// Re-surface server errors as a notification so the cashier
// never gets stuck with no feedback.
this.pos.notification.add(this._humanizeError(err), { type: "danger" });
}
}
/**
* Standard PDF Work Order via the existing report action.
* The thermal-printer path is currently disabled (component
* excluded from the bundle until re-validated). PDF is the
* proven fallback — works without hardware-printer configuration.
*/
async onClickPrintWorkOrder(order) {
try {
await this.pos.env.services.action.doAction(
"laundry_management.action_report_laundry_work_order",
{ additionalContext: { active_ids: [order.id] } }
);
} catch (err) {
this.notification.add(this._humanizeError(err), { type: "danger" });
}
}
// ─── UI helpers ──────────────────────────────────────────────────
stateBadge(order) {
return WORKFLOW_BADGE[order.state] || WORKFLOW_BADGE.intake;
}
paymentBadge(order) {
return PAYMENT_BADGE[order.payment_state] || PAYMENT_BADGE.paid;
}
formatDate(order) {
if (!order.create_date) return "";
const d = new Date(order.create_date.replace(" ", "T") + "Z");
if (isNaN(d.getTime())) return order.create_date;
return d.toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
_fmt(amount) {
try {
return this.pos.env.utils.formatCurrency(amount || 0);
} catch {
const sym = this.pos.currency?.symbol || "";
return `${(amount || 0).toFixed(2)} ${sym}`.trim();
}
}
fmt(amount) {
return this._fmt(amount);
}
isAllowed(order, key) {
return (order.allowed_actions || []).includes(key);
}
isBusy(order, key) {
return this.state.busyOrderIds[order.id] === key;
}
isOrderBusy(order) {
return !!this.state.busyOrderIds[order.id];
}
get hasResults() {
return !this.state.loading && !this.state.error && this.state.orders.length > 0;
}
get isEmpty() {
return !this.state.loading && !this.state.error && this.state.orders.length === 0;
}
close() {
this.props.close();
}
}

View File

@@ -1,994 +0,0 @@
// ============================================================================
// Laundry Management — POS / Backend Design System
// 8px grid, soft shadows, premium pill components.
// ============================================================================
// ── Tokens ─────────────────────────────────────────────────────────────────
$lm-space-1: 4px;
$lm-space-2: 8px;
$lm-space-3: 12px;
$lm-space-4: 16px;
$lm-space-5: 24px;
$lm-radius-sm: 6px;
$lm-radius-md: 10px;
$lm-radius-lg: 14px;
$lm-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 1px rgba(15, 23, 42, 0.04);
$lm-shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08), 0 2px 4px rgba(15, 23, 42, 0.05);
$lm-shadow-lg: 0 10px 30px rgba(15, 23, 42, 0.12), 0 4px 8px rgba(15, 23, 42, 0.06);
$lm-color-urgent: #EF4444;
$lm-color-delivery: #10B981;
$lm-color-vip: #8B5CF6;
$lm-color-normal: #6B7280;
$lm-color-type: #4F46E5;
$lm-font-xs: 11px;
$lm-font-sm: 13px;
$lm-font-md: 14px;
$lm-font-lg: 16px;
$lm-font-xl: 18px;
$lm-bg-card: #FFFFFF;
$lm-bg-soft: #F8FAFC;
$lm-border: #E2E8F0;
$lm-text: #0F172A;
$lm-text-muted: #64748B;
// ── Pill component (shared across panel / kanban / receipt) ───────────────
.laundry-pill {
display: inline-flex;
align-items: center;
padding: $lm-space-1 $lm-space-3;
border-radius: 9999px;
font-size: $lm-font-sm;
font-weight: 600;
line-height: 1.2;
color: #fff;
background-color: $lm-color-normal;
box-shadow: $lm-shadow-sm;
white-space: nowrap;
transition: transform 0.12s ease, box-shadow 0.12s ease;
&:hover { transform: translateY(-1px); box-shadow: $lm-shadow-md; }
.fa { font-size: 0.95em; }
&--type { background-color: $lm-color-type; }
&--attr { background-color: $lm-color-normal; }
&--urgent { background-color: $lm-color-urgent; }
&--delivery { background-color: $lm-color-delivery; }
&--vip { background-color: $lm-color-vip; }
&--deferred { background-color: #F59E0B; }
// Semantic data overrides — win over default --attr
&[data-priority="urgent"] { background-color: $lm-color-urgent; }
&[data-delivery="1"] { background-color: $lm-color-delivery; }
}
// ── Settle Due Banner (POS Mode indicator) ────────────────────────────────
.laundry-settle-banner {
margin: $lm-space-2 $lm-space-3 0 $lm-space-3;
padding: $lm-space-3 $lm-space-4;
background: linear-gradient(135deg, #F59E0B 0%, #F97316 100%);
color: #fff;
border-radius: $lm-radius-md;
box-shadow: $lm-shadow-md;
display: flex;
align-items: center;
justify-content: space-between;
gap: $lm-space-3;
animation: laundry-settle-banner-pulse 2.4s ease-in-out infinite;
&__lead {
display: flex;
align-items: center;
gap: $lm-space-3;
min-width: 0;
}
&__icon {
font-size: $lm-font-xl;
flex-shrink: 0;
}
&__text {
display: flex;
flex-direction: column;
min-width: 0;
}
&__title {
font-size: $lm-font-md;
font-weight: 800;
letter-spacing: 0.06em;
line-height: 1.15;
text-transform: uppercase;
}
&__meta {
display: flex;
align-items: baseline;
gap: $lm-space-3;
margin-top: 3px;
min-width: 0;
}
&__partner {
font-size: $lm-font-sm;
font-weight: 600;
opacity: 0.95;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40ch;
}
&__amount {
font-size: $lm-font-md;
font-weight: 800;
letter-spacing: 0.01em;
padding: 1px 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: $lm-radius-sm;
white-space: nowrap;
}
&__hint {
font-size: $lm-font-xs;
opacity: 0.85;
margin-top: 2px;
font-style: italic;
}
&__exit {
background: rgba(255, 255, 255, 0.18);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.4);
padding: $lm-space-2 $lm-space-3;
border-radius: $lm-radius-sm;
font-size: $lm-font-sm;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s ease, transform 0.12s ease;
&:hover { background: rgba(255, 255, 255, 0.3); transform: translateY(-1px); }
&:active { transform: translateY(0); }
&:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
}
}
@keyframes laundry-settle-banner-pulse {
0%, 100% { box-shadow: 0 4px 12px rgba(245, 158, 11, 0.35); }
50% { box-shadow: 0 4px 20px rgba(245, 158, 11, 0.65); }
}
// ── Global lock of POS chrome while settle-due mode is active ─────────────
// The banner component toggles `pos-laundry-settle-active` on <body>.
// CSS is a clarity layer, not the safety layer — JS (pos_store_patch /
// order_tabs_patch / navbar_patch) is the real gate. But here we also
// disable pointer-events on the peripherals because SINGLE-ORDER MODE
// guarantees the only tab is the settle order itself; everything else
// is visual chrome that shouldn't respond to clicks.
body.pos-laundry-settle-active {
// Every non-active tab (should be zero under single-order mode —
// but defense in depth): hatched, dim, unclickable.
.floating-order-container .btn:not(.active) {
pointer-events: none;
opacity: 0.35;
filter: saturate(0.3);
position: relative;
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: repeating-linear-gradient(
135deg,
rgba(245, 158, 11, 0.0) 0 6px,
rgba(245, 158, 11, 0.15) 6px 12px
);
pointer-events: none;
}
}
// Active settle tab — keep visible & highlighted in warning orange.
.floating-order-container .btn.active {
outline: 2px solid #F59E0B;
outline-offset: 2px;
pointer-events: none; // the tab IS the current order; no switch needed
}
// "+" new-order button inside ListContainer — hard-disabled visually.
// JS addNewOrder patch also blocks it.
.list-container-add,
.o_list_container_add,
button[title*="Add a new order" i],
button[aria-label*="Add a new order" i] {
pointer-events: none;
opacity: 0.35;
filter: saturate(0.3);
}
}
// ── Order Context Panel (POS right-side card) ─────────────────────────────
.laundry-context-panel {
margin: $lm-space-2 $lm-space-3;
padding: $lm-space-3 $lm-space-4;
background: $lm-bg-card;
border: 1px solid $lm-border;
border-radius: $lm-radius-md;
box-shadow: $lm-shadow-sm;
display: flex;
flex-direction: column;
gap: $lm-space-2;
transition: box-shadow 0.18s ease, transform 0.18s ease;
&:hover { box-shadow: $lm-shadow-md; }
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__title {
font-size: $lm-font-xs;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $lm-text-muted;
}
&__edit {
background: transparent;
border: 0;
color: $lm-text-muted;
padding: $lm-space-1 $lm-space-2;
border-radius: $lm-radius-sm;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
&:hover { background: $lm-bg-soft; color: $lm-text; }
&:focus-visible {
outline: 2px solid rgba(99, 102, 241, 0.45);
outline-offset: 2px;
}
}
&__row {
display: flex;
flex-wrap: wrap;
gap: $lm-space-2;
}
&__attrs .laundry-pill { font-size: $lm-font-xs; padding: 2px $lm-space-2; }
&__empty {
font-size: $lm-font-sm;
color: $lm-text-muted;
background: $lm-bg-soft;
padding: $lm-space-2 $lm-space-3;
border-radius: $lm-radius-sm;
text-align: center;
}
&__cta {
display: flex;
align-items: center;
justify-content: center;
gap: $lm-space-2;
width: 100%;
padding: $lm-space-3 $lm-space-4;
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%);
color: #fff;
font-size: $lm-font-md;
font-weight: 600;
border: 0;
border-radius: $lm-radius-md;
box-shadow: $lm-shadow-sm;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
.fa { font-size: 1.05em; }
&:hover {
transform: translateY(-1px);
box-shadow: $lm-shadow-md;
filter: brightness(1.05);
}
&:active { transform: translateY(0); filter: brightness(0.95); }
&:focus-visible {
outline: 2px solid rgba(99, 102, 241, 0.55);
outline-offset: 2px;
}
}
&__edit-label {
margin-inline-start: $lm-space-1;
font-size: $lm-font-xs;
font-weight: 600;
}
&__delivery {
display: flex;
flex-direction: column;
gap: $lm-space-1;
padding-top: $lm-space-2;
border-top: 1px dashed $lm-border;
}
&__delivery-row {
display: flex;
align-items: center;
gap: $lm-space-2;
font-size: $lm-font-sm;
color: $lm-text;
.fa { color: $lm-color-delivery; width: 14px; text-align: center; }
}
&[data-empty="1"] { background: $lm-bg-soft; }
&[data-delivery="1"] { border-left: 3px solid $lm-color-delivery; }
}
// ── Popup polish (shared by all 3 laundry popups) ─────────────────────────
.modal .modal-dialog {
.btn.btn-outline-primary,
.btn.btn-primary {
border-radius: $lm-radius-md;
transition: transform 0.12s ease, box-shadow 0.12s ease,
background-color 0.12s ease, border-color 0.12s ease;
}
.btn.btn-primary {
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
}
.btn.btn-outline-primary:hover {
transform: translateY(-1px);
box-shadow: $lm-shadow-md;
}
.modal-footer .btn-primary,
.modal-footer .btn-secondary {
position: sticky;
bottom: 0;
}
}
// ── Receipt details (printer-friendly) ────────────────────────────────────
.laundry-receipt-details {
margin-top: $lm-space-2;
text-align: center;
&__sep {
border-top: 1px dashed #000;
margin: $lm-space-2 0;
}
&__title {
font-weight: 700;
font-size: $lm-font-md;
margin-bottom: $lm-space-1;
letter-spacing: 0.04em;
}
&__row {
display: flex;
justify-content: space-between;
gap: $lm-space-3;
font-size: $lm-font-sm;
margin: 2px 0;
}
&__label {
font-weight: 600;
color: #000;
min-width: 80px;
text-align: start;
}
&__value {
text-align: end;
flex: 1;
}
&__chip {
display: inline-block;
padding: 0 $lm-space-2;
margin: 0 2px;
border: 1px solid #000;
border-radius: $lm-radius-sm;
font-size: $lm-font-xs;
}
}
// RTL safety
[dir="rtl"] .laundry-receipt-details__label { text-align: end; }
[dir="rtl"] .laundry-receipt-details__value { text-align: start; }
// ── Operational Control Board (laundry.order kanban) ──────────────────────
.laundry-board {
.laundry-board__card {
position: relative;
background: $lm-bg-card;
border: 1px solid $lm-border;
border-radius: $lm-radius-md;
box-shadow: $lm-shadow-sm;
padding: $lm-space-3 $lm-space-4 $lm-space-3 ($lm-space-4 + 4px);
display: flex;
flex-direction: column;
gap: $lm-space-2;
transition: box-shadow 0.18s ease, transform 0.18s ease;
&:hover { box-shadow: $lm-shadow-md; transform: translateY(-1px); }
}
.laundry-board__strip {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
border-top-left-radius: $lm-radius-md;
border-bottom-left-radius: $lm-radius-md;
background: $lm-color-normal;
}
.laundry-board__card[data-state="intake"] .laundry-board__strip { background: #3B82F6; }
.laundry-board__card[data-state="processing"] .laundry-board__strip { background: #F59E0B; }
.laundry-board__card[data-state="ready"] .laundry-board__strip { background: $lm-color-delivery; }
.laundry-board__card[data-state="delivered"] .laundry-board__strip { background: $lm-color-normal; }
.laundry-board__card[data-priority="urgent"] .laundry-board__strip { background: $lm-color-urgent; }
.laundry-board__card[data-priority="urgent"] { border-color: rgba(239, 68, 68, 0.35); }
.laundry-board__head {
display: flex;
flex-direction: column;
gap: 2px;
}
.laundry-board__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $lm-space-2;
}
.laundry-board__name {
font-size: $lm-font-md;
color: $lm-text;
}
.laundry-board__total {
font-size: $lm-font-md;
font-weight: 700;
color: $lm-text;
}
.laundry-board__customer {
font-size: $lm-font-sm;
color: $lm-text-muted;
display: flex;
align-items: center;
}
.laundry-board__badges {
display: flex;
flex-wrap: wrap;
gap: $lm-space-1;
}
.laundry-board__meta {
display: flex;
flex-wrap: wrap;
gap: $lm-space-3;
font-size: $lm-font-xs;
color: $lm-text-muted;
}
.laundry-board__due {
color: $lm-color-urgent;
font-weight: 700;
}
.laundry-board__actions { margin-top: $lm-space-1; }
}
// ── ProductConfiguratorPopup — laundry enhancement ─────────────────────
// Scoped exclusively under `.popup-product-configurator.laundry-enhanced`,
// applied to the modal-content via Dialog's `contentClass` prop from
// xml/product_configurator_popup.xml. Non-laundry configurator popups are
// unaffected.
.popup-product-configurator.laundry-enhanced {
// Attribute group — stronger title, better spacing.
.modal-body .attribute {
margin-bottom: $lm-space-4 !important;
padding-bottom: $lm-space-3;
border-bottom: 1px dashed $lm-border;
&:last-child {
margin-bottom: 0 !important;
padding-bottom: 0;
border-bottom: 0;
}
.attribute_name {
font-size: $lm-font-sm;
font-weight: 800 !important;
text-transform: uppercase;
letter-spacing: 0.09em;
color: $lm-text-muted;
margin-bottom: $lm-space-3 !important;
}
}
// Touch-first tiles — applies to both Radio and Pills renderers,
// which share the same `.configurator_radio > .attribute-name-cell`
// structure in core.
.configurator_radio {
> .d-flex {
flex-wrap: wrap !important;
gap: $lm-space-3 !important;
}
.attribute-name-cell {
flex: 1 1 calc(33% - #{$lm-space-3});
min-width: 140px;
margin: 0;
padding: 0;
// Hide the native radio — the label IS the tile.
.form-check-input,
.radio-check {
position: absolute !important;
opacity: 0 !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
}
> label {
display: flex !important;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: $lm-space-2;
width: 100%;
min-height: 64px;
padding: 12px 16px !important;
border: 2px solid $lm-border !important;
border-radius: 12px !important;
background: #fff !important;
color: $lm-text !important;
font-size: $lm-font-md;
font-weight: 700;
line-height: 1.25;
cursor: pointer;
position: relative;
box-shadow: none !important;
transition: transform 0.14s ease,
border-color 0.14s ease,
box-shadow 0.16s ease,
background 0.14s ease,
color 0.14s ease;
&:hover {
transform: translateY(-1px);
box-shadow: $lm-shadow-sm !important;
}
&:active { transform: translateY(0); }
&:focus-visible {
outline: 2px solid rgba(79, 70, 229, 0.45);
outline-offset: 2px;
}
// Wrap text span so conflict styling still readable.
> span:first-child { word-break: break-word; }
}
}
// Selected state:
// - Pills renderer: core toggles `.active` on the <label>.
// - Radio renderer: input is :checked and precedes label.
.attribute-name-cell > input:checked + label,
.attribute-name-cell > label.active {
border-color: $lm-color-type !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14),
$lm-shadow-sm !important;
background: linear-gradient(135deg,
rgba(79, 70, 229, 0.08) 0%,
rgba(79, 70, 229, 0.00) 100%) !important;
color: $lm-color-type !important;
> span:first-child { color: $lm-color-type !important; }
&::after {
content: "\f00c"; // FontAwesome check
font-family: "FontAwesome", "Font Awesome 6 Free", sans-serif;
font-weight: 900;
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: $lm-color-type;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
box-shadow: $lm-shadow-sm;
}
}
// Price extra chip — stronger, more scannable.
.price-extra-cell {
margin: 0 !important;
.price_extra {
font-size: $lm-font-xs !important;
font-weight: 800 !important;
padding: 2px 10px !important;
border-radius: 9999px !important;
background: rgba(245, 158, 11, 0.18) !important;
color: #B45309 !important;
letter-spacing: 0.02em;
}
}
// Conflict state (core adds `text-muted` on the inner span).
.attribute-name-cell > label > span.text-muted {
opacity: 0.6;
text-decoration: line-through;
}
// Custom-value text input — keep larger, consistent.
.custom-value-cell {
flex: 1 1 100%;
.form-control { height: 48px; font-size: $lm-font-md; }
}
}
// CTA footer — bigger, bolder, full width on tablet.
.modal-footer {
padding: $lm-space-3 $lm-space-4;
> .d-flex {
gap: $lm-space-3 !important;
}
.btn.btn-primary,
.btn.btn-secondary {
min-height: 56px;
font-size: $lm-font-lg;
font-weight: 800;
letter-spacing: 0.02em;
border-radius: $lm-radius-md;
}
.btn.btn-primary {
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.22);
&:hover:not(.disabled) {
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.32);
}
&.disabled {
opacity: 0.55;
box-shadow: none;
}
}
}
// Full-width primary button below tablet breakpoint.
@media (max-width: 768px) {
.modal-footer > .d-flex {
flex-direction: column-reverse;
.btn { width: 100% !important; }
}
}
// Archived-combination banner — keep legible but calmer.
.alert.alert-warning {
border-radius: $lm-radius-md;
border: 0;
background: rgba(245, 158, 11, 0.12);
color: #92400E;
font-weight: 600;
}
}
// ── Laundry Orders POS popup ──────────────────────────────────────────
// Scoped to `.laundry-orders-popup` (applied to .modal-content via the
// Dialog `contentClass` prop). Touches only modal contents — no leak
// to other POS popups.
.laundry-orders-popup {
.laundry-orders-popup__body {
padding: $lm-space-3;
display: flex;
flex-direction: column;
gap: $lm-space-3;
max-height: 70vh;
overflow-y: auto;
}
.laundry-orders-popup__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $lm-space-3;
padding-bottom: $lm-space-2;
border-bottom: 1px solid $lm-border;
}
.laundry-orders-popup__partner {
font-size: $lm-font-lg;
font-weight: 700;
color: $lm-text;
.fa { color: $lm-color-type; }
}
.laundry-orders-popup__phone {
font-size: $lm-font-sm;
font-weight: 500;
color: $lm-text-muted;
.fa { color: $lm-text-muted; font-size: 0.85em; }
}
.laundry-orders-popup__count { font-size: $lm-font-sm; }
.laundry-orders-popup__search {
margin-bottom: 0;
.input-group .btn { font-weight: 600; }
}
.laundry-orders-popup__error {
margin-bottom: 0;
border-radius: $lm-radius-md;
}
// Loading skeleton
.laundry-orders-popup__loading {
display: flex;
flex-direction: column;
gap: $lm-space-2;
}
.laundry-orders-popup__skeleton {
height: 96px;
border-radius: $lm-radius-md;
background: linear-gradient(90deg,
$lm-bg-soft 0%, #ECF0F4 50%, $lm-bg-soft 100%);
background-size: 200% 100%;
animation: laundry-skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes laundry-skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// Empty state
.laundry-orders-popup__empty {
background: $lm-bg-soft;
border: 1px dashed $lm-border;
border-radius: $lm-radius-md;
.fa { color: $lm-text-muted; opacity: 0.5; }
}
// Card list
.laundry-orders-popup__list {
display: flex;
flex-direction: column;
gap: $lm-space-3;
}
.laundry-orders-popup__card {
background: $lm-bg-card;
border: 1px solid $lm-border;
border-left: 4px solid $lm-color-normal;
border-radius: $lm-radius-md;
padding: $lm-space-3 $lm-space-4;
display: flex;
flex-direction: column;
gap: $lm-space-2;
box-shadow: $lm-shadow-sm;
transition: box-shadow 0.16s ease;
&:hover { box-shadow: $lm-shadow-md; }
&[data-state="intake"] { border-left-color: #3B82F6; }
&[data-state="processing"] { border-left-color: #F59E0B; }
&[data-state="ready"] { border-left-color: $lm-color-delivery; }
&[data-state="delivered"] { border-left-color: $lm-color-normal; }
&[data-state="cancelled"] {
border-left-color: $lm-text-muted;
opacity: 0.75;
}
&[data-payment="due"] { border-left-color: $lm-color-urgent; }
&[data-busy="1"] { opacity: 0.7; pointer-events: none; }
}
.laundry-orders-popup__card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: $lm-space-3;
}
.laundry-orders-popup__order-name {
font-size: $lm-font-md;
font-weight: 800;
color: $lm-text;
letter-spacing: 0.01em;
}
.laundry-orders-popup__pos-ref {
font-size: $lm-font-xs;
color: $lm-text-muted;
font-weight: 500;
}
.laundry-orders-popup__date {
font-size: $lm-font-xs;
color: $lm-text-muted;
white-space: nowrap;
}
.laundry-orders-popup__card-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: $lm-space-3;
font-size: $lm-font-sm;
color: $lm-text;
.fa { color: $lm-text-muted; }
}
.laundry-orders-popup__services {
font-size: $lm-font-xs;
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: bottom;
}
.laundry-orders-popup__total { font-weight: 700; font-size: $lm-font-md; }
.laundry-orders-popup__due { color: $lm-color-urgent; font-weight: 700; }
// Badges
.laundry-orders-popup__badges {
display: flex;
flex-wrap: wrap;
gap: $lm-space-2;
}
.laundry-badge {
display: inline-flex;
align-items: center;
padding: 3px $lm-space-2;
font-size: $lm-font-xs;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: 9999px;
white-space: nowrap;
line-height: 1.3;
.fa { font-size: 0.85em; }
}
.badge-state-intake { background: rgba(59,130,246,.12); color: #1E40AF; }
.badge-state-processing { background: rgba(245,158,11,.14); color: #92400E; }
.badge-state-ready { background: rgba(16,185,129,.14); color: #065F46; }
.badge-state-delivered { background: rgba(107,114,128,.16); color: #1F2937; }
.badge-state-cancelled { background: rgba(107,114,128,.10); color: $lm-text-muted; }
.badge-payment-paid { background: rgba(16,185,129,.16); color: #065F46; }
.badge-payment-deferred { background: rgba(245,158,11,.16); color: #92400E; }
.badge-payment-settled { background: rgba(99,102,241,.12); color: $lm-color-type; }
.badge-payment-due { background: rgba(239,68,68,.16); color: #991B1B; }
.laundry-badge--delivery {
background: rgba(16,185,129,.14);
color: $lm-color-delivery;
}
.laundry-badge--source {
background: $lm-bg-soft;
color: $lm-text-muted;
}
// Actions row
.laundry-orders-popup__actions {
display: flex;
flex-wrap: wrap;
gap: $lm-space-2;
padding-top: $lm-space-2;
border-top: 1px dashed $lm-border;
.btn {
min-height: 44px; // touch target
font-weight: 700;
letter-spacing: 0.01em;
padding-inline: $lm-space-3;
}
}
.laundry-orders-popup__due-hint { margin-top: 2px; }
}
// ── Thermal Work Order receipt ─────────────────────────────────────────
// Rendered by LaundryWorkOrderThermal when sent through pos.printer.print.
// Sized for an 80 mm POS printer; @media print collapses to full-width
// for the browser fallback (`webPrintFallback: true`).
.laundry-thermal {
font-family: "Courier New", "Courier", monospace;
font-size: 12px;
line-height: 1.4;
width: 76mm;
padding: 4mm;
color: #000;
background: #fff;
&__header { text-align: center; margin-bottom: 4px; }
&__company { font-weight: 700; font-size: 13px; }
&__title {
font-weight: 800; font-size: 16px;
margin: 4px 0 2px; letter-spacing: 0.06em;
}
&__name { font-size: 13px; font-weight: 700; }
&__divider { border-top: 1px dashed #000; margin: 4px 0; }
&__row {
display: flex; justify-content: space-between; gap: 8px;
margin: 1px 0;
}
&__label { font-weight: 700; }
&__lines {
width: 100%;
font-size: 11px;
border-collapse: collapse;
th, td { padding: 2px 4px; vertical-align: top; text-align: left; }
th { border-bottom: 1px solid #000; font-weight: 700; }
td.qty, th.qty { width: 28px; text-align: center; }
td.amount, th.amount { text-align: right; white-space: nowrap; }
}
&__tracking { font-size: 10px; color: #555; margin-top: 1px; }
&__totals { margin-top: 4px; }
&__total { font-weight: 700; }
&__due {
font-weight: 800;
border-top: 1px solid #000;
padding-top: 2px;
margin-top: 2px;
}
&__status {
text-align: center;
font-weight: 700;
font-size: 13px;
margin-top: 4px;
letter-spacing: 0.05em;
}
&__footer {
text-align: center;
font-size: 10px;
margin-top: 6px;
font-style: italic;
}
}
@media print {
// Browser-fallback path — let the receipt fill the printable area.
.laundry-thermal { width: 100%; max-width: 80mm; margin: 0 auto; }
}

View File

@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Inject a READ-ONLY Laundry Settlements panel inside the closing
popup, just below the per-method breakdown and above the cash
count input. The panel does NOT modify any expected/counted
math — it is purely a visibility aid for non-cash settlement
collections that don't show up in the standard breakdown. -->
<t t-name="laundry_management.ClosePosPopupSettlements"
t-inherit="point_of_sale.ClosePosPopup"
t-inherit-mode="extension">
<xpath expr="//div[hasclass('payment-methods-overview')]" position="after">
<div class="laundry-closing-settlements w-100 mb-3 p-2"
t-if="laundrySettlements.loaded and laundrySettlements.count > 0">
<div class="d-flex align-items-center justify-content-between fs-5 fw-bold"
style="color:#b45309;border-bottom:2px solid #f59e0b;padding-bottom:4px;">
<span>
<i class="fa fa-tshirt me-2"/>Laundry Settlements
</span>
<span t-esc="env.utils.formatCurrency(laundrySettlements.total)"/>
</div>
<div class="text-muted small ps-1">
<t t-esc="laundrySettlements.count"/> settlement payment(s) collected
this session — already posted to accounting.
</div>
<div t-foreach="laundrySettlements.by_journal" t-as="j" t-key="j_index"
class="d-flex align-items-center justify-content-between ps-2 mt-1">
<span>
<i t-if="j.type === 'cash'" class="fa fa-money me-1 text-success"/>
<i t-else="" class="fa fa-credit-card me-1 text-primary"/>
<t t-esc="j.name"/>
<span class="text-muted small ms-1">
(<t t-esc="j.type"/>)
</span>
</span>
<span t-esc="env.utils.formatCurrency(j.total)"/>
</div>
<div class="text-muted small fst-italic ps-1 mt-1">
Cash settlements already counted in expected drawer above.
Non-cash entries are informational only.
</div>
</div>
</xpath>
</t>
</templates>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.ControlButtons" t-inherit="point_of_sale.ControlButtons" t-inherit-mode="extension">
<xpath expr="//t[@t-if='props.showRemainingButtons']/div[hasclass('control-buttons-modal')]" position="inside">
<button t-att-class="buttonClass" t-on-click="onClickSettleDues">
<i class="fa fa-money me-1" role="img" aria-label="Settle Dues" title="Settle Dues" />
Settle Dues
</button>
<button t-att-class="buttonClass" t-on-click="onClickViewLaundryOrders">
<i class="fa fa-list me-1" role="img" aria-label="View Laundry Orders" title="View Laundry Orders" />
Laundry Orders
</button>
</xpath>
</t>
</templates>

View File

@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.LaundryOrderContextPanel">
<div class="laundry-context-panel"
t-if="isFeatureEnabled and order"
t-att-data-empty="hasContent ? '0' : '1'"
t-att-data-delivery="isDelivery ? '1' : '0'">
<div class="laundry-context-panel__header">
<span class="laundry-context-panel__title">
<i class="fa fa-tshirt me-2" title="Laundry Order"/>
<span>Laundry Order</span>
</span>
<button t-if="hasContent"
class="laundry-context-panel__edit"
t-on-click="onClickEdit"
title="Edit order type">
<i class="fa fa-pencil" title="Edit"/>
<span class="laundry-context-panel__edit-label">Edit</span>
</button>
</div>
<t t-if="!hasContent">
<button class="laundry-context-panel__cta"
t-on-click="onClickEdit">
<i class="fa fa-plus-circle me-2" title="Set order type"/>
<span>Set Order Type</span>
</button>
</t>
<t t-else="">
<t t-if="orderType">
<div class="laundry-context-panel__row">
<span class="laundry-pill laundry-pill--type"
t-att-data-priority="orderType.priority"
t-att-style="typeStyle">
<i t-attf-class="fa #{typeIcon(orderType)} me-1"
t-att-title="orderType.name"/>
<t t-esc="orderType.name"/>
</span>
</div>
</t>
<t t-if="attributes.length">
<div class="laundry-context-panel__row laundry-context-panel__attrs">
<t t-foreach="attributes" t-as="attr" t-key="attr.id">
<span class="laundry-pill laundry-pill--attr"
t-att-data-delivery="attr.is_delivery_related ? '1' : '0'"
t-att-data-priority="attr.is_priority_related ? 'urgent' : 'normal'"
t-att-style="attrStyle(attr)">
<t t-esc="attr.name"/>
</span>
</t>
</div>
</t>
<t t-if="isDelivery">
<div class="laundry-context-panel__delivery">
<div t-if="deliveryAddress" class="laundry-context-panel__delivery-row">
<i class="fa fa-map-marker" title="Address"/>
<span t-esc="deliveryAddress"/>
</div>
<div t-if="deliveryScheduledAt" class="laundry-context-panel__delivery-row">
<i class="fa fa-clock-o" title="Time"/>
<span t-esc="deliveryScheduledAt"/>
</div>
<div t-if="!deliveryAddress and !deliveryScheduledAt"
class="laundry-context-panel__delivery-row text-muted">
<i class="fa fa-truck" title="Delivery"/>
<span>Delivery</span>
</div>
</div>
</t>
</t>
</div>
</t>
</templates>

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.LaundrySettleBanner">
<div class="laundry-settle-banner" t-if="isActive">
<div class="laundry-settle-banner__lead">
<i class="fa fa-exclamation-triangle laundry-settle-banner__icon"/>
<div class="laundry-settle-banner__text">
<span class="laundry-settle-banner__title">SETTLEMENT MODE ACTIVE</span>
<span class="laundry-settle-banner__meta" t-if="partnerName or amountLabel">
<span class="laundry-settle-banner__partner" t-if="partnerName">
<i class="fa fa-user me-1"/><t t-esc="partnerName"/>
</span>
<span class="laundry-settle-banner__amount" t-if="amountLabel">
<t t-esc="amountLabel"/>
</span>
</span>
<span class="laundry-settle-banner__hint">Finish payment or press Exit</span>
</div>
</div>
<button class="laundry-settle-banner__exit"
t-on-click="onClickExit"
title="Exit settle dues mode">
<i class="fa fa-times me-1"/>
<span>Exit</span>
</button>
</div>
</t>
</templates>

View File

@@ -1,120 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.LaundryWorkOrderThermal">
<div class="laundry-thermal pos-receipt-container">
<div class="laundry-thermal__header">
<div class="laundry-thermal__company"
t-esc="props.data.company_name"/>
<div class="laundry-thermal__title">WORK ORDER</div>
<div class="laundry-thermal__name"
t-esc="props.data.name"/>
</div>
<div class="laundry-thermal__divider"/>
<div class="laundry-thermal__row">
<span class="laundry-thermal__label">Customer</span>
<span class="laundry-thermal__value"
t-esc="props.data.partner_name"/>
</div>
<div t-if="props.data.partner_phone"
class="laundry-thermal__row">
<span class="laundry-thermal__label">Phone</span>
<span class="laundry-thermal__value"
t-esc="props.data.partner_phone"/>
</div>
<div t-if="props.data.pos_reference"
class="laundry-thermal__row">
<span class="laundry-thermal__label">POS Ref</span>
<span class="laundry-thermal__value"
t-esc="props.data.pos_reference"/>
</div>
<div class="laundry-thermal__row">
<span class="laundry-thermal__label">Date</span>
<span class="laundry-thermal__value"
t-esc="fmtDate(props.data.create_date)"/>
</div>
<t t-if="props.data.is_delivery">
<div class="laundry-thermal__divider"/>
<div class="laundry-thermal__row">
<span class="laundry-thermal__label">Delivery</span>
<span class="laundry-thermal__value"
t-esc="props.data.delivery_address"/>
</div>
<div t-if="props.data.delivery_scheduled_at"
class="laundry-thermal__row">
<span class="laundry-thermal__label">Scheduled</span>
<span class="laundry-thermal__value"
t-esc="fmtDate(props.data.delivery_scheduled_at)"/>
</div>
</t>
<div class="laundry-thermal__divider"/>
<table class="laundry-thermal__lines">
<thead>
<tr>
<th class="qty">Qty</th>
<th>Item</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
<tr t-foreach="props.data.lines" t-as="line"
t-key="line_index">
<td class="qty" t-esc="line.qty"/>
<td>
<t t-esc="line.description"/>
<div t-if="line.tracking_code"
class="laundry-thermal__tracking">
#<t t-esc="line.tracking_code"/>
</div>
</td>
<td class="amount" t-esc="money(line.subtotal)"/>
</tr>
</tbody>
</table>
<div class="laundry-thermal__divider"/>
<div class="laundry-thermal__totals">
<div class="laundry-thermal__row laundry-thermal__total">
<span class="laundry-thermal__label">Total</span>
<span class="laundry-thermal__value"
t-esc="money(props.data.amount_total)"/>
</div>
<div t-if="props.data.amount_paid"
class="laundry-thermal__row">
<span class="laundry-thermal__label">Paid</span>
<span class="laundry-thermal__value"
t-esc="money(props.data.amount_paid)"/>
</div>
<div t-if="props.data.amount_settled"
class="laundry-thermal__row">
<span class="laundry-thermal__label">Settled</span>
<span class="laundry-thermal__value"
t-esc="money(props.data.amount_settled)"/>
</div>
<div t-if="props.data.amount_due"
class="laundry-thermal__row laundry-thermal__due">
<span class="laundry-thermal__label">DUE</span>
<span class="laundry-thermal__value"
t-esc="money(props.data.amount_due)"/>
</div>
</div>
<div class="laundry-thermal__divider"/>
<div class="laundry-thermal__status">
STATUS: <strong t-esc="props.data.state_label"/>
</div>
<div class="laundry-thermal__footer">
* Keep this slip until pickup *
</div>
</div>
</t>
</templates>

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.OrderSummary" t-inherit-mode="extension">
<xpath expr="//OrderDisplay" position="before">
<LaundrySettleBanner/>
</xpath>
<xpath expr="//OrderDisplay" position="after">
<LaundryOrderContextPanel/>
</xpath>
</t>
</templates>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.PartnerLine" t-inherit="point_of_sale.PartnerLine" t-inherit-mode="extension">
<!-- Desktop: add unpaid badge after partner name (first occurrence) -->
<xpath expr="//tr[hasclass('partner-line')]//td[1]//b" position="after">
<span t-if="props.partner.laundry_unpaid_count"
class="badge text-bg-danger ms-2"
t-att-title="'' + props.partner.laundry_unpaid_count + ' unpaid laundry order(s)'">
<i class="fa fa-exclamation-circle me-1"/>
<t t-esc="props.partner.laundry_unpaid_count"/> unpaid
</span>
</xpath>
<!-- Mobile: add unpaid badge after partner name -->
<xpath expr="//div[hasclass('partner-info') and hasclass('d-flex')]//b" position="after">
<span t-if="props.partner.laundry_unpaid_count"
class="badge text-bg-danger ms-2"
t-att-title="'' + props.partner.laundry_unpaid_count + ' unpaid laundry order(s)'">
<i class="fa fa-exclamation-circle me-1"/>
<t t-esc="props.partner.laundry_unpaid_count"/> unpaid
</span>
</xpath>
</t>
</templates>

View File

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.LaundryDeliveryDetailsPopup">
<Dialog title="props.title" size="'md'">
<div class="p-3">
<div class="mb-3">
<label class="form-label fw-bold">
Delivery Address
<span t-if="props.requireAddress" class="text-danger">*</span>
</label>
<textarea class="form-control form-control-lg"
rows="3"
t-att-class="{'is-invalid': state.addressError}"
t-att-value="state.address"
t-on-input="(ev) => { this.state.address = ev.target.value; this.state.addressError = false; }"
placeholder="Building, street, district, city..."
t-ref="addressInput"/>
<div t-if="state.addressError" class="invalid-feedback">
Address is required.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">
Scheduled Date / Time
<span t-if="props.requireScheduledTime" class="text-danger">*</span>
</label>
<input type="datetime-local"
class="form-control form-control-lg"
t-att-class="{'is-invalid': state.timeError}"
t-att-value="state.scheduledAt"
t-on-input="(ev) => { this.state.scheduledAt = ev.target.value; this.state.timeError = false; }"/>
<div t-if="state.timeError" class="invalid-feedback">
Scheduled time is required.
</div>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary btn-lg flex-fill" t-on-click="confirm">
Save
</button>
<button class="btn btn-secondary btn-lg flex-fill" t-on-click="cancel">
Cancel
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.LaundryOrderAttributePopup">
<Dialog title="props.title" size="'lg'">
<div class="p-2">
<t t-if="!props.attributes.length">
<div class="alert alert-info text-center">
No attributes available.
</div>
</t>
<div class="d-flex flex-wrap gap-2">
<t t-foreach="props.attributes" t-as="attr" t-key="attr.id">
<button class="btn btn-lg px-3 py-2"
t-att-class="isSelected(attr.id) ? 'btn-primary' : 'btn-outline-primary'"
t-att-style="attr.color ? ('border-color:' + attr.color + ';color:' + (isSelected(attr.id) ? '#fff' : attr.color)) : ''"
t-on-click="() => this.toggle(attr.id)">
<span t-esc="attr.name"/>
</button>
</t>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary btn-lg flex-fill" t-on-click="confirm">
Confirm
</button>
<button class="btn btn-secondary btn-lg flex-fill" t-on-click="skip">
Skip
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.LaundryOrderTypePopup">
<Dialog title="props.title" size="'lg'">
<div class="p-2">
<t t-if="!props.types.length">
<div class="alert alert-warning text-center">
No order types are configured. Please ask a manager to add some
in Laundry / Configuration / Order Types.
</div>
</t>
<div class="row g-3">
<t t-foreach="props.types" t-as="type" t-key="type.id">
<div class="col-6 col-md-4">
<button class="btn w-100 h-100 p-3 d-flex flex-column align-items-center justify-content-center"
t-att-class="state.selectedId === type.id ? 'btn-primary' : 'btn-outline-primary'"
t-att-style="type.color ? ('border-color:' + type.color + ';color:' + (state.selectedId === type.id ? '#fff' : type.color)) : ''"
t-on-click="() => this.select(type.id)">
<div class="fw-bold fs-5" t-esc="type.name"/>
<div t-if="type.description" class="small text-muted mt-1" t-esc="type.description"/>
<div class="d-flex gap-1 mt-2">
<span t-if="type.priority === 'urgent'" class="badge bg-danger">Urgent</span>
<span t-if="type.is_delivery" class="badge bg-info">Delivery</span>
</div>
</button>
</div>
</t>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary btn-lg flex-fill"
t-att-disabled="!state.selectedId"
t-on-click="confirm">
Confirm
</button>
<button t-if="props.allowSkip"
class="btn btn-secondary btn-lg flex-fill"
t-on-click="skip">
Skip
</button>
<button class="btn btn-light btn-lg" t-on-click="cancel">
Cancel
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!--
Laundry UI enhancement for the core ProductConfiguratorPopup.
Pure presentation layer — adds two classes to the Dialog's
modal-content so the SCSS in laundry_pos.scss can scope all
enhancements under `.popup-product-configurator.laundry-enhanced`.
Non-laundry products receive no class → default Odoo styling
applies everywhere else in the POS.
No behavior override: variant resolution, archived-combination
detection, custom-value handling, close/confirm — all remain in
core. Selected state is already applied by core (`.active` on
Pills; `input:checked` on Radio); our SCSS keys off those to
draw the big tile state.
-->
<t t-name="laundry_management.ProductConfiguratorPopupLaundryEnhanced"
t-inherit="point_of_sale.ProductConfiguratorPopup"
t-inherit-mode="extension">
<xpath expr="//Dialog" position="attributes">
<attribute name="contentClass">props.productTemplate?.is_laundry_service ? 'popup-product-configurator laundry-enhanced' : ''</attribute>
</xpath>
</t>
</templates>

View File

@@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!--
Quick Create Customer popup (POS only).
Layout: Phone first (required), Name second (optional — falls back
to phone if left empty), Street last.
Title and labels are bound to component getters that wrap _t() so
Odoo's user-language switching applies. Do not put Arabic/English
strings side-by-side in any label.
-->
<t t-name="laundry_management.QuickCreatePartner">
<Dialog title="title" size="'md'">
<div class="p-3">
<!-- Phone / Mobile — required, first, autofocused -->
<div class="mb-3">
<label class="form-label fw-bold">
<t t-esc="labelPhone"/>
<span class="text-danger ms-1">*</span>
</label>
<input type="tel"
class="form-control form-control-lg"
t-att-class="{'is-invalid': state.phoneError}"
t-att-value="state.phone"
t-on-input="(ev) => { this.state.phone = ev.target.value; this.state.phoneError = false; }"
t-on-keydown="(ev) => ev.key === 'Enter' &amp;&amp; this.confirm()"
t-att-placeholder="placeholderPhone"
t-ref="phoneInput"
inputmode="tel"
autocomplete="tel"/>
<div t-if="state.phoneError" class="invalid-feedback"
t-esc="errorPhoneRequired"/>
</div>
<!-- Name — optional. If empty on confirm, the server
defaults the partner name to the phone number. -->
<div class="mb-3">
<label class="form-label">
<t t-esc="labelName"/>
<span class="text-muted ms-1" style="font-weight:400;"
t-esc="nameOptionalHint"/>
</label>
<input type="text"
class="form-control form-control-lg"
t-att-value="state.name"
t-on-input="(ev) => this.state.name = ev.target.value"
t-on-keydown="(ev) => ev.key === 'Enter' &amp;&amp; this.confirm()"
t-att-placeholder="placeholderName"/>
</div>
<!-- Street — fully optional -->
<div class="mb-3">
<label class="form-label text-muted"
t-esc="labelStreet"/>
<input type="text"
class="form-control form-control-lg"
t-att-value="state.street"
t-on-input="(ev) => this.state.street = ev.target.value"
t-on-keydown="(ev) => ev.key === 'Enter' &amp;&amp; this.confirm()"
t-att-placeholder="placeholderStreet"/>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary btn-lg flex-fill"
t-on-click="confirm"
t-esc="labelCreate"/>
<button class="btn btn-secondary btn-lg flex-fill"
t-on-click="cancel"
t-esc="labelCancel"/>
</t>
</Dialog>
</t>
</templates>

View File

@@ -1,105 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- ─────────────────────────────────────────────────────────────
Reusable receipt details block for laundry orders.
Renders only when at least one of type / attributes / delivery
is present — otherwise renders nothing. RTL/LTR safe.
───────────────────────────────────────────────────────────── -->
<t t-name="laundry_management.LaundryReceiptDetails">
<div class="laundry-receipt-details" t-if="hasContent">
<div class="laundry-receipt-details__sep"/>
<div class="laundry-receipt-details__title">Order Details</div>
<div t-if="orderType" class="laundry-receipt-details__row">
<span class="laundry-receipt-details__label">Type</span>
<span class="laundry-receipt-details__value">
<t t-if="isUrgent"></t>
<t t-elif="orderType.is_delivery">🚚 </t>
<t t-esc="orderType.name"/>
</span>
</div>
<div t-if="attributes.length" class="laundry-receipt-details__row">
<span class="laundry-receipt-details__label">Attributes</span>
<span class="laundry-receipt-details__value">
<t t-foreach="attributes" t-as="attr" t-key="attr.id">
<span class="laundry-receipt-details__chip">
<t t-esc="attr.name"/>
</span>
</t>
</span>
</div>
<t t-if="isDelivery">
<div class="laundry-receipt-details__row">
<span class="laundry-receipt-details__label">Delivery</span>
<span class="laundry-receipt-details__value">🚚 Yes</span>
</div>
<div t-if="deliveryAddress" class="laundry-receipt-details__row">
<span class="laundry-receipt-details__label">Address</span>
<span class="laundry-receipt-details__value" t-esc="deliveryAddress"/>
</div>
<div t-if="deliveryScheduledAt" class="laundry-receipt-details__row">
<span class="laundry-receipt-details__label">Scheduled</span>
<span class="laundry-receipt-details__value" t-esc="deliveryScheduledAt"/>
</div>
</t>
<div class="laundry-receipt-details__sep"/>
</div>
</t>
<!-- ─────────────────────────────────────────────────────────────
Inject the details block + the (legacy) settlement block
into the core OrderReceipt template.
───────────────────────────────────────────────────────────── -->
<t t-name="laundry_management.OrderReceipt"
t-inherit="point_of_sale.OrderReceipt"
t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt')]" position="inside">
<LaundryReceiptDetails order="order"/>
<t t-if="order.laundry_settlement_info">
<div class="pos-receipt-laundry-settlement mt-2 pt-2"
style="border-top: 1px dashed #000;">
<div class="text-center fw-bold mb-1">Laundry Dues Payment</div>
<div class="small">
Customer: <t t-esc="order.laundry_settlement_info.partnerName"/>
</div>
<div class="small mb-1">
Total outstanding:
<span t-esc="order.laundry_settlement_info.totalDue.toFixed(2)"/>
</div>
<table class="w-100 small">
<thead>
<tr>
<th class="text-start">Order</th>
<th class="text-end">Due</th>
</tr>
</thead>
<tbody>
<t t-foreach="order.laundry_settlement_info.orders"
t-as="lorder" t-key="lorder.name">
<tr>
<td class="text-start" t-esc="lorder.name"/>
<td class="text-end" t-esc="lorder.amount_due.toFixed(2)"/>
</tr>
</t>
</tbody>
</table>
<div class="d-flex justify-content-between fw-bold mt-1">
<span>Paid now:</span>
<span t-esc="order.laundry_settlement_info.amount.toFixed(2)"/>
</div>
<div class="d-flex justify-content-between">
<span>Remaining balance:</span>
<span t-esc="order.laundry_settlement_info.remaining.toFixed(2)"/>
</div>
</div>
</t>
</xpath>
</t>
</templates>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Settlement popup replaced by native POS PaymentScreen flow. -->
</templates>

View File

@@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.SettlementReceipt">
<Dialog title="'Payment Receipt'" size="'md'">
<div class="settlement-receipt-content p-3" style="font-family: monospace; max-width: 320px; margin: 0 auto;">
<!-- Header -->
<div class="text-center mb-2">
<div class="fw-bold fs-5">Payment Receipt</div>
<div class="small text-muted">Laundry Dues Settlement</div>
</div>
<div class="border-top border-bottom mb-2 py-2">
<div class="d-flex justify-content-between">
<span>Customer:</span>
<span class="fw-bold" t-esc="props.partnerName"/>
</div>
<div class="d-flex justify-content-between">
<span>Date:</span>
<span t-esc="dateTime"/>
</div>
</div>
<!-- Payment breakdown (grouped by method, sorted cash first) -->
<div class="mb-2">
<div class="fw-bold mb-1">Payment Details</div>
<t t-foreach="groupedPayments" t-as="pmt" t-key="pmt.name">
<div class="d-flex justify-content-between">
<span t-esc="pmt.name"/>
<span t-esc="fmt(pmt.amount)"/>
</div>
</t>
<div class="d-flex justify-content-between fw-bold border-top mt-2 pt-1">
<span>Total Paid:</span>
<span t-esc="fmt(props.settledTotal)"/>
</div>
</div>
<!-- Settled orders (grouped by order name) -->
<t t-if="groupedOrders.length > 0">
<div class="mb-2">
<div class="fw-bold mb-1">Orders Settled</div>
<table class="w-100 small">
<thead>
<tr>
<th class="text-start">Order</th>
<th class="text-end">Applied</th>
<th class="text-end">Remaining</th>
</tr>
</thead>
<tbody>
<t t-foreach="groupedOrders" t-as="so" t-key="so.name">
<tr>
<td t-esc="so.name"/>
<td class="text-end" t-esc="fmt(so.applied)"/>
<td class="text-end" t-esc="fmt(so.remaining_on_order)"/>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<!-- Footer -->
<div class="border-top pt-2">
<div class="d-flex justify-content-between fw-bold">
<span>Remaining Balance:</span>
<span t-esc="fmt(props.remainingDue)"/>
</div>
</div>
<div class="text-center mt-2 small text-muted">
Thank you for your payment
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary btn-lg flex-fill" t-on-click="printReceipt">
<i class="fa fa-print me-1"/>Print
</button>
<button class="btn btn-secondary btn-lg" t-on-click="props.close">
Close
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -1,223 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="laundry_management.LaundryOrdersViewPopup">
<Dialog title="'Laundry Orders'" size="'lg'"
contentClass="'laundry-orders-popup'">
<div class="laundry-orders-popup__body">
<!-- Header: partner + counts -->
<div class="laundry-orders-popup__header">
<div class="laundry-orders-popup__partner">
<i class="fa fa-user-circle-o me-2" title="Customer"/>
<strong t-esc="props.partnerName"/>
<span t-if="props.partnerPhone"
class="laundry-orders-popup__phone ms-2">
<i class="fa fa-phone me-1"/>
<t t-esc="props.partnerPhone"/>
</span>
</div>
<div class="laundry-orders-popup__count text-muted"
t-if="hasResults">
<t t-esc="state.orders.length"/> order(s)
</div>
</div>
<!-- Search -->
<form class="laundry-orders-popup__search"
t-on-submit="onSearchSubmit">
<div class="input-group input-group-lg">
<span class="input-group-text">
<i class="fa fa-search" title="Search"/>
</span>
<input type="text"
class="form-control"
placeholder="Order number, POS reference, or phone…"
t-att-value="state.searchQuery"
t-on-input="onSearchInput"
t-ref="searchInput"/>
<button type="submit" class="btn btn-primary">
Search
</button>
<button type="button"
class="btn btn-outline-secondary"
t-on-click="onClearSearch"
t-att-disabled="state.loading"
t-if="state.searchQuery">
Clear
</button>
</div>
</form>
<!-- Error state -->
<div t-if="state.error"
class="alert alert-danger laundry-orders-popup__error"
role="alert">
<strong>Could not load orders.</strong>
<span class="ms-2" t-esc="state.error"/>
<button class="btn btn-sm btn-outline-danger ms-3"
t-on-click="_fetch">
Retry
</button>
</div>
<!-- Loading state -->
<div t-if="state.loading" class="laundry-orders-popup__loading">
<t t-foreach="[1,2,3]" t-as="i" t-key="i">
<div class="laundry-orders-popup__skeleton"/>
</t>
</div>
<!-- Empty state -->
<div t-if="isEmpty"
class="laundry-orders-popup__empty text-center text-muted py-5">
<i class="fa fa-inbox fa-3x mb-2 d-block"
title="No orders"/>
<t t-if="state.searchQuery">
No orders matched "<t t-esc="state.searchQuery"/>".
</t>
<t t-else="">
No laundry orders yet for this customer.
</t>
</div>
<!-- Orders list -->
<div t-if="hasResults" class="laundry-orders-popup__list">
<div t-foreach="state.orders" t-as="order" t-key="order.id"
class="laundry-orders-popup__card"
t-att-data-state="order.state"
t-att-data-payment="order.payment_state"
t-att-data-busy="isOrderBusy(order) ? '1' : '0'">
<!-- Row 1: identity + date -->
<div class="laundry-orders-popup__card-head">
<div class="laundry-orders-popup__card-title">
<span class="laundry-orders-popup__order-name"
t-esc="order.name"/>
<span t-if="order.pos_reference"
class="laundry-orders-popup__pos-ref ms-2">
POS: <t t-esc="order.pos_reference"/>
</span>
</div>
<div class="laundry-orders-popup__date"
t-esc="formatDate(order)"/>
</div>
<!-- Row 2: items + totals -->
<div class="laundry-orders-popup__card-meta">
<span class="laundry-orders-popup__items">
<i class="fa fa-tshirt me-1" title="Items"/>
<t t-esc="order.item_count"/> item(s)
<span t-if="order.service_summary"
class="laundry-orders-popup__services ms-2 text-muted"
t-att-title="order.service_summary"
t-esc="order.service_summary"/>
</span>
<span class="laundry-orders-popup__totals">
<span class="laundry-orders-popup__total"
t-esc="fmt(order.amount_total)"/>
<span t-if="order.amount_due > 0"
class="laundry-orders-popup__due ms-2"
t-esc="'(' + fmt(order.amount_due) + ' due)'"/>
</span>
</div>
<!-- Row 3: badges -->
<div class="laundry-orders-popup__badges">
<span class="laundry-badge"
t-att-class="paymentBadge(order).klass">
<t t-esc="paymentBadge(order).label"/>
<span t-if="order.payment_state === 'due'"
class="ms-1"
t-esc="fmt(order.amount_due)"/>
</span>
<span class="laundry-badge"
t-att-class="stateBadge(order).klass">
<t t-esc="stateBadge(order).label"/>
</span>
<span t-if="order.is_delivery"
class="laundry-badge laundry-badge--delivery">
<i class="fa fa-truck me-1" title="Delivery"/>
Delivery
</span>
<span t-if="order.is_from_pos"
class="laundry-badge laundry-badge--source">
From POS
</span>
</div>
<!-- Row 4: actions -->
<div class="laundry-orders-popup__actions">
<button t-if="isAllowed(order, 'start_processing')"
class="btn btn-primary btn-lg"
t-att-disabled="isOrderBusy(order) ? 'disabled' : null"
t-on-click="() => this.onClickStartProcessing(order)">
<i t-if="!isBusy(order, 'start_processing')"
class="fa fa-play me-2" title="Start Processing"/>
<i t-else=""
class="fa fa-spinner fa-spin me-2"
title="Starting"/>
Start Processing
</button>
<button t-if="isAllowed(order, 'mark_ready')"
class="btn btn-primary btn-lg"
t-att-disabled="isOrderBusy(order) ? 'disabled' : null"
t-on-click="() => this.onClickMarkReady(order)">
<i t-if="!isBusy(order, 'mark_ready')"
class="fa fa-check-circle me-2" title="Mark Ready"/>
<i t-else=""
class="fa fa-spinner fa-spin me-2"
title="Marking"/>
Mark Ready
</button>
<button t-if="isAllowed(order, 'deliver')"
class="btn btn-success btn-lg"
t-att-disabled="isOrderBusy(order) ? 'disabled' : null"
t-on-click="() => this.onClickDeliver(order)">
<i t-if="!isBusy(order, 'deliver')"
class="fa fa-truck me-2" title="Deliver"/>
<i t-else=""
class="fa fa-spinner fa-spin me-2"
title="Delivering"/>
Deliver
</button>
<button t-if="isAllowed(order, 'collect_payment')"
class="btn btn-warning btn-lg"
t-on-click="() => this.onClickCollectPayment(order)">
<i class="fa fa-credit-card me-2" title="Collect"/>
Collect Payment in POS
</button>
<button t-if="isAllowed(order, 'print_work_order')"
class="btn btn-outline-secondary btn-lg"
t-on-click="() => this.onClickPrintWorkOrder(order)">
<i class="fa fa-print me-2" title="Print"/>
Print Work Order
</button>
</div>
<!-- Due hint -->
<div t-if="order.payment_state === 'due' and order.state !== 'delivered'"
class="laundry-orders-popup__due-hint text-muted small">
<i class="fa fa-info-circle me-1" title="Note"/>
Cannot deliver until
<strong t-esc="fmt(order.amount_due)"/>
is paid.
</div>
</div>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-secondary btn-lg"
t-on-click="close">
Close
</button>
</t>
</Dialog>
</t>
</templates>