@classytic/pos-ui 0.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +75 -0
- package/dist/components/CartSidebar.d.ts +56 -0
- package/dist/components/CartSidebar.js +10 -9
- package/dist/components/PosTopBar.d.ts +19 -0
- package/dist/components/PosTopBar.js +4 -4
- package/dist/components/PrinterSettingsDialog.d.ts +8 -0
- package/dist/components/PrinterSettingsDialog.js +506 -20
- package/dist/components/ReceiptDeliveryDialog.d.ts +11 -0
- package/dist/components/ReceiptDeliveryDialog.js +98 -0
- package/dist/components/ReceiptReprintDialog.d.ts +10 -0
- package/dist/components/ReceiptReprintDialog.js +49 -22
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +7 -0
- package/dist/dashboard/components/CustomerLookupDialog.js +108 -90
- package/dist/dashboard/components/CustomerQuickAddDialog.js +3 -1
- package/dist/dashboard/components/DeliveryPanel.js +134 -0
- package/dist/dashboard/components/ProductCard.js +17 -7
- package/dist/dashboard/components/ProductsPanel.js +4 -4
- package/dist/dashboard/components/SplitPaymentPanel.js +5 -5
- package/dist/dashboard/components/VariantSelectorDialog.js +53 -11
- package/dist/dashboard/components/cart/AddChargeDialog.js +3 -2
- package/dist/dashboard/components/cart/CartItems.js +5 -5
- package/dist/dashboard/components/cart/CartSummary.js +6 -6
- package/dist/dashboard/components/cart/CustomerSection.js +3 -3
- package/dist/dashboard/components/cart/DiscountSection.js +4 -3
- package/dist/dashboard/components/cart/PointsRedemptionSection.js +5 -4
- package/dist/dashboard/pos.types.d.ts +32 -0
- package/dist/hardware/agent-adapter.js +127 -0
- package/dist/hardware/agent-pairing.d.ts +18 -0
- package/dist/hardware/agent-pairing.js +63 -0
- package/dist/hardware/context.d.ts +1 -5
- package/dist/hardware/index.d.ts +4 -3
- package/dist/hardware/index.js +3 -2
- package/dist/hardware/ports.d.ts +21 -4
- package/dist/hardware/tauri-adapter.d.ts +0 -1
- package/dist/hardware/tauri-adapter.js +19 -5
- package/dist/hardware/web-adapter.d.ts +22 -7
- package/dist/hardware/web-adapter.js +56 -14
- package/dist/hooks/branch-scoped-key.js +64 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/sale-attempt.js +62 -0
- package/dist/hooks/useManagerAuth.js +9 -1
- package/dist/hooks/usePosCart.d.ts +55 -0
- package/dist/hooks/usePosCart.js +224 -117
- package/dist/hooks/usePosCustomer.d.ts +31 -0
- package/dist/hooks/usePosCustomer.js +32 -78
- package/dist/hooks/usePosDelivery.d.ts +28 -0
- package/dist/hooks/usePosDelivery.js +133 -0
- package/dist/hooks/usePosMultiOrder.d.ts +22 -0
- package/dist/hooks/usePosMultiOrder.js +14 -4
- package/dist/hooks/usePosPayment.d.ts +22 -0
- package/dist/lib/delivery-payload.js +83 -0
- package/dist/lib/money.js +96 -23
- package/dist/node_modules/react-hook-form/dist/index.esm.js +468 -332
- package/dist/runtime/auth-port.d.ts +1 -5
- package/dist/runtime/branch-port.d.ts +1 -5
- package/dist/runtime/config.d.ts +43 -6
- package/dist/runtime/config.js +18 -1
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.js +2 -2
- package/dist/screens/OrderHistoryDrawer.d.ts +8 -0
- package/dist/screens/OrderHistoryDrawer.js +15 -15
- package/dist/screens/ParkedOrdersDrawer.d.ts +12 -0
- package/dist/screens/ParkedOrdersDrawer.js +3 -3
- package/dist/screens/PaymentScreen.d.ts +4 -0
- package/dist/screens/PaymentScreen.js +194 -33
- package/dist/screens/ProductScreen.d.ts +4 -0
- package/dist/screens/ProductScreen.js +13 -10
- package/dist/screens/ReceiptScreen.d.ts +4 -0
- package/dist/screens/ReceiptScreen.js +96 -72
- package/dist/screens/ShiftCloseScreen.d.ts +4 -0
- package/dist/screens/ShiftCloseScreen.js +18 -15
- package/dist/screens/ShiftOpenScreen.d.ts +4 -0
- package/dist/screens/ShiftOpenScreen.js +75 -19
- package/dist/screens/index.d.ts +8 -0
- package/dist/screens/index.js +9 -0
- package/dist/shell/index.d.ts +2 -0
- package/dist/shell/pos-shell.d.ts +41 -8
- package/dist/shell/pos-shell.js +25 -18
- package/dist/state/index.d.ts +3 -0
- package/dist/state/index.js +4 -0
- package/dist/state/pos-context.d.ts +13 -0
- package/dist/state/pos-state.d.ts +49 -0
- package/dist/state/pos-state.js +6 -7
- package/dist/utils/pos-helpers.js +137 -34
- package/package.json +27 -7
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { usePosBranch } from "../runtime/branch-port.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { isTauri } from "../hardware/tauri-adapter.js";
|
|
5
|
+
import { readAgentPairing } from "../hardware/agent-pairing.js";
|
|
6
|
+
import { formatMinor } from "../lib/money.js";
|
|
7
|
+
import { useHardware } from "../hardware/context.js";
|
|
8
|
+
import { buildReceiptPayload, transformOrderToReceipt } from "../utils/pos-helpers.js";
|
|
9
|
+
import { useCallback, useMemo } from "react";
|
|
7
10
|
import { PrintableView, printDocument } from "@classytic/fluid/client/core";
|
|
8
11
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
12
|
import { Loader2, Printer } from "lucide-react";
|
|
10
13
|
import { Button } from "@/components/ui/button";
|
|
11
14
|
import { getReceiptLocale, paymentMethodLabel } from "@classytic/commerce-sdk/client";
|
|
15
|
+
import { toast } from "sonner";
|
|
12
16
|
import { DocumentFooter, DocumentHeader, DocumentLineTable, DocumentPage, DocumentParty, DocumentSummary } from "@classytic/fluid/document";
|
|
13
17
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
14
18
|
|
|
@@ -26,10 +30,18 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|
|
26
30
|
* locally is faster, requires no extra round-trip, and gives the host
|
|
27
31
|
* full control over branding/format without involving a backend schema.
|
|
28
32
|
*
|
|
29
|
-
* Print path:
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
+
* Print path: for the plain browser (no agent paired, no Tauri),
|
|
34
|
+
* `printDocument("pos-receipt-reprint")` isolates the rendered
|
|
35
|
+
* `<PrintableView>` and triggers `window.print()` directly — the DOM id is
|
|
36
|
+
* intentionally *different* from the live ReceiptScreen's `pos-receipt` so
|
|
37
|
+
* a reprint never clobbers an in-flight sale, and the web adapter's port
|
|
38
|
+
* always targets the LIVE screen's DOM id, not this one.
|
|
39
|
+
*
|
|
40
|
+
* When an agent is paired or Tauri is active, this instead routes through
|
|
41
|
+
* `hardware.printer.print(payload, "reprint")` — those adapters use the
|
|
42
|
+
* structured `ReceiptPayload`, not DOM, so they reach the real thermal
|
|
43
|
+
* printer correctly. Before this, a reprint ALWAYS called `printDocument()`
|
|
44
|
+
* regardless of adapter, so a paired ESC/POS printer never received one.
|
|
33
45
|
*/
|
|
34
46
|
const REPRINT_DOM_ID = "pos-receipt-reprint";
|
|
35
47
|
function isShownPhone(phone) {
|
|
@@ -51,31 +63,46 @@ function buildLines(receipt) {
|
|
|
51
63
|
taxAmount: item.vatAmount ?? 0,
|
|
52
64
|
lineTotal: item.total,
|
|
53
65
|
formatted: {
|
|
54
|
-
unitPrice:
|
|
55
|
-
subtotal:
|
|
56
|
-
taxAmount:
|
|
57
|
-
lineTotal:
|
|
66
|
+
unitPrice: formatMinor(item.unitPrice ?? 0),
|
|
67
|
+
subtotal: formatMinor(subtotal),
|
|
68
|
+
taxAmount: formatMinor(item.vatAmount ?? 0),
|
|
69
|
+
lineTotal: formatMinor(item.total)
|
|
58
70
|
}
|
|
59
71
|
};
|
|
60
72
|
});
|
|
61
73
|
}
|
|
62
74
|
function ReceiptReprintDialog({ order, open, onOpenChange }) {
|
|
63
75
|
const selectedBranch = usePosBranch();
|
|
76
|
+
const hardware = useHardware();
|
|
64
77
|
const receipt = useMemo(() => {
|
|
65
78
|
if (!order) return null;
|
|
66
|
-
|
|
79
|
+
const branchInfo = {
|
|
67
80
|
name: selectedBranch?.name ?? "Store",
|
|
68
81
|
...selectedBranch?.phone ? { phone: selectedBranch.phone } : {}
|
|
69
|
-
}
|
|
82
|
+
};
|
|
83
|
+
return transformOrderToReceipt(order, branchInfo);
|
|
70
84
|
}, [
|
|
71
85
|
order,
|
|
72
86
|
selectedBranch?.name,
|
|
73
87
|
selectedBranch?.phone
|
|
74
88
|
]);
|
|
75
|
-
const handlePrint = () => {
|
|
89
|
+
const handlePrint = useCallback(() => {
|
|
76
90
|
if (!receipt) return;
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
if (!isTauri() && !readAgentPairing()) {
|
|
92
|
+
printDocument(REPRINT_DOM_ID);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const payload = buildReceiptPayload(receipt, selectedBranch?.name ?? receipt.branch?.name ?? "Store", selectedBranch?.phone ?? receipt.branch?.phone);
|
|
96
|
+
hardware.printer.print(payload, "reprint").then((outcome) => {
|
|
97
|
+
if (outcome === "confirmed") return;
|
|
98
|
+
toast.error(outcome === "unknown" ? "Printer status unknown — it may have already printed." : "Couldn't reach the printer.");
|
|
99
|
+
});
|
|
100
|
+
}, [
|
|
101
|
+
receipt,
|
|
102
|
+
hardware.printer,
|
|
103
|
+
selectedBranch?.name,
|
|
104
|
+
selectedBranch?.phone
|
|
105
|
+
]);
|
|
79
106
|
const customerName = receipt?.customer?.name?.trim() ?? "";
|
|
80
107
|
const showCustomer = customerName.length > 0 && customerName !== "Walk-in Customer";
|
|
81
108
|
const customerPhone = isShownPhone(receipt?.customer?.phone) ? receipt.customer.phone : null;
|
|
@@ -83,7 +110,7 @@ function ReceiptReprintDialog({ order, open, onOpenChange }) {
|
|
|
83
110
|
const summaryExtra = [];
|
|
84
111
|
if (receipt?.payment) summaryExtra.push({
|
|
85
112
|
label: `Paid (${paymentMethodLabel(receipt.payment.method)})`,
|
|
86
|
-
value:
|
|
113
|
+
value: formatMinor(receipt.payment.amount ?? receipt.total ?? 0),
|
|
87
114
|
muted: true,
|
|
88
115
|
separator: true
|
|
89
116
|
});
|
|
@@ -144,10 +171,10 @@ function ReceiptReprintDialog({ order, open, onOpenChange }) {
|
|
|
144
171
|
compact: true
|
|
145
172
|
}),
|
|
146
173
|
/* @__PURE__ */ jsx(DocumentSummary, {
|
|
147
|
-
subtotal:
|
|
148
|
-
...receipt.discount && receipt.discount > 0 ? { discount:
|
|
149
|
-
...receipt.vat?.amount && receipt.vat.amount > 0 ? { tax:
|
|
150
|
-
total:
|
|
174
|
+
subtotal: formatMinor(receipt.subtotal ?? 0),
|
|
175
|
+
...receipt.discount && receipt.discount > 0 ? { discount: formatMinor(receipt.discount) } : {},
|
|
176
|
+
...receipt.vat?.amount && receipt.vat.amount > 0 ? { tax: formatMinor(receipt.vat.amount) } : {},
|
|
177
|
+
total: formatMinor(receipt.total ?? 0),
|
|
151
178
|
extraRows: summaryExtra,
|
|
152
179
|
currency: locale.currency,
|
|
153
180
|
labels: { tax: locale.taxLabel },
|
|
@@ -170,7 +197,7 @@ function ReceiptReprintDialog({ order, open, onOpenChange }) {
|
|
|
170
197
|
}), /* @__PURE__ */ jsxs(Button, {
|
|
171
198
|
onClick: handlePrint,
|
|
172
199
|
disabled: !receipt,
|
|
173
|
-
children: [/* @__PURE__ */ jsx(Printer, { className: "h-4 w-4
|
|
200
|
+
children: [/* @__PURE__ */ jsx(Printer, { className: "h-4 w-4 me-2" }), "Print"]
|
|
174
201
|
})]
|
|
175
202
|
})
|
|
176
203
|
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { CartSidebar } from "./CartSidebar.js";
|
|
2
|
+
import { PosTopBar, PosTopBarProps } from "./PosTopBar.js";
|
|
3
|
+
import { PrinterSettingsDialog } from "./PrinterSettingsDialog.js";
|
|
4
|
+
import { ReceiptDeliveryDialog } from "./ReceiptDeliveryDialog.js";
|
|
5
|
+
import { ReceiptReprintDialog } from "./ReceiptReprintDialog.js";
|
|
6
|
+
export { CartSidebar, PosTopBar, type PosTopBarProps, PrinterSettingsDialog, ReceiptDeliveryDialog, ReceiptReprintDialog };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { PosTopBar } from "./PosTopBar.js";
|
|
2
|
+
import { CartSidebar } from "./CartSidebar.js";
|
|
3
|
+
import { ReceiptReprintDialog } from "./ReceiptReprintDialog.js";
|
|
4
|
+
import { PrinterSettingsDialog } from "./PrinterSettingsDialog.js";
|
|
5
|
+
import { ReceiptDeliveryDialog } from "./ReceiptDeliveryDialog.js";
|
|
6
|
+
|
|
7
|
+
export { CartSidebar, PosTopBar, PrinterSettingsDialog, ReceiptDeliveryDialog, ReceiptReprintDialog };
|
|
@@ -3,116 +3,134 @@
|
|
|
3
3
|
import { customerDisplayName, customerDisplayPhone } from "../../utils/customer-display.js";
|
|
4
4
|
import { getReadableTextColor, getTierColor } from "../../lib/loyalty.js";
|
|
5
5
|
import { useMemo } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { useCustomers } from "@classytic/commerce-sdk/sales";
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
-
import {
|
|
8
|
+
import { UserPlus } from "lucide-react";
|
|
9
9
|
import { Button } from "@/components/ui/button";
|
|
10
10
|
import { Badge } from "@/components/ui/badge";
|
|
11
|
-
import {
|
|
12
|
-
import { Skeleton } from "@/components/ui/skeleton";
|
|
11
|
+
import { PickerQuery, PickerResults, PickerRow, SearchPickerDialog, useLocalSearch } from "@classytic/fluid/search";
|
|
13
12
|
|
|
14
13
|
//#region src/dashboard/components/CustomerLookupDialog.tsx
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return /* @__PURE__ */ jsxs(DialogWrapper, {
|
|
14
|
+
/**
|
|
15
|
+
* Which field a cashier is searching. PHONE leads because a till is
|
|
16
|
+
* phone-first — the number is what a returning customer recites.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the field is CHOSEN, not guessed
|
|
19
|
+
*
|
|
20
|
+
* This used to infer the field from the text: `isValidPhone()` → exact phone,
|
|
21
|
+
* `/^\d{4,}$/` → phone contains, otherwise name. Two problems. A guess that
|
|
22
|
+
* lands wrong returns an empty list, which is indistinguishable from "this
|
|
23
|
+
* customer is not in the system" — the cashier creates a duplicate. And the
|
|
24
|
+
* inference lived in the POS hook while the accounting picker inferred nothing,
|
|
25
|
+
* so one product had two answers to "how do I find a customer". Now both
|
|
26
|
+
* declare their fields the same way and the operator picks.
|
|
27
|
+
*/
|
|
28
|
+
const SEARCH_TYPES = [
|
|
29
|
+
{
|
|
30
|
+
value: "phone",
|
|
31
|
+
label: "Phone"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
value: "name",
|
|
35
|
+
label: "Name"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
value: "email",
|
|
39
|
+
label: "Email"
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
/** type → API param. The same `searchFields` grammar the list pages use. */
|
|
43
|
+
const SEARCH_FIELDS = {
|
|
44
|
+
phone: "contact.phone[contains]",
|
|
45
|
+
name: "name.given[like]",
|
|
46
|
+
email: "contact.email[like]"
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Find a customer at the till.
|
|
50
|
+
*
|
|
51
|
+
* Composed from fluid's picker parts, so the till, the invoice screen and the
|
|
52
|
+
* partner statement all search through ONE implementation. What stays local is
|
|
53
|
+
* the row: POS shows the loyalty tier, which nothing else does — and that is
|
|
54
|
+
* exactly the part a shared `renderRow` prop would have made awkward.
|
|
55
|
+
*
|
|
56
|
+
* Nothing is fetched until the cashier presses Search (`enabled:
|
|
57
|
+
* hasActiveSearch`), so opening the dialog costs no query and the customer
|
|
58
|
+
* collection is never listed.
|
|
59
|
+
*/
|
|
60
|
+
function CustomerLookupDialog({ open, onOpenChange, onSelect, onCreate }) {
|
|
61
|
+
const search = useLocalSearch({
|
|
62
|
+
searchFields: SEARCH_FIELDS,
|
|
63
|
+
defaultSearchType: "phone",
|
|
64
|
+
baseParams: { limit: "10" }
|
|
65
|
+
});
|
|
66
|
+
const params = search.getSearchParams();
|
|
67
|
+
const { items, isLoading, isFetching } = useCustomers(params, {
|
|
68
|
+
enabled: open && search.hasActiveSearch,
|
|
69
|
+
refetchOnWindowFocus: false
|
|
70
|
+
});
|
|
71
|
+
const results = useMemo(() => items ?? [], [items]);
|
|
72
|
+
const typed = search.searchValue.trim();
|
|
73
|
+
const prefill = search.searchType === "phone" ? { phone: typed } : { name: typed };
|
|
74
|
+
return /* @__PURE__ */ jsxs(SearchPickerDialog, {
|
|
77
75
|
open,
|
|
78
76
|
onOpenChange,
|
|
77
|
+
search,
|
|
79
78
|
title: "Find customer",
|
|
80
|
-
description: "Search by phone or
|
|
81
|
-
size: "lg",
|
|
79
|
+
description: "Search by phone, name or email, then attach loyalty.",
|
|
82
80
|
footer: /* @__PURE__ */ jsxs("div", {
|
|
83
81
|
className: "flex justify-end gap-2",
|
|
84
82
|
children: [/* @__PURE__ */ jsxs(Button, {
|
|
85
83
|
variant: "outline",
|
|
86
|
-
onClick: onCreate,
|
|
87
|
-
children: [/* @__PURE__ */ jsx(UserPlus, { className: "h-4 w-4
|
|
84
|
+
onClick: () => onCreate(prefill),
|
|
85
|
+
children: [/* @__PURE__ */ jsx(UserPlus, { className: "h-4 w-4 me-2" }), "Add new"]
|
|
88
86
|
}), /* @__PURE__ */ jsx(Button, {
|
|
89
87
|
variant: "ghost",
|
|
90
88
|
onClick: () => onOpenChange(false),
|
|
91
89
|
children: "Close"
|
|
92
90
|
})]
|
|
93
91
|
}),
|
|
92
|
+
children: [/* @__PURE__ */ jsx(PickerQuery, {
|
|
93
|
+
types: SEARCH_TYPES,
|
|
94
|
+
placeholder: "Phone, name or email"
|
|
95
|
+
}), /* @__PURE__ */ jsx(PickerResults, {
|
|
96
|
+
isLoading: isLoading || isFetching,
|
|
97
|
+
isEmpty: results.length === 0,
|
|
98
|
+
prompt: "Enter a phone number or name and press Search.",
|
|
99
|
+
empty: "No customer found — use Add new to create one.",
|
|
100
|
+
children: results.map((customer) => /* @__PURE__ */ jsx(PickerRow, {
|
|
101
|
+
onSelect: () => onSelect(customer),
|
|
102
|
+
children: /* @__PURE__ */ jsx(CustomerRow, { customer })
|
|
103
|
+
}, customer._id))
|
|
104
|
+
})]
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** The till's own row: identity plus loyalty tier. */
|
|
108
|
+
function CustomerRow({ customer }) {
|
|
109
|
+
const phone = customerDisplayPhone(customer);
|
|
110
|
+
const email = customer.contact?.email;
|
|
111
|
+
const tierName = customer.membership?.tier || customer.revenueTier;
|
|
112
|
+
const tierColor = tierName ? getTierColor(tierName) : void 0;
|
|
113
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
114
|
+
className: "flex items-center justify-between gap-3",
|
|
94
115
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
95
|
-
className: "
|
|
96
|
-
children: [/* @__PURE__ */ jsx(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
e.preventDefault();
|
|
103
|
-
onSearch();
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}), /* @__PURE__ */ jsxs(Button, {
|
|
107
|
-
type: "button",
|
|
108
|
-
onClick: onSearch,
|
|
109
|
-
className: "shrink-0",
|
|
110
|
-
children: [/* @__PURE__ */ jsx(Search, { className: "h-4 w-4 mr-2" }), "Search"]
|
|
116
|
+
className: "min-w-0",
|
|
117
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
118
|
+
className: "truncate text-sm font-medium",
|
|
119
|
+
children: customerDisplayName(customer)
|
|
120
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
121
|
+
className: "truncate text-xs text-muted-foreground",
|
|
122
|
+
children: phone ?? email ?? "No contact info"
|
|
111
123
|
})]
|
|
112
|
-
}), /* @__PURE__ */ jsx(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
124
|
+
}), tierName ? /* @__PURE__ */ jsx(Badge, {
|
|
125
|
+
variant: "secondary",
|
|
126
|
+
className: "capitalize shrink-0",
|
|
127
|
+
style: tierColor ? {
|
|
128
|
+
backgroundColor: tierColor,
|
|
129
|
+
color: getReadableTextColor(tierColor),
|
|
130
|
+
borderColor: "transparent"
|
|
131
|
+
} : void 0,
|
|
132
|
+
children: tierName
|
|
133
|
+
}) : null]
|
|
116
134
|
});
|
|
117
135
|
}
|
|
118
136
|
|
|
@@ -7,6 +7,7 @@ import { customerApi } from "@classytic/commerce-sdk/sales";
|
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
import { getReceiptLocale, isValidPhone } from "@classytic/commerce-sdk/client";
|
|
9
9
|
import { toast } from "sonner";
|
|
10
|
+
import { presentError } from "@classytic/fluid/error-contract";
|
|
10
11
|
import { Input } from "@/components/ui/input";
|
|
11
12
|
|
|
12
13
|
//#region src/dashboard/components/CustomerQuickAddDialog.tsx
|
|
@@ -61,7 +62,8 @@ function CustomerQuickAddDialog({ token, open, onOpenChange, defaultName, defaul
|
|
|
61
62
|
onCreated(created);
|
|
62
63
|
onOpenChange(false);
|
|
63
64
|
} catch (error) {
|
|
64
|
-
|
|
65
|
+
const { title, message } = presentError(error, "The customer was not saved. Try again.", { fallbackTitle: "Could not add the customer" });
|
|
66
|
+
toast.error(title, { description: message });
|
|
65
67
|
} finally {
|
|
66
68
|
setIsSaving(false);
|
|
67
69
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { Banknote, Store, Truck } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
|
|
8
|
+
//#region src/dashboard/components/DeliveryPanel.tsx
|
|
9
|
+
/**
|
|
10
|
+
* Delivery + payment-term controls for the sale being rung.
|
|
11
|
+
*
|
|
12
|
+
* Two decisions, deliberately rendered as two separate choices because they are
|
|
13
|
+
* orthogonal: WHERE the goods go, and WHEN the customer pays. A phone order can be
|
|
14
|
+
* prepaid by bKash and shipped; a walk-in can pay cash for something delivered
|
|
15
|
+
* tomorrow.
|
|
16
|
+
*
|
|
17
|
+
* ## Collect-on-delivery is shown only where the deployment allows it
|
|
18
|
+
*
|
|
19
|
+
* `collectOnDeliveryEnabled` comes from the host (`PosShellProps.sales`), and it
|
|
20
|
+
* decides whether the CONTROL RENDERS — never whether the sale is permitted. The
|
|
21
|
+
* server is the authority and refuses an undeclared COD sale with
|
|
22
|
+
* `PAYMENT_TERM_NOT_ENABLED`, so a host flag that disagrees produces a visible
|
|
23
|
+
* error, not a sale that quietly books wrong. That asymmetry is the point: this is
|
|
24
|
+
* a UI affordance, not a second copy of the policy.
|
|
25
|
+
*
|
|
26
|
+
* The option also only appears for a DELIVERY sale. "Collect on delivery" names a
|
|
27
|
+
* delivery that has not happened yet; on a carryout the customer is standing at the
|
|
28
|
+
* counter, so there is no later collection and the term is meaningless.
|
|
29
|
+
*/
|
|
30
|
+
function DeliveryPanel({ delivery, collectOnDeliveryEnabled, disabled }) {
|
|
31
|
+
const isDelivery = delivery.method === "delivery";
|
|
32
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
33
|
+
className: "space-y-3 rounded-lg border p-3",
|
|
34
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
35
|
+
className: "grid grid-cols-2 gap-2",
|
|
36
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
37
|
+
type: "button",
|
|
38
|
+
variant: delivery.method === "pickup" ? "default" : "outline",
|
|
39
|
+
className: "h-11 text-sm",
|
|
40
|
+
disabled,
|
|
41
|
+
onClick: () => delivery.setMethod("pickup"),
|
|
42
|
+
children: [/* @__PURE__ */ jsx(Store, { className: "h-4 w-4 me-2" }), "Carryout"]
|
|
43
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
44
|
+
type: "button",
|
|
45
|
+
variant: isDelivery ? "default" : "outline",
|
|
46
|
+
className: "h-11 text-sm",
|
|
47
|
+
disabled,
|
|
48
|
+
onClick: () => delivery.setMethod("delivery"),
|
|
49
|
+
children: [/* @__PURE__ */ jsx(Truck, { className: "h-4 w-4 me-2" }), "Delivery"]
|
|
50
|
+
})]
|
|
51
|
+
}), isDelivery && /* @__PURE__ */ jsxs("div", {
|
|
52
|
+
className: "space-y-2",
|
|
53
|
+
children: [
|
|
54
|
+
/* @__PURE__ */ jsx(Input, {
|
|
55
|
+
placeholder: "Recipient name",
|
|
56
|
+
value: delivery.address.recipientName,
|
|
57
|
+
disabled,
|
|
58
|
+
onChange: (e) => delivery.setAddressField("recipientName", e.target.value)
|
|
59
|
+
}),
|
|
60
|
+
/* @__PURE__ */ jsx(Input, {
|
|
61
|
+
placeholder: "Recipient phone",
|
|
62
|
+
inputMode: "tel",
|
|
63
|
+
value: delivery.address.recipientPhone,
|
|
64
|
+
disabled,
|
|
65
|
+
onChange: (e) => delivery.setAddressField("recipientPhone", e.target.value)
|
|
66
|
+
}),
|
|
67
|
+
/* @__PURE__ */ jsx(Input, {
|
|
68
|
+
placeholder: "Address *",
|
|
69
|
+
value: delivery.address.addressLine1,
|
|
70
|
+
disabled,
|
|
71
|
+
onChange: (e) => delivery.setAddressField("addressLine1", e.target.value)
|
|
72
|
+
}),
|
|
73
|
+
/* @__PURE__ */ jsxs("div", {
|
|
74
|
+
className: "grid grid-cols-2 gap-2",
|
|
75
|
+
children: [/* @__PURE__ */ jsx(Input, {
|
|
76
|
+
placeholder: "City / town *",
|
|
77
|
+
value: delivery.address.city,
|
|
78
|
+
disabled,
|
|
79
|
+
onChange: (e) => delivery.setAddressField("city", e.target.value)
|
|
80
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
81
|
+
placeholder: "Area",
|
|
82
|
+
value: delivery.address.area,
|
|
83
|
+
disabled,
|
|
84
|
+
onChange: (e) => delivery.setAddressField("area", e.target.value)
|
|
85
|
+
})]
|
|
86
|
+
}),
|
|
87
|
+
/* @__PURE__ */ jsx(Input, {
|
|
88
|
+
placeholder: "Delivery note (landmark, timing)",
|
|
89
|
+
value: delivery.address.note,
|
|
90
|
+
disabled,
|
|
91
|
+
onChange: (e) => delivery.setAddressField("note", e.target.value)
|
|
92
|
+
}),
|
|
93
|
+
/* @__PURE__ */ jsx(Input, {
|
|
94
|
+
placeholder: "Delivery charge",
|
|
95
|
+
inputMode: "decimal",
|
|
96
|
+
value: delivery.feeInput,
|
|
97
|
+
disabled,
|
|
98
|
+
onChange: (e) => delivery.setFeeInput(e.target.value)
|
|
99
|
+
}),
|
|
100
|
+
collectOnDeliveryEnabled && /* @__PURE__ */ jsxs("div", {
|
|
101
|
+
className: "space-y-2 pt-1",
|
|
102
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
103
|
+
className: "grid grid-cols-2 gap-2",
|
|
104
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
105
|
+
type: "button",
|
|
106
|
+
variant: delivery.paymentTerm === "prepaid" ? "default" : "outline",
|
|
107
|
+
className: "h-10 text-sm",
|
|
108
|
+
disabled,
|
|
109
|
+
onClick: () => delivery.setPaymentTerm("prepaid"),
|
|
110
|
+
children: "Pay now"
|
|
111
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
112
|
+
type: "button",
|
|
113
|
+
variant: delivery.paymentTerm === "collect_on_delivery" ? "default" : "outline",
|
|
114
|
+
className: "h-10 text-sm",
|
|
115
|
+
disabled,
|
|
116
|
+
onClick: () => delivery.setPaymentTerm("collect_on_delivery"),
|
|
117
|
+
children: [/* @__PURE__ */ jsx(Banknote, { className: "h-4 w-4 me-2" }), "Cash on delivery"]
|
|
118
|
+
})]
|
|
119
|
+
}), delivery.paymentTerm === "collect_on_delivery" && /* @__PURE__ */ jsxs("p", {
|
|
120
|
+
className: "text-xs text-muted-foreground",
|
|
121
|
+
children: [
|
|
122
|
+
"No payment is taken now. The courier collects ",
|
|
123
|
+
delivery.fee > 0 ? "the goods and the delivery charge" : "the full amount",
|
|
124
|
+
" on delivery, and this sale adds nothing to the drawer."
|
|
125
|
+
]
|
|
126
|
+
})]
|
|
127
|
+
})
|
|
128
|
+
]
|
|
129
|
+
})]
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//#endregion
|
|
134
|
+
export { DeliveryPanel };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { formatMajor } from "../../lib/money.js";
|
|
4
4
|
import { cn } from "../../lib/cn.js";
|
|
5
5
|
import { calculateVariantPrice, getAvailableVariants, getPosPrice, getPosProductImage, isVariantProduct } from "@classytic/commerce-sdk/sales";
|
|
6
6
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -10,8 +10,18 @@ import { Card, CardContent } from "@/components/ui/card";
|
|
|
10
10
|
//#region src/dashboard/components/ProductCard.tsx
|
|
11
11
|
function ProductCard({ product, onAddToCart, onOpenVariantSelector }) {
|
|
12
12
|
const hasVariants = isVariantProduct(product);
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Is this product COUNTED at all? Per-product, absent ⇒ tracked (the catalog default).
|
|
15
|
+
*
|
|
16
|
+
* A membership month, a PT session and a haircut hold no stock, so every stock signal
|
|
17
|
+
* below is meaningless for them — and the defaults all resolve to "unavailable":
|
|
18
|
+
* `inStock ?? false` refused them outright, and `quantity ?? 0` rendered "Qty: 0" beside
|
|
19
|
+
* a ৳3,000 membership. An untracked product is always sellable and has no quantity to
|
|
20
|
+
* show, which is what `isInStock` in the SDK now says too.
|
|
21
|
+
*/
|
|
22
|
+
const tracked = product.inventory?.stockTracked !== false;
|
|
23
|
+
const inStock = tracked ? product.branchStock?.inStock ?? false : true;
|
|
24
|
+
const lowStock = tracked ? product.branchStock?.lowStock ?? false : false;
|
|
15
25
|
const stockQty = product.branchStock?.quantity ?? 0;
|
|
16
26
|
const getPriceDisplay = () => {
|
|
17
27
|
if (!hasVariants) return {
|
|
@@ -46,7 +56,7 @@ function ProductCard({ product, onAddToCart, onOpenVariantSelector }) {
|
|
|
46
56
|
className: "p-0",
|
|
47
57
|
children: [
|
|
48
58
|
/* @__PURE__ */ jsxs("div", {
|
|
49
|
-
className: "absolute top-2
|
|
59
|
+
className: "absolute top-2 end-2 z-10 flex flex-col gap-1 items-end",
|
|
50
60
|
children: [!inStock ? /* @__PURE__ */ jsx(Badge, {
|
|
51
61
|
variant: "destructive",
|
|
52
62
|
className: "text-xs shadow-md",
|
|
@@ -79,10 +89,10 @@ function ProductCard({ product, onAddToCart, onOpenVariantSelector }) {
|
|
|
79
89
|
children: [/* @__PURE__ */ jsxs("span", {
|
|
80
90
|
className: "text-base font-bold text-primary",
|
|
81
91
|
children: [showFrom && /* @__PURE__ */ jsx("span", {
|
|
82
|
-
className: "text-xs font-normal text-muted-foreground
|
|
92
|
+
className: "text-xs font-normal text-muted-foreground me-1",
|
|
83
93
|
children: "From"
|
|
84
|
-
}),
|
|
85
|
-
}), inStock && /* @__PURE__ */ jsxs("span", {
|
|
94
|
+
}), formatMajor(displayPrice)]
|
|
95
|
+
}), tracked && inStock && /* @__PURE__ */ jsxs("span", {
|
|
86
96
|
className: "text-xs text-muted-foreground",
|
|
87
97
|
children: ["Qty: ", stockQty]
|
|
88
98
|
})]
|
|
@@ -24,11 +24,11 @@ const ProductsPanel = memo(function ProductsPanel({ searchQuery, selectedCategor
|
|
|
24
24
|
children: [
|
|
25
25
|
/* @__PURE__ */ jsxs("div", {
|
|
26
26
|
className: "relative flex-1",
|
|
27
|
-
children: [/* @__PURE__ */ jsx(Search, { className: "absolute
|
|
27
|
+
children: [/* @__PURE__ */ jsx(Search, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), /* @__PURE__ */ jsx(Input, {
|
|
28
28
|
placeholder: "Search products...",
|
|
29
29
|
value: searchQuery,
|
|
30
30
|
onChange: (e) => onSearchChange(e.target.value),
|
|
31
|
-
className: "
|
|
31
|
+
className: "ps-9"
|
|
32
32
|
})]
|
|
33
33
|
}),
|
|
34
34
|
/* @__PURE__ */ jsx(Button, {
|
|
@@ -52,11 +52,11 @@ const ProductsPanel = memo(function ProductsPanel({ searchQuery, selectedCategor
|
|
|
52
52
|
className: "flex items-center gap-2",
|
|
53
53
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
54
54
|
className: "relative flex-1",
|
|
55
|
-
children: [/* @__PURE__ */ jsx(Barcode, { className: "absolute
|
|
55
|
+
children: [/* @__PURE__ */ jsx(Barcode, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), /* @__PURE__ */ jsx(Input, {
|
|
56
56
|
placeholder: "Scan barcode or SKU...",
|
|
57
57
|
value: barcodeInput,
|
|
58
58
|
onChange: (e) => onBarcodeInputChange(e.target.value),
|
|
59
|
-
className: "
|
|
59
|
+
className: "ps-9",
|
|
60
60
|
disabled: isLookingUp
|
|
61
61
|
})]
|
|
62
62
|
}), /* @__PURE__ */ jsx(Button, {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { formatMajor } from "../../lib/money.js";
|
|
4
4
|
import { cn } from "../../lib/cn.js";
|
|
5
5
|
import { useMemo } from "react";
|
|
6
6
|
import { SelectDropdown } from "@classytic/fluid/client/core";
|
|
@@ -40,7 +40,7 @@ function SplitPaymentPanel({ options, entries, total, remaining, onAdd, onRemove
|
|
|
40
40
|
size: "sm",
|
|
41
41
|
className: "h-8",
|
|
42
42
|
onClick: () => onAdd(),
|
|
43
|
-
children: [/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5
|
|
43
|
+
children: [/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5 me-1" }), "Add"]
|
|
44
44
|
})]
|
|
45
45
|
}),
|
|
46
46
|
/* @__PURE__ */ jsx("div", {
|
|
@@ -92,7 +92,7 @@ function SplitPaymentPanel({ options, entries, total, remaining, onAdd, onRemove
|
|
|
92
92
|
size: "sm",
|
|
93
93
|
className: "h-6 px-2",
|
|
94
94
|
onClick: () => onUpdate(entry.id, { amount: String(remainingForRow) }),
|
|
95
|
-
children: ["Fill ",
|
|
95
|
+
children: ["Fill ", formatMajor(remainingForRow)]
|
|
96
96
|
})]
|
|
97
97
|
}),
|
|
98
98
|
needsRef && /* @__PURE__ */ jsx(Input, {
|
|
@@ -113,13 +113,13 @@ function SplitPaymentPanel({ options, entries, total, remaining, onAdd, onRemove
|
|
|
113
113
|
className: "flex items-center justify-between text-xs text-muted-foreground",
|
|
114
114
|
children: [/* @__PURE__ */ jsxs("span", { children: ["Total: ", /* @__PURE__ */ jsx("span", {
|
|
115
115
|
className: "font-medium text-foreground",
|
|
116
|
-
children:
|
|
116
|
+
children: formatMajor(total)
|
|
117
117
|
})] }), /* @__PURE__ */ jsxs("span", { children: [
|
|
118
118
|
"Remaining:",
|
|
119
119
|
" ",
|
|
120
120
|
/* @__PURE__ */ jsx("span", {
|
|
121
121
|
className: remaining === 0 ? "font-medium text-foreground" : "text-destructive font-medium",
|
|
122
|
-
children:
|
|
122
|
+
children: formatMajor(remaining)
|
|
123
123
|
})
|
|
124
124
|
] })]
|
|
125
125
|
})
|