@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 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, OutboxState, PosOrder } from '@tallyui/pos';
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__ */ jsxs52(Text37, { className: "text-foreground", children: [
4397
- "Order ",
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
- totals.discountMinor > 0 ? row("Discount", `\u2212${money(totals.discountMinor)}`) : null,
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(Text39, { className: "text-foreground", children: [
4582
- order.serverRefs?.displayId ? `Order #${order.serverRefs.displayId} \xB7 ` : "",
4583
- count2,
4584
- " ",
4585
- count2 === 1 ? "item" : "items"
4586
- ] }),
4587
- /* @__PURE__ */ jsxs54(Text39, { className: "text-muted-foreground", children: [
4588
- formatDate(order.createdAt),
4589
- " \xB7 ",
4590
- formatMoney14({ amount: order.totalMinor, currency: order.currency }),
4591
- " \xB7 ",
4592
- STATUS_LABEL[order.syncStatus]
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.1",
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.1",
31
- "@tallyui/primitives": "^3.0.1",
32
- "@tallyui/theme": "^3.0.1",
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.1"
39
+ "@tallyui/pos": "3.0.2"
40
40
  },
41
41
  "devDependencies": {
42
- "@tallyui/connector-medusa": "3.0.1",
43
- "@tallyui/connector-vendure": "3.0.1",
44
- "@tallyui/connector-woocommerce": "3.0.1",
45
- "@tallyui/mock-api": "3.0.1",
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" />
@@ -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
+ }
@@ -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
- <Text className="text-foreground">{order.serverRefs?.displayId ? `Order #${order.serverRefs.displayId} · ` : ''}{count} {count === 1 ? 'item' : 'items'}</Text>
62
- <Text className="text-muted-foreground">{formatDate(order.createdAt)} · {formatMoney({ amount: order.totalMinor, currency: order.currency })} · {STATUS_LABEL[order.syncStatus]}</Text>
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)}.`}
@@ -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
- {receipt.orderDiscountMinor > 0 ? row('Order discount', `−${money(receipt.orderDiscountMinor)}`) : null}
46
- {/* order.display's figures (ADR-063; medusapos ADR 0008), in the cart's order. Exclusive: subtotal − discount + tax
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
- {totals.discountMinor > 0 ? row('Discount', `−${money(totals.discountMinor)}`) : null}
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',