@classytic/pos-ui 0.1.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/README.md +38 -0
- package/dist/components/CartSidebar.js +222 -0
- package/dist/components/PosTopBar.js +156 -0
- package/dist/components/PrinterSettingsDialog.js +163 -0
- package/dist/components/ReceiptReprintDialog.js +182 -0
- package/dist/dashboard/components/CustomerLookupDialog.js +120 -0
- package/dist/dashboard/components/CustomerQuickAddDialog.js +101 -0
- package/dist/dashboard/components/ManagerAuthDialog.js +77 -0
- package/dist/dashboard/components/ProductCard.js +98 -0
- package/dist/dashboard/components/ProductsPanel.js +119 -0
- package/dist/dashboard/components/SplitPaymentPanel.js +131 -0
- package/dist/dashboard/components/VariantSelectorDialog.js +150 -0
- package/dist/dashboard/components/cart/AddChargeDialog.js +99 -0
- package/dist/dashboard/components/cart/CartItems.js +103 -0
- package/dist/dashboard/components/cart/CartSummary.js +73 -0
- package/dist/dashboard/components/cart/CustomerSection.js +127 -0
- package/dist/dashboard/components/cart/DiscountSection.js +50 -0
- package/dist/dashboard/components/cart/PointsRedemptionSection.js +58 -0
- package/dist/hardware/context.d.ts +15 -0
- package/dist/hardware/context.js +33 -0
- package/dist/hardware/index.d.ts +7 -0
- package/dist/hardware/index.js +7 -0
- package/dist/hardware/ports.d.ts +116 -0
- package/dist/hardware/tauri-adapter.d.ts +7 -0
- package/dist/hardware/tauri-adapter.js +77 -0
- package/dist/hardware/use-printer-availability.d.ts +12 -0
- package/dist/hardware/use-printer-availability.js +50 -0
- package/dist/hardware/use-printer-config.d.ts +16 -0
- package/dist/hardware/use-printer-config.js +62 -0
- package/dist/hardware/web-adapter.d.ts +15 -0
- package/dist/hardware/web-adapter.js +80 -0
- package/dist/hooks/useManagerAuth.js +83 -0
- package/dist/hooks/usePosCart.js +142 -0
- package/dist/hooks/usePosCustomer.js +192 -0
- package/dist/hooks/usePosMultiOrder.js +48 -0
- package/dist/hooks/usePosPayment.js +194 -0
- package/dist/lib/cn.js +12 -0
- package/dist/lib/loyalty.js +30 -0
- package/dist/lib/money.js +41 -0
- package/dist/node_modules/react-hook-form/dist/index.esm.js +1661 -0
- package/dist/runtime/auth-port.d.ts +46 -0
- package/dist/runtime/auth-port.js +21 -0
- package/dist/runtime/branch-port.d.ts +38 -0
- package/dist/runtime/branch-port.js +26 -0
- package/dist/runtime/config.d.ts +24 -0
- package/dist/runtime/config.js +21 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +5 -0
- package/dist/screens/OrderHistoryDrawer.js +245 -0
- package/dist/screens/ParkedOrdersDrawer.js +128 -0
- package/dist/screens/PaymentScreen.js +397 -0
- package/dist/screens/ProductScreen.js +322 -0
- package/dist/screens/ReceiptScreen.js +288 -0
- package/dist/screens/ShiftCloseScreen.js +365 -0
- package/dist/screens/ShiftOpenScreen.js +176 -0
- package/dist/shell/index.d.ts +8 -0
- package/dist/shell/index.js +5 -0
- package/dist/shell/pos-shell.d.ts +23 -0
- package/dist/shell/pos-shell.js +132 -0
- package/dist/state/pos-context.js +33 -0
- package/dist/state/pos-state.js +70 -0
- package/dist/utils/customer-display.js +35 -0
- package/dist/utils/pos-helpers.js +242 -0
- package/package.json +92 -0
- package/styles.css +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @classytic/pos-ui
|
|
2
|
+
|
|
3
|
+
> Point-of-sale register UI — a self-contained, full-screen state-machine app (shift → products → payment → receipt → close) with a hardware DI port (printer / drawer / scanner / terminal). Frontend sibling of the [@classytic/pos](https://npmjs.com/package/@classytic/pos) kernel.
|
|
4
|
+
|
|
5
|
+
Multi-surface by design: web (browser), Tauri desktop, and React Native share the same screens and state machine; only the runtime + hardware adapters differ. Independent of the dashboard stack — no `@classytic/erp-shell`, no route framework. The host mounts one component and injects adapters.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @classytic/pos-ui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peers: React 19, Next 14+, `@classytic/commerce-sdk` ≥0.9, `@classytic/fluid` ≥1.7, `@classytic/primitives` ≥0.9, TanStack Query 5, sonner, lucide-react. Host-owned `@/components/ui/*` shadcn primitives (same ambient-stub convention as `@classytic/commerce-ui`).
|
|
14
|
+
|
|
15
|
+
## Subpath exports
|
|
16
|
+
|
|
17
|
+
| Subpath | What it gives you |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `@classytic/pos-ui/shell` | `<PosShell>` + props + the DI port types the host implements |
|
|
20
|
+
| `@classytic/pos-ui/hardware` | `PosHardware` ports, `createWebHardware()`, Tauri adapter, printer hooks |
|
|
21
|
+
| `@classytic/pos-ui/runtime` | Surface adapters (web, Tauri) — storage, focus, fullscreen, keyboard |
|
|
22
|
+
|
|
23
|
+
Internal layers (`screens/`, `state/`, `dashboard/`, `components/`) are not part of the public surface.
|
|
24
|
+
|
|
25
|
+
## Quick start (web)
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { PosShell } from '@classytic/pos-ui/shell';
|
|
29
|
+
import { createWebHardware } from '@classytic/pos-ui/hardware';
|
|
30
|
+
|
|
31
|
+
<PosShell hardware={createWebHardware()} />
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Hardware adapters are a port — implement the interface for your printer/drawer/terminal vendor and inject it; the screens never talk to devices directly.
|
|
35
|
+
|
|
36
|
+
## Releasing
|
|
37
|
+
|
|
38
|
+
See sibling packages' `RELEASING.md` — standard `@classytic/*` flow (`npm run release`).
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatBdt } from "../lib/money.js";
|
|
4
|
+
import { getReadableTextColor, getTierColor } from "../lib/loyalty.js";
|
|
5
|
+
import { CartItems } from "../dashboard/components/cart/CartItems.js";
|
|
6
|
+
import { CartSummary } from "../dashboard/components/cart/CartSummary.js";
|
|
7
|
+
import { CustomerSection } from "../dashboard/components/cart/CustomerSection.js";
|
|
8
|
+
import { DiscountSection } from "../dashboard/components/cart/DiscountSection.js";
|
|
9
|
+
import { PointsRedemptionSection } from "../dashboard/components/cart/PointsRedemptionSection.js";
|
|
10
|
+
import { AddChargeDialog } from "../dashboard/components/cart/AddChargeDialog.js";
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { Inbox, PauseCircle, ShoppingCart, Trash2 } from "lucide-react";
|
|
14
|
+
import { Button } from "@/components/ui/button";
|
|
15
|
+
import { Badge } from "@/components/ui/badge";
|
|
16
|
+
|
|
17
|
+
//#region src/components/CartSidebar.tsx
|
|
18
|
+
/**
|
|
19
|
+
* CartSidebar — Compact cart panel for the product screen.
|
|
20
|
+
*
|
|
21
|
+
* Shows: cart items, customer, discount, points, totals, checkout button.
|
|
22
|
+
* Payment and receipt are separate screens — not inline here.
|
|
23
|
+
*/
|
|
24
|
+
function CartSidebar({ cart, customer, membership, orderTotals, discountAuth, onCheckout, onRequestDiscountAuth, onClearDiscountAuth, onParkSale, onOpenParked, parkedCount = 0, canPark = true }) {
|
|
25
|
+
const isEmpty = cart.cart.length === 0;
|
|
26
|
+
useMemo(() => ({
|
|
27
|
+
items: cart.cart,
|
|
28
|
+
subtotal: orderTotals.subtotal,
|
|
29
|
+
discount: orderTotals.manualDiscount,
|
|
30
|
+
tierDiscount: orderTotals.tierDiscount,
|
|
31
|
+
tierName: orderTotals.tierName,
|
|
32
|
+
redemptionDiscount: orderTotals.redemptionDiscount,
|
|
33
|
+
total: orderTotals.total,
|
|
34
|
+
pointsToEarn: orderTotals.pointsToEarn
|
|
35
|
+
}), [cart.cart, orderTotals]);
|
|
36
|
+
const customerViewModel = useMemo(() => {
|
|
37
|
+
const tierName = customer.selectedCustomer?.membership?.tier || null;
|
|
38
|
+
const tierColor = getTierColor(tierName);
|
|
39
|
+
const tierTextColor = getReadableTextColor(tierColor);
|
|
40
|
+
return {
|
|
41
|
+
selected: customer.selectedCustomer,
|
|
42
|
+
name: customer.customerName,
|
|
43
|
+
phone: customer.customerPhone,
|
|
44
|
+
membershipCardId: customer.membershipCardId,
|
|
45
|
+
membershipStatus: customer.membershipLookupStatus,
|
|
46
|
+
tierName,
|
|
47
|
+
tierColor,
|
|
48
|
+
tierTextColor
|
|
49
|
+
};
|
|
50
|
+
}, [
|
|
51
|
+
customer.selectedCustomer,
|
|
52
|
+
customer.customerName,
|
|
53
|
+
customer.customerPhone,
|
|
54
|
+
customer.membershipCardId,
|
|
55
|
+
customer.membershipLookupStatus
|
|
56
|
+
]);
|
|
57
|
+
const discountAuthViewModel = useMemo(() => ({
|
|
58
|
+
isAuthorized: discountAuth.isAuthorized,
|
|
59
|
+
authorizedByName: discountAuth.authorizedBy?.name || null
|
|
60
|
+
}), [discountAuth.isAuthorized, discountAuth.authorizedBy]);
|
|
61
|
+
const redemption = useMemo(() => ({
|
|
62
|
+
enabled: !!membership?.enabled && !!membership?.redemption?.enabled && !!customer.selectedCustomer?.membership?.cardId,
|
|
63
|
+
pointsBalance: customer.selectedCustomer?.membership?.points?.current ?? 0,
|
|
64
|
+
minRedeemPoints: membership?.redemption?.minRedeemPoints ?? 0,
|
|
65
|
+
maxRedeemPoints: Math.min(orderTotals.maxAllowedPoints, customer.selectedCustomer?.membership?.points?.current ?? 0),
|
|
66
|
+
pointsPerBdt: membership?.redemption?.pointsPerBdt ?? 1,
|
|
67
|
+
estimatedDiscount: orderTotals.redemptionDiscount,
|
|
68
|
+
error: orderTotals.redemptionError
|
|
69
|
+
}), [
|
|
70
|
+
membership,
|
|
71
|
+
customer.selectedCustomer,
|
|
72
|
+
orderTotals
|
|
73
|
+
]);
|
|
74
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
75
|
+
className: "flex flex-col h-full",
|
|
76
|
+
children: [
|
|
77
|
+
/* @__PURE__ */ jsxs("div", {
|
|
78
|
+
className: "flex items-center justify-between px-4 py-3 border-b",
|
|
79
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
80
|
+
className: "flex items-center gap-2",
|
|
81
|
+
children: [
|
|
82
|
+
/* @__PURE__ */ jsx(ShoppingCart, { className: "h-4 w-4" }),
|
|
83
|
+
/* @__PURE__ */ jsx("span", {
|
|
84
|
+
className: "font-semibold text-sm",
|
|
85
|
+
children: "Cart"
|
|
86
|
+
}),
|
|
87
|
+
!isEmpty && /* @__PURE__ */ jsx(Badge, {
|
|
88
|
+
variant: "secondary",
|
|
89
|
+
className: "text-xs",
|
|
90
|
+
children: cart.cart.length
|
|
91
|
+
})
|
|
92
|
+
]
|
|
93
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
94
|
+
className: "flex items-center gap-1",
|
|
95
|
+
children: [
|
|
96
|
+
onOpenParked && /* @__PURE__ */ jsxs(Button, {
|
|
97
|
+
variant: "ghost",
|
|
98
|
+
size: "sm",
|
|
99
|
+
className: "h-7 text-xs",
|
|
100
|
+
onClick: onOpenParked,
|
|
101
|
+
"aria-label": `Parked sales (${parkedCount})`,
|
|
102
|
+
children: [
|
|
103
|
+
/* @__PURE__ */ jsx(Inbox, { className: "h-3.5 w-3.5 mr-1" }),
|
|
104
|
+
"Parked",
|
|
105
|
+
parkedCount > 0 && /* @__PURE__ */ jsx(Badge, {
|
|
106
|
+
variant: "secondary",
|
|
107
|
+
className: "ml-1.5 h-4 px-1.5 text-[10px]",
|
|
108
|
+
children: parkedCount
|
|
109
|
+
})
|
|
110
|
+
]
|
|
111
|
+
}),
|
|
112
|
+
onParkSale && !isEmpty && /* @__PURE__ */ jsxs(Button, {
|
|
113
|
+
variant: "ghost",
|
|
114
|
+
size: "sm",
|
|
115
|
+
className: "h-7 text-xs",
|
|
116
|
+
onClick: onParkSale,
|
|
117
|
+
disabled: !canPark,
|
|
118
|
+
title: canPark ? "Park sale (F6)" : "Max 5 parked — resume one first",
|
|
119
|
+
children: [/* @__PURE__ */ jsx(PauseCircle, { className: "h-3.5 w-3.5 mr-1" }), "Park"]
|
|
120
|
+
}),
|
|
121
|
+
!isEmpty && /* @__PURE__ */ jsxs(Button, {
|
|
122
|
+
variant: "ghost",
|
|
123
|
+
size: "sm",
|
|
124
|
+
className: "h-7 text-xs text-destructive",
|
|
125
|
+
onClick: cart.clearCart,
|
|
126
|
+
children: [/* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5 mr-1" }), "Clear"]
|
|
127
|
+
})
|
|
128
|
+
]
|
|
129
|
+
})]
|
|
130
|
+
}),
|
|
131
|
+
/* @__PURE__ */ jsx("div", {
|
|
132
|
+
className: "flex-1 overflow-y-auto min-h-0 px-4 py-3 space-y-4",
|
|
133
|
+
children: isEmpty ? /* @__PURE__ */ jsxs("div", {
|
|
134
|
+
className: "flex flex-col items-center justify-center h-full text-center text-muted-foreground",
|
|
135
|
+
children: [
|
|
136
|
+
/* @__PURE__ */ jsx(ShoppingCart, { className: "h-10 w-10 mb-3 opacity-30" }),
|
|
137
|
+
/* @__PURE__ */ jsx("p", {
|
|
138
|
+
className: "text-sm",
|
|
139
|
+
children: "Scan or select products"
|
|
140
|
+
}),
|
|
141
|
+
/* @__PURE__ */ jsx("p", {
|
|
142
|
+
className: "text-xs mt-1",
|
|
143
|
+
children: "Press F3 to search, F4 for barcode"
|
|
144
|
+
})
|
|
145
|
+
]
|
|
146
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
147
|
+
/* @__PURE__ */ jsx(CartItems, {
|
|
148
|
+
items: cart.cart,
|
|
149
|
+
onUpdateQuantity: cart.updateQuantity,
|
|
150
|
+
onRemoveItem: cart.removeItem,
|
|
151
|
+
onClear: cart.clearCart
|
|
152
|
+
}),
|
|
153
|
+
/* @__PURE__ */ jsx(CartSummary, {
|
|
154
|
+
subtotal: orderTotals.subtotal,
|
|
155
|
+
discount: orderTotals.manualDiscount,
|
|
156
|
+
tierDiscount: orderTotals.tierDiscount,
|
|
157
|
+
tierName: orderTotals.tierName ?? void 0,
|
|
158
|
+
redemptionDiscount: orderTotals.redemptionDiscount,
|
|
159
|
+
total: orderTotals.total,
|
|
160
|
+
pointsToEarn: orderTotals.pointsToEarn
|
|
161
|
+
}),
|
|
162
|
+
/* @__PURE__ */ jsx(CustomerSection, {
|
|
163
|
+
selected: customerViewModel.selected,
|
|
164
|
+
name: customerViewModel.name,
|
|
165
|
+
phone: customerViewModel.phone,
|
|
166
|
+
membershipCardId: customerViewModel.membershipCardId,
|
|
167
|
+
membershipStatus: customerViewModel.membershipStatus,
|
|
168
|
+
tierName: customerViewModel.tierName,
|
|
169
|
+
tierColor: customerViewModel.tierColor,
|
|
170
|
+
tierTextColor: customerViewModel.tierTextColor,
|
|
171
|
+
onNameChange: customer.setCustomerName,
|
|
172
|
+
onPhoneChange: customer.setCustomerPhone,
|
|
173
|
+
onMembershipChange: (value) => {
|
|
174
|
+
customer.setMembershipCardId(value);
|
|
175
|
+
cart.setMembershipCardId(value);
|
|
176
|
+
},
|
|
177
|
+
onMembershipLookup: customer.triggerMembershipLookup,
|
|
178
|
+
onClear: customer.clearCustomer,
|
|
179
|
+
onOpenLookup: () => customer.setLookupDialogOpen(true)
|
|
180
|
+
}),
|
|
181
|
+
/* @__PURE__ */ jsx(AddChargeDialog, {
|
|
182
|
+
disabled: isEmpty,
|
|
183
|
+
onAddCharge: cart.addCharge
|
|
184
|
+
}),
|
|
185
|
+
/* @__PURE__ */ jsx(DiscountSection, {
|
|
186
|
+
value: cart.discountInput,
|
|
187
|
+
onChange: cart.setDiscountInput,
|
|
188
|
+
isAuthorized: discountAuthViewModel.isAuthorized,
|
|
189
|
+
authorizedByName: discountAuthViewModel.authorizedByName,
|
|
190
|
+
onRequestAuth: onRequestDiscountAuth,
|
|
191
|
+
onClearAuth: onClearDiscountAuth
|
|
192
|
+
}),
|
|
193
|
+
redemption.enabled && /* @__PURE__ */ jsx(PointsRedemptionSection, {
|
|
194
|
+
enabled: redemption.enabled,
|
|
195
|
+
pointsBalance: redemption.pointsBalance,
|
|
196
|
+
minRedeemPoints: redemption.minRedeemPoints,
|
|
197
|
+
maxRedeemPoints: redemption.maxRedeemPoints,
|
|
198
|
+
pointsPerBdt: redemption.pointsPerBdt,
|
|
199
|
+
estimatedDiscount: redemption.estimatedDiscount,
|
|
200
|
+
error: redemption.error,
|
|
201
|
+
value: cart.pointsToRedeemInput,
|
|
202
|
+
onChange: cart.setPointsToRedeemInput,
|
|
203
|
+
onUseMax: () => {
|
|
204
|
+
if (orderTotals.maxAllowedPoints > 0) cart.setPointsToRedeemInput(String(orderTotals.maxAllowedPoints));
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
] })
|
|
208
|
+
}),
|
|
209
|
+
!isEmpty && /* @__PURE__ */ jsx("div", {
|
|
210
|
+
className: "px-4 py-3 border-t bg-card",
|
|
211
|
+
children: /* @__PURE__ */ jsxs(Button, {
|
|
212
|
+
className: "w-full h-12 text-base font-semibold",
|
|
213
|
+
onClick: onCheckout,
|
|
214
|
+
children: ["Checkout ", formatBdt(orderTotals.total)]
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
]
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
//#endregion
|
|
222
|
+
export { CartSidebar };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePosRuntime } from "../runtime/config.js";
|
|
4
|
+
import { usePosBranch } from "../runtime/branch-port.js";
|
|
5
|
+
import { usePosContext } from "../state/pos-context.js";
|
|
6
|
+
import { useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { ActionDropdown } from "@classytic/fluid/client/core";
|
|
8
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { ChevronDown, Clock, History, Keyboard, LayoutDashboard, LogOut, Plus, Printer, Store, XCircle } from "lucide-react";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Badge } from "@/components/ui/badge";
|
|
12
|
+
|
|
13
|
+
//#region src/components/PosTopBar.tsx
|
|
14
|
+
/**
|
|
15
|
+
* PosTopBar — Minimal chrome for the full-screen POS terminal.
|
|
16
|
+
*
|
|
17
|
+
* Uses fluid's ActionDropdown for the menu — no raw Base UI primitives.
|
|
18
|
+
*/
|
|
19
|
+
function useClock() {
|
|
20
|
+
const [time, setTime] = useState("");
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
function tick() {
|
|
23
|
+
setTime((/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", {
|
|
24
|
+
hour: "2-digit",
|
|
25
|
+
minute: "2-digit",
|
|
26
|
+
hour12: true
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
tick();
|
|
30
|
+
const id = setInterval(tick, 3e4);
|
|
31
|
+
return () => clearInterval(id);
|
|
32
|
+
}, []);
|
|
33
|
+
return time;
|
|
34
|
+
}
|
|
35
|
+
function PosTopBar({ userName, onNewSale, onOpenHistory, onOpenHelp, onOpenPrinterSettings }) {
|
|
36
|
+
const selectedBranch = usePosBranch();
|
|
37
|
+
const { onNavigate } = usePosRuntime();
|
|
38
|
+
const { state, dispatch } = usePosContext();
|
|
39
|
+
const time = useClock();
|
|
40
|
+
const branchName = selectedBranch?.name || "POS Terminal";
|
|
41
|
+
const menuItems = useMemo(() => {
|
|
42
|
+
const items = [];
|
|
43
|
+
if (onOpenHistory) items.push({
|
|
44
|
+
key: "history",
|
|
45
|
+
label: "Order History",
|
|
46
|
+
icon: History,
|
|
47
|
+
shortcut: "F9",
|
|
48
|
+
onClick: () => onOpenHistory()
|
|
49
|
+
});
|
|
50
|
+
if (state.shiftOpen) items.push({
|
|
51
|
+
key: "close",
|
|
52
|
+
label: "Close Register",
|
|
53
|
+
icon: XCircle,
|
|
54
|
+
onClick: () => dispatch({ type: "CLOSE_SHIFT" })
|
|
55
|
+
});
|
|
56
|
+
if (onOpenPrinterSettings) items.push({
|
|
57
|
+
key: "printer-settings",
|
|
58
|
+
label: "Printer Settings",
|
|
59
|
+
icon: Printer,
|
|
60
|
+
onClick: () => onOpenPrinterSettings()
|
|
61
|
+
});
|
|
62
|
+
items.push({
|
|
63
|
+
type: "separator",
|
|
64
|
+
key: "sep"
|
|
65
|
+
});
|
|
66
|
+
items.push({
|
|
67
|
+
key: "dashboard",
|
|
68
|
+
label: "Back to Dashboard",
|
|
69
|
+
icon: LayoutDashboard,
|
|
70
|
+
onClick: () => {
|
|
71
|
+
onNavigate("/dashboard");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
items.push({
|
|
75
|
+
key: "logout",
|
|
76
|
+
label: "Logout",
|
|
77
|
+
icon: LogOut,
|
|
78
|
+
onClick: () => {
|
|
79
|
+
onNavigate("/sign-in");
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return items;
|
|
83
|
+
}, [
|
|
84
|
+
onOpenHistory,
|
|
85
|
+
onOpenPrinterSettings,
|
|
86
|
+
state.shiftOpen,
|
|
87
|
+
dispatch,
|
|
88
|
+
onNavigate
|
|
89
|
+
]);
|
|
90
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
91
|
+
className: "flex items-center justify-between h-12 px-4 bg-card border-b shrink-0",
|
|
92
|
+
children: [
|
|
93
|
+
/* @__PURE__ */ jsxs("div", {
|
|
94
|
+
className: "flex items-center gap-2 min-w-0",
|
|
95
|
+
children: [
|
|
96
|
+
/* @__PURE__ */ jsx(Store, { className: "h-4 w-4 text-muted-foreground shrink-0" }),
|
|
97
|
+
/* @__PURE__ */ jsx("span", {
|
|
98
|
+
className: "font-semibold text-sm truncate",
|
|
99
|
+
children: branchName
|
|
100
|
+
}),
|
|
101
|
+
state.shiftOpen && /* @__PURE__ */ jsx(Badge, {
|
|
102
|
+
variant: "outline",
|
|
103
|
+
className: "text-xs font-normal shrink-0",
|
|
104
|
+
children: "Shift Open"
|
|
105
|
+
})
|
|
106
|
+
]
|
|
107
|
+
}),
|
|
108
|
+
/* @__PURE__ */ jsx("div", {
|
|
109
|
+
className: "flex-1 flex items-center justify-center gap-1 px-4",
|
|
110
|
+
children: state.screen === "products" && onNewSale && /* @__PURE__ */ jsxs(Button, {
|
|
111
|
+
variant: "ghost",
|
|
112
|
+
size: "sm",
|
|
113
|
+
onClick: onNewSale,
|
|
114
|
+
className: "h-8 text-xs gap-1",
|
|
115
|
+
children: [/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5" }), "New Sale"]
|
|
116
|
+
})
|
|
117
|
+
}),
|
|
118
|
+
/* @__PURE__ */ jsxs("div", {
|
|
119
|
+
className: "flex items-center gap-3 shrink-0",
|
|
120
|
+
children: [
|
|
121
|
+
onOpenHelp && /* @__PURE__ */ jsxs(Button, {
|
|
122
|
+
variant: "ghost",
|
|
123
|
+
size: "sm",
|
|
124
|
+
onClick: onOpenHelp,
|
|
125
|
+
className: "h-8 px-2 text-xs gap-1 text-muted-foreground",
|
|
126
|
+
title: "Keyboard shortcuts (?)",
|
|
127
|
+
"aria-label": "Keyboard shortcuts",
|
|
128
|
+
children: [/* @__PURE__ */ jsx(Keyboard, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ jsx("span", {
|
|
129
|
+
className: "hidden sm:inline",
|
|
130
|
+
children: "?"
|
|
131
|
+
})]
|
|
132
|
+
}),
|
|
133
|
+
/* @__PURE__ */ jsxs("div", {
|
|
134
|
+
className: "flex items-center gap-1.5 text-muted-foreground text-sm",
|
|
135
|
+
children: [/* @__PURE__ */ jsx(Clock, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ jsx("span", {
|
|
136
|
+
className: "tabular-nums",
|
|
137
|
+
children: time
|
|
138
|
+
})]
|
|
139
|
+
}),
|
|
140
|
+
/* @__PURE__ */ jsx(ActionDropdown, {
|
|
141
|
+
items: menuItems,
|
|
142
|
+
triggerIcon: ChevronDown,
|
|
143
|
+
triggerLabel: userName || "Cashier",
|
|
144
|
+
triggerVariant: "ghost",
|
|
145
|
+
triggerSize: "sm",
|
|
146
|
+
triggerClassName: "h-8 text-xs",
|
|
147
|
+
align: "end"
|
|
148
|
+
})
|
|
149
|
+
]
|
|
150
|
+
})
|
|
151
|
+
]
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
export { PosTopBar };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePrinterConfig } from "../hardware/use-printer-config.js";
|
|
4
|
+
import { isTauri } from "../hardware/tauri-adapter.js";
|
|
5
|
+
import { useHardware } from "../hardware/context.js";
|
|
6
|
+
import { useForm } from "../node_modules/react-hook-form/dist/index.esm.js";
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { DialogWrapper } from "@classytic/fluid/client/core";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { AlertTriangle, Check, Loader2, Printer } from "lucide-react";
|
|
11
|
+
import { Button } from "@/components/ui/button";
|
|
12
|
+
import { FormInput } from "@classytic/fluid/forms";
|
|
13
|
+
|
|
14
|
+
//#region src/components/PrinterSettingsDialog.tsx
|
|
15
|
+
/**
|
|
16
|
+
* PrinterSettingsDialog — runtime printer config for the cashier.
|
|
17
|
+
*
|
|
18
|
+
* Persists `{ host, port, timeoutMs }` to localStorage via
|
|
19
|
+
* `usePrinterConfig`. The Tauri adapter reads the same key on every
|
|
20
|
+
* invoke, so saved settings take effect immediately on the next print
|
|
21
|
+
* — no app restart, no env-var changes.
|
|
22
|
+
*
|
|
23
|
+
* Browser users see this dialog too (via the topbar action menu) but
|
|
24
|
+
* it has no effect on browser printing — `window.print()` runs against
|
|
25
|
+
* whatever printer the OS has configured. The dialog is informational
|
|
26
|
+
* for browser, functional for Tauri.
|
|
27
|
+
*/
|
|
28
|
+
function PrinterSettingsDialog({ open, onOpenChange }) {
|
|
29
|
+
const { config, setConfig } = usePrinterConfig();
|
|
30
|
+
const { printer } = useHardware();
|
|
31
|
+
const [probe, setProbe] = useState({ kind: "idle" });
|
|
32
|
+
const form = useForm({ defaultValues: {
|
|
33
|
+
host: config.host,
|
|
34
|
+
port: config.port?.toString() ?? "",
|
|
35
|
+
timeoutMs: config.timeoutMs?.toString() ?? ""
|
|
36
|
+
} });
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!open) return;
|
|
39
|
+
form.reset({
|
|
40
|
+
host: config.host,
|
|
41
|
+
port: config.port?.toString() ?? "",
|
|
42
|
+
timeoutMs: config.timeoutMs?.toString() ?? ""
|
|
43
|
+
});
|
|
44
|
+
setProbe({ kind: "idle" });
|
|
45
|
+
}, [
|
|
46
|
+
open,
|
|
47
|
+
config,
|
|
48
|
+
form
|
|
49
|
+
]);
|
|
50
|
+
const handleSave = useCallback((values) => {
|
|
51
|
+
const next = { host: values.host.trim() };
|
|
52
|
+
const portNum = Number(values.port);
|
|
53
|
+
if (Number.isFinite(portNum) && portNum > 0) next.port = portNum;
|
|
54
|
+
const timeoutNum = Number(values.timeoutMs);
|
|
55
|
+
if (Number.isFinite(timeoutNum) && timeoutNum > 0) next.timeoutMs = timeoutNum;
|
|
56
|
+
setConfig(next);
|
|
57
|
+
onOpenChange(false);
|
|
58
|
+
}, [setConfig, onOpenChange]);
|
|
59
|
+
const handleTest = useCallback(async () => {
|
|
60
|
+
const values = form.getValues();
|
|
61
|
+
const trimmed = values.host.trim();
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
setProbe({
|
|
64
|
+
kind: "fail",
|
|
65
|
+
reason: "Set a host first"
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const prior = config;
|
|
70
|
+
const tentative = { host: trimmed };
|
|
71
|
+
const portNum = Number(values.port);
|
|
72
|
+
if (Number.isFinite(portNum) && portNum > 0) tentative.port = portNum;
|
|
73
|
+
setConfig(tentative);
|
|
74
|
+
setProbe({ kind: "probing" });
|
|
75
|
+
try {
|
|
76
|
+
if (await printer.isAvailable()) setProbe({ kind: "ok" });
|
|
77
|
+
else {
|
|
78
|
+
setProbe({
|
|
79
|
+
kind: "fail",
|
|
80
|
+
reason: "No response from printer"
|
|
81
|
+
});
|
|
82
|
+
setConfig(prior);
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
setProbe({
|
|
86
|
+
kind: "fail",
|
|
87
|
+
reason: err instanceof Error ? err.message : "Probe failed"
|
|
88
|
+
});
|
|
89
|
+
setConfig(prior);
|
|
90
|
+
}
|
|
91
|
+
}, [
|
|
92
|
+
form,
|
|
93
|
+
config,
|
|
94
|
+
printer,
|
|
95
|
+
setConfig
|
|
96
|
+
]);
|
|
97
|
+
return /* @__PURE__ */ jsx(DialogWrapper, {
|
|
98
|
+
open,
|
|
99
|
+
onOpenChange,
|
|
100
|
+
title: "Printer settings",
|
|
101
|
+
description: typeof window !== "undefined" && isTauri() ? "Configure the network thermal printer for receipts and cash-drawer kicks." : "Browser POS uses the OS print dialog. These settings only apply in the desktop (Tauri) shell.",
|
|
102
|
+
footer: /* @__PURE__ */ jsxs("div", {
|
|
103
|
+
className: "flex justify-between gap-2 w-full",
|
|
104
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
105
|
+
variant: "outline",
|
|
106
|
+
onClick: handleTest,
|
|
107
|
+
disabled: probe.kind === "probing",
|
|
108
|
+
children: [probe.kind === "probing" ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 mr-2 animate-spin" }) : /* @__PURE__ */ jsx(Printer, { className: "h-4 w-4 mr-2" }), "Test connection"]
|
|
109
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
110
|
+
className: "flex gap-2",
|
|
111
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
112
|
+
variant: "ghost",
|
|
113
|
+
onClick: () => onOpenChange(false),
|
|
114
|
+
children: "Cancel"
|
|
115
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
116
|
+
onClick: form.handleSubmit(handleSave),
|
|
117
|
+
children: "Save"
|
|
118
|
+
})]
|
|
119
|
+
})]
|
|
120
|
+
}),
|
|
121
|
+
children: /* @__PURE__ */ jsxs("form", {
|
|
122
|
+
onSubmit: form.handleSubmit(handleSave),
|
|
123
|
+
className: "space-y-4",
|
|
124
|
+
children: [
|
|
125
|
+
/* @__PURE__ */ jsx(FormInput, {
|
|
126
|
+
control: form.control,
|
|
127
|
+
name: "host",
|
|
128
|
+
label: "Printer host",
|
|
129
|
+
placeholder: "192.168.1.100",
|
|
130
|
+
helperText: "LAN IP of the network thermal printer (Epson TM-T82, Xprinter, etc.)",
|
|
131
|
+
required: true
|
|
132
|
+
}),
|
|
133
|
+
/* @__PURE__ */ jsxs("div", {
|
|
134
|
+
className: "grid grid-cols-2 gap-3",
|
|
135
|
+
children: [/* @__PURE__ */ jsx(FormInput, {
|
|
136
|
+
control: form.control,
|
|
137
|
+
name: "port",
|
|
138
|
+
label: "Port",
|
|
139
|
+
placeholder: "9100",
|
|
140
|
+
helperText: "Default 9100"
|
|
141
|
+
}), /* @__PURE__ */ jsx(FormInput, {
|
|
142
|
+
control: form.control,
|
|
143
|
+
name: "timeoutMs",
|
|
144
|
+
label: "Timeout (ms)",
|
|
145
|
+
placeholder: "2000",
|
|
146
|
+
helperText: "Default 2000"
|
|
147
|
+
})]
|
|
148
|
+
}),
|
|
149
|
+
probe.kind === "ok" && /* @__PURE__ */ jsxs("div", {
|
|
150
|
+
className: "flex items-center gap-2 text-sm text-green-700 dark:text-green-300",
|
|
151
|
+
children: [/* @__PURE__ */ jsx(Check, { className: "h-4 w-4" }), "Printer responded — saved."]
|
|
152
|
+
}),
|
|
153
|
+
probe.kind === "fail" && /* @__PURE__ */ jsxs("div", {
|
|
154
|
+
className: "flex items-start gap-2 text-sm text-amber-700 dark:text-amber-300",
|
|
155
|
+
children: [/* @__PURE__ */ jsx(AlertTriangle, { className: "h-4 w-4 mt-0.5 shrink-0" }), /* @__PURE__ */ jsx("span", { children: probe.reason })]
|
|
156
|
+
})
|
|
157
|
+
]
|
|
158
|
+
})
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
//#endregion
|
|
163
|
+
export { PrinterSettingsDialog };
|