@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
|
@@ -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 {
|
|
1
|
+
import { View } from 'react-native';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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<
|
|
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<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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<
|
|
22
|
-
|
|
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-
|
|
29
|
+
text: 'text-foreground',
|
|
26
30
|
},
|
|
27
|
-
|
|
28
|
-
badge: 'bg-
|
|
29
|
-
dot: 'bg-
|
|
30
|
-
text: 'text-
|
|
31
|
+
out_of_stock: {
|
|
32
|
+
badge: 'bg-destructive/15',
|
|
33
|
+
dot: 'bg-destructive',
|
|
34
|
+
text: 'text-foreground',
|
|
31
35
|
},
|
|
32
|
-
|
|
36
|
+
backorder: {
|
|
33
37
|
badge: 'bg-warning/15',
|
|
34
38
|
dot: 'bg-warning',
|
|
35
|
-
text: 'text-
|
|
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
|
|
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, ...
|
|
55
|
-
const {
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
{
|
|
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
|
-
</
|
|
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('
|
|
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
|
+
}
|