@tallyui/components 3.0.0 → 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 +15 -4
- package/dist/index.js +788 -749
- package/package.json +9 -9
- package/src/register/register-panel.tsx +11 -2
- 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/src/ui/icon/x-icon.tsx +9 -0
- package/src/ui/index.ts +1 -0
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",
|
|
@@ -4,7 +4,9 @@ import type { MovementType, useRegisterSession } from '@tallyui/pos';
|
|
|
4
4
|
|
|
5
5
|
import { formatMoney } from '@tallyui/core';
|
|
6
6
|
import { Button } from '../ui/button';
|
|
7
|
-
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
|
|
7
|
+
import { Dialog, DialogClose, DialogContent, DialogTitle } from '../ui/dialog';
|
|
8
|
+
import { IconButton } from '../ui/icon-button';
|
|
9
|
+
import { XIcon } from '../ui/icon/x-icon';
|
|
8
10
|
import { Text } from '../ui/text';
|
|
9
11
|
import { MovementSheet } from './movement-sheet';
|
|
10
12
|
|
|
@@ -64,7 +66,14 @@ export function RegisterPanel({ register, currency, registerName, open, onOpenCh
|
|
|
64
66
|
>
|
|
65
67
|
{/* The heading stays visible; a phone-height viewport must never push it under the page
|
|
66
68
|
header (the Front desk review, 2026-09-28) — only the body below it scrolls. */}
|
|
67
|
-
<
|
|
69
|
+
<View className="flex-row items-center justify-between gap-2">
|
|
70
|
+
<DialogTitle testID="register-panel-amount">{registerName ?? 'Register'}</DialogTitle>
|
|
71
|
+
<DialogClose asChild>
|
|
72
|
+
<IconButton variant="ghost" size="sm" testID="register-panel-dismiss" accessibilityLabel="Close panel">
|
|
73
|
+
<XIcon size={16} />
|
|
74
|
+
</IconButton>
|
|
75
|
+
</DialogClose>
|
|
76
|
+
</View>
|
|
68
77
|
<Text testID="register-panel-sales-count">{salesCount} sale{salesCount === 1 ? '' : 's'} this session</Text>
|
|
69
78
|
<ScrollView testID="register-panel-body" className="flex-1" contentContainerClassName="gap-3">
|
|
70
79
|
<View className="flex-row gap-2">
|
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',
|
package/src/ui/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { VStack, vstackVariants, type VStackProps } from './vstack';
|
|
|
7
7
|
export { Icon, iconVariants, type IconProps } from './icon';
|
|
8
8
|
export { createSvgIcon, type SvgIconProps } from './icon/svg-icon';
|
|
9
9
|
export { SearchIcon } from './icon/search-icon';
|
|
10
|
+
export { XIcon } from './icon/x-icon';
|
|
10
11
|
export { Loader, loaderVariants, type LoaderProps } from './loader';
|
|
11
12
|
export { Badge, badgeVariants, badgeTextVariants, type BadgeProps } from './badge';
|
|
12
13
|
export { Avatar, avatarVariants, type AvatarProps } from './avatar';
|