@tallyui/components 1.0.0 → 3.0.0-next.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 +1113 -83
- package/dist/index.js +4936 -353
- 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 +16 -12
- package/src/customer/customer-form.tsx +72 -0
- package/src/customer/customer-order-history.tsx +34 -0
- package/src/customer/customer-picker.tsx +124 -0
- package/src/customer/customer-select.tsx +80 -31
- package/src/customer/index.ts +3 -0
- package/src/index.ts +114 -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 +46 -13
- 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 +161 -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 +139 -0
- package/src/sale/discount-form.tsx +85 -0
- package/src/sale/index.ts +9 -0
- package/src/sale/orders-list.tsx +108 -0
- package/src/sale/print-style.ts +11 -0
- package/src/sale/receipt.tsx +62 -0
- package/src/sale/sync-status.tsx +141 -0
- package/src/sale/tender.tsx +38 -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,44 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { ScrollView, View, type ViewProps } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@tallyui/theme';
|
|
5
|
+
|
|
6
|
+
export interface ProductListProps extends Omit<ViewProps, 'children'> {
|
|
7
|
+
items: any[];
|
|
8
|
+
renderItem: (item: any, index: number) => ReactNode;
|
|
9
|
+
searchSlot?: ReactNode;
|
|
10
|
+
filterSlot?: ReactNode;
|
|
11
|
+
emptyState?: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ProductList({
|
|
16
|
+
items,
|
|
17
|
+
renderItem,
|
|
18
|
+
searchSlot,
|
|
19
|
+
filterSlot,
|
|
20
|
+
emptyState,
|
|
21
|
+
className,
|
|
22
|
+
...viewProps
|
|
23
|
+
}: ProductListProps) {
|
|
24
|
+
const hasItems = items.length > 0;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View className={cn('flex-1', className)} {...viewProps}>
|
|
28
|
+
{searchSlot && <View className="px-3 py-2">{searchSlot}</View>}
|
|
29
|
+
{filterSlot && <View className="px-3 pb-2">{filterSlot}</View>}
|
|
30
|
+
|
|
31
|
+
{hasItems ? (
|
|
32
|
+
<ScrollView className="flex-1">
|
|
33
|
+
{items.map((item, index) => (
|
|
34
|
+
<View key={item.id ?? index} className="border-b border-border">
|
|
35
|
+
{renderItem(item, index)}
|
|
36
|
+
</View>
|
|
37
|
+
))}
|
|
38
|
+
</ScrollView>
|
|
39
|
+
) : (
|
|
40
|
+
emptyState ?? null
|
|
41
|
+
)}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -1,53 +1,108 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { useProductTraits } from '@tallyui/core';
|
|
1
|
+
import { formatMoney, minorUnitDigits, moneyToMajor, resolvePrice, resolvePriceRange, useProductTraits, useTraitContext } from '@tallyui/core';
|
|
2
|
+
import type { Money } from '@tallyui/core';
|
|
4
3
|
import { cn } from '@tallyui/theme';
|
|
4
|
+
import { Text, type TextProps } from '../ui';
|
|
5
5
|
|
|
6
6
|
export interface ProductPriceProps extends Omit<TextProps, 'children'> {
|
|
7
7
|
/** The raw RxDB product document (connector-specific shape) */
|
|
8
8
|
doc: any;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Currency to show when the product is priced in several. Defaults to the
|
|
11
|
+
* provider's store currency, then to the first currency listed.
|
|
12
|
+
*/
|
|
13
|
+
currency?: string;
|
|
14
|
+
/** BCP 47 locale for number formatting. Defaults to the runtime locale. */
|
|
15
|
+
locale?: string;
|
|
16
|
+
/** Symbol to prepend when the currency is unknown (defaults to '$') */
|
|
10
17
|
currencySymbol?: string;
|
|
18
|
+
/**
|
|
19
|
+
* When the connector's variants have differing prices, show
|
|
20
|
+
* `{fromLabel} <lowest price>` instead of the default variant's price.
|
|
21
|
+
* Defaults to true.
|
|
22
|
+
*/
|
|
23
|
+
showFromPrice?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Label shown before the lowest price when variant prices differ.
|
|
26
|
+
* @deprecated Use formatFrom.
|
|
27
|
+
*/
|
|
28
|
+
fromLabel?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Formats the lowest price when variant prices differ, for languages
|
|
31
|
+
* whose word order puts the label after the price. Defaults to
|
|
32
|
+
* `` (price) => `from ${price}` ``. Wins over `fromLabel` when both are given.
|
|
33
|
+
*/
|
|
34
|
+
formatFrom?: (price: string) => string;
|
|
11
35
|
className?: string;
|
|
12
36
|
}
|
|
13
37
|
|
|
14
38
|
/**
|
|
15
|
-
* Displays a product's price.
|
|
39
|
+
* Displays a product's price, and the price it replaces while on sale.
|
|
16
40
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
41
|
+
* Reads the backend-neutral price list (`getPrices`), so a WooCommerce sale
|
|
42
|
+
* price, a Medusa sale price list and a Shopify compare-at price all render
|
|
43
|
+
* the same way.
|
|
20
44
|
*
|
|
21
45
|
* ```tsx
|
|
22
|
-
* <ProductPrice doc={productDocument}
|
|
46
|
+
* <ProductPrice doc={productDocument} currency="EUR" />
|
|
23
47
|
* ```
|
|
24
48
|
*/
|
|
25
|
-
export function ProductPrice({ doc, currencySymbol = '$', className, ...textProps }: ProductPriceProps) {
|
|
26
|
-
const {
|
|
27
|
-
const
|
|
49
|
+
export function ProductPrice({ doc, currency, locale, currencySymbol = '$', className, showFromPrice = true, fromLabel, formatFrom, ...textProps }: ProductPriceProps) {
|
|
50
|
+
const { getPrices, getVariants } = useProductTraits();
|
|
51
|
+
const traitContext = useTraitContext();
|
|
52
|
+
const effectiveCurrency = currency ?? traitContext.currency;
|
|
53
|
+
|
|
54
|
+
// Unknown currency ('XXX'): plain decimal behind the fallback symbol.
|
|
55
|
+
const format = (money: Money) =>
|
|
56
|
+
formatMoney(money, locale)
|
|
57
|
+
?? `${currencySymbol}${moneyToMajor(money).toFixed(minorUnitDigits(money.currency))}`;
|
|
58
|
+
|
|
59
|
+
const variants = showFromPrice ? getVariants?.(doc, traitContext) : undefined;
|
|
60
|
+
const range = variants && variants.length >= 2 ? resolvePriceRange(variants, effectiveCurrency) : undefined;
|
|
61
|
+
|
|
62
|
+
const applyFormatFrom = formatFrom ?? (fromLabel !== undefined ? (price: string) => `${fromLabel} ${price}` : (price: string) => `from ${price}`);
|
|
63
|
+
|
|
64
|
+
if (range && range.min.amount !== range.max.amount) {
|
|
65
|
+
return (
|
|
66
|
+
<Text
|
|
67
|
+
className={cn('text-sm font-medium text-price', className)}
|
|
68
|
+
{...textProps}
|
|
69
|
+
style={[{ fontVariant: ['tabular-nums'] }, textProps.style]}
|
|
70
|
+
>
|
|
71
|
+
{applyFormatFrom(format(range.min))}
|
|
72
|
+
</Text>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const resolved = resolvePrice(getPrices(doc, traitContext), effectiveCurrency);
|
|
28
77
|
|
|
29
|
-
if (!
|
|
78
|
+
if (!resolved) {
|
|
30
79
|
return (
|
|
31
|
-
<Text className={cn('text-sm text-muted', className)} {...textProps}>
|
|
80
|
+
<Text className={cn('text-sm text-muted-foreground', className)} {...textProps}>
|
|
32
81
|
-
|
|
33
82
|
</Text>
|
|
34
83
|
);
|
|
35
84
|
}
|
|
36
85
|
|
|
37
|
-
if (
|
|
38
|
-
const salePrice = getSalePrice(doc);
|
|
39
|
-
const regularPrice = getRegularPrice(doc);
|
|
86
|
+
if (resolved.was) {
|
|
40
87
|
return (
|
|
41
|
-
<Text
|
|
42
|
-
{
|
|
43
|
-
{
|
|
88
|
+
<Text
|
|
89
|
+
className={cn('text-sm font-semibold text-sale', className)}
|
|
90
|
+
{...textProps}
|
|
91
|
+
style={[{ fontVariant: ['tabular-nums'] }, textProps.style]}
|
|
92
|
+
>
|
|
93
|
+
{format(resolved.current)}{' '}
|
|
94
|
+
<Text className="text-muted-foreground line-through" style={{ textDecorationLine: 'line-through' }}>(was {format(resolved.was)})</Text>
|
|
44
95
|
</Text>
|
|
45
96
|
);
|
|
46
97
|
}
|
|
47
98
|
|
|
48
99
|
return (
|
|
49
|
-
<Text
|
|
50
|
-
{
|
|
100
|
+
<Text
|
|
101
|
+
className={cn('text-sm font-medium text-price', className)}
|
|
102
|
+
{...textProps}
|
|
103
|
+
style={[{ fontVariant: ['tabular-nums'] }, textProps.style]}
|
|
104
|
+
>
|
|
105
|
+
{format(resolved.current)}
|
|
51
106
|
</Text>
|
|
52
107
|
);
|
|
53
108
|
}
|
|
@@ -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,161 @@
|
|
|
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, TAX_ROUNDING_MIXED_NOTE, 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
|
+
{/* Not a figure either (#287). The wording is approved by the Front desk, 2026-09-30. */}
|
|
140
|
+
{closure.breakdowns.tax_rounding_mixed === true && (
|
|
141
|
+
<Text testID="closure-tax-rounding-mixed" className="text-muted-foreground">
|
|
142
|
+
{TAX_ROUNDING_MIXED_NOTE}
|
|
143
|
+
</Text>
|
|
144
|
+
)}
|
|
145
|
+
{!!error && (
|
|
146
|
+
<Text testID="closure-print-error" className="text-destructive">
|
|
147
|
+
{error}
|
|
148
|
+
</Text>
|
|
149
|
+
)}
|
|
150
|
+
{!blind && !!onPrint && (
|
|
151
|
+
<Button testID="closure-print" className="min-h-14" disabled={busy} onPress={print}>
|
|
152
|
+
<Text>{busy ? 'Printing…' : 'Print the Z report'}</Text>
|
|
153
|
+
</Button>
|
|
154
|
+
)}
|
|
155
|
+
<Button testID="closure-done" variant="outline" className="min-h-14" onPress={onDone}>
|
|
156
|
+
<Text>Done</Text>
|
|
157
|
+
</Button>
|
|
158
|
+
</DialogContent>
|
|
159
|
+
</Dialog>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -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';
|