@tallyui/components 1.0.0 → 2.0.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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1076 -76
  3. package/dist/index.js +4662 -355
  4. package/package.json +27 -5
  5. package/src/cart/cart-line-actions.tsx +40 -0
  6. package/src/cart/cart-line.tsx +18 -32
  7. package/src/cart/cart-note-input.tsx +46 -0
  8. package/src/cart/cart-panel.tsx +39 -26
  9. package/src/cart/cart-total.tsx +43 -47
  10. package/src/cart/discount-badge.tsx +19 -0
  11. package/src/cart/index.ts +4 -1
  12. package/src/checkout/cash-tendered.tsx +101 -0
  13. package/src/checkout/change-display.tsx +19 -0
  14. package/src/checkout/index.ts +3 -0
  15. package/src/checkout/order-summary-line.tsx +7 -8
  16. package/src/checkout/order-summary.tsx +8 -7
  17. package/src/checkout/payment-method-card.tsx +10 -9
  18. package/src/checkout/payment-selector.tsx +5 -5
  19. package/src/checkout/receipt-preview.tsx +78 -0
  20. package/src/customer/customer-card.tsx +8 -9
  21. package/src/customer/customer-form.tsx +70 -0
  22. package/src/customer/customer-order-history.tsx +34 -0
  23. package/src/customer/customer-select.tsx +63 -24
  24. package/src/customer/index.ts +2 -0
  25. package/src/index.ts +112 -1
  26. package/src/input/filter-chip.tsx +85 -27
  27. package/src/input/index.ts +1 -0
  28. package/src/input/quantity-stepper.tsx +64 -0
  29. package/src/input/search-input.tsx +30 -7
  30. package/src/layout/checkout-layout.tsx +46 -0
  31. package/src/layout/index.ts +5 -0
  32. package/src/layout/live-tab-screen.tsx +54 -0
  33. package/src/layout/pos-layout.tsx +64 -0
  34. package/src/layout/settings-layout.tsx +58 -0
  35. package/src/layout/store-settings-choice-screen.tsx +142 -0
  36. package/src/order/index.ts +4 -0
  37. package/src/order/order-card.tsx +48 -0
  38. package/src/order/order-detail.tsx +82 -0
  39. package/src/order/order-list.tsx +39 -0
  40. package/src/order/order-status-badge.tsx +32 -0
  41. package/src/product/category-nav.tsx +70 -0
  42. package/src/product/index.ts +3 -0
  43. package/src/product/product-card.tsx +36 -8
  44. package/src/product/product-grid.tsx +25 -6
  45. package/src/product/product-image.tsx +28 -7
  46. package/src/product/product-list.tsx +44 -0
  47. package/src/product/product-price.tsx +77 -22
  48. package/src/product/product-sku.tsx +5 -5
  49. package/src/product/product-stock-badge.tsx +41 -28
  50. package/src/product/product-title.tsx +2 -3
  51. package/src/product/product-variant-picker.tsx +61 -0
  52. package/src/register/cash-count-input.tsx +85 -0
  53. package/src/register/closure-sheet.tsx +155 -0
  54. package/src/register/index.ts +12 -0
  55. package/src/register/movement-sheet.tsx +118 -0
  56. package/src/register/open-register-card.tsx +109 -0
  57. package/src/register/register-bar.helpers.ts +57 -0
  58. package/src/register/register-bar.tsx +85 -0
  59. package/src/register/register-column.tsx +117 -0
  60. package/src/register/register-count.tsx +330 -0
  61. package/src/register/register-open-close.tsx +52 -0
  62. package/src/register/register-panel.tsx +151 -0
  63. package/src/register/register-picker.tsx +60 -0
  64. package/src/register/register-summary.tsx +72 -0
  65. package/src/sale/cart-bar.tsx +18 -0
  66. package/src/sale/cart.tsx +66 -0
  67. package/src/sale/catalogue.tsx +131 -0
  68. package/src/sale/discount-form.tsx +85 -0
  69. package/src/sale/index.ts +9 -0
  70. package/src/sale/orders-list.tsx +66 -0
  71. package/src/sale/print-style.ts +11 -0
  72. package/src/sale/receipt.tsx +61 -0
  73. package/src/sale/sync-status.tsx +20 -0
  74. package/src/sale/tender.tsx +35 -0
  75. package/src/settings/connector-status.tsx +65 -0
  76. package/src/settings/index.ts +3 -0
  77. package/src/settings/settings-group.tsx +23 -0
  78. package/src/settings/settings-row.tsx +23 -0
  79. package/src/ui/accordion/index.tsx +59 -0
  80. package/src/ui/alert-dialog/index.tsx +91 -0
  81. package/src/ui/avatar/index.tsx +50 -0
  82. package/src/ui/badge/index.tsx +60 -0
  83. package/src/ui/button/index.tsx +90 -0
  84. package/src/ui/card/index.tsx +60 -0
  85. package/src/ui/checkbox/index.tsx +29 -0
  86. package/src/ui/collapsible/index.tsx +24 -0
  87. package/src/ui/combobox/index.tsx +98 -0
  88. package/src/ui/context-menu/index.tsx +159 -0
  89. package/src/ui/dialog/index.tsx +92 -0
  90. package/src/ui/dropdown-menu/index.tsx +161 -0
  91. package/src/ui/hover-card/index.tsx +31 -0
  92. package/src/ui/hstack/index.tsx +43 -0
  93. package/src/ui/icon/index.tsx +44 -0
  94. package/src/ui/icon/search-icon.tsx +9 -0
  95. package/src/ui/icon/svg-icon.tsx +36 -0
  96. package/src/ui/icon-button/index.tsx +58 -0
  97. package/src/ui/index.ts +99 -0
  98. package/src/ui/input/index.tsx +100 -0
  99. package/src/ui/label/index.tsx +26 -0
  100. package/src/ui/loader/index.tsx +37 -0
  101. package/src/ui/popover/index.tsx +31 -0
  102. package/src/ui/progress/index.tsx +28 -0
  103. package/src/ui/radio-group/index.tsx +38 -0
  104. package/src/ui/select/index.tsx +96 -0
  105. package/src/ui/separator/index.tsx +29 -0
  106. package/src/ui/slider/index.tsx +28 -0
  107. package/src/ui/switch/index.tsx +31 -0
  108. package/src/ui/table/index.tsx +59 -0
  109. package/src/ui/tabs/index.tsx +53 -0
  110. package/src/ui/text/index.tsx +36 -0
  111. package/src/ui/textarea/index.tsx +29 -0
  112. package/src/ui/toast/index.tsx +52 -0
  113. package/src/ui/toggle/index.tsx +69 -0
  114. package/src/ui/toggle-group/index.tsx +66 -0
  115. package/src/ui/tooltip/index.tsx +30 -0
  116. package/src/ui/vstack/index.tsx +43 -0
  117. package/src/uniwind-env.d.ts +9 -1
  118. package/src/__tests__/cart-line.test.tsx +0 -53
  119. package/src/__tests__/cart-panel.test.tsx +0 -55
  120. package/src/__tests__/cart-total.test.tsx +0 -60
  121. package/src/__tests__/customer-card.test.tsx +0 -47
  122. package/src/__tests__/customer-select.test.tsx +0 -33
  123. package/src/__tests__/filter-chip.test.tsx +0 -40
  124. package/src/__tests__/helpers.ts +0 -111
  125. package/src/__tests__/order-summary-line.test.tsx +0 -18
  126. package/src/__tests__/order-summary.test.tsx +0 -32
  127. package/src/__tests__/payment-method-card.test.tsx +0 -24
  128. package/src/__tests__/payment-selector.test.tsx +0 -23
  129. package/src/__tests__/product-card.test.tsx +0 -31
  130. package/src/__tests__/product-grid.test.tsx +0 -37
  131. package/src/__tests__/product-image.test.tsx +0 -54
  132. package/src/__tests__/product-price.test.tsx +0 -68
  133. package/src/__tests__/product-sku.test.tsx +0 -47
  134. package/src/__tests__/product-stock-badge.test.tsx +0 -58
  135. package/src/__tests__/product-title.test.tsx +0 -38
  136. package/src/__tests__/search-input.test.tsx +0 -22
