From e225ff2780f8d67540a21c9c50e975f8e4853956 Mon Sep 17 00:00:00 2001 From: git_admin Date: Fri, 1 May 2026 15:00:24 +0000 Subject: [PATCH] Tower: upload laundry_management 19.0.19.0.4 (via marketplace) --- .../static/src/js/view_laundry_orders.js | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 addons/laundry_management/static/src/js/view_laundry_orders.js diff --git a/addons/laundry_management/static/src/js/view_laundry_orders.js b/addons/laundry_management/static/src/js/view_laundry_orders.js new file mode 100644 index 0000000..63bc582 --- /dev/null +++ b/addons/laundry_management/static/src/js/view_laundry_orders.js @@ -0,0 +1,273 @@ +/** @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(); + } +}