@tallyui/components 3.0.3 → 3.1.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/dist/index.d.ts +42 -5
- package/dist/index.js +676 -473
- package/package.json +9 -9
- package/src/customer/customer-picker.tsx +1 -1
- package/src/customer/customer-select.tsx +16 -0
- package/src/index.ts +2 -0
- package/src/register/register-count.tsx +5 -2
- package/src/register/register-panel.tsx +15 -2
- package/src/sale/cart.tsx +24 -7
- package/src/sale/catalogue.tsx +6 -3
- package/src/sale/index.ts +2 -0
- package/src/sale/parked-sales.tsx +103 -0
- package/src/sale/price-form.tsx +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/components",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Universal POS UI components for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,22 +27,22 @@
|
|
|
27
27
|
"directory": "packages/components"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@tallyui/core": "^3.0
|
|
31
|
-
"@tallyui/primitives": "^3.0
|
|
32
|
-
"@tallyui/theme": "^3.0
|
|
30
|
+
"@tallyui/core": "^3.1.0",
|
|
31
|
+
"@tallyui/primitives": "^3.1.0",
|
|
32
|
+
"@tallyui/theme": "^3.1.0",
|
|
33
33
|
"react": ">=18",
|
|
34
34
|
"react-native": ">=0.74",
|
|
35
35
|
"react-native-svg": ">=15"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"class-variance-authority": "^0.7.1",
|
|
39
|
-
"@tallyui/pos": "3.0
|
|
39
|
+
"@tallyui/pos": "3.1.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@tallyui/connector-medusa": "3.0
|
|
43
|
-
"@tallyui/connector-vendure": "3.0
|
|
44
|
-
"@tallyui/connector-woocommerce": "3.0
|
|
45
|
-
"@tallyui/mock-api": "3.0
|
|
42
|
+
"@tallyui/connector-medusa": "3.1.0",
|
|
43
|
+
"@tallyui/connector-vendure": "3.1.0",
|
|
44
|
+
"@tallyui/connector-woocommerce": "3.1.0",
|
|
45
|
+
"@tallyui/mock-api": "3.1.0",
|
|
46
46
|
"@types/react": ">=18",
|
|
47
47
|
"react": "^19.2.4",
|
|
48
48
|
"react-native-svg": "15.12.1",
|
|
@@ -102,7 +102,7 @@ export function CustomerPicker({ search, create, selected, onSelect, onError, on
|
|
|
102
102
|
{!online && <Text className="text-sm text-muted-foreground">Connect to search or add customers.</Text>}
|
|
103
103
|
{noResults && <Text className="text-sm text-muted-foreground">No customers found.</Text>}
|
|
104
104
|
{error && <Text accessibilityRole="alert" className="text-sm text-destructive">{error}</Text>}
|
|
105
|
-
<CustomerSelect customers={customers} traits={customerTraits} onSearch={setQuery}
|
|
105
|
+
<CustomerSelect customers={customers} traits={customerTraits} onSearch={setQuery} showSearch={false}
|
|
106
106
|
onSelect={(customer) => { onSelect(customer); setQuery(''); }} />
|
|
107
107
|
{create && <Pressable accessibilityRole="button" disabled={!online || creating} onPress={() => setShowForm(true)}
|
|
108
108
|
className={cn('items-center rounded-lg border border-border px-4 py-3', (!online || creating) && 'opacity-50')}>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { Pressable, ScrollView, View } from 'react-native';
|
|
2
3
|
import { Select } from '@tallyui/primitives';
|
|
3
4
|
import { useCustomerTraits, type CustomerTraits } from '@tallyui/core';
|
|
4
5
|
import { cn } from '@tallyui/theme';
|
|
5
6
|
import { VStack, type VStackProps } from '../ui';
|
|
7
|
+
import { SearchInput } from '../input/search-input';
|
|
6
8
|
import { CustomerCard } from './customer-card';
|
|
7
9
|
|
|
8
10
|
export interface CustomerSelectProps<Doc = any> extends Omit<VStackProps, 'children'> {
|
|
@@ -17,6 +19,8 @@ export interface CustomerSelectProps<Doc = any> extends Omit<VStackProps, 'child
|
|
|
17
19
|
onSearch: (query: string) => void;
|
|
18
20
|
/** Placeholder text for the search input */
|
|
19
21
|
placeholder?: string;
|
|
22
|
+
/** Renders the search input; pass false when the parent renders its own (as CustomerPicker does) */
|
|
23
|
+
showSearch?: boolean;
|
|
20
24
|
className?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -54,10 +58,12 @@ function CustomerSelectView<Doc>({
|
|
|
54
58
|
onSelect,
|
|
55
59
|
onSearch,
|
|
56
60
|
placeholder = 'Search customers...',
|
|
61
|
+
showSearch = true,
|
|
57
62
|
className,
|
|
58
63
|
traits,
|
|
59
64
|
...props
|
|
60
65
|
}: CustomerSelectProps<Doc>) {
|
|
66
|
+
const [search, setSearch] = useState('');
|
|
61
67
|
|
|
62
68
|
// Map selected customer to Select primitive's Option format
|
|
63
69
|
const selectedOption = selected && traits
|
|
@@ -84,6 +90,16 @@ function CustomerSelectView<Doc>({
|
|
|
84
90
|
defaultOpen={true}
|
|
85
91
|
>
|
|
86
92
|
<VStack space="sm" className={cn(className)} {...props}>
|
|
93
|
+
{showSearch && <SearchInput
|
|
94
|
+
value={search}
|
|
95
|
+
onChangeText={(text) => {
|
|
96
|
+
setSearch(text);
|
|
97
|
+
onSearch(text);
|
|
98
|
+
}}
|
|
99
|
+
placeholder={placeholder}
|
|
100
|
+
testID="customer-select-search"
|
|
101
|
+
accessibilityLabel="Search customers"
|
|
102
|
+
/>}
|
|
87
103
|
{selected && traits && (
|
|
88
104
|
<Select.Trigger asChild>
|
|
89
105
|
<View className="rounded-lg border border-primary bg-primary/5 px-3 py-2">
|
package/src/index.ts
CHANGED
|
@@ -155,7 +155,9 @@ export {
|
|
|
155
155
|
// Sale components
|
|
156
156
|
export {
|
|
157
157
|
Cart, CartBar, Tender, DiscountForm, DiscountChips, parseDiscount, discountLabel,
|
|
158
|
+
PriceForm, parsePrice,
|
|
158
159
|
Catalogue, formatStockSyncTime, Receipt, injectPrintStyle, SyncStatus, OrdersList, orderReference,
|
|
160
|
+
ParkedSales, type ParkedSalesProps,
|
|
159
161
|
} from './sale';
|
|
160
162
|
|
|
161
163
|
// Layout components
|
|
@@ -52,8 +52,11 @@ function tileLabel(value: number, currency: string): string {
|
|
|
52
52
|
return formatter.format(value / 10 ** minorUnitDigits(currency));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/** Plain-English label for a tender method key: `external` is the till's card tender ('Card');
|
|
56
|
+
* other keys are title-cased. Shared with `ClosureSheet` and `RegisterPanel`. */
|
|
57
|
+
export const tenderLabel = (method: string) =>
|
|
58
|
+
new Map([['cash', 'Cash'], ['external', 'Card']]).get(method) ??
|
|
59
|
+
method.charAt(0).toUpperCase() + method.slice(1);
|
|
57
60
|
|
|
58
61
|
interface DenominationTileProps {
|
|
59
62
|
value: number;
|
|
@@ -9,6 +9,7 @@ import { IconButton } from '../ui/icon-button';
|
|
|
9
9
|
import { XIcon } from '../ui/icon/x-icon';
|
|
10
10
|
import { Text } from '../ui/text';
|
|
11
11
|
import { MovementSheet } from './movement-sheet';
|
|
12
|
+
import { tenderLabel } from './register-count';
|
|
12
13
|
|
|
13
14
|
/** Margin kept clear above and below the dialog (LEDGER-free layout fix, 2026-09-28 review):
|
|
14
15
|
* a phone-height viewport must never let the panel's top go under the page header. */
|
|
@@ -37,6 +38,7 @@ export interface RegisterPanelProps {
|
|
|
37
38
|
*/
|
|
38
39
|
export function RegisterPanel({ register, currency, registerName, open, onOpenChange, className }: RegisterPanelProps) {
|
|
39
40
|
const { session, expected, salesCount, blind, movements, lastClosure, actions } = register;
|
|
41
|
+
const otherTenders = Object.entries(expected).filter(([method, amount]) => method !== 'cash' && amount !== 0);
|
|
40
42
|
const { height: windowHeight } = useWindowDimensions();
|
|
41
43
|
const maxHeight = Math.max(280, windowHeight - DIALOG_VERTICAL_MARGIN);
|
|
42
44
|
const [movementType, setMovementType] = useState<MovementType | null>(null);
|
|
@@ -93,9 +95,20 @@ export function RegisterPanel({ register, currency, registerName, open, onOpenCh
|
|
|
93
95
|
{!blind && (
|
|
94
96
|
<View testID="register-panel-expected" className="gap-1">
|
|
95
97
|
<Text className="text-muted-foreground text-sm font-semibold">Expected in the drawer</Text>
|
|
96
|
-
{Object.entries(expected).map(([method, amount]) => (
|
|
98
|
+
{Object.entries(expected).filter(([method]) => method === 'cash').map(([method, amount]) => (
|
|
97
99
|
<View key={method} className="min-h-11 flex-row items-center justify-between">
|
|
98
|
-
<Text
|
|
100
|
+
<Text>{tenderLabel('cash')}</Text>
|
|
101
|
+
<Text className="tabular-nums">{formatMoney({ amount, currency })}</Text>
|
|
102
|
+
</View>
|
|
103
|
+
))}
|
|
104
|
+
</View>
|
|
105
|
+
)}
|
|
106
|
+
{!blind && otherTenders.length > 0 && (
|
|
107
|
+
<View testID="register-panel-other-tenders" className="gap-1">
|
|
108
|
+
<Text className="text-muted-foreground text-sm font-semibold">Other tenders</Text>
|
|
109
|
+
{otherTenders.map(([method, amount]) => (
|
|
110
|
+
<View key={method} className="min-h-11 flex-row items-center justify-between">
|
|
111
|
+
<Text>{tenderLabel(method)}</Text>
|
|
99
112
|
<Text className="tabular-nums">{formatMoney({ amount, currency })}</Text>
|
|
100
113
|
</View>
|
|
101
114
|
))}
|
package/src/sale/cart.tsx
CHANGED
|
@@ -4,10 +4,16 @@ import { buildReceiptData } from '@tallyui/pos';
|
|
|
4
4
|
import type { useSale } from '@tallyui/pos';
|
|
5
5
|
import { CartLine, CartLineActions, CartPanel, CartTotal } from '../cart';
|
|
6
6
|
import { DiscountChips, DiscountForm } from './discount-form';
|
|
7
|
+
import { PriceForm } from './price-form';
|
|
7
8
|
|
|
8
9
|
/** `taxLabel` names each tax row; VAT isn't universal, so a platform passes its own (medusapos passes `VAT ${rate}%`). */
|
|
9
|
-
export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}
|
|
10
|
-
{ sale: ReturnType<typeof useSale>; taxLabel?: (ratePpm: number) => string
|
|
10
|
+
export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`, canEditPrice = false, onPriceChange }:
|
|
11
|
+
{ sale: ReturnType<typeof useSale>; taxLabel?: (ratePpm: number) => string;
|
|
12
|
+
/** Shows a Price action on each line. Pass the till's own permission; the control isn't shown without it. */
|
|
13
|
+
canEditPrice?: boolean;
|
|
14
|
+
/** Called after a price is applied, for the app's audit log. The reason is kept only here. */
|
|
15
|
+
onPriceChange?: (change: { lineId: string; fromMinor: number; toMinor: number; reason?: string }) => void;
|
|
16
|
+
}) {
|
|
11
17
|
const { order } = sale;
|
|
12
18
|
const money = (amount: number) => ({ amount, currency: order.currency });
|
|
13
19
|
// order.display's figures (TallyUI ADR-063), with its tax split by rate; never app arithmetic (medusapos ADR 0008).
|
|
@@ -18,14 +24,17 @@ export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10
|
|
|
18
24
|
const display = (lineId: string) => order.display.lines.find((line) => line.lineId === lineId)!;
|
|
19
25
|
const orderAmounts = order.discounts.length === 1
|
|
20
26
|
? [{ discountId: order.discounts[0].id, amountMinor: order.display.orderDiscountMinor }] : [];
|
|
21
|
-
//
|
|
22
|
-
const [form, setForm] = useState<{ lineId: string | null } | null>(null);
|
|
23
|
-
const discountForm = (lineId: string | null, title: string) => form?.lineId === lineId
|
|
27
|
+
// One open form: a line's discount or price, or the order's discount (lineId null).
|
|
28
|
+
const [form, setForm] = useState<{ type: 'discount'; lineId: string | null } | { type: 'price'; lineId: string } | null>(null);
|
|
29
|
+
const discountForm = (lineId: string | null, title: string) => form?.type === 'discount' && form.lineId === lineId
|
|
24
30
|
? <DiscountForm key={lineId ?? 'order'} title={title} currency={order.currency} onClose={() => setForm(null)}
|
|
25
31
|
onApply={(discount) => sale.applyDiscount(lineId, discount)} /> : null;
|
|
26
32
|
const pay = `min-h-12 flex-1 justify-center rounded-md bg-primary px-4 py-3 ${!order.lineItems.length ? 'opacity-50' : ''}`;
|
|
27
33
|
const renderItem = (line: typeof order.lineItems[number]) => <>
|
|
28
|
-
<CartLineActions actions={[
|
|
34
|
+
<CartLineActions actions={[
|
|
35
|
+
{ id: 'discount', label: 'Discount', color: 'text-primary', onPress: () => setForm({ type: 'discount', lineId: line.id }) },
|
|
36
|
+
...(canEditPrice ? [{ id: 'price', label: 'Price', color: 'text-primary', onPress: () => setForm({ type: 'price', lineId: line.id }) }] : []),
|
|
37
|
+
]}>
|
|
29
38
|
<CartLine name={line.name} quantity={line.quantity} unitPrice={money(line.unitPriceMinor)}
|
|
30
39
|
lineTotal={money(display(line.id).amountMinor)} />
|
|
31
40
|
</CartLineActions>
|
|
@@ -39,6 +48,14 @@ export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10
|
|
|
39
48
|
onPress={() => sale.remove(line.id)} className="rounded-md border border-border bg-card px-3 py-2 min-h-11 min-w-11 items-center justify-center"><Text className="text-center text-foreground">Remove</Text></Pressable>
|
|
40
49
|
</View>
|
|
41
50
|
{discountForm(line.id, `Discount on ${line.name}`)}
|
|
51
|
+
{canEditPrice && form?.type === 'price' && form.lineId === line.id ? <PriceForm key={line.id} lineName={line.name}
|
|
52
|
+
currency={order.currency} currentMinor={line.unitPriceMinor} onClose={() => setForm(null)}
|
|
53
|
+
onApply={(amount, reason) => {
|
|
54
|
+
const fromMinor = line.unitPriceMinor;
|
|
55
|
+
const refused = sale.setUnitPrice(line.id, amount);
|
|
56
|
+
if (refused === null) onPriceChange?.({ lineId: line.id, fromMinor, toMinor: amount, reason });
|
|
57
|
+
return refused;
|
|
58
|
+
}} /> : null}
|
|
42
59
|
</>;
|
|
43
60
|
return <CartPanel dataSet={{ print: 'hide' }} items={order.lineItems} renderItem={renderItem}
|
|
44
61
|
emptyState={<Text testID="cart-empty" className="px-3 py-2 text-muted-foreground">Scan or tap a product to start a sale.</Text>}
|
|
@@ -46,7 +63,7 @@ export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10
|
|
|
46
63
|
<DiscountChips discounts={order.discounts} amounts={orderAmounts} currency={order.currency} onRemove={sale.removeDiscount}
|
|
47
64
|
prefix="Order discount" />
|
|
48
65
|
{form?.lineId === null ? discountForm(null, 'Order discount') : <Pressable accessibilityRole="button"
|
|
49
|
-
onPress={() => setForm({ lineId: null })} className="self-start rounded-md border border-border bg-card px-4 py-2 min-h-11 justify-center">
|
|
66
|
+
onPress={() => setForm({ type: 'discount', lineId: null })} className="self-start rounded-md border border-border bg-card px-4 py-2 min-h-11 justify-center">
|
|
50
67
|
<Text className="text-foreground">Order discount</Text></Pressable>}
|
|
51
68
|
</View> : undefined}
|
|
52
69
|
footer={<View testID="cart-footer" className="gap-3 pb-4">
|
package/src/sale/catalogue.tsx
CHANGED
|
@@ -32,7 +32,7 @@ function laterOf(a: Date | null | undefined, b: Date | null | undefined): Date |
|
|
|
32
32
|
return a.getTime() >= b.getTime() ? a : b;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, lastStockCheckAt, hour12, minCodeLength }: {
|
|
35
|
+
export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, pullError, lastStockCheckAt, hour12, minCodeLength }: {
|
|
36
36
|
products: Doc[];
|
|
37
37
|
traits: ProductTraits<Doc>;
|
|
38
38
|
currency: string;
|
|
@@ -42,9 +42,12 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
42
42
|
/** Rendered at the end of the status line, e.g. a register control at phone width; the row renders even without `statusText`. */
|
|
43
43
|
statusAccessory?: ReactNode;
|
|
44
44
|
lastSyncedAt: Date | null;
|
|
45
|
-
/** The catalogue's first pull is still running, so an empty grid says "Loading products…" rather than "No products yet.". Defaults to lastSyncedAt === null; pass false
|
|
45
|
+
/** The catalogue's first pull is still running, so an empty grid says "Loading products…" rather than "No products yet.". Defaults to lastSyncedAt === null with no pullError; pass false
|
|
46
46
|
* when a null lastSyncedAt doesn't mean a pull is running (e.g. offline on first start). */
|
|
47
47
|
loading?: boolean;
|
|
48
|
+
/** The product pull's last error (an Error, a SyncNotice, anything the app holds), or null/undefined when none. While it is
|
|
49
|
+
* set, a catalogue that has never synced isn't treated as loading, so its empty grid says "No products yet." beside the app's error. */
|
|
50
|
+
pullError?: unknown;
|
|
48
51
|
/** The last completed stock reconcile pass, persisted across restarts. The "stock as of" time shown is the
|
|
49
52
|
* latest of this, the provider's stockOverlayAsOf (when valid) and lastSyncedAt; null and omitted alike. */
|
|
50
53
|
lastStockCheckAt?: Date | null;
|
|
@@ -56,7 +59,7 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
56
59
|
* in its unfocused wedge listener — not this prop's job. */
|
|
57
60
|
minCodeLength?: number;
|
|
58
61
|
}) {
|
|
59
|
-
const isLoading = loading ?? lastSyncedAt === null;
|
|
62
|
+
const isLoading = loading ?? (lastSyncedAt === null && pullError == null);
|
|
60
63
|
// Idempotent (the adapter's overlay returns just the fields the stock map sets), so an app that already
|
|
61
64
|
// merges the overlay itself keeps working, and can drop its own merge.
|
|
62
65
|
const shown = useStockOverlaid(products) as Doc[];
|
package/src/sale/index.ts
CHANGED
|
@@ -2,9 +2,11 @@ export { Cart } from './cart';
|
|
|
2
2
|
export { CartBar } from './cart-bar';
|
|
3
3
|
export { Tender } from './tender';
|
|
4
4
|
export { DiscountForm, DiscountChips, parseDiscount, discountLabel } from './discount-form';
|
|
5
|
+
export { PriceForm, parsePrice } from './price-form';
|
|
5
6
|
export { Catalogue, formatStockSyncTime } from './catalogue';
|
|
6
7
|
export { Receipt } from './receipt';
|
|
7
8
|
export { orderReference } from './order-reference';
|
|
8
9
|
export { injectPrintStyle } from './print-style';
|
|
9
10
|
export { SyncStatus } from './sync-status';
|
|
10
11
|
export { OrdersList } from './orders-list';
|
|
12
|
+
export { ParkedSales, type ParkedSalesProps } from './parked-sales';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useState, type JSX } from 'react';
|
|
2
|
+
import { ScrollView, useWindowDimensions, View } from 'react-native';
|
|
3
|
+
import { formatMoney } from '@tallyui/core';
|
|
4
|
+
import type { ParkedOrderSummary, useSale } from '@tallyui/pos';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
import { Dialog, DialogClose, DialogContent, DialogTitle } from '../ui/dialog';
|
|
7
|
+
import { IconButton } from '../ui/icon-button';
|
|
8
|
+
import { XIcon } from '../ui/icon/x-icon';
|
|
9
|
+
import { Text } from '../ui/text';
|
|
10
|
+
import { formatStockSyncTime } from './catalogue';
|
|
11
|
+
|
|
12
|
+
/** Pass `useParkedSales(drafts)`'s `parked` and `discard`. */
|
|
13
|
+
export interface ParkedSalesProps {
|
|
14
|
+
sale: ReturnType<typeof useSale>;
|
|
15
|
+
parked: ParkedOrderSummary[];
|
|
16
|
+
onDiscard: (id: string) => Promise<void>;
|
|
17
|
+
currency: string;
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
hour12?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ParkedSales({ sale, parked, onDiscard, currency, open, onOpenChange, hour12 }: ParkedSalesProps): JSX.Element {
|
|
24
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
25
|
+
const maxHeight = Math.max(280, windowHeight - 64);
|
|
26
|
+
const [confirming, setConfirming] = useState<string | null>(null);
|
|
27
|
+
const [error, setError] = useState('');
|
|
28
|
+
|
|
29
|
+
const attempt = async (action: () => Promise<unknown>) => {
|
|
30
|
+
setError('');
|
|
31
|
+
try {
|
|
32
|
+
await action();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
setError(String(e));
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
40
|
+
<DialogContent testID="parked-sales" className="flex-col overflow-hidden" style={{ maxHeight }}>
|
|
41
|
+
<View className="flex-row items-center justify-between gap-2">
|
|
42
|
+
<DialogTitle>Parked sales</DialogTitle>
|
|
43
|
+
<DialogClose asChild>
|
|
44
|
+
<IconButton variant="ghost" size="sm" testID="parked-sales-dismiss" accessibilityLabel="Close panel">
|
|
45
|
+
<XIcon size={16} />
|
|
46
|
+
</IconButton>
|
|
47
|
+
</DialogClose>
|
|
48
|
+
</View>
|
|
49
|
+
<ScrollView className="flex-1" contentContainerClassName="gap-3">
|
|
50
|
+
<Button testID="parked-sales-park" accessibilityLabel="Park this sale"
|
|
51
|
+
disabled={sale.order.lineItems.length === 0}
|
|
52
|
+
onPress={() => attempt(async () => setError((await sale.park()) ?? ''))}>
|
|
53
|
+
<Text>Park this sale</Text>
|
|
54
|
+
</Button>
|
|
55
|
+
{!!error && <Text testID="parked-sales-error" className="text-destructive">{error}</Text>}
|
|
56
|
+
{parked.length === 0 && <Text testID="parked-sales-empty">No parked sales.</Text>}
|
|
57
|
+
{parked.map((row) => (
|
|
58
|
+
<View key={row.id} testID={`parked-row-${row.id}`} className="gap-2 rounded-md border border-border p-3">
|
|
59
|
+
{confirming === row.id ? (
|
|
60
|
+
<>
|
|
61
|
+
<Text>Discard this parked sale?</Text>
|
|
62
|
+
<View className="flex-row gap-2">
|
|
63
|
+
<Button testID={`parked-discard-confirm-${row.id}`} variant="destructive"
|
|
64
|
+
onPress={() => attempt(async () => {
|
|
65
|
+
await onDiscard(row.id);
|
|
66
|
+
setConfirming(null);
|
|
67
|
+
})}>
|
|
68
|
+
<Text>Discard</Text>
|
|
69
|
+
</Button>
|
|
70
|
+
<Button testID={`parked-discard-cancel-${row.id}`} variant="outline" onPress={() => setConfirming(null)}>
|
|
71
|
+
<Text>Cancel</Text>
|
|
72
|
+
</Button>
|
|
73
|
+
</View>
|
|
74
|
+
</>
|
|
75
|
+
) : (
|
|
76
|
+
<>
|
|
77
|
+
<Text>{formatStockSyncTime(new Date(row.parkedAt), undefined, hour12)}</Text>
|
|
78
|
+
<Text>{row.itemCount} {row.itemCount === 1 ? 'item' : 'items'}</Text>
|
|
79
|
+
<Text>{formatMoney({ amount: row.totalMinor, currency })}</Text>
|
|
80
|
+
{!!row.customerName && <Text>{row.customerName}</Text>}
|
|
81
|
+
<View className="flex-row gap-2">
|
|
82
|
+
<Button testID={`parked-resume-${row.id}`} accessibilityLabel="Resume parked sale"
|
|
83
|
+
onPress={() => attempt(async () => {
|
|
84
|
+
const refusal = await sale.resume(row.id);
|
|
85
|
+
if (refusal === null) onOpenChange(false);
|
|
86
|
+
else setError(refusal);
|
|
87
|
+
})}>
|
|
88
|
+
<Text>Resume</Text>
|
|
89
|
+
</Button>
|
|
90
|
+
<Button testID={`parked-discard-${row.id}`} accessibilityLabel="Discard parked sale"
|
|
91
|
+
variant="outline" onPress={() => setConfirming(row.id)}>
|
|
92
|
+
<Text>Discard</Text>
|
|
93
|
+
</Button>
|
|
94
|
+
</View>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</View>
|
|
98
|
+
))}
|
|
99
|
+
</ScrollView>
|
|
100
|
+
</DialogContent>
|
|
101
|
+
</Dialog>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState, type JSX } from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { formatMoney, minorUnitDigits, moneyFromDecimalString } from '@tallyui/core';
|
|
4
|
+
|
|
5
|
+
/** The cashier's entry as integer minor units of `currency` (0 allowed), or the inline error to show. */
|
|
6
|
+
export function parsePrice(text: string, currency: string): number | string {
|
|
7
|
+
const raw = text.trim();
|
|
8
|
+
const trimmed = raw.split(',').length === 2 ? raw.replace(',', '.') : raw;
|
|
9
|
+
if (!trimmed) return 'Enter a price.';
|
|
10
|
+
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return 'Enter a number.';
|
|
11
|
+
if (Number(trimmed) < 0) return 'Enter a price of 0 or more.';
|
|
12
|
+
const digits = Math.min(2, minorUnitDigits(currency));
|
|
13
|
+
if ((trimmed.split('.')[1]?.length ?? 0) > digits) return digits ? `Use at most ${digits} decimal places.` : 'Use a whole amount.';
|
|
14
|
+
return moneyFromDecimalString(trimmed, currency)?.amount ?? 'Enter a number.';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const button = 'rounded-md border border-border px-3 py-2 min-h-11 items-center justify-center';
|
|
18
|
+
|
|
19
|
+
/** `onApply` returns a refusal to show inline, or null once applied. */
|
|
20
|
+
export function PriceForm({ lineName, currency, currentMinor, onApply, onClose }: {
|
|
21
|
+
lineName: string; currency: string; currentMinor: number;
|
|
22
|
+
onApply: (amountMinor: number, reason?: string) => string | null; onClose: () => void;
|
|
23
|
+
}): JSX.Element {
|
|
24
|
+
const [text, setText] = useState('');
|
|
25
|
+
const [reason, setReason] = useState('');
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const title = `Price for ${lineName}`;
|
|
28
|
+
function apply() {
|
|
29
|
+
const parsed = parsePrice(text, currency);
|
|
30
|
+
const refused = typeof parsed === 'string' ? parsed : onApply(parsed, reason.trim() || undefined);
|
|
31
|
+
setError(refused);
|
|
32
|
+
if (refused === null) onClose();
|
|
33
|
+
}
|
|
34
|
+
return <View testID="price-form" role="group" accessibilityLabel={title} className="gap-2 px-3 py-2">
|
|
35
|
+
<Text className="font-semibold text-foreground">{title}</Text>
|
|
36
|
+
<Text className="text-muted-foreground">{formatMoney({ amount: currentMinor, currency })}</Text>
|
|
37
|
+
<TextInput testID="price-input" accessibilityLabel="Price value" value={text} onChangeText={setText}
|
|
38
|
+
onSubmitEditing={apply} inputMode="decimal" placeholder={currency}
|
|
39
|
+
className="min-w-0 rounded-md border border-border bg-background px-3 py-2 text-foreground" />
|
|
40
|
+
<TextInput testID="price-reason" accessibilityLabel="Reason (optional)" placeholder="Reason (optional)"
|
|
41
|
+
value={reason} onChangeText={setReason}
|
|
42
|
+
className="min-w-0 rounded-md border border-border bg-background px-3 py-2 text-foreground" />
|
|
43
|
+
{error ? <Text testID="price-error" accessibilityRole="alert" className="text-destructive">{error}</Text> : null}
|
|
44
|
+
<View className="flex-row gap-2">
|
|
45
|
+
<Pressable testID="price-apply" accessibilityRole="button" onPress={apply} className={`${button} bg-primary`}>
|
|
46
|
+
<Text className="font-semibold text-primary-foreground">Apply</Text></Pressable>
|
|
47
|
+
<Pressable testID="price-cancel" accessibilityRole="button" onPress={onClose} className={`${button} bg-card`}>
|
|
48
|
+
<Text className="text-foreground">Cancel</Text></Pressable>
|
|
49
|
+
</View>
|
|
50
|
+
</View>;
|
|
51
|
+
}
|