@tallyui/components 3.0.1 → 3.0.2
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/dist/index.d.ts +9 -3
- package/dist/index.js +50 -23
- package/package.json +9 -9
- package/src/sale/cart.tsx +1 -1
- package/src/sale/catalogue.tsx +6 -2
- package/src/sale/index.ts +1 -0
- package/src/sale/order-reference.ts +6 -0
- package/src/sale/orders-list.tsx +21 -2
- package/src/sale/receipt.tsx +13 -7
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { SvgProps } from 'react-native-svg';
|
|
|
9
9
|
import { Separator as Separator$1, Label as Label$1, Tabs as Tabs$1, Dialog as Dialog$1, AlertDialog as AlertDialog$1, Select as Select$1, Combobox as Combobox$1, DropdownMenu as DropdownMenu$1, ContextMenu as ContextMenu$1, Popover as Popover$1, Tooltip as Tooltip$1, HoverCard as HoverCard$1, Accordion as Accordion$1, Collapsible as Collapsible$1, Checkbox as Checkbox$1, Switch as Switch$1, RadioGroup as RadioGroup$1, Toggle as Toggle$1, ToggleGroup as ToggleGroup$1, Progress as Progress$1, Slider as Slider$1, Image } from '@tallyui/primitives';
|
|
10
10
|
export { TextClassContext } from '@tallyui/primitives';
|
|
11
11
|
import { Money, CustomerTraits, Customer, CustomerInput, ProductTraits, SyncNotice, StoreSettingsChoices, StoreSettingsChoice } from '@tallyui/core';
|
|
12
|
-
import { useRegisterSession, MovementType, useSale, AppliedDiscount, Discount, CatalogueEntry, SentOrder,
|
|
12
|
+
import { useRegisterSession, MovementType, useSale, AppliedDiscount, Discount, CatalogueEntry, SentOrder, PosOrder, OutboxState } from '@tallyui/pos';
|
|
13
13
|
|
|
14
14
|
declare const textVariants: (props?: ({
|
|
15
15
|
variant?: "link" | "default" | null | undefined;
|
|
@@ -1255,7 +1255,7 @@ declare function DiscountChips({ discounts, currency, onRemove, prefix, amounts
|
|
|
1255
1255
|
}): react_jsx_runtime.JSX.Element | null;
|
|
1256
1256
|
|
|
1257
1257
|
declare function formatStockSyncTime(time: Date, locale?: string, hour12?: boolean, now?: Date): string;
|
|
1258
|
-
declare function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, lastStockCheckAt, hour12, minCodeLength }: {
|
|
1258
|
+
declare function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, lastStockCheckAt, hour12, minCodeLength }: {
|
|
1259
1259
|
products: Doc[];
|
|
1260
1260
|
traits: ProductTraits<Doc>;
|
|
1261
1261
|
currency: string;
|
|
@@ -1265,6 +1265,9 @@ declare function Catalogue<Doc>({ products, traits, currency, onSelect, statusTe
|
|
|
1265
1265
|
/** Rendered at the end of the status line, e.g. a register control at phone width; the row renders even without `statusText`. */
|
|
1266
1266
|
statusAccessory?: ReactNode;
|
|
1267
1267
|
lastSyncedAt: Date | null;
|
|
1268
|
+
/** The catalogue's first pull is still running, so an empty grid says "Loading products…" rather than "No products yet.". Defaults to lastSyncedAt === null; pass false
|
|
1269
|
+
* when a null lastSyncedAt doesn't mean a pull is running (e.g. offline on first start). */
|
|
1270
|
+
loading?: boolean;
|
|
1268
1271
|
/** The last completed stock reconcile pass, persisted across restarts. The "stock as of" time shown is the
|
|
1269
1272
|
* latest of this, the provider's stockOverlayAsOf (when valid) and lastSyncedAt; null and omitted alike. */
|
|
1270
1273
|
lastStockCheckAt?: Date | null;
|
|
@@ -1279,7 +1282,7 @@ declare function Catalogue<Doc>({ products, traits, currency, onSelect, statusTe
|
|
|
1279
1282
|
|
|
1280
1283
|
/** `store`/`taxLabel`/`topInset`/`formatDate` replace the app's Medusa settings, hard-coded VAT (default matches
|
|
1281
1284
|
* TV6a's `Cart`), outbox strip and date util, so `Receipt` stays platform-neutral (ADR-052). */
|
|
1282
|
-
declare function Receipt({ order, store, cashier, registerId, newSale, taxLabel, topInset, formatDate }: {
|
|
1285
|
+
declare function Receipt({ order, posOrder, store, cashier, registerId, newSale, taxLabel, topInset, formatDate }: {
|
|
1283
1286
|
order: SentOrder;
|
|
1284
1287
|
store: {
|
|
1285
1288
|
name: string;
|
|
@@ -1288,6 +1291,9 @@ declare function Receipt({ order, store, cashier, registerId, newSale, taxLabel,
|
|
|
1288
1291
|
cashier: string;
|
|
1289
1292
|
registerId: string;
|
|
1290
1293
|
newSale: () => void;
|
|
1294
|
+
/** The finalized order (useSale's receipt stage has it). With it the receipt prints its reference and time, the
|
|
1295
|
+
* same as Orders; without it (a preview before finalize) the sale id is marked as a draft. */
|
|
1296
|
+
posOrder?: PosOrder;
|
|
1291
1297
|
taxLabel?: (ratePpm: number) => string;
|
|
1292
1298
|
topInset?: number;
|
|
1293
1299
|
formatDate?: (iso: string) => string;
|
package/dist/index.js
CHANGED
|
@@ -4102,7 +4102,7 @@ function Cart({ sale, taxLabel = (ratePpm) => `Tax ${ratePpm / 1e4}%` }) {
|
|
|
4102
4102
|
dataSet: { print: "hide" },
|
|
4103
4103
|
items: order.lineItems,
|
|
4104
4104
|
renderItem,
|
|
4105
|
-
emptyState: /* @__PURE__ */ jsx89(Text33, { className: "text-muted-foreground", children: "Scan or tap a product to start a sale." }),
|
|
4105
|
+
emptyState: /* @__PURE__ */ jsx89(Text33, { testID: "cart-empty", className: "px-3 py-2 text-muted-foreground", children: "Scan or tap a product to start a sale." }),
|
|
4106
4106
|
afterItems: order.lineItems.length ? /* @__PURE__ */ jsxs48(View55, { className: "gap-3 px-3 py-2", children: [
|
|
4107
4107
|
/* @__PURE__ */ jsx89(
|
|
4108
4108
|
DiscountChips,
|
|
@@ -4250,7 +4250,8 @@ function laterOf(a, b) {
|
|
|
4250
4250
|
if (!b) return a;
|
|
4251
4251
|
return a.getTime() >= b.getTime() ? a : b;
|
|
4252
4252
|
}
|
|
4253
|
-
function Catalogue({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, lastStockCheckAt, hour12, minCodeLength }) {
|
|
4253
|
+
function Catalogue({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, lastStockCheckAt, hour12, minCodeLength }) {
|
|
4254
|
+
const isLoading = loading ?? lastSyncedAt === null;
|
|
4254
4255
|
const shown = useStockOverlaid(products);
|
|
4255
4256
|
const overlayAsOf = useStockOverlayAsOf();
|
|
4256
4257
|
const parsedOverlayAsOf = overlayAsOf ? new Date(overlayAsOf) : void 0;
|
|
@@ -4339,7 +4340,7 @@ function Catalogue({ products, traits, currency, onSelect, statusText, statusAcc
|
|
|
4339
4340
|
/* @__PURE__ */ jsx92(ProductStockBadge, { doc: product, showAsOf: false, className: "self-center" })
|
|
4340
4341
|
] }) })
|
|
4341
4342
|
),
|
|
4342
|
-
emptyState: /* @__PURE__ */ jsx92(Text36, { className: "mt-10 text-center text-sm text-muted-foreground", children: query.trim() ? `No products match "${query.trim()}".` : "No products yet." })
|
|
4343
|
+
emptyState: /* @__PURE__ */ jsx92(Text36, { className: "mt-10 text-center text-sm text-muted-foreground", children: query.trim() ? `No products match "${query.trim()}".` : isLoading ? "Loading products\u2026" : "No products yet." })
|
|
4343
4344
|
}
|
|
4344
4345
|
)
|
|
4345
4346
|
] });
|
|
@@ -4361,11 +4362,18 @@ function injectPrintStyle() {
|
|
|
4361
4362
|
document.head.appendChild(style);
|
|
4362
4363
|
}
|
|
4363
4364
|
|
|
4365
|
+
// src/sale/order-reference.ts
|
|
4366
|
+
function orderReference(order) {
|
|
4367
|
+
const local = order.id.slice(-8);
|
|
4368
|
+
return order.serverRefs?.displayId ? `${local} \xB7 #${order.serverRefs.displayId}` : local;
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4364
4371
|
// src/sale/receipt.tsx
|
|
4365
4372
|
import { Fragment as Fragment7, jsx as jsx93, jsxs as jsxs52 } from "react/jsx-runtime";
|
|
4366
4373
|
var defaultFormatDate = (iso) => new Intl.DateTimeFormat(void 0, { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
|
4367
4374
|
function Receipt({
|
|
4368
4375
|
order,
|
|
4376
|
+
posOrder,
|
|
4369
4377
|
store,
|
|
4370
4378
|
cashier,
|
|
4371
4379
|
registerId,
|
|
@@ -4387,17 +4395,15 @@ function Receipt({
|
|
|
4387
4395
|
/* @__PURE__ */ jsx93(Text37, { className: `text-right text-foreground ${bold ? "font-semibold" : ""}`, children: amount })
|
|
4388
4396
|
] }, key);
|
|
4389
4397
|
const { totals } = receipt;
|
|
4398
|
+
const lineDiscountMinor = totals.discountMinor - receipt.orderDiscountMinor;
|
|
4390
4399
|
const taxRows = totals.taxLines.map((line, index) => row(`${totals.taxInclusive ? "incl. " : ""}${taxLabel(line.ratePpm)}`, money(line.amountMinor), false, index));
|
|
4391
4400
|
return /* @__PURE__ */ jsxs52(Fragment7, { children: [
|
|
4392
4401
|
topInset > 0 ? /* @__PURE__ */ jsx93(View58, { dataSet: { print: "hide" }, style: { height: topInset, flexShrink: 0 } }) : null,
|
|
4393
4402
|
/* @__PURE__ */ jsxs52(View58, { className: "w-full max-w-md self-center gap-3 p-4 bg-card", children: [
|
|
4394
4403
|
/* @__PURE__ */ jsx93(Text37, { className: "text-lg font-semibold text-foreground", children: receipt.header.storeName }),
|
|
4395
4404
|
receipt.header.storeAddress ? /* @__PURE__ */ jsx93(Text37, { className: "text-muted-foreground", children: receipt.header.storeAddress }) : null,
|
|
4396
|
-
/* @__PURE__ */
|
|
4397
|
-
|
|
4398
|
-
receipt.header.orderNumber.slice(-8)
|
|
4399
|
-
] }),
|
|
4400
|
-
/* @__PURE__ */ jsx93(Text37, { className: "text-muted-foreground", children: formatDate(receipt.header.date) }),
|
|
4405
|
+
/* @__PURE__ */ jsx93(Text37, { testID: "receipt-order", className: "text-foreground", children: posOrder ? `Order ${orderReference(posOrder)}` : `Order ${receipt.header.orderNumber.slice(-8)} (draft)` }),
|
|
4406
|
+
/* @__PURE__ */ jsx93(Text37, { className: "text-muted-foreground", children: formatDate(posOrder?.createdAt ?? receipt.header.date) }),
|
|
4401
4407
|
/* @__PURE__ */ jsxs52(Text37, { className: "text-muted-foreground", children: [
|
|
4402
4408
|
"Cashier: ",
|
|
4403
4409
|
receipt.header.cashier
|
|
@@ -4414,9 +4420,9 @@ function Receipt({
|
|
|
4414
4420
|
receipt.currency
|
|
4415
4421
|
)} off`, `\u2212${money(discount.amountMinor)}`) }, i))
|
|
4416
4422
|
] }, index)),
|
|
4417
|
-
receipt.orderDiscountMinor > 0 ? row("Order discount", `\u2212${money(receipt.orderDiscountMinor)}`) : null,
|
|
4418
4423
|
row("Subtotal", money(totals.subtotalMinor)),
|
|
4419
|
-
|
|
4424
|
+
lineDiscountMinor > 0 ? row("Line discounts", `\u2212${money(lineDiscountMinor)}`) : null,
|
|
4425
|
+
receipt.orderDiscountMinor > 0 ? row("Order discount", `\u2212${money(receipt.orderDiscountMinor)}`) : null,
|
|
4420
4426
|
taxRows,
|
|
4421
4427
|
row("Total", money(totals.totalMinor), true),
|
|
4422
4428
|
receipt.payments.map((payment, index) => row(
|
|
@@ -4565,6 +4571,7 @@ function defaultFormatDate2(iso) {
|
|
|
4565
4571
|
function OrdersList({ orders, onRetry, formatDate = defaultFormatDate2, footer, stuck }) {
|
|
4566
4572
|
const retrying = useRef7(/* @__PURE__ */ new Set());
|
|
4567
4573
|
const [retryingIds, setRetryingIds] = useState19(/* @__PURE__ */ new Set());
|
|
4574
|
+
const [expanded, setExpanded] = useState19(/* @__PURE__ */ new Set());
|
|
4568
4575
|
useEffect8(() => {
|
|
4569
4576
|
for (const id of retrying.current) {
|
|
4570
4577
|
if (!orders.some((order) => order.id === id && order.syncStatus === "rejected")) retrying.current.delete(id);
|
|
@@ -4574,23 +4581,43 @@ function OrdersList({ orders, onRetry, formatDate = defaultFormatDate2, footer,
|
|
|
4574
4581
|
return /* @__PURE__ */ jsxs54(ScrollView13, { className: "flex-1 bg-background p-4", children: [
|
|
4575
4582
|
[{ title: "Needs attention", orders: needsAttention(orders, { stuckCommandIds: stuck?.commandIds }) }, { title: "Recent", orders }].filter((section) => section.title === "Recent" || section.orders.length > 0).map((section) => /* @__PURE__ */ jsxs54(View60, { className: "mb-6 gap-3", children: [
|
|
4576
4583
|
/* @__PURE__ */ jsx95(Text39, { accessibilityRole: "header", className: "text-xl font-semibold text-foreground", children: section.title }),
|
|
4584
|
+
section.title === "Recent" && orders.length === 0 ? /* @__PURE__ */ jsx95(Text39, { testID: "orders-empty", className: "text-muted-foreground", children: "No sales yet. Completed sales appear here." }) : null,
|
|
4577
4585
|
section.orders.map((order) => {
|
|
4578
4586
|
const count2 = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
4587
|
+
const key = `${section.title}:${order.id}`;
|
|
4588
|
+
const isExpanded = expanded.has(key);
|
|
4579
4589
|
const stuckEntry = order.syncStatus === "pending" ? stuck?.orders.find((entry) => entry.commandId === order.commandId) : void 0;
|
|
4580
4590
|
return /* @__PURE__ */ jsxs54(View60, { className: "gap-1 rounded-md border border-border bg-card p-3", children: [
|
|
4581
|
-
/* @__PURE__ */ jsxs54(
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4591
|
+
/* @__PURE__ */ jsxs54(
|
|
4592
|
+
Pressable29,
|
|
4593
|
+
{
|
|
4594
|
+
accessibilityRole: "button",
|
|
4595
|
+
"aria-expanded": isExpanded,
|
|
4596
|
+
accessibilityHint: "Shows the items and payments of this sale",
|
|
4597
|
+
testID: `order-row-${order.id}`,
|
|
4598
|
+
className: "gap-1",
|
|
4599
|
+
onPress: () => setExpanded((current) => {
|
|
4600
|
+
const next = new Set(current);
|
|
4601
|
+
if (next.has(key)) next.delete(key);
|
|
4602
|
+
else next.add(key);
|
|
4603
|
+
return next;
|
|
4604
|
+
}),
|
|
4605
|
+
children: [
|
|
4606
|
+
/* @__PURE__ */ jsx95(Text39, { className: "text-foreground", children: `Order ${orderReference(order)} \xB7 ${count2} ${count2 === 1 ? "item" : "items"}` }),
|
|
4607
|
+
/* @__PURE__ */ jsxs54(Text39, { className: "text-muted-foreground", children: [
|
|
4608
|
+
formatDate(order.createdAt),
|
|
4609
|
+
" \xB7 ",
|
|
4610
|
+
formatMoney14({ amount: order.totalMinor, currency: order.currency }),
|
|
4611
|
+
" \xB7 ",
|
|
4612
|
+
STATUS_LABEL[order.syncStatus]
|
|
4613
|
+
] })
|
|
4614
|
+
]
|
|
4615
|
+
}
|
|
4616
|
+
),
|
|
4617
|
+
isExpanded ? /* @__PURE__ */ jsxs54(View60, { testID: `order-detail-${order.id}`, className: "mt-1 gap-1 border-t border-border pt-2", children: [
|
|
4618
|
+
order.lines.map((line) => /* @__PURE__ */ jsx95(Text39, { className: "text-foreground", children: `${line.quantity} \xD7 ${line.name}` }, line.id)),
|
|
4619
|
+
order.payments.map((payment) => /* @__PURE__ */ jsx95(Text39, { className: "text-muted-foreground", children: `${payment.method === "cash" ? "Cash" : "Card terminal"} ${formatMoney14({ amount: payment.amountMinor, currency: order.currency })}${payment.reference ? ` \xB7 ${payment.reference}` : ""}` }, payment.id))
|
|
4620
|
+
] }) : null,
|
|
4594
4621
|
stuckEntry ? /* @__PURE__ */ jsx95(Text39, { className: "text-destructive", children: `Hasn't reached the online store since ${sinceText(stuckEntry)}.` }) : null,
|
|
4595
4622
|
order.syncStatus === "rejected" ? /* @__PURE__ */ jsx95(Text39, { className: "text-destructive", children: refusalSentence(order.error?.code) }) : null,
|
|
4596
4623
|
order.lateSessionId !== void 0 ? /* @__PURE__ */ jsx95(Text39, { className: "text-foreground", children: "Taken after the register closed. It is not in that register's closure." }) : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/components",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Universal POS UI components for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,22 +27,22 @@
|
|
|
27
27
|
"directory": "packages/components"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@tallyui/core": "^3.0.
|
|
31
|
-
"@tallyui/primitives": "^3.0.
|
|
32
|
-
"@tallyui/theme": "^3.0.
|
|
30
|
+
"@tallyui/core": "^3.0.2",
|
|
31
|
+
"@tallyui/primitives": "^3.0.2",
|
|
32
|
+
"@tallyui/theme": "^3.0.2",
|
|
33
33
|
"react": ">=18",
|
|
34
34
|
"react-native": ">=0.74",
|
|
35
35
|
"react-native-svg": ">=15"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"class-variance-authority": "^0.7.1",
|
|
39
|
-
"@tallyui/pos": "3.0.
|
|
39
|
+
"@tallyui/pos": "3.0.2"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@tallyui/connector-medusa": "3.0.
|
|
43
|
-
"@tallyui/connector-vendure": "3.0.
|
|
44
|
-
"@tallyui/connector-woocommerce": "3.0.
|
|
45
|
-
"@tallyui/mock-api": "3.0.
|
|
42
|
+
"@tallyui/connector-medusa": "3.0.2",
|
|
43
|
+
"@tallyui/connector-vendure": "3.0.2",
|
|
44
|
+
"@tallyui/connector-woocommerce": "3.0.2",
|
|
45
|
+
"@tallyui/mock-api": "3.0.2",
|
|
46
46
|
"@types/react": ">=18",
|
|
47
47
|
"react": "^19.2.4",
|
|
48
48
|
"react-native-svg": "15.12.1",
|
package/src/sale/cart.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10
|
|
|
41
41
|
{discountForm(line.id, `Discount on ${line.name}`)}
|
|
42
42
|
</>;
|
|
43
43
|
return <CartPanel dataSet={{ print: 'hide' }} items={order.lineItems} renderItem={renderItem}
|
|
44
|
-
emptyState={<Text className="text-muted-foreground">Scan or tap a product to start a sale.</Text>}
|
|
44
|
+
emptyState={<Text testID="cart-empty" className="px-3 py-2 text-muted-foreground">Scan or tap a product to start a sale.</Text>}
|
|
45
45
|
afterItems={order.lineItems.length ? <View className="gap-3 px-3 py-2">
|
|
46
46
|
<DiscountChips discounts={order.discounts} amounts={orderAmounts} currency={order.currency} onRemove={sale.removeDiscount}
|
|
47
47
|
prefix="Order discount" />
|
package/src/sale/catalogue.tsx
CHANGED
|
@@ -32,7 +32,7 @@ function laterOf(a: Date | null | undefined, b: Date | null | undefined): Date |
|
|
|
32
32
|
return a.getTime() >= b.getTime() ? a : b;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, lastStockCheckAt, hour12, minCodeLength }: {
|
|
35
|
+
export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, lastStockCheckAt, hour12, minCodeLength }: {
|
|
36
36
|
products: Doc[];
|
|
37
37
|
traits: ProductTraits<Doc>;
|
|
38
38
|
currency: string;
|
|
@@ -42,6 +42,9 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
42
42
|
/** Rendered at the end of the status line, e.g. a register control at phone width; the row renders even without `statusText`. */
|
|
43
43
|
statusAccessory?: ReactNode;
|
|
44
44
|
lastSyncedAt: Date | null;
|
|
45
|
+
/** The catalogue's first pull is still running, so an empty grid says "Loading products…" rather than "No products yet.". Defaults to lastSyncedAt === null; pass false
|
|
46
|
+
* when a null lastSyncedAt doesn't mean a pull is running (e.g. offline on first start). */
|
|
47
|
+
loading?: boolean;
|
|
45
48
|
/** The last completed stock reconcile pass, persisted across restarts. The "stock as of" time shown is the
|
|
46
49
|
* latest of this, the provider's stockOverlayAsOf (when valid) and lastSyncedAt; null and omitted alike. */
|
|
47
50
|
lastStockCheckAt?: Date | null;
|
|
@@ -53,6 +56,7 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
53
56
|
* in its unfocused wedge listener — not this prop's job. */
|
|
54
57
|
minCodeLength?: number;
|
|
55
58
|
}) {
|
|
59
|
+
const isLoading = loading ?? lastSyncedAt === null;
|
|
56
60
|
// Idempotent (the adapter's overlay returns just the fields the stock map sets), so an app that already
|
|
57
61
|
// merges the overlay itself keeps working, and can drop its own merge.
|
|
58
62
|
const shown = useStockOverlaid(products) as Doc[];
|
|
@@ -132,7 +136,7 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
132
136
|
</Pressable>
|
|
133
137
|
)}
|
|
134
138
|
emptyState={<Text className="mt-10 text-center text-sm text-muted-foreground">
|
|
135
|
-
{query.trim() ? `No products match "${query.trim()}".` : 'No products yet.'}
|
|
139
|
+
{query.trim() ? `No products match "${query.trim()}".` : isLoading ? 'Loading products…' : 'No products yet.'}
|
|
136
140
|
</Text>} />
|
|
137
141
|
</View>
|
|
138
142
|
);
|
package/src/sale/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { Tender } from './tender';
|
|
|
4
4
|
export { DiscountForm, DiscountChips, parseDiscount, discountLabel } from './discount-form';
|
|
5
5
|
export { Catalogue, formatStockSyncTime } from './catalogue';
|
|
6
6
|
export { Receipt } from './receipt';
|
|
7
|
+
export { orderReference } from './order-reference';
|
|
7
8
|
export { injectPrintStyle } from './print-style';
|
|
8
9
|
export { SyncStatus } from './sync-status';
|
|
9
10
|
export { OrdersList } from './orders-list';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The order reference a visitor sees on the receipt and in Orders (front desk ruling, 2026-10-06): the finalized
|
|
2
|
+
* PosOrder id's last 8 characters, then the backend's display id once the store has one. */
|
|
3
|
+
export function orderReference(order: { id: string; serverRefs?: { displayId?: string } }): string {
|
|
4
|
+
const local = order.id.slice(-8);
|
|
5
|
+
return order.serverRefs?.displayId ? `${local} · #${order.serverRefs.displayId}` : local;
|
|
6
|
+
}
|
package/src/sale/orders-list.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
|
2
2
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
3
|
import { formatMoney, knownWarnings } from '@tallyui/core';
|
|
4
4
|
import { needsAttention, type OutboxState, type PosOrder } from '@tallyui/pos';
|
|
5
|
+
import { orderReference } from './order-reference';
|
|
5
6
|
import { sinceText } from './since-text';
|
|
6
7
|
|
|
7
8
|
const STATUS_LABEL = { pending: 'Waiting to sync', applied: 'Synced', rejected: 'Not accepted' };
|
|
@@ -42,6 +43,7 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
|
|
|
42
43
|
}) {
|
|
43
44
|
const retrying = useRef(new Set<string>());
|
|
44
45
|
const [retryingIds, setRetryingIds] = useState(new Set<string>());
|
|
46
|
+
const [expanded, setExpanded] = useState(new Set<string>());
|
|
45
47
|
useEffect(() => {
|
|
46
48
|
for (const id of retrying.current) {
|
|
47
49
|
if (!orders.some((order) => order.id === id && order.syncStatus === 'rejected')) retrying.current.delete(id);
|
|
@@ -52,14 +54,31 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
|
|
|
52
54
|
{[{ title: 'Needs attention', orders: needsAttention(orders, { stuckCommandIds: stuck?.commandIds }) }, { title: 'Recent', orders }].filter((section) => section.title === 'Recent' || section.orders.length > 0).map((section) => (
|
|
53
55
|
<View key={section.title} className="mb-6 gap-3">
|
|
54
56
|
<Text accessibilityRole="header" className="text-xl font-semibold text-foreground">{section.title}</Text>
|
|
57
|
+
{section.title === 'Recent' && orders.length === 0 ? <Text testID="orders-empty" className="text-muted-foreground">No sales yet. Completed sales appear here.</Text> : null}
|
|
55
58
|
{section.orders.map((order) => {
|
|
56
59
|
const count = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
60
|
+
const key = `${section.title}:${order.id}`;
|
|
61
|
+
const isExpanded = expanded.has(key);
|
|
57
62
|
// The order's own stuck entry: its clock and latest reason, not the earliest across all stuck orders.
|
|
58
63
|
const stuckEntry = order.syncStatus === 'pending' ? stuck?.orders.find((entry) => entry.commandId === order.commandId) : undefined;
|
|
59
64
|
return (
|
|
60
65
|
<View key={order.id} className="gap-1 rounded-md border border-border bg-card p-3">
|
|
61
|
-
<
|
|
62
|
-
|
|
66
|
+
<Pressable accessibilityRole="button" aria-expanded={isExpanded}
|
|
67
|
+
accessibilityHint="Shows the items and payments of this sale" testID={`order-row-${order.id}`} className="gap-1"
|
|
68
|
+
onPress={() => setExpanded((current) => {
|
|
69
|
+
const next = new Set(current);
|
|
70
|
+
if (next.has(key)) next.delete(key); else next.add(key);
|
|
71
|
+
return next;
|
|
72
|
+
})}>
|
|
73
|
+
<Text className="text-foreground">{`Order ${orderReference(order)} · ${count} ${count === 1 ? 'item' : 'items'}`}</Text>
|
|
74
|
+
<Text className="text-muted-foreground">{formatDate(order.createdAt)} · {formatMoney({ amount: order.totalMinor, currency: order.currency })} · {STATUS_LABEL[order.syncStatus]}</Text>
|
|
75
|
+
</Pressable>
|
|
76
|
+
{isExpanded ? <View testID={`order-detail-${order.id}`} className="mt-1 gap-1 border-t border-border pt-2">
|
|
77
|
+
{order.lines.map((line) => <Text key={line.id} className="text-foreground">{`${line.quantity} × ${line.name}`}</Text>)}
|
|
78
|
+
{order.payments.map((payment) => <Text key={payment.id} className="text-muted-foreground">
|
|
79
|
+
{`${payment.method === 'cash' ? 'Cash' : 'Card terminal'} ${formatMoney({ amount: payment.amountMinor, currency: order.currency })}${payment.reference ? ` · ${payment.reference}` : ''}`}
|
|
80
|
+
</Text>)}
|
|
81
|
+
</View> : null}
|
|
63
82
|
{stuckEntry ? <Text className="text-destructive">
|
|
64
83
|
{/* The same words for any reason, with no reason code; the hour as the status line has it (numeric, #245). */}
|
|
65
84
|
{`Hasn't reached the online store since ${sinceText(stuckEntry)}.`}
|
package/src/sale/receipt.tsx
CHANGED
|
@@ -2,16 +2,21 @@ import { useEffect } from 'react';
|
|
|
2
2
|
import { Pressable, Text, View } from 'react-native';
|
|
3
3
|
import { formatMoney } from '@tallyui/core';
|
|
4
4
|
import { buildReceiptData, type SentOrder } from '@tallyui/pos';
|
|
5
|
+
import type { PosOrder } from '@tallyui/pos';
|
|
5
6
|
import { injectPrintStyle } from './print-style';
|
|
6
7
|
import { discountLabel } from './discount-form';
|
|
8
|
+
import { orderReference } from './order-reference';
|
|
7
9
|
|
|
8
10
|
const defaultFormatDate = (iso: string) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
|
9
11
|
|
|
10
12
|
/** `store`/`taxLabel`/`topInset`/`formatDate` replace the app's Medusa settings, hard-coded VAT (default matches
|
|
11
13
|
* TV6a's `Cart`), outbox strip and date util, so `Receipt` stays platform-neutral (ADR-052). */
|
|
12
|
-
export function Receipt({ order, store, cashier, registerId, newSale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`,
|
|
14
|
+
export function Receipt({ order, posOrder, store, cashier, registerId, newSale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`,
|
|
13
15
|
topInset = 0, formatDate = defaultFormatDate }: {
|
|
14
16
|
order: SentOrder; store: { name: string; address?: string }; cashier: string; registerId: string; newSale: () => void;
|
|
17
|
+
/** The finalized order (useSale's receipt stage has it). With it the receipt prints its reference and time, the
|
|
18
|
+
* same as Orders; without it (a preview before finalize) the sale id is marked as a draft. */
|
|
19
|
+
posOrder?: PosOrder;
|
|
15
20
|
taxLabel?: (ratePpm: number) => string; topInset?: number; formatDate?: (iso: string) => string;
|
|
16
21
|
}) {
|
|
17
22
|
useEffect(injectPrintStyle, []);
|
|
@@ -24,6 +29,7 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
|
|
|
24
29
|
<Text className={`text-right text-foreground ${bold ? 'font-semibold' : ''}`}>{amount}</Text>
|
|
25
30
|
</View>;
|
|
26
31
|
const { totals } = receipt;
|
|
32
|
+
const lineDiscountMinor = totals.discountMinor - receipt.orderDiscountMinor;
|
|
27
33
|
const taxRows = totals.taxLines.map((line, index) =>
|
|
28
34
|
row(`${totals.taxInclusive ? 'incl. ' : ''}${taxLabel(line.ratePpm)}`, money(line.amountMinor), false, index));
|
|
29
35
|
return <>
|
|
@@ -31,8 +37,8 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
|
|
|
31
37
|
<View className="w-full max-w-md self-center gap-3 p-4 bg-card">
|
|
32
38
|
<Text className="text-lg font-semibold text-foreground">{receipt.header.storeName}</Text>
|
|
33
39
|
{receipt.header.storeAddress ? <Text className="text-muted-foreground">{receipt.header.storeAddress}</Text> : null}
|
|
34
|
-
<Text className="text-foreground">Order {receipt.header.orderNumber.slice(-8)}</Text>
|
|
35
|
-
<Text className="text-muted-foreground">{formatDate(receipt.header.date)}</Text>
|
|
40
|
+
<Text testID="receipt-order" className="text-foreground">{posOrder ? `Order ${orderReference(posOrder)}` : `Order ${receipt.header.orderNumber.slice(-8)} (draft)`}</Text>
|
|
41
|
+
<Text className="text-muted-foreground">{formatDate(posOrder?.createdAt ?? receipt.header.date)}</Text>
|
|
36
42
|
<Text className="text-muted-foreground">Cashier: {receipt.header.cashier}</Text>
|
|
37
43
|
{receipt.header.customer && <Text className="text-muted-foreground">Customer: {receipt.header.customer}</Text>}
|
|
38
44
|
{receipt.lineItems.map((line, index) => <View key={index}>
|
|
@@ -42,11 +48,11 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
|
|
|
42
48
|
{line.displayDiscounts.map((discount, i) => <View key={i} className="pl-4">{row(`${discountLabel(
|
|
43
49
|
order.lineItems[index].discounts[i], receipt.currency)} off`, `−${money(discount.amountMinor)}`)}</View>)}
|
|
44
50
|
</View>)}
|
|
45
|
-
{
|
|
46
|
-
|
|
47
|
-
= total; inclusive: subtotal − discount = total, and the tax is "incl.", not added. */}
|
|
51
|
+
{/* Each discount appears once in the totals: line discounts are itemised under their lines and summed once here;
|
|
52
|
+
the order discount appears once. order.display's figures (ADR-063; medusapos ADR 0008). */}
|
|
48
53
|
{row('Subtotal', money(totals.subtotalMinor))}
|
|
49
|
-
{
|
|
54
|
+
{lineDiscountMinor > 0 ? row('Line discounts', `−${money(lineDiscountMinor)}`) : null}
|
|
55
|
+
{receipt.orderDiscountMinor > 0 ? row('Order discount', `−${money(receipt.orderDiscountMinor)}`) : null}
|
|
50
56
|
{taxRows}
|
|
51
57
|
{row('Total', money(totals.totalMinor), true)}
|
|
52
58
|
{receipt.payments.map((payment, index) => row(payment.method === 'cash' ? 'Cash tendered' : 'Card terminal',
|