@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/components",
3
- "version": "3.0.3",
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.3",
31
- "@tallyui/primitives": "^3.0.3",
32
- "@tallyui/theme": "^3.0.3",
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.3"
39
+ "@tallyui/pos": "3.1.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@tallyui/connector-medusa": "3.0.3",
43
- "@tallyui/connector-vendure": "3.0.3",
44
- "@tallyui/connector-woocommerce": "3.0.3",
45
- "@tallyui/mock-api": "3.0.3",
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
- /** Title-cases a tender method key ('card' -> 'Card') for a plain-English label. Shared with `ClosureSheet`. */
56
- export const tenderLabel = (method: string) => method.charAt(0).toUpperCase() + method.slice(1);
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 className="capitalize">{method === 'cash' ? 'Cash' : method}</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
- // 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">
@@ -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
+ }