@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
@@ -1,7 +1,6 @@
1
- import { Text, type TextProps } from 'react-native';
2
-
3
1
  import { useProductTraits } from '@tallyui/core';
4
2
  import { cn } from '@tallyui/theme';
3
+ import { Text, type TextProps } from '../ui';
5
4
 
6
5
  export interface ProductSkuProps extends Omit<TextProps, 'children'> {
7
6
  /** The raw RxDB product document (connector-specific shape) */
@@ -22,12 +21,13 @@ export interface ProductSkuProps extends Omit<TextProps, 'children'> {
22
21
  * ```
23
22
  */
24
23
  export function ProductSku({ doc, fallback = '—', className, ...textProps }: ProductSkuProps) {
25
- const { getSku } = useProductTraits();
24
+ const { getSku, getVariantCount } = useProductTraits();
25
+ const count = getVariantCount(doc);
26
26
  const sku = getSku(doc);
27
27
 
28
28
  return (
29
- <Text className={cn('text-xs text-muted', className)} {...textProps}>
30
- {sku ?? fallback}
29
+ <Text className={cn('text-xs text-muted-foreground', className)} {...textProps}>
30
+ {count > 1 ? `${count} variants` : sku ?? fallback}
31
31
  </Text>
32
32
  );
33
33
  }
@@ -1,73 +1,86 @@
1
- import { Text, View, type ViewProps } from 'react-native';
1
+ import { View } from 'react-native';
2
2
 
3
- import { useProductTraits } from '@tallyui/core';
3
+ import { useProductStock } from '@tallyui/core';
4
+ import type { StockStatus } from '@tallyui/core';
4
5
  import { cn } from '@tallyui/theme';
6
+ import { Text, HStack, type HStackProps } from '../ui';
5
7
 
6
- export interface ProductStockBadgeProps extends Omit<ViewProps, 'children'> {
8
+ export interface ProductStockBadgeProps extends Omit<HStackProps, 'children'> {
7
9
  /** The raw RxDB product document (connector-specific shape) */
8
10
  doc: any;
9
11
  /** Whether to show the quantity alongside the status */
10
12
  showQuantity?: boolean;
13
+ /** Append when reconciled stock was last confirmed, if the provider gives it (default true) */
14
+ showAsOf?: boolean;
11
15
  className?: string;
12
16
  }
13
17
 
14
- const statusLabels: Record<string, string> = {
15
- instock: 'In Stock',
16
- outofstock: 'Out of Stock',
17
- onbackorder: 'On Backorder',
18
+ const statusLabels: Record<StockStatus, string> = {
19
+ in_stock: 'In Stock',
20
+ out_of_stock: 'Out of Stock',
21
+ backorder: 'On Backorder',
18
22
  unknown: 'Unknown',
19
23
  };
20
24
 
21
- const STATUS_STYLES: Record<string, { badge: string; dot: string; text: string }> = {
22
- instock: {
25
+ const STATUS_STYLES: Record<StockStatus, { badge: string; dot: string; text: string }> = {
26
+ in_stock: {
23
27
  badge: 'bg-success/15',
24
28
  dot: 'bg-success',
25
- text: 'text-success',
29
+ text: 'text-foreground',
26
30
  },
27
- outofstock: {
28
- badge: 'bg-danger/15',
29
- dot: 'bg-danger',
30
- text: 'text-danger',
31
+ out_of_stock: {
32
+ badge: 'bg-destructive/15',
33
+ dot: 'bg-destructive',
34
+ text: 'text-foreground',
31
35
  },
32
- onbackorder: {
36
+ backorder: {
33
37
  badge: 'bg-warning/15',
34
38
  dot: 'bg-warning',
35
- text: 'text-warning',
39
+ text: 'text-foreground',
36
40
  },
37
41
  unknown: {
38
42
  badge: 'bg-muted/15',
39
43
  dot: 'bg-muted',
40
- text: 'text-muted',
44
+ text: 'text-muted-foreground',
41
45
  },
42
46
  };
43
47
 
48
+ /** Local HH:MM when `iso` is today, otherwise a short local date. */
49
+ function formatAsOf(iso: string): string {
50
+ const date = new Date(iso);
51
+ return date.toDateString() === new Date().toDateString()
52
+ ? date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
53
+ : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
54
+ }
55
+
44
56
  /**
45
57
  * Displays a visual stock status badge for a product.
46
58
  *
47
- * Shows a colored label (In Stock / Out of Stock / On Backorder)
48
- * with an optional quantity count.
59
+ * Shows a label (In Stock / Out of Stock / On Backorder) with a dot carrying the colour
60
+ * with an optional quantity count. When the ConnectorProvider is given a
61
+ * reconciled stock overlay, it shows that stock and when it was confirmed.
49
62
  *
50
63
  * ```tsx
51
64
  * <ProductStockBadge doc={productDocument} showQuantity />
52
65
  * ```
53
66
  */
54
- export function ProductStockBadge({ doc, showQuantity = false, className, ...viewProps }: ProductStockBadgeProps) {
55
- const { getStockStatus, getStockQuantity } = useProductTraits();
56
- const status = getStockStatus(doc);
57
- const quantity = getStockQuantity(doc);
67
+ export function ProductStockBadge({ doc, showQuantity = false, showAsOf = true, className, ...props }: ProductStockBadgeProps) {
68
+ const { status, quantity, asOf } = useProductStock(doc);
58
69
  const label = statusLabels[status] ?? statusLabels.unknown;
59
70
  const styles = STATUS_STYLES[status] ?? STATUS_STYLES.unknown;
60
71
 
61
72
  return (
62
- <View
63
- className={cn('flex-row items-center self-start gap-1.5 rounded-xl px-2 py-1', styles.badge, className)}
64
- {...viewProps}
73
+ <HStack
74
+ space="none"
75
+ className={cn('gap-1.5 self-start rounded-xl px-2 py-1', styles.badge, className)}
76
+ {...props}
65
77
  >
66
78
  <View className={cn('h-1.5 w-1.5 rounded-full', styles.dot)} />
67
79
  <Text className={cn('text-xs font-semibold', styles.text)}>
68
80
  {label}
69
- {showQuantity && quantity != null ? ` (${quantity})` : ''}
81
+ {status !== 'out_of_stock' && showQuantity && quantity != null ? ` (${quantity})` : ''}
82
+ {asOf && showAsOf ? <Text className="text-xs font-normal text-muted-foreground">{` · as of ${formatAsOf(asOf)}`}</Text> : null}
70
83
  </Text>
71
- </View>
84
+ </HStack>
72
85
  );
73
86
  }
@@ -1,7 +1,6 @@
1
- import { Text, type TextProps } from 'react-native';
2
-
3
1
  import { useProductTraits } from '@tallyui/core';
4
2
  import { cn } from '@tallyui/theme';
3
+ import { Text, type TextProps } from '../ui';
5
4
 
6
5
  export interface ProductTitleProps extends Omit<TextProps, 'children'> {
7
6
  /** The raw RxDB product document (connector-specific shape) */
@@ -23,7 +22,7 @@ export interface ProductTitleProps extends Omit<TextProps, 'children'> {
23
22
  export function ProductTitle({ doc, className, ...textProps }: ProductTitleProps) {
24
23
  const { getName } = useProductTraits();
25
24
  return (
26
- <Text className={cn('text-base font-semibold text-foreground', className)} {...textProps}>
25
+ <Text className={cn('font-semibold', className)} {...textProps}>
27
26
  {getName(doc)}
28
27
  </Text>
29
28
  );
@@ -0,0 +1,61 @@
1
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ export interface VariantOption {
6
+ name: string;
7
+ values: string[];
8
+ }
9
+
10
+ export interface ProductVariantPickerProps extends Omit<ViewProps, 'children'> {
11
+ options: VariantOption[];
12
+ selected?: Record<string, string>;
13
+ onSelect?: (selection: Record<string, string>) => void;
14
+ className?: string;
15
+ }
16
+
17
+ export function ProductVariantPicker({
18
+ options,
19
+ selected = {},
20
+ onSelect,
21
+ className,
22
+ ...viewProps
23
+ }: ProductVariantPickerProps) {
24
+ const handleSelect = (optionName: string, value: string) => {
25
+ onSelect?.({ ...selected, [optionName]: value });
26
+ };
27
+
28
+ return (
29
+ <View className={cn('gap-3', className)} {...viewProps}>
30
+ {options.map((option) => (
31
+ <View key={option.name} className="gap-1.5">
32
+ <Text className="text-xs font-semibold text-muted-foreground">{option.name}</Text>
33
+ <View className="flex-row flex-wrap gap-2">
34
+ {option.values.map((value) => {
35
+ const active = selected[option.name] === value;
36
+ return (
37
+ <Pressable
38
+ key={value}
39
+ onPress={() => handleSelect(option.name, value)}
40
+ className={cn(
41
+ 'rounded-lg border px-3 py-1.5',
42
+ active ? 'border-primary bg-primary/10' : 'border-border bg-card',
43
+ )}
44
+ >
45
+ <Text
46
+ className={cn(
47
+ 'text-sm font-medium',
48
+ active ? 'text-primary' : 'text-foreground',
49
+ )}
50
+ >
51
+ {value}
52
+ </Text>
53
+ </Pressable>
54
+ );
55
+ })}
56
+ </View>
57
+ </View>
58
+ ))}
59
+ </View>
60
+ );
61
+ }
@@ -0,0 +1,85 @@
1
+ import { useState } from 'react';
2
+ import { Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ import { QuantityStepper } from '../input/quantity-stepper';
7
+
8
+ export interface Denomination {
9
+ label: string;
10
+ value: number;
11
+ }
12
+
13
+ const DEFAULT_DENOMINATIONS: Denomination[] = [
14
+ { label: '$100', value: 100 },
15
+ { label: '$50', value: 50 },
16
+ { label: '$20', value: 20 },
17
+ { label: '$10', value: 10 },
18
+ { label: '$5', value: 5 },
19
+ { label: '$1', value: 1 },
20
+ { label: '25\u00a2', value: 0.25 },
21
+ { label: '10\u00a2', value: 0.10 },
22
+ { label: '5\u00a2', value: 0.05 },
23
+ { label: '1\u00a2', value: 0.01 },
24
+ ];
25
+
26
+ export interface CashCountInputProps extends Omit<ViewProps, 'children'> {
27
+ denominations?: Denomination[];
28
+ defaultCounts?: Record<number, number>;
29
+ counts?: Record<number, number>;
30
+ onChangeCounts?: (counts: Record<number, number>) => void;
31
+ onChangeTotal?: (total: number) => void;
32
+ className?: string;
33
+ }
34
+
35
+ export function CashCountInput({
36
+ denominations = DEFAULT_DENOMINATIONS,
37
+ defaultCounts,
38
+ counts: controlledCounts,
39
+ onChangeCounts,
40
+ onChangeTotal,
41
+ className,
42
+ ...viewProps
43
+ }: CashCountInputProps) {
44
+ const [internalCounts, setInternalCounts] = useState<Record<number, number>>(defaultCounts ?? {});
45
+ const counts = controlledCounts ?? internalCounts;
46
+
47
+ const total = denominations.reduce(
48
+ (sum, d) => sum + d.value * (counts[d.value] ?? 0),
49
+ 0,
50
+ );
51
+
52
+ const handleCountChange = (denomination: number, quantity: number) => {
53
+ const next = { ...counts, [denomination]: quantity };
54
+ if (controlledCounts === undefined) setInternalCounts(next);
55
+ onChangeCounts?.(next);
56
+ const newTotal = denominations.reduce(
57
+ (sum, d) => sum + d.value * (next[d.value] ?? 0),
58
+ 0,
59
+ );
60
+ onChangeTotal?.(newTotal);
61
+ };
62
+
63
+ return (
64
+ <View className={cn('gap-2', className)} {...viewProps}>
65
+ {denominations.map((d) => (
66
+ <View key={d.value} className="flex-row items-center justify-between px-3 py-1">
67
+ <Text className="w-16 text-sm font-medium text-foreground">{d.label}</Text>
68
+ <QuantityStepper
69
+ quantity={counts[d.value] ?? 0}
70
+ min={0}
71
+ onChangeQuantity={(qty) => handleCountChange(d.value, qty)}
72
+ />
73
+ <Text className="w-20 text-right text-sm text-muted-foreground">
74
+ {(d.value * (counts[d.value] ?? 0)).toFixed(2)}
75
+ </Text>
76
+ </View>
77
+ ))}
78
+ <View className="border-t border-border px-3 pt-2">
79
+ <Text className="text-right text-base font-bold text-foreground">
80
+ Total: {total.toFixed(2)}
81
+ </Text>
82
+ </View>
83
+ </View>
84
+ );
85
+ }
@@ -0,0 +1,155 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, Animated, View } from 'react-native';
3
+ import type { useRegisterSession } from '@tallyui/pos';
4
+ import { countVariance, varianceText } from '@tallyui/pos';
5
+
6
+ import { formatMoney, minorUnitDigits } from '@tallyui/core';
7
+ import { Button } from '../ui/button';
8
+ import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
9
+ import { Text } from '../ui/text';
10
+ import { tenderLabel, varianceWord } from './register-count';
11
+
12
+ /**
13
+ * The completion checkmark's entrance: fades and scales in over 180ms unless the platform
14
+ * reports reduced motion (`AccessibilityInfo.isReduceMotionEnabled`; on web, react-native-web
15
+ * resolves it from `matchMedia('(prefers-reduced-motion: reduce)')`), in which case it appears
16
+ * at once. Plain `Animated`, not `react-native-reanimated`: no new dependency.
17
+ */
18
+ function useCompletionMotion() {
19
+ const opacity = useRef(new Animated.Value(0)).current;
20
+ const scale = useRef(new Animated.Value(0.6)).current;
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ AccessibilityInfo.isReduceMotionEnabled().then((reduced) => {
24
+ if (cancelled) return;
25
+ if (reduced) {
26
+ opacity.setValue(1);
27
+ scale.setValue(1);
28
+ return;
29
+ }
30
+ Animated.parallel([
31
+ Animated.timing(opacity, { toValue: 1, duration: 180, useNativeDriver: false }),
32
+ Animated.timing(scale, { toValue: 1, duration: 180, useNativeDriver: false }),
33
+ ]).start();
34
+ });
35
+ return () => {
36
+ cancelled = true;
37
+ };
38
+ }, [opacity, scale]);
39
+ return { opacity, scale };
40
+ }
41
+
42
+ export interface ClosureSheetProps {
43
+ /** `useRegisterSession`'s return value: reads `lastClosure` and `blind`. */
44
+ register: ReturnType<typeof useRegisterSession>;
45
+ /** ISO 4217 code, for the figures. */
46
+ currency: string;
47
+ /** Renders no print button when absent — printing is a later job (registers c2). */
48
+ onPrint?: () => void | Promise<void>;
49
+ onDone: () => void;
50
+ }
51
+
52
+ /**
53
+ * Confirms a closed session from `register.lastClosure`: the local closure number always, and
54
+ * the counted figures and variance per tender unless `blind`. Port provenance (ADR-032
55
+ * amendment 1): WCPOS `next` `3b5331b5c` `closure-sheet.tsx` (LEDGER 54, 62). Leaves out the
56
+ * Z-report preview, print/reprint and the server-number adoption (registers c2) — `onPrint` is
57
+ * an optional hook for a host that later wires up printing; without it, no button renders.
58
+ * Unlike WCPOS (which still shows counted cash to a blind cashier), every figure here is hidden
59
+ * while blind, per the register-screens-b design, since TallyUI's closure lists every tender.
60
+ */
61
+ export function ClosureSheet({ register, currency, onPrint, onDone }: ClosureSheetProps) {
62
+ const { lastClosure: closure, blind } = register;
63
+ const digits = minorUnitDigits(currency);
64
+ const { opacity, scale } = useCompletionMotion();
65
+ const [busy, setBusy] = useState(false);
66
+ const [error, setError] = useState('');
67
+ if (!closure) return null;
68
+ const methods = Object.keys(closure.counted);
69
+ const approvedByName = closure.breakdowns.approved_by_name;
70
+ const approvedById = closure.breakdowns.approved_by;
71
+ const approvedBy =
72
+ typeof approvedByName === 'string' && approvedByName
73
+ ? approvedByName
74
+ : typeof approvedById === 'string' && approvedById
75
+ ? approvedById
76
+ : '';
77
+ const print = async () => {
78
+ if (!onPrint) return;
79
+ setBusy(true);
80
+ setError('');
81
+ try {
82
+ await onPrint();
83
+ } catch (e) {
84
+ setError(e instanceof Error ? e.message : String(e));
85
+ } finally {
86
+ setBusy(false);
87
+ }
88
+ };
89
+ return (
90
+ <Dialog
91
+ open
92
+ onOpenChange={(open) => {
93
+ if (!open) onDone();
94
+ }}
95
+ >
96
+ <DialogContent testID="closure-sheet">
97
+ <Animated.View
98
+ testID="closure-check"
99
+ style={{ opacity, transform: [{ scale }] }}
100
+ className="bg-success/10 h-10 w-10 items-center justify-center rounded-full"
101
+ >
102
+ <Text className="text-success text-lg font-bold" accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
103
+ {'✓'}
104
+ </Text>
105
+ </Animated.View>
106
+ <DialogTitle testID="closure-title">Register closed</DialogTitle>
107
+ {/* The number stays visible, blind or not: the Z report references it (the Front desk, 2026-09-28). */}
108
+ <Text testID="closure-number" className="text-muted-foreground min-h-11">{`Closure #${closure.number}`}</Text>
109
+ {!blind && (
110
+ <View testID="closure-figures" className="gap-3">
111
+ {methods.map((method) => {
112
+ const countedMinor = closure.counted[method] ?? 0;
113
+ const expectedMinor = closure.expected[method] ?? 0;
114
+ const varianceMinor = closure.variance[method] ?? countVariance(countedMinor, expectedMinor);
115
+ return (
116
+ <View key={method} className="gap-1">
117
+ <Text className="text-muted-foreground text-xs font-semibold">{tenderLabel(method)}</Text>
118
+ <Text testID={`closure-counted-${method}`} className="min-h-11 tabular-nums">
119
+ {`Counted ${formatMoney({ amount: countedMinor, currency })}`}
120
+ </Text>
121
+ <Text testID={`closure-expected-${method}`} className="min-h-11 tabular-nums">
122
+ {`Expected ${formatMoney({ amount: expectedMinor, currency })}`}
123
+ </Text>
124
+ <Text testID={`closure-variance-${method}`} className="min-h-11 tabular-nums">
125
+ {varianceText(
126
+ varianceMinor,
127
+ digits,
128
+ (major) => formatMoney({ amount: Math.round(major * 10 ** digits), currency }) ?? '',
129
+ varianceWord,
130
+ )}
131
+ </Text>
132
+ </View>
133
+ );
134
+ })}
135
+ </View>
136
+ )}
137
+ {/* Not a figure: shows who approved the close even while blind. */}
138
+ {!!approvedBy && <Text testID="closure-approved-by">Approved by {approvedBy}</Text>}
139
+ {!!error && (
140
+ <Text testID="closure-print-error" className="text-destructive">
141
+ {error}
142
+ </Text>
143
+ )}
144
+ {!blind && !!onPrint && (
145
+ <Button testID="closure-print" className="min-h-14" disabled={busy} onPress={print}>
146
+ <Text>{busy ? 'Printing…' : 'Print the Z report'}</Text>
147
+ </Button>
148
+ )}
149
+ <Button testID="closure-done" variant="outline" className="min-h-14" onPress={onDone}>
150
+ <Text>Done</Text>
151
+ </Button>
152
+ </DialogContent>
153
+ </Dialog>
154
+ );
155
+ }
@@ -0,0 +1,12 @@
1
+ export { CashCountInput, type CashCountInputProps, type Denomination } from './cash-count-input';
2
+ export { RegisterSummary, type RegisterSummaryProps, type TransactionSummary } from './register-summary';
3
+ export { RegisterOpenClose, type RegisterOpenCloseProps } from './register-open-close';
4
+ export { RegisterPicker, type RegisterPickerProps, type RegisterPickerRegister } from './register-picker';
5
+ export { OpenRegisterCard, type OpenRegisterCardProps } from './open-register-card';
6
+ export { RegisterBar, type RegisterBarProps } from './register-bar';
7
+ export { describeRegisterBarPill, type RegisterBarPill } from './register-bar.helpers';
8
+ export { MovementSheet, type MovementSheetProps } from './movement-sheet';
9
+ export { RegisterPanel, type RegisterPanelProps } from './register-panel';
10
+ export { RegisterColumn, type RegisterColumnProps } from './register-column';
11
+ export { RegisterCount, APPROVAL_REQUIRED_TEXT, type RegisterCountProps } from './register-count';
12
+ export { ClosureSheet, type ClosureSheetProps } from './closure-sheet';
@@ -0,0 +1,118 @@
1
+ import { useRef, useState } from 'react';
2
+ import type { MovementType, useRegisterSession } from '@tallyui/pos';
3
+ import { movementFieldError, normalizeAmount, parseMinor } from '@tallyui/pos';
4
+
5
+ import { currencySymbol, minorUnitDigits } from '@tallyui/core';
6
+ import { Button } from '../ui/button';
7
+ import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
8
+ import { Input } from '../ui/input';
9
+ import { Label } from '../ui/label';
10
+ import { Text } from '../ui/text';
11
+
12
+ const LABELS: Record<MovementType, string> = {
13
+ paid_in: 'Paid in',
14
+ paid_out: 'Paid out',
15
+ no_sale: 'No sale',
16
+ };
17
+
18
+ const AMOUNT_LABEL_ID = 'movement-amount-label';
19
+ const REASON_LABEL_ID = 'movement-reason-label';
20
+
21
+ export interface MovementSheetProps {
22
+ /** `useRegisterSession`'s return value: records the movement through `register.actions.recordMovement`. */
23
+ register: ReturnType<typeof useRegisterSession>;
24
+ /** The caller mounts this only once a type is chosen, so the sheet always has one to record. */
25
+ type: MovementType;
26
+ /** ISO 4217 code, for parsing the typed amount into minor units. */
27
+ currency: string;
28
+ onDone?: (id: string, type: MovementType, amountMinor: number) => void;
29
+ onOpenChange: (open: boolean) => void;
30
+ /** A no-sale opens the drawer once recorded (LEDGER 66); there is nothing to open otherwise. */
31
+ onOpenDrawer?: () => void;
32
+ }
33
+
34
+ /**
35
+ * Records a paid-in, paid-out or no-sale cash movement. Port provenance (ADR-032 amendment 1):
36
+ * WCPOS `next` `3b5331b5c` `movement-sheet.tsx` (LEDGER 55, 56, 66).
37
+ *
38
+ * The cash moves the moment the cashier confirms, so validation happens before the tap, never
39
+ * after: a refusal afterwards would mean cash already moved with no record of it. A no-sale
40
+ * hides the amount field, always records zero, and opens the drawer once the row is written.
41
+ */
42
+ export function MovementSheet({ register, type, currency, onDone, onOpenChange, onOpenDrawer }: MovementSheetProps) {
43
+ const digits = minorUnitDigits(currency);
44
+ const [amount, setAmount] = useState('');
45
+ const [reason, setReason] = useState('');
46
+ const [busy, setBusy] = useState(false);
47
+ // Two taps before a render must not mint two rows (LEDGER 56): a synchronous ref, not only
48
+ // the rendered busy state, claims the submission the instant the first tap runs.
49
+ const busyRef = useRef(false);
50
+ const [error, setError] = useState('');
51
+ const invalid = movementFieldError({ type, amount, reason });
52
+ const confirm = async () => {
53
+ if (invalid || busyRef.current) return;
54
+ busyRef.current = true;
55
+ setBusy(true);
56
+ setError('');
57
+ const normalized = type === 'no_sale' ? '0' : normalizeAmount(amount);
58
+ const trimmedReason = reason.trim();
59
+ try {
60
+ const amountMinor = parseMinor(normalized, digits);
61
+ const row = await register.actions.recordMovement({ type, amountMinor, reason: trimmedReason });
62
+ onDone?.(row.id, type, amountMinor);
63
+ if (type === 'no_sale') onOpenDrawer?.();
64
+ onOpenChange(false);
65
+ } catch (e) {
66
+ setError(String(e));
67
+ } finally {
68
+ busyRef.current = false;
69
+ setBusy(false);
70
+ }
71
+ };
72
+ return (
73
+ <Dialog open onOpenChange={onOpenChange}>
74
+ <DialogContent testID="movement-sheet">
75
+ <DialogTitle>{LABELS[type]}</DialogTitle>
76
+ {type !== 'no_sale' && (
77
+ <>
78
+ <Label testID="movement-amount-label" nativeID={AMOUNT_LABEL_ID}>
79
+ {`Amount (${currencySymbol(currency)})`}
80
+ </Label>
81
+ <Input>
82
+ <Input.Field
83
+ testID="movement-amount"
84
+ value={amount}
85
+ onChangeText={setAmount}
86
+ keyboardType="decimal-pad"
87
+ accessibilityLabel={`Amount (${currencySymbol(currency)})`}
88
+ accessibilityLabelledBy={AMOUNT_LABEL_ID}
89
+ />
90
+ </Input>
91
+ </>
92
+ )}
93
+ <Label testID="movement-reason-label" nativeID={REASON_LABEL_ID}>
94
+ Reason
95
+ </Label>
96
+ <Input>
97
+ <Input.Field
98
+ testID="movement-reason"
99
+ placeholder="Reason"
100
+ value={reason}
101
+ onChangeText={setReason}
102
+ accessibilityLabel="Reason"
103
+ accessibilityLabelledBy={REASON_LABEL_ID}
104
+ />
105
+ </Input>
106
+ {!!error && <Text className="text-destructive">{error}</Text>}
107
+ {!!invalid && (
108
+ <Text testID="movement-invalid" className="text-muted-foreground">
109
+ {invalid === 'amount' ? 'Enter a valid amount.' : 'Enter a reason.'}
110
+ </Text>
111
+ )}
112
+ <Button testID="movement-confirm" disabled={!!invalid || busy} onPress={confirm}>
113
+ <Text>{busy ? 'Recording…' : 'Record'}</Text>
114
+ </Button>
115
+ </DialogContent>
116
+ </Dialog>
117
+ );
118
+ }