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