Tower: upload laundry_management 19.0.19.0.4 (via marketplace)
This commit is contained in:
273
addons/laundry_management/static/src/js/view_laundry_orders.js
Normal file
273
addons/laundry_management/static/src/js/view_laundry_orders.js
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user