@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,109 @@
1
+ import { useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import type { useRegisterSession } from '@tallyui/pos';
4
+ import { parseMinor, validAmount } from '@tallyui/pos';
5
+
6
+ import { currencySymbol, formatMoney, minorUnitDigits } from '@tallyui/core';
7
+ import { cn } from '@tallyui/theme';
8
+ import { Button } from '../ui/button';
9
+ import { Input } from '../ui/input';
10
+ import { Label } from '../ui/label';
11
+ import { Text } from '../ui/text';
12
+
13
+ const AMOUNT_LABEL_ID = 'open-register-amount-label';
14
+
15
+ export interface OpenRegisterCardProps {
16
+ /** `useRegisterSession`'s return value: opens the session through `register.actions.openSession`. */
17
+ register: ReturnType<typeof useRegisterSession>;
18
+ /** ISO 4217 code, for parsing and formatting the typed amount. */
19
+ currency: string;
20
+ /** The store's configured opening float, minor units; preferred over the last counted cash. */
21
+ configuredFloatMinor?: number | null;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * Opens a register session, prefilled with the configured float or the last counted cash.
27
+ * Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c` `open-register-card.tsx`
28
+ * (LEDGER 52).
29
+ *
30
+ * The chip choices are already resolved once `expectedFloatMinor` is computed: a late-arriving
31
+ * `configuredFloatMinor` or `register.lastClosure` never overwrites what the cashier already
32
+ * typed, because `enteredAmount` only ever starts from it, never re-derives from it.
33
+ */
34
+ export function OpenRegisterCard({ register, currency, configuredFloatMinor, className }: OpenRegisterCardProps) {
35
+ const digits = minorUnitDigits(currency);
36
+ const lastCountedMinor = register.lastClosure?.counted?.cash ?? null;
37
+ const expectedFloatMinor = configuredFloatMinor ?? lastCountedMinor ?? null;
38
+ const [enteredAmount, setEnteredAmount] = useState<string | null>(null);
39
+ const amount = enteredAmount ?? (expectedFloatMinor != null ? decimal(expectedFloatMinor, digits) : '');
40
+ const [busy, setBusy] = useState(false);
41
+ const [error, setError] = useState('');
42
+ const valid = validAmount(amount, digits);
43
+ const countedMinor = valid ? parseMinor(amount, digits) : NaN;
44
+ const open = async () => {
45
+ if (busy || !valid) return;
46
+ setBusy(true);
47
+ setError('');
48
+ try {
49
+ await register.actions.openSession({ expectedFloatMinor, countedFloatMinor: countedMinor });
50
+ } catch (e) {
51
+ setError(String(e));
52
+ } finally {
53
+ setBusy(false);
54
+ }
55
+ };
56
+ const amountLabel = `Cash in the drawer to start (${currencySymbol(currency)})`;
57
+ return (
58
+ <View testID="open-register-card" className={cn('bg-card gap-3 rounded-md p-4', className)}>
59
+ <Text className="text-lg font-semibold">Open register</Text>
60
+ <Label testID="open-register-amount-label" nativeID={AMOUNT_LABEL_ID}>
61
+ {amountLabel}
62
+ </Label>
63
+ <Input>
64
+ <Input.Field
65
+ testID="open-register-amount"
66
+ value={amount}
67
+ onChangeText={setEnteredAmount}
68
+ keyboardType="decimal-pad"
69
+ accessibilityLabel={amountLabel}
70
+ accessibilityLabelledBy={AMOUNT_LABEL_ID}
71
+ />
72
+ </Input>
73
+ {configuredFloatMinor != null && (
74
+ <Button
75
+ testID="open-register-chip-configured"
76
+ variant="outline"
77
+ className="min-h-11"
78
+ onPress={() => setEnteredAmount(decimal(configuredFloatMinor, digits))}
79
+ >
80
+ <Text>Use the configured float ({formatMoney({ amount: configuredFloatMinor, currency })})</Text>
81
+ </Button>
82
+ )}
83
+ {lastCountedMinor != null && (
84
+ <Button
85
+ testID="open-register-chip-last"
86
+ variant="outline"
87
+ className="min-h-11"
88
+ onPress={() => setEnteredAmount(decimal(lastCountedMinor, digits))}
89
+ >
90
+ <Text>Use the last counted cash ({formatMoney({ amount: lastCountedMinor, currency })})</Text>
91
+ </Button>
92
+ )}
93
+ {expectedFloatMinor !== null && valid && countedMinor !== expectedFloatMinor && (
94
+ <Text testID="opening-variance">
95
+ {formatMoney({ amount: countedMinor - expectedFloatMinor, currency })} vs the expected float
96
+ </Text>
97
+ )}
98
+ {!!error && <Text className="text-destructive">{error}</Text>}
99
+ <Button testID="open-register-button" className="min-h-14" disabled={busy || !valid} onPress={open}>
100
+ <Text>{busy ? 'Opening…' : 'Open register'}</Text>
101
+ </Button>
102
+ </View>
103
+ );
104
+ }
105
+
106
+ function decimal(minor: number, digits: number): string {
107
+ const scale = 10 ** digits;
108
+ return (minor / scale).toFixed(digits);
109
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The register bar's status pill: one pill, one place. Port provenance (ADR-032 amendment 1):
3
+ * WCPOS `next` `3b5331b5c` `register-bar.helpers.ts` (LEDGER 48).
4
+ *
5
+ * Neutral changes: WCPOS decides "choosing a register" from a WooCommerce binding status
6
+ * (`bindingStatus: 'bound' | 'choose' | 'none' | 'unknown'`) and folds the store's display name
7
+ * into `place`. TallyUI's binding is entirely the app's own (ADR-032 amendment 1's "Not ported"
8
+ * note), so this takes the plain `registerId` the app already passes to `useRegisterSession`
9
+ * (`null` means unbound) instead, and drops the store name — RegisterBar here shows only the
10
+ * register name (`multiRegister` decides whether that's shown at all) and the status pill.
11
+ * Copy is plain English (no i18n keys, ADR-064's neutral-copy rule).
12
+ */
13
+
14
+ export type RegisterBarPill =
15
+ | 'Choose a register'
16
+ | 'Close not finished'
17
+ | 'Offline'
18
+ | 'Approval needed'
19
+ | 'Counting'
20
+ | 'Overdue'
21
+ | 'Register closed';
22
+
23
+ /**
24
+ * Priority, matching WCPOS's ordering: choosing a register outranks everything (LEDGER 48);
25
+ * then a closed session whose close isn't in flight (TallyUI: it blocks selling until Finish
26
+ * closing); then an unreachable store reads as offline, ahead of any session state; approval needed
27
+ * outranks plain counting; an open, overdue session outranks a session-less "closed" pill.
28
+ */
29
+ export function describeRegisterBarPill({
30
+ registerId,
31
+ online,
32
+ sessionStatus,
33
+ overdue = false,
34
+ approvalRequired = false,
35
+ sessionsOn = false,
36
+ closing = false,
37
+ }: {
38
+ /** The register this till is bound to, or `null` when it isn't bound yet. */
39
+ registerId: string | null;
40
+ online: boolean;
41
+ sessionStatus?: 'open' | 'counting' | 'closed' | null;
42
+ overdue?: boolean;
43
+ approvalRequired?: boolean;
44
+ /** The store uses register sessions at all (`useRegisterSession`'s `enabled`). */
45
+ sessionsOn?: boolean;
46
+ /** A close is in flight (`useRegisterSession`'s `closing`), so a closed session isn't unfinished. */
47
+ closing?: boolean;
48
+ }): RegisterBarPill | null {
49
+ if (registerId === null) return 'Choose a register';
50
+ if (sessionStatus === 'closed' && !closing) return 'Close not finished';
51
+ if (!online) return 'Offline';
52
+ if (sessionStatus === 'counting' && !approvalRequired) return 'Counting';
53
+ if (approvalRequired) return 'Approval needed';
54
+ if (sessionStatus === 'open' && overdue) return 'Overdue';
55
+ if (sessionsOn && !sessionStatus) return 'Register closed';
56
+ return null;
57
+ }
@@ -0,0 +1,85 @@
1
+ import { Pressable, View } from 'react-native';
2
+ import type { useRegisterSession } from '@tallyui/pos';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+ import { Badge } from '../ui/badge';
6
+ import { Button } from '../ui/button';
7
+ import { HStack } from '../ui/hstack';
8
+ import { Text } from '../ui/text';
9
+ import { describeRegisterBarPill } from './register-bar.helpers';
10
+
11
+ export interface RegisterBarProps {
12
+ /** `useRegisterSession`'s return value: the session's status, `overdue`, `enabled` and so on. */
13
+ register: ReturnType<typeof useRegisterSession>;
14
+ /** The register this till is bound to, or `null` when no register is chosen yet. */
15
+ registerId: string | null;
16
+ /** `false` when the store, or the app itself, can't be reached — reads to the cashier as offline. */
17
+ online: boolean;
18
+ /** Hidden when `multiRegister` is `false`: a single-register store doesn't need to name it. */
19
+ registerName?: string;
20
+ multiRegister: boolean;
21
+ onOpenPanel: () => void;
22
+ /** Turns the pill into a button (opens the gate/picker or the panel) when given; the plain badge otherwise. */
23
+ onPressPill?: () => void;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * The till's one status bar: which register (multi-register stores only), one status pill
29
+ * (LEDGER 48), and the button that opens the register panel. Port provenance (ADR-032 amendment
30
+ * 1): WCPOS `next` `3b5331b5c` `register-bar.tsx`.
31
+ *
32
+ * Dropped for this neutral port: the user avatar and user sheet, switch-store, and the
33
+ * WooCommerce OAuth-return rehosting — all app/backend concerns `useRegisterSession` never
34
+ * carries. The drawer/menu button is gone too: TallyUI has no fixed navigation shell to open.
35
+ */
36
+ export function RegisterBar({
37
+ register,
38
+ registerId,
39
+ online,
40
+ registerName,
41
+ multiRegister,
42
+ onOpenPanel,
43
+ onPressPill,
44
+ className,
45
+ }: RegisterBarProps) {
46
+ const { session, overdue, enabled, lastClosure, closing } = register;
47
+ const pill = describeRegisterBarPill({
48
+ registerId,
49
+ online,
50
+ sessionStatus: session?.status ?? null,
51
+ overdue,
52
+ approvalRequired: session?.approval_required,
53
+ sessionsOn: enabled,
54
+ closing,
55
+ });
56
+ return (
57
+ <HStack testID="register-bar" className={cn('bg-card border-border h-12 border-b px-3', className)}>
58
+ <View className="min-w-0 flex-1 shrink">
59
+ {multiRegister && registerId !== null && (
60
+ <Text testID="register-bar-name" numberOfLines={1}>
61
+ {registerName}
62
+ </Text>
63
+ )}
64
+ </View>
65
+ {pill && (onPressPill ? (
66
+ <Pressable testID="register-bar-pill" accessibilityRole="button" accessibilityLabel={pill} onPress={onPressPill}>
67
+ <Badge label={pill} variant="warning" />
68
+ </Pressable>
69
+ ) : (
70
+ <Badge testID="register-bar-pill" label={pill} variant="warning" />
71
+ ))}
72
+ {(session || lastClosure) && (
73
+ <Button
74
+ variant="ghost"
75
+ size="sm"
76
+ testID="register-bar-open-panel"
77
+ accessibilityLabel="Open register panel"
78
+ onPress={onOpenPanel}
79
+ >
80
+ <Text className={overdue ? 'text-warning' : undefined}>Register ›</Text>
81
+ </Button>
82
+ )}
83
+ </HStack>
84
+ );
85
+ }
@@ -0,0 +1,117 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { View } from 'react-native';
3
+ import type { useRegisterSession } from '@tallyui/pos';
4
+
5
+ import { Button } from '../ui/button';
6
+ import { Text } from '../ui/text';
7
+ import { OpenRegisterCard } from './open-register-card';
8
+ import { RegisterPicker, type RegisterPickerRegister } from './register-picker';
9
+
10
+ export interface RegisterColumnProps {
11
+ /** `useRegisterSession`'s return value: decides the picker/open/counting/selling swap. */
12
+ register: ReturnType<typeof useRegisterSession>;
13
+ /** The register this till is bound to, the same value passed into `useRegisterSession`; `null`
14
+ * means unbound, since the hook's own return carries no such field (it's the app's input, not
15
+ * its output). */
16
+ registerId: string | null;
17
+ registers: RegisterPickerRegister[];
18
+ onPick: (id: string) => void;
19
+ /** ISO 4217 code, forwarded to `OpenRegisterCard`. */
20
+ currency: string;
21
+ configuredFloatMinor?: number | null;
22
+ /** Rendered instead of `children` while a session is counting (Job B fills this), and while its close is in flight
23
+ * (`register.closing`). A closed session that isn't closing shows the Finish-closing card instead, whose button has
24
+ * `nativeID="register-column-finish-close-button"`, so an app's `onPressPill` can focus it on the web. */
25
+ countSlot?: ReactNode;
26
+ /** Whether the cart has no lines; gates the overdue Close register offer (LEDGER 53). */
27
+ cartEmpty?: boolean;
28
+ /** The cart, shown once a register is bound, open, and not counting. */
29
+ children: ReactNode;
30
+ }
31
+
32
+ /**
33
+ * Swaps the cart column's content for register selection, opening or counting when the till
34
+ * needs it, offering Close register when an open session runs overdue with nothing left to
35
+ * sell. Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`'s cart-column register
36
+ * swap (LEDGER 53), composed here as one small component rather than inline cart-screen logic.
37
+ */
38
+ export function RegisterColumn({
39
+ register,
40
+ registerId,
41
+ registers,
42
+ onPick,
43
+ currency,
44
+ configuredFloatMinor,
45
+ countSlot,
46
+ cartEmpty,
47
+ children,
48
+ }: RegisterColumnProps) {
49
+ const [finishing, setFinishing] = useState(false); // Lifted, so the card stays up through its own close.
50
+ if (registerId === null) {
51
+ return <RegisterPicker registers={registers} onPick={onPick} className="flex-1" />;
52
+ }
53
+ if (register.session?.status === 'closed') {
54
+ // A close in flight stores the session closed before its closure lands: unless the card started it, not an unfinished close.
55
+ return register.closing && !finishing ? <>{countSlot}</> : <FinishClose register={register} busy={finishing} setBusy={setFinishing} />;
56
+ }
57
+ if (!register.session) {
58
+ return <OpenRegisterCard register={register} currency={currency} configuredFloatMinor={configuredFloatMinor} className="flex-1" />;
59
+ }
60
+ if (register.session.status === 'counting') {
61
+ return <>{countSlot}</>;
62
+ }
63
+ return (
64
+ <View testID="register-column" className="flex-1">
65
+ {register.overdue && cartEmpty && (
66
+ <View testID="register-column-overdue" className="bg-warning/10 flex-row items-center justify-between gap-2 px-3 py-2">
67
+ <Text className="flex-1">This register is overdue for closing.</Text>
68
+ <Button
69
+ testID="register-column-close-overdue"
70
+ variant="outline"
71
+ className="min-h-11"
72
+ onPress={() => register.actions.startCounting()}
73
+ >
74
+ <Text>Close register</Text>
75
+ </Button>
76
+ </View>
77
+ )}
78
+ {children}
79
+ </View>
80
+ );
81
+ }
82
+
83
+ /**
84
+ * `currentSession` (`use-register-session.ts` ~110-125) can return a closed session whose closure
85
+ * row was never written: an unapplied reservation's session, or a closed session with no closure
86
+ * row. `openSession` then refuses with `RegisterCloseIncompleteError`, so the cashier is stuck
87
+ * behind the cart unless this offers a way to resume the close.
88
+ */
89
+ function FinishClose({ register, busy, setBusy }: { register: ReturnType<typeof useRegisterSession>; busy: boolean; setBusy: (busy: boolean) => void }) {
90
+ const [error, setError] = useState('');
91
+ const finish = async () => {
92
+ setBusy(true);
93
+ setError('');
94
+ try {
95
+ // A resumed close: the store keeps the count persisted on the session (a retry's count is
96
+ // ignored for a session already closed), and the approval gate doesn't run again.
97
+ await register.actions.closeSession({ counted: register.session?.counted ?? {} });
98
+ } catch (e) {
99
+ setError(e instanceof Error ? e.message : String(e));
100
+ } finally {
101
+ setBusy(false);
102
+ }
103
+ };
104
+ return (
105
+ <View testID="register-column-finish-close" className="flex-1 gap-3 p-4">
106
+ <Text>The last close didn&apos;t finish.</Text>
107
+ <Button testID="register-column-finish-close-button" nativeID="register-column-finish-close-button" disabled={busy} onPress={finish}>
108
+ <Text>Finish closing</Text>
109
+ </Button>
110
+ {!!error && (
111
+ <Text testID="register-column-finish-close-error" className="text-destructive">
112
+ {error}
113
+ </Text>
114
+ )}
115
+ </View>
116
+ );
117
+ }