@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1076 -76
- package/dist/index.js +4662 -355
- package/package.json +27 -5
- package/src/cart/cart-line-actions.tsx +40 -0
- package/src/cart/cart-line.tsx +18 -32
- package/src/cart/cart-note-input.tsx +46 -0
- package/src/cart/cart-panel.tsx +39 -26
- package/src/cart/cart-total.tsx +43 -47
- package/src/cart/discount-badge.tsx +19 -0
- package/src/cart/index.ts +4 -1
- package/src/checkout/cash-tendered.tsx +101 -0
- package/src/checkout/change-display.tsx +19 -0
- package/src/checkout/index.ts +3 -0
- package/src/checkout/order-summary-line.tsx +7 -8
- package/src/checkout/order-summary.tsx +8 -7
- package/src/checkout/payment-method-card.tsx +10 -9
- package/src/checkout/payment-selector.tsx +5 -5
- package/src/checkout/receipt-preview.tsx +78 -0
- package/src/customer/customer-card.tsx +8 -9
- package/src/customer/customer-form.tsx +70 -0
- package/src/customer/customer-order-history.tsx +34 -0
- package/src/customer/customer-select.tsx +63 -24
- package/src/customer/index.ts +2 -0
- package/src/index.ts +112 -1
- package/src/input/filter-chip.tsx +85 -27
- package/src/input/index.ts +1 -0
- package/src/input/quantity-stepper.tsx +64 -0
- package/src/input/search-input.tsx +30 -7
- package/src/layout/checkout-layout.tsx +46 -0
- package/src/layout/index.ts +5 -0
- package/src/layout/live-tab-screen.tsx +54 -0
- package/src/layout/pos-layout.tsx +64 -0
- package/src/layout/settings-layout.tsx +58 -0
- package/src/layout/store-settings-choice-screen.tsx +142 -0
- package/src/order/index.ts +4 -0
- package/src/order/order-card.tsx +48 -0
- package/src/order/order-detail.tsx +82 -0
- package/src/order/order-list.tsx +39 -0
- package/src/order/order-status-badge.tsx +32 -0
- package/src/product/category-nav.tsx +70 -0
- package/src/product/index.ts +3 -0
- package/src/product/product-card.tsx +36 -8
- package/src/product/product-grid.tsx +25 -6
- package/src/product/product-image.tsx +28 -7
- package/src/product/product-list.tsx +44 -0
- package/src/product/product-price.tsx +77 -22
- package/src/product/product-sku.tsx +5 -5
- package/src/product/product-stock-badge.tsx +41 -28
- package/src/product/product-title.tsx +2 -3
- package/src/product/product-variant-picker.tsx +61 -0
- package/src/register/cash-count-input.tsx +85 -0
- package/src/register/closure-sheet.tsx +155 -0
- package/src/register/index.ts +12 -0
- package/src/register/movement-sheet.tsx +118 -0
- package/src/register/open-register-card.tsx +109 -0
- package/src/register/register-bar.helpers.ts +57 -0
- package/src/register/register-bar.tsx +85 -0
- package/src/register/register-column.tsx +117 -0
- package/src/register/register-count.tsx +330 -0
- package/src/register/register-open-close.tsx +52 -0
- package/src/register/register-panel.tsx +151 -0
- package/src/register/register-picker.tsx +60 -0
- package/src/register/register-summary.tsx +72 -0
- package/src/sale/cart-bar.tsx +18 -0
- package/src/sale/cart.tsx +66 -0
- package/src/sale/catalogue.tsx +131 -0
- package/src/sale/discount-form.tsx +85 -0
- package/src/sale/index.ts +9 -0
- package/src/sale/orders-list.tsx +66 -0
- package/src/sale/print-style.ts +11 -0
- package/src/sale/receipt.tsx +61 -0
- package/src/sale/sync-status.tsx +20 -0
- package/src/sale/tender.tsx +35 -0
- package/src/settings/connector-status.tsx +65 -0
- package/src/settings/index.ts +3 -0
- package/src/settings/settings-group.tsx +23 -0
- package/src/settings/settings-row.tsx +23 -0
- package/src/ui/accordion/index.tsx +59 -0
- package/src/ui/alert-dialog/index.tsx +91 -0
- package/src/ui/avatar/index.tsx +50 -0
- package/src/ui/badge/index.tsx +60 -0
- package/src/ui/button/index.tsx +90 -0
- package/src/ui/card/index.tsx +60 -0
- package/src/ui/checkbox/index.tsx +29 -0
- package/src/ui/collapsible/index.tsx +24 -0
- package/src/ui/combobox/index.tsx +98 -0
- package/src/ui/context-menu/index.tsx +159 -0
- package/src/ui/dialog/index.tsx +92 -0
- package/src/ui/dropdown-menu/index.tsx +161 -0
- package/src/ui/hover-card/index.tsx +31 -0
- package/src/ui/hstack/index.tsx +43 -0
- package/src/ui/icon/index.tsx +44 -0
- package/src/ui/icon/search-icon.tsx +9 -0
- package/src/ui/icon/svg-icon.tsx +36 -0
- package/src/ui/icon-button/index.tsx +58 -0
- package/src/ui/index.ts +99 -0
- package/src/ui/input/index.tsx +100 -0
- package/src/ui/label/index.tsx +26 -0
- package/src/ui/loader/index.tsx +37 -0
- package/src/ui/popover/index.tsx +31 -0
- package/src/ui/progress/index.tsx +28 -0
- package/src/ui/radio-group/index.tsx +38 -0
- package/src/ui/select/index.tsx +96 -0
- package/src/ui/separator/index.tsx +29 -0
- package/src/ui/slider/index.tsx +28 -0
- package/src/ui/switch/index.tsx +31 -0
- package/src/ui/table/index.tsx +59 -0
- package/src/ui/tabs/index.tsx +53 -0
- package/src/ui/text/index.tsx +36 -0
- package/src/ui/textarea/index.tsx +29 -0
- package/src/ui/toast/index.tsx +52 -0
- package/src/ui/toggle/index.tsx +69 -0
- package/src/ui/toggle-group/index.tsx +66 -0
- package/src/ui/tooltip/index.tsx +30 -0
- package/src/ui/vstack/index.tsx +43 -0
- package/src/uniwind-env.d.ts +9 -1
- package/src/__tests__/cart-line.test.tsx +0 -53
- package/src/__tests__/cart-panel.test.tsx +0 -55
- package/src/__tests__/cart-total.test.tsx +0 -60
- package/src/__tests__/customer-card.test.tsx +0 -47
- package/src/__tests__/customer-select.test.tsx +0 -33
- package/src/__tests__/filter-chip.test.tsx +0 -40
- package/src/__tests__/helpers.ts +0 -111
- package/src/__tests__/order-summary-line.test.tsx +0 -18
- package/src/__tests__/order-summary.test.tsx +0 -32
- package/src/__tests__/payment-method-card.test.tsx +0 -24
- package/src/__tests__/payment-selector.test.tsx +0 -23
- package/src/__tests__/product-card.test.tsx +0 -31
- package/src/__tests__/product-grid.test.tsx +0 -37
- package/src/__tests__/product-image.test.tsx +0 -54
- package/src/__tests__/product-price.test.tsx +0 -68
- package/src/__tests__/product-sku.test.tsx +0 -47
- package/src/__tests__/product-stock-badge.test.tsx +0 -58
- package/src/__tests__/product-title.test.tsx +0 -38
- 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
|
+
}
|