Tower: upload laundry_management 19.0.19.0.4 (via marketplace)

This commit is contained in:
2026-05-01 15:00:24 +00:00
parent b98f7b4769
commit e225ff2780

View File

@@ -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();
}
}