@tallyui/components 3.0.4 → 3.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/components",
3
- "version": "3.0.4",
3
+ "version": "3.1.1",
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.4",
31
- "@tallyui/primitives": "^3.0.4",
32
- "@tallyui/theme": "^3.0.4",
30
+ "@tallyui/core": "^3.1.1",
31
+ "@tallyui/primitives": "^3.1.1",
32
+ "@tallyui/theme": "^3.1.1",
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.4"
39
+ "@tallyui/pos": "3.1.1"
40
40
  },
41
41
  "devDependencies": {
42
- "@tallyui/connector-medusa": "3.0.4",
43
- "@tallyui/connector-vendure": "3.0.4",
44
- "@tallyui/connector-woocommerce": "3.0.4",
45
- "@tallyui/mock-api": "3.0.4",
42
+ "@tallyui/connector-medusa": "3.1.1",
43
+ "@tallyui/connector-vendure": "3.1.1",
44
+ "@tallyui/connector-woocommerce": "3.1.1",
45
+ "@tallyui/mock-api": "3.1.1",
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
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
- // The open discount form: a line's, or the order's (lineId null).
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={[{ id: 'discount', label: 'Discount', color: 'text-primary', onPress: () => setForm({ lineId: line.id }) }]}>
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/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
+ }