@@ -0,0 +1,330 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Platform, Pressable, ScrollView, useWindowDimensions, View } from 'react-native';
3
+ import type { useRegisterSession } from '@tallyui/pos';
4
+ import {
5
+ closeNeedsApproval,
6
+ countVariance,
7
+ denominationTotal,
8
+ denominations,
9
+ minorToDecimal,
10
+ parseMinor,
11
+ validAmount,
12
+ varianceText,
13
+ } from '@tallyui/pos';
14
+
15
+ import { currencySymbol, formatMoney, minorUnitDigits } from '@tallyui/core';
16
+ import { cn } from '@tallyui/theme';
17
+ import { Button } from '../ui/button';
18
+ import { Input } from '../ui/input';
19
+ import { Label } from '../ui/label';
20
+ import { Text } from '../ui/text';
21
+
22
+ const AMOUNT_LABEL_ID = 'count-amount-label';
23
+
24
+ /** The exact refusal shown when Close is over `varianceThreshold` and the host gives no `approve` (the Front desk, 2026-09-28). */
25
+ export const APPROVAL_REQUIRED_TEXT = 'Manager approval needed. Ask a manager to approve, or count again.';
26
+
27
+ /** `varianceText`'s `t`: TallyUI has no i18n system, so this maps its three keys to plain English directly. Shared with `ClosureSheet`. */
28
+ export function varianceWord(key: string): string {
29
+ if (key === 'register.exact') return 'Exact';
30
+ if (key === 'register.short') return 'short';
31
+ return 'over';
32
+ }
33
+
34
+ function errorMessage(error: unknown): string {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
37
+
38
+ /**
39
+ * A tile's face value as a locale-aware whole amount ('€500', '€1') or fraction ('€0.50'), never
40
+ * a bare '.00' — `Intl`'s `trailingZeroDisplay` (`@tallyui/core`'s `formatMoney` has nothing
41
+ * like it yet, so this stays local rather than inventing a second money formatter there).
42
+ */
43
+ type TileFormatOptions = Intl.NumberFormatOptions & { trailingZeroDisplay?: 'auto' | 'stripIfInteger' };
44
+ const tileFormatters = new Map<string, Intl.NumberFormat>();
45
+ function tileLabel(value: number, currency: string): string {
46
+ let formatter = tileFormatters.get(currency);
47
+ if (!formatter) {
48
+ const options: TileFormatOptions = { style: 'currency', currency, trailingZeroDisplay: 'stripIfInteger' };
49
+ formatter = new Intl.NumberFormat(undefined, options);
50
+ tileFormatters.set(currency, formatter);
51
+ }
52
+ return formatter.format(value / 10 ** minorUnitDigits(currency));
53
+ }
54
+
55
+ /** Title-cases a tender method key ('card' -> 'Card') for a plain-English label. Shared with `ClosureSheet`. */
56
+ export const tenderLabel = (method: string) => method.charAt(0).toUpperCase() + method.slice(1);
57
+
58
+ interface DenominationTileProps {
59
+ value: number;
60
+ count: number;
61
+ currency: string;
62
+ onAdd: (pieces: number) => void;
63
+ }
64
+
65
+ /**
66
+ * One denomination face. A tap adds one; a 400 ms hold adds ten, repeating every 150 ms, with
67
+ * no release tap on top (LEDGER 60): `held` claims the hold before `onPress` can also fire for
68
+ * the same gesture, and the interval is cleared on release and on unmount.
69
+ */
70
+ function DenominationTile({ value, count, currency, onAdd }: DenominationTileProps) {
71
+ const label = tileLabel(value, currency);
72
+ const held = useRef(false);
73
+ const timer = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
74
+ const stop = () => {
75
+ if (timer.current !== undefined) clearInterval(timer.current);
76
+ timer.current = undefined;
77
+ };
78
+ useEffect(() => stop, []);
79
+ return (
80
+ <Pressable
81
+ testID={`den-tile-${value}`}
82
+ accessibilityRole="button"
83
+ accessibilityLabel={label}
84
+ className="border-border bg-muted active:bg-accent h-16 flex-1 items-center justify-center gap-0.5 overflow-hidden rounded-md border px-1"
85
+ // Web adds its own ~50ms press-in delay, which would otherwise pad the 400ms hold.
86
+ {...(Platform.OS === 'web' ? { delayPressIn: 0 } : {})}
87
+ delayLongPress={400}
88
+ onPressIn={() => {
89
+ held.current = false;
90
+ }}
91
+ onLongPress={() => {
92
+ held.current = true;
93
+ onAdd(10);
94
+ timer.current = setInterval(() => onAdd(10), 150);
95
+ }}
96
+ onPressOut={stop}
97
+ onPress={() => {
98
+ if (!held.current) onAdd(1);
99
+ }}
100
+ >
101
+ <Text className="text-sm tabular-nums" numberOfLines={1}>{label}</Text>
102
+ <Text testID={`den-count-${value}`} className="bg-background min-w-6 rounded-full px-1.5 text-center text-xs tabular-nums">
103
+ {count}
104
+ </Text>
105
+ </Pressable>
106
+ );
107
+ }
108
+
109
+ export interface RegisterCountProps {
110
+ /** `useRegisterSession`'s return value: `expected`, `blind`, `varianceThreshold`, and `backToSelling`/`closeSession`. */
111
+ register: ReturnType<typeof useRegisterSession>;
112
+ /** ISO 4217 code: denomination faces, parsing and display. */
113
+ currency: string;
114
+ /**
115
+ * Above `register.varianceThreshold`, Close asks for this first. `null` refuses and the count
116
+ * stays; without it, Close refuses outright with `APPROVAL_REQUIRED_TEXT`. Its `approvedBy`
117
+ * and `approvedByName` go to `closeSession`, which enforces the same gate and puts them on the Z.
118
+ */
119
+ approve?: () => Promise<{ approvedBy: string; approvedByName?: string } | null>;
120
+ className?: string;
121
+ }
122
+
123
+ /**
124
+ * Counts the drawer at close: denomination tiles or a typed cash amount, other tenders kept as
125
+ * typed, a live variance line, and Close (gated by `approve` over threshold) or Back to selling.
126
+ * Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c` `register-count.tsx` (LEDGER
127
+ * 59, 60, 65, 67). Leaves out WCPOS's ApproveSheet and server-demanded `approval_required`
128
+ * (registers c2) — `approve` is the host's own gate, and it runs regardless of `blind`: dropping
129
+ * the server flag would otherwise leave a blind count with no gate at all over threshold.
130
+ */
131
+ export function RegisterCount({ register, currency, approve, className }: RegisterCountProps) {
132
+ const { session, expected, blind, varianceThreshold, actions } = register;
133
+ const digits = minorUnitDigits(currency);
134
+ const faces = denominations[currency] ?? denominations.default;
135
+ // Below 480px, a note's full label ('€500') and its count badge collide across 4 columns; 3
136
+ // gives each tile room for both without either overflowing into its neighbour.
137
+ const { width } = useWindowDimensions();
138
+ const columns = width < 480 ? 3 : 4;
139
+ const [pieces, setPieces] = useState<Record<number, number>>({});
140
+ // Resumes an interrupted close's persisted count (LEDGER 59); ordinary counting starts blank.
141
+ const [cashText, setCashText] = useState(() =>
142
+ session?.counted?.cash != null ? minorToDecimal(session.counted.cash, digits) : '',
143
+ );
144
+ const [others, setOthers] = useState<Record<string, string>>(() =>
145
+ Object.fromEntries(
146
+ Object.entries(session?.counted ?? {})
147
+ .filter(([method]) => method !== 'cash')
148
+ .map(([method, minor]) => [method, minorToDecimal(minor, digits)]),
149
+ ),
150
+ );
151
+ const [busy, setBusy] = useState(false);
152
+ const [error, setError] = useState('');
153
+ const [refusal, setRefusal] = useState('');
154
+
155
+ const usingTiles = Object.keys(pieces).length > 0;
156
+ const tilesTotal = denominationTotal(pieces);
157
+ const displayAmount = usingTiles ? minorToDecimal(tilesTotal, digits) : cashText;
158
+ const valid = usingTiles || validAmount(cashText, digits);
159
+ const amountMinor = usingTiles ? tilesTotal : valid ? parseMinor(cashText, digits) : NaN;
160
+ const variance = countVariance(amountMinor, expected.cash ?? 0);
161
+ const needsApproval = valid && closeNeedsApproval(amountMinor, expected.cash ?? 0, varianceThreshold);
162
+
163
+ const otherMethods = Object.keys(expected).filter((method) => method !== 'cash');
164
+ const otherCounted = otherMethods
165
+ .map((method) => [method, others[method] ?? ''] as const)
166
+ .filter(([, value]) => value !== '');
167
+ const otherTendersInvalid = otherCounted.some(([, value]) => !validAmount(value, digits));
168
+ const counted: Record<string, number> = {
169
+ cash: amountMinor,
170
+ ...Object.fromEntries(otherCounted.map(([method, value]) => [method, parseMinor(value, digits)])),
171
+ };
172
+
173
+ const attempt = async (action: () => Promise<unknown>) => {
174
+ setBusy(true);
175
+ setError('');
176
+ try {
177
+ await action();
178
+ } catch (e) {
179
+ // The hook's own gate (by name: components import no class from pos, ADR-064).
180
+ if (e instanceof Error && e.name === 'RegisterApprovalRequiredError') setRefusal(APPROVAL_REQUIRED_TEXT);
181
+ else setError(errorMessage(e));
182
+ } finally {
183
+ setBusy(false);
184
+ }
185
+ };
186
+
187
+ const addPieces = (value: number, n: number) =>
188
+ setPieces((previous) => ({ ...previous, [value]: (previous[value] ?? 0) + n }));
189
+
190
+ const onClose = () => {
191
+ setRefusal('');
192
+ if (needsApproval && !approve) {
193
+ setRefusal(APPROVAL_REQUIRED_TEXT);
194
+ return;
195
+ }
196
+ if (needsApproval) {
197
+ void attempt(async () => {
198
+ const result = await approve!();
199
+ if (!result) {
200
+ setRefusal('Approval was not granted. The count is unchanged.');
201
+ return;
202
+ }
203
+ await actions.closeSession({ counted, approvedBy: result.approvedBy, approvedByName: result.approvedByName });
204
+ });
205
+ return;
206
+ }
207
+ void attempt(() => actions.closeSession({ counted }));
208
+ };
209
+
210
+ // Guarded as RegisterPanel is, for a caller rendering this before the hook's first snapshot.
211
+ if (!session) return null;
212
+
213
+ return (
214
+ <View testID="register-count" className={cn('bg-card flex-1 rounded-md', className)}>
215
+ <ScrollView className="flex-1" contentContainerClassName="gap-3 p-4">
216
+ <Text className="min-h-11 text-lg font-semibold">Count the drawer</Text>
217
+ <Label testID="count-amount-label" nativeID={AMOUNT_LABEL_ID}>
218
+ {`Cash counted (${currencySymbol(currency)})`}
219
+ </Label>
220
+ <Input>
221
+ <Input.Field
222
+ testID="count-amount"
223
+ value={displayAmount}
224
+ onChangeText={(value) => {
225
+ // Typing clears the tiles, and a later tile tap replaces the typed amount (LEDGER 65).
226
+ setPieces({});
227
+ setCashText(value);
228
+ }}
229
+ keyboardType="decimal-pad"
230
+ accessibilityLabel={`Cash counted (${currencySymbol(currency)})`}
231
+ accessibilityLabelledBy={AMOUNT_LABEL_ID}
232
+ />
233
+ </Input>
234
+ {!blind && valid && (
235
+ <Text testID="count-variance" className="min-h-11 tabular-nums">
236
+ {`Expected ${formatMoney({ amount: expected.cash ?? 0, currency })} · ${varianceText(
237
+ variance,
238
+ digits,
239
+ (major) => formatMoney({ amount: Math.round(major * 10 ** digits), currency }) ?? '',
240
+ varianceWord,
241
+ )}`}
242
+ </Text>
243
+ )}
244
+ <View testID="count-denominations" className="gap-2">
245
+ {Array.from({ length: Math.ceil(faces.length / columns) }, (_, row) => (
246
+ <View key={row} className="flex-row gap-2">
247
+ {Array.from({ length: columns }, (_, column) => {
248
+ const value = faces[row * columns + column];
249
+ return value ? (
250
+ <DenominationTile
251
+ key={value}
252
+ value={value}
253
+ count={pieces[value] ?? 0}
254
+ currency={currency}
255
+ onAdd={(n) => addPieces(value, n)}
256
+ />
257
+ ) : (
258
+ <View key={column} className="flex-1" />
259
+ );
260
+ })}
261
+ </View>
262
+ ))}
263
+ <Button
264
+ testID="count-clear"
265
+ variant="ghost"
266
+ className="min-h-11 self-end"
267
+ onPress={() => {
268
+ setPieces({});
269
+ setCashText(minorToDecimal(0, digits));
270
+ }}
271
+ >
272
+ <Text>Clear</Text>
273
+ </Button>
274
+ </View>
275
+ {otherMethods.map((method) => {
276
+ const labelId = `count-tender-${method}-label`;
277
+ const label = `${tenderLabel(method)} counted (${currencySymbol(currency)})`;
278
+ return (
279
+ <View key={method} className="gap-2">
280
+ <Label testID={labelId} nativeID={labelId}>
281
+ {label}
282
+ </Label>
283
+ <Input>
284
+ <Input.Field
285
+ testID={`count-tender-${method}`}
286
+ value={others[method] ?? ''}
287
+ onChangeText={(value) => setOthers((previous) => ({ ...previous, [method]: value }))}
288
+ keyboardType="decimal-pad"
289
+ accessibilityLabel={label}
290
+ accessibilityLabelledBy={labelId}
291
+ />
292
+ </Input>
293
+ </View>
294
+ );
295
+ })}
296
+ {!!refusal && (
297
+ <Text testID="count-manager-line" className="text-warning min-h-11">
298
+ {refusal}
299
+ </Text>
300
+ )}
301
+ {!!error && (
302
+ <Text testID="count-error" className="text-destructive min-h-11">
303
+ {error}
304
+ </Text>
305
+ )}
306
+ </ScrollView>
307
+ {/* Pinned below the scroll, not inside it: at phone height the tiles alone can push these
308
+ past the fold, and the Front desk asked for Close to stay reachable without hunting. */}
309
+ <View className="gap-2 border-t border-border p-4">
310
+ <Button
311
+ testID="count-back"
312
+ variant="outline"
313
+ className="min-h-11"
314
+ disabled={busy}
315
+ onPress={() => attempt(actions.backToSelling)}
316
+ >
317
+ <Text>Back to selling</Text>
318
+ </Button>
319
+ <Button
320
+ testID="count-close"
321
+ className="min-h-14"
322
+ disabled={!valid || otherTendersInvalid || busy}
323
+ onPress={onClose}
324
+ >
325
+ <Text>{busy ? 'Closing…' : 'Close register'}</Text>
326
+ </Button>
327
+ </View>
328
+ </View>
329
+ );
330
+ }
@@ -0,0 +1,52 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface RegisterOpenCloseProps extends Omit<ViewProps, 'children'> {
7
+ isOpen: boolean;
8
+ onOpen?: () => void;
9
+ onClose?: () => void;
10
+ expectedBalance?: number;
11
+ cashCountSlot?: ReactNode;
12
+ notesSlot?: ReactNode;
13
+ className?: string;
14
+ }
15
+
16
+ export function RegisterOpenClose({
17
+ isOpen,
18
+ onOpen,
19
+ onClose,
20
+ expectedBalance,
21
+ cashCountSlot,
22
+ notesSlot,
23
+ className,
24
+ ...viewProps
25
+ }: RegisterOpenCloseProps) {
26
+ return (
27
+ <View className={cn('gap-4 p-4', className)} {...viewProps}>
28
+ <Text className="text-lg font-bold text-foreground">
29
+ {isOpen ? 'Close Register' : 'Open Register'}
30
+ </Text>
31
+
32
+ {isOpen && expectedBalance != null && (
33
+ <View className="flex-row justify-between rounded-lg bg-muted px-3 py-2">
34
+ <Text className="text-sm text-muted-foreground">Expected Balance</Text>
35
+ <Text className="text-sm font-semibold text-foreground">{expectedBalance.toFixed(2)}</Text>
36
+ </View>
37
+ )}
38
+
39
+ {cashCountSlot && <View>{cashCountSlot}</View>}
40
+ {notesSlot && <View>{notesSlot}</View>}
41
+
42
+ <Pressable
43
+ onPress={isOpen ? onClose : onOpen}
44
+ className="items-center rounded-lg bg-primary px-4 py-3"
45
+ >
46
+ <Text className="text-sm font-semibold text-primary-foreground">
47
+ {isOpen ? 'Close Register' : 'Open Register'}
48
+ </Text>
49
+ </Pressable>
50
+ </View>
51
+ );
52
+ }
@@ -0,0 +1,151 @@
1
+ import { useState } from 'react';
2
+ import { ScrollView, useWindowDimensions, View } from 'react-native';
3
+ import type { MovementType, useRegisterSession } from '@tallyui/pos';
4
+
5
+ import { formatMoney } from '@tallyui/core';
6
+ import { Button } from '../ui/button';
7
+ import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
8
+ import { Text } from '../ui/text';
9
+ import { MovementSheet } from './movement-sheet';
10
+
11
+ /** Margin kept clear above and below the dialog (LEDGER-free layout fix, 2026-09-28 review):
12
+ * a phone-height viewport must never let the panel's top go under the page header. */
13
+ const DIALOG_VERTICAL_MARGIN = 64;
14
+
15
+ export interface RegisterPanelProps {
16
+ /** `useRegisterSession`'s return value: movements, `expected`, `blind`, and every action used here. */
17
+ register: ReturnType<typeof useRegisterSession>;
18
+ /** ISO 4217 code, for money display and movement amounts. */
19
+ currency: string;
20
+ /** Shown as the panel's title while blind counting hides the expected amount. */
21
+ registerName?: string;
22
+ open: boolean;
23
+ onOpenChange: (open: boolean) => void;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * The register's drawer panel: expected figures, cash movements with Undo, and Close register.
29
+ * Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c` `register-panel.tsx` (LEDGER
30
+ * 54, 56, 58).
31
+ *
32
+ * Leaves out (registers job c2 or a later job, per `useRegisterSession`'s own "Not ported"
33
+ * note): refused-movement Retry and "Unsynced" (no outbox here to refuse a movement), the
34
+ * X-report, Z and print/reprint, and the Reports deep link.
35
+ */
36
+ export function RegisterPanel({ register, currency, registerName, open, onOpenChange, className }: RegisterPanelProps) {
37
+ const { session, expected, salesCount, blind, movements, lastClosure, actions } = register;
38
+ const { height: windowHeight } = useWindowDimensions();
39
+ const maxHeight = Math.max(280, windowHeight - DIALOG_VERTICAL_MARGIN);
40
+ const [movementType, setMovementType] = useState<MovementType | null>(null);
41
+ const [expanded, setExpanded] = useState(false);
42
+ const [error, setError] = useState('');
43
+ const attempt = async (action: () => Promise<unknown>) => {
44
+ try {
45
+ await action();
46
+ } catch (e) {
47
+ setError(String(e));
48
+ }
49
+ };
50
+ // Refused cash keeps a panel open on its own in WCPOS; there is no outbox here to refuse a
51
+ // movement, so a session or a written closure is always what keeps this panel worth showing.
52
+ if (!session && !lastClosure) return null;
53
+ // Voided and reversal rows are hidden from the active list (LEDGER 58): the audit trail keeps
54
+ // them, but the cashier sees only what's still standing.
55
+ const activeMovements = movements.filter(
56
+ (row) => row.type !== 'void' && !row.voided_by && !movements.some((entry) => entry.voids === row.id),
57
+ );
58
+ return (
59
+ <Dialog open={open} onOpenChange={onOpenChange}>
60
+ <DialogContent
61
+ testID="register-panel"
62
+ className={`flex-col overflow-hidden ${className ?? ''}`}
63
+ style={{ maxHeight }}
64
+ >
65
+ {/* The heading stays visible; a phone-height viewport must never push it under the page
66
+ header (the Front desk review, 2026-09-28) — only the body below it scrolls. */}
67
+ <DialogTitle testID="register-panel-amount">{registerName ?? 'Register'}</DialogTitle>
68
+ <Text testID="register-panel-sales-count">{salesCount} sale{salesCount === 1 ? '' : 's'} this session</Text>
69
+ <ScrollView testID="register-panel-body" className="flex-1" contentContainerClassName="gap-3">
70
+ <View className="flex-row gap-2">
71
+ {(['paid_in', 'paid_out', 'no_sale'] as const).map((type) => (
72
+ <Button
73
+ key={type}
74
+ testID={`register-panel-${type.replace('_', '-')}`}
75
+ className="min-h-14 flex-1"
76
+ variant="outline"
77
+ disabled={session?.status !== 'open'}
78
+ onPress={() => setMovementType(type)}
79
+ >
80
+ <Text>{type === 'paid_in' ? 'Paid in' : type === 'paid_out' ? 'Paid out' : 'No sale'}</Text>
81
+ </Button>
82
+ ))}
83
+ </View>
84
+ {!blind && (
85
+ <View testID="register-panel-expected" className="gap-1">
86
+ <Text className="text-muted-foreground text-sm font-semibold">Expected in the drawer</Text>
87
+ {Object.entries(expected).map(([method, amount]) => (
88
+ <View key={method} className="min-h-11 flex-row items-center justify-between">
89
+ <Text className="capitalize">{method === 'cash' ? 'Cash' : method}</Text>
90
+ <Text className="tabular-nums">{formatMoney({ amount, currency })}</Text>
91
+ </View>
92
+ ))}
93
+ </View>
94
+ )}
95
+ <Button
96
+ testID="register-panel-movements"
97
+ variant="ghost"
98
+ className="min-h-11"
99
+ onPress={() => setExpanded(!expanded)}
100
+ >
101
+ <Text>Paid in / out</Text>
102
+ </Button>
103
+ {expanded &&
104
+ activeMovements.map((row) => (
105
+ <View key={row.id} className="min-h-11 flex-row items-center gap-2">
106
+ <Text testID={`movement-row-${row.id}`} className="flex-1">
107
+ {row.type === 'paid_in' ? 'Paid in' : row.type === 'paid_out' ? 'Paid out' : 'No sale'}
108
+ {!blind ? ` · ${formatMoney({ amount: row.amountMinor, currency })} · ${row.reason}` : ''}
109
+ </Text>
110
+ <Button
111
+ variant="ghost"
112
+ className="min-h-11"
113
+ testID={`movement-void-${row.id}`}
114
+ disabled={session?.status !== 'open'}
115
+ onPress={() => attempt(() => actions.voidMovement(row.id))}
116
+ >
117
+ <Text>Undo</Text>
118
+ </Button>
119
+ </View>
120
+ ))}
121
+ {!!error && <Text className="text-destructive">{error}</Text>}
122
+ <Button
123
+ testID="register-panel-close"
124
+ disabled={!session}
125
+ variant="outline"
126
+ className="min-h-14"
127
+ onPress={() =>
128
+ attempt(async () => {
129
+ await actions.startCounting();
130
+ onOpenChange(false);
131
+ })
132
+ }
133
+ >
134
+ <Text>Close register</Text>
135
+ </Button>
136
+ </ScrollView>
137
+ {movementType && (
138
+ <MovementSheet
139
+ register={register}
140
+ type={movementType}
141
+ currency={currency}
142
+ onOpenChange={(value) => {
143
+ if (!value) setMovementType(null);
144
+ }}
145
+ onDone={() => setMovementType(null)}
146
+ />
147
+ )}
148
+ </DialogContent>
149
+ </Dialog>
150
+ );
151
+ }
@@ -0,0 +1,60 @@
1
+ import { ScrollView } from 'react-native';
2
+
3
+ import { Button } from '../ui/button';
4
+ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
5
+ import { Text } from '../ui/text';
6
+ import { VStack } from '../ui/vstack';
7
+
8
+ export interface RegisterPickerRegister {
9
+ id: string;
10
+ name: string;
11
+ }
12
+
13
+ export interface RegisterPickerProps {
14
+ /** The app's own list of registers for this store; there's no `useRegisterSession` yet to bind. */
15
+ registers: RegisterPickerRegister[];
16
+ onPick: (id: string) => void;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * The register-choice screen: shown before a till is bound to any register. Port provenance
22
+ * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c` `register-picker.tsx`.
23
+ *
24
+ * Neutral change: the app supplies `registers` and binds on `onPick` itself — TallyUI has no
25
+ * `useRegisterBinding` concept; binding is entirely the app's own (ADR-032 amendment 1).
26
+ */
27
+ export function RegisterPicker({ registers, onPick, className }: RegisterPickerProps) {
28
+ return (
29
+ <Card testID="register-picker" className={className}>
30
+ <CardHeader>
31
+ <CardTitle>Choose a register</CardTitle>
32
+ </CardHeader>
33
+ <CardContent>
34
+ <ScrollView>
35
+ {registers.map((register) => (
36
+ <Button
37
+ key={register.id}
38
+ testID={`register-picker-row-${register.id}`}
39
+ variant="ghost"
40
+ className="h-auto min-h-11 items-start justify-start py-2"
41
+ onPress={() => onPick(register.id)}
42
+ >
43
+ <VStack space="none">
44
+ <Text numberOfLines={1}>{register.name}</Text>
45
+ <Text testID={`register-picker-row-${register.id}-status`} className="text-muted-foreground text-sm">
46
+ Not opened
47
+ </Text>
48
+ </VStack>
49
+ </Button>
50
+ ))}
51
+ {registers.length === 0 && (
52
+ <Text testID="register-picker-empty" className="text-muted-foreground p-4">
53
+ No registers are set up for this store yet.
54
+ </Text>
55
+ )}
56
+ </ScrollView>
57
+ </CardContent>
58
+ </Card>
59
+ );
60
+ }
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface TransactionSummary {
7
+ method: string;
8
+ count: number;
9
+ total: number;
10
+ }
11
+
12
+ export interface RegisterSummaryProps extends Omit<ViewProps, 'children'> {
13
+ expectedCash: number;
14
+ actualCash: number;
15
+ transactions: TransactionSummary[];
16
+ headerSlot?: ReactNode;
17
+ footerSlot?: ReactNode;
18
+ className?: string;
19
+ }
20
+
21
+ export function RegisterSummary({
22
+ expectedCash,
23
+ actualCash,
24
+ transactions,
25
+ headerSlot,
26
+ footerSlot,
27
+ className,
28
+ ...viewProps
29
+ }: RegisterSummaryProps) {
30
+ const discrepancy = actualCash - expectedCash;
31
+
32
+ return (
33
+ <View className={cn('gap-4 rounded-lg border border-border bg-card p-4', className)} {...viewProps}>
34
+ {headerSlot}
35
+
36
+ <View className="gap-2">
37
+ <Text className="text-xs font-semibold text-muted-foreground">Cash Drawer</Text>
38
+ <View className="flex-row justify-between">
39
+ <Text className="text-sm text-foreground">Expected</Text>
40
+ <Text className="text-sm text-foreground">{expectedCash.toFixed(2)}</Text>
41
+ </View>
42
+ <View className="flex-row justify-between">
43
+ <Text className="text-sm text-foreground">Actual</Text>
44
+ <Text className="text-sm text-foreground">{actualCash.toFixed(2)}</Text>
45
+ </View>
46
+ <View className="flex-row justify-between border-t border-border pt-1">
47
+ <Text className="text-sm font-semibold text-foreground">Discrepancy</Text>
48
+ <Text className={cn('text-sm font-semibold', discrepancy === 0 ? 'text-success' : 'text-destructive')}>
49
+ {discrepancy >= 0 ? '' : '-'}{Math.abs(discrepancy).toFixed(2)}
50
+ </Text>
51
+ </View>
52
+ </View>
53
+
54
+ {transactions.length > 0 && (
55
+ <View className="gap-2">
56
+ <Text className="text-xs font-semibold text-muted-foreground">Transactions</Text>
57
+ {transactions.map((t) => (
58
+ <View key={t.method} className="flex-row items-center justify-between">
59
+ <View className="gap-0.5">
60
+ <Text className="text-sm text-foreground">{t.method}</Text>
61
+ <Text className="text-xs text-muted-foreground">{t.count} transactions</Text>
62
+ </View>
63
+ <Text className="text-sm font-semibold text-foreground">{t.total.toFixed(2)}</Text>
64
+ </View>
65
+ ))}
66
+ </View>
67
+ )}
68
+
69
+ {footerSlot}
70
+ </View>
71
+ );
72
+ }