@tallyui/components 2.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/dist/index.d.ts +50 -20
- package/dist/index.js +914 -638
- package/package.json +10 -10
- package/src/customer/customer-card.tsx +10 -5
- package/src/customer/customer-form.tsx +3 -1
- package/src/customer/customer-picker.tsx +124 -0
- package/src/customer/customer-select.tsx +24 -14
- package/src/customer/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/product/product-grid.tsx +23 -9
- package/src/register/closure-sheet.tsx +7 -1
- package/src/sale/catalogue.tsx +13 -5
- package/src/sale/orders-list.tsx +52 -10
- package/src/sale/receipt.tsx +3 -2
- package/src/sale/sync-status.tsx +133 -12
- package/src/sale/tender.tsx +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/components",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Universal POS UI components for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,26 +27,26 @@
|
|
|
27
27
|
"directory": "packages/components"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@tallyui/core": "
|
|
31
|
-
"@tallyui/primitives": "
|
|
32
|
-
"@tallyui/theme": "
|
|
30
|
+
"@tallyui/core": "^3.0.0-next.0",
|
|
31
|
+
"@tallyui/primitives": "^3.0.0-next.0",
|
|
32
|
+
"@tallyui/theme": "^3.0.0-next.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": "
|
|
39
|
+
"@tallyui/pos": "3.0.0-next.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@tallyui/connector-medusa": "
|
|
43
|
-
"@tallyui/connector-vendure": "
|
|
44
|
-
"@tallyui/connector-woocommerce": "
|
|
45
|
-
"@tallyui/mock-api": "
|
|
42
|
+
"@tallyui/connector-medusa": "3.0.0-next.0",
|
|
43
|
+
"@tallyui/connector-vendure": "3.0.0-next.0",
|
|
44
|
+
"@tallyui/connector-woocommerce": "3.0.0-next.0",
|
|
45
|
+
"@tallyui/mock-api": "3.0.0-next.0",
|
|
46
46
|
"@types/react": ">=18",
|
|
47
47
|
"react": "^19.2.4",
|
|
48
48
|
"react-native-svg": "15.12.1",
|
|
49
|
-
"rxdb": "
|
|
49
|
+
"rxdb": "17.5.0",
|
|
50
50
|
"rxjs": "7.8.2"
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import { useCustomerTraits } from '@tallyui/core';
|
|
1
|
+
import { useCustomerTraits, type CustomerTraits } from '@tallyui/core';
|
|
2
2
|
import { cn } from '@tallyui/theme';
|
|
3
3
|
import { Text, VStack, type VStackProps } from '../ui';
|
|
4
4
|
|
|
5
|
-
export interface CustomerCardProps extends Omit<VStackProps, 'children'> {
|
|
6
|
-
doc:
|
|
5
|
+
export interface CustomerCardProps<Doc = any> extends Omit<VStackProps, 'children'> {
|
|
6
|
+
doc: Doc;
|
|
7
|
+
traits?: CustomerTraits<Doc>;
|
|
7
8
|
className?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
function CustomerCardFromContext<Doc>(props: CustomerCardProps<Doc>) {
|
|
11
12
|
const traits = useCustomerTraits();
|
|
12
|
-
|
|
13
|
+
return traits ? <CustomerCard {...props} traits={traits} /> : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CustomerCard<Doc>({ doc, traits, className, ...props }: CustomerCardProps<Doc>) {
|
|
17
|
+
if (!traits) return <CustomerCardFromContext doc={doc} className={className} {...props} />;
|
|
13
18
|
|
|
14
19
|
const name = traits.getName(doc);
|
|
15
20
|
const email = traits.getEmail(doc);
|
|
@@ -15,6 +15,7 @@ export interface CustomerFormProps extends Omit<ViewProps, 'children'> {
|
|
|
15
15
|
onChangeField: (field: keyof CustomerFormValues, value: string) => void;
|
|
16
16
|
onSubmit?: () => void;
|
|
17
17
|
submitLabel?: string;
|
|
18
|
+
showAddress?: boolean;
|
|
18
19
|
className?: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -43,6 +44,7 @@ export function CustomerForm({
|
|
|
43
44
|
onChangeField,
|
|
44
45
|
onSubmit,
|
|
45
46
|
submitLabel = 'Save Customer',
|
|
47
|
+
showAddress = true,
|
|
46
48
|
className,
|
|
47
49
|
...viewProps
|
|
48
50
|
}: CustomerFormProps) {
|
|
@@ -58,7 +60,7 @@ export function CustomerForm({
|
|
|
58
60
|
</View>
|
|
59
61
|
<FormField label="Email" value={values.email} onChangeText={(v) => onChangeField('email', v)} placeholder="email@example.com" keyboardType="email-address" />
|
|
60
62
|
<FormField label="Phone" value={values.phone} onChangeText={(v) => onChangeField('phone', v)} placeholder="Phone number" keyboardType="phone-pad" />
|
|
61
|
-
<FormField label="Address" value={values.address} onChangeText={(v) => onChangeField('address', v)} placeholder="Street address" multiline />
|
|
63
|
+
{showAddress && <FormField label="Address" value={values.address} onChangeText={(v) => onChangeField('address', v)} placeholder="Street address" multiline />}
|
|
62
64
|
|
|
63
65
|
{onSubmit && (
|
|
64
66
|
<Pressable onPress={onSubmit} className="items-center rounded-lg bg-primary px-4 py-3">
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { customerTraits, CustomerServiceError, ConnectorUnauthorizedError, type Customer, type CustomerInput } from '@tallyui/core';
|
|
4
|
+
import { cn } from '@tallyui/theme';
|
|
5
|
+
import { CustomerCard } from './customer-card';
|
|
6
|
+
import { CustomerSelect } from './customer-select';
|
|
7
|
+
import { CustomerForm, type CustomerFormValues } from './customer-form';
|
|
8
|
+
|
|
9
|
+
export interface CustomerPickerProps {
|
|
10
|
+
/** The connector's searchCustomers, bound to its context by the app. */
|
|
11
|
+
search: (query: string) => Promise<Customer[]>;
|
|
12
|
+
/** The connector's createCustomer, bound; absent hides "New customer". */
|
|
13
|
+
create?: (input: CustomerInput) => Promise<Customer>;
|
|
14
|
+
selected: Customer | null;
|
|
15
|
+
onSelect: (customer: Customer | null) => void;
|
|
16
|
+
/** Called for errors the picker can't explain to the cashier, including ConnectorUnauthorizedError, which means the app should sign in again. */
|
|
17
|
+
onError?: (error: unknown) => void;
|
|
18
|
+
/** false disables search and create (online only in v1). */
|
|
19
|
+
online: boolean;
|
|
20
|
+
/** Debounce before a search, ms. */
|
|
21
|
+
debounceMs?: number;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `@tallyui/core/server`'s `payloadBoundErrors` bound on `customer.email`, in UTF-16 code units. */
|
|
26
|
+
const EMAIL_MAX = 254;
|
|
27
|
+
/** A simple shape, not RFC 5322: one `@`, a non-empty local part, a domain with text either side of a dot, no whitespace or NUL. */
|
|
28
|
+
const EMAIL_SHAPE = /^[^\s@\u0000]+@[^\s@\u0000]+\.[^\s@\u0000]+$/u;
|
|
29
|
+
|
|
30
|
+
export function CustomerPicker({ search, create, selected, onSelect, onError, online, debounceMs = 250, className }: CustomerPickerProps) {
|
|
31
|
+
const [query, setQuery] = useState('');
|
|
32
|
+
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
const [showForm, setShowForm] = useState(false);
|
|
35
|
+
const [creating, setCreating] = useState(false);
|
|
36
|
+
const [values, setValues] = useState<CustomerFormValues>({ firstName: '', lastName: '', email: '', phone: '', address: '' });
|
|
37
|
+
const sequence = useRef(0);
|
|
38
|
+
const searchRef = useRef(search);
|
|
39
|
+
searchRef.current = search;
|
|
40
|
+
const [noResults, setNoResults] = useState(false);
|
|
41
|
+
|
|
42
|
+
function handleError(error: unknown) {
|
|
43
|
+
if (error instanceof CustomerServiceError) {
|
|
44
|
+
setError(error.code === 'network' ? "Couldn't reach the store. Try again."
|
|
45
|
+
: error.code === 'server' ? "The store couldn't do that. Try again." : error.message);
|
|
46
|
+
} else {
|
|
47
|
+
setError(error instanceof ConnectorUnauthorizedError ? 'Sign in again to continue.' : 'Something went wrong. Try again.');
|
|
48
|
+
onError?.(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const current = ++sequence.current;
|
|
54
|
+
setNoResults(false);
|
|
55
|
+
setError(null);
|
|
56
|
+
if (!online || !query.trim()) { setCustomers([]); return; }
|
|
57
|
+
const timer = setTimeout(async () => {
|
|
58
|
+
try {
|
|
59
|
+
const results = await searchRef.current(query);
|
|
60
|
+
if (current === sequence.current) { setCustomers(results); setNoResults(results.length === 0); }
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (current === sequence.current) handleError(error);
|
|
63
|
+
}
|
|
64
|
+
}, debounceMs);
|
|
65
|
+
return () => { clearTimeout(timer); sequence.current++; };
|
|
66
|
+
}, [query, online, debounceMs]);
|
|
67
|
+
|
|
68
|
+
async function submit() {
|
|
69
|
+
const email = values.email.trim();
|
|
70
|
+
if (!online || !create || creating || !email) return;
|
|
71
|
+
// The till sends this email in order.create, whose shape check refuses one over 254 characters: refuse it here instead.
|
|
72
|
+
if (email.length > EMAIL_MAX || !EMAIL_SHAPE.test(email)) return setError(`Enter a valid email address (up to ${EMAIL_MAX} characters)`);
|
|
73
|
+
setCreating(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
try {
|
|
76
|
+
const created = await create({ email,
|
|
77
|
+
...(values.firstName.trim() ? { firstName: values.firstName.trim() } : {}),
|
|
78
|
+
...(values.lastName.trim() ? { lastName: values.lastName.trim() } : {}),
|
|
79
|
+
...(values.phone.trim() ? { phone: values.phone.trim() } : {}),
|
|
80
|
+
});
|
|
81
|
+
onSelect(created);
|
|
82
|
+
setValues({ firstName: '', lastName: '', email: '', phone: '', address: '' });
|
|
83
|
+
setShowForm(false);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
handleError(error);
|
|
86
|
+
} finally {
|
|
87
|
+
setCreating(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return <View className={cn('gap-3', className)}>
|
|
92
|
+
{selected && <View>
|
|
93
|
+
<CustomerCard doc={selected} traits={customerTraits} />
|
|
94
|
+
<Pressable accessibilityRole="button" className="items-center rounded-lg border border-border px-4 py-3" onPress={() => onSelect(null)}><Text className="text-sm font-semibold text-foreground">Remove customer</Text></Pressable>
|
|
95
|
+
</View>}
|
|
96
|
+
<Text className="text-xs font-medium text-muted-foreground">Search customers</Text>
|
|
97
|
+
<TextInput accessibilityLabel="Search customers" value={query} onChangeText={setQuery} editable={online}
|
|
98
|
+
className="rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground"
|
|
99
|
+
placeholderTextColorClassName="accent-muted-foreground" placeholder="Name or email" />
|
|
100
|
+
{!online && <Text className="text-sm text-muted-foreground">Connect to search or add customers.</Text>}
|
|
101
|
+
{noResults && <Text className="text-sm text-muted-foreground">No customers found.</Text>}
|
|
102
|
+
{error && <Text accessibilityRole="alert" className="text-sm text-destructive">{error}</Text>}
|
|
103
|
+
<CustomerSelect customers={customers} traits={customerTraits} onSearch={setQuery}
|
|
104
|
+
onSelect={(customer) => { onSelect(customer); setQuery(''); }} />
|
|
105
|
+
{create && <Pressable accessibilityRole="button" disabled={!online || creating} onPress={() => setShowForm(true)}
|
|
106
|
+
className={cn('items-center rounded-lg border border-border px-4 py-3', (!online || creating) && 'opacity-50')}>
|
|
107
|
+
<Text className="text-sm font-semibold text-foreground">New customer</Text>
|
|
108
|
+
</Pressable>}
|
|
109
|
+
{showForm && <View>
|
|
110
|
+
<CustomerForm values={values} showAddress={false}
|
|
111
|
+
onChangeField={(field, value) => setValues((current) => ({ ...current, [field]: value }))} />
|
|
112
|
+
<View className="flex-row gap-3">
|
|
113
|
+
<Pressable accessibilityRole="button" disabled={!online || creating || !values.email.trim()} onPress={submit}
|
|
114
|
+
className={cn('items-center rounded-lg bg-primary px-4 py-3', (!online || creating || !values.email.trim()) && 'opacity-50')}>
|
|
115
|
+
<Text className="text-sm font-semibold text-primary-foreground">Save Customer</Text>
|
|
116
|
+
</Pressable>
|
|
117
|
+
<Pressable accessibilityRole="button" className="items-center rounded-lg border border-border px-4 py-3"
|
|
118
|
+
onPress={() => { setShowForm(false); setValues({ firstName: '', lastName: '', email: '', phone: '', address: '' }); }}>
|
|
119
|
+
<Text className="text-sm font-semibold text-foreground">Cancel</Text>
|
|
120
|
+
</Pressable>
|
|
121
|
+
</View>
|
|
122
|
+
</View>}
|
|
123
|
+
</View>;
|
|
124
|
+
}
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import { ScrollView, View } from 'react-native';
|
|
1
|
+
import { Pressable, ScrollView, View } from 'react-native';
|
|
2
2
|
import { Select } from '@tallyui/primitives';
|
|
3
|
-
import { useCustomerTraits } from '@tallyui/core';
|
|
3
|
+
import { useCustomerTraits, type CustomerTraits } from '@tallyui/core';
|
|
4
4
|
import { cn } from '@tallyui/theme';
|
|
5
5
|
import { VStack, type VStackProps } from '../ui';
|
|
6
6
|
import { CustomerCard } from './customer-card';
|
|
7
7
|
|
|
8
|
-
export interface CustomerSelectProps extends Omit<VStackProps, 'children'> {
|
|
8
|
+
export interface CustomerSelectProps<Doc = any> extends Omit<VStackProps, 'children'> {
|
|
9
9
|
/** Customer documents to display as options */
|
|
10
|
-
customers:
|
|
10
|
+
customers: Doc[];
|
|
11
11
|
/** Currently selected customer (if any) */
|
|
12
|
-
selected?:
|
|
12
|
+
selected?: Doc | null;
|
|
13
13
|
/** Called when a customer is selected */
|
|
14
|
-
onSelect: (customer:
|
|
14
|
+
onSelect: (customer: Doc) => void;
|
|
15
|
+
traits?: CustomerTraits<Doc>;
|
|
15
16
|
/** Called when the search text changes */
|
|
16
17
|
onSearch: (query: string) => void;
|
|
17
18
|
/** Placeholder text for the search input */
|
|
@@ -39,16 +40,24 @@ export interface CustomerSelectProps extends Omit<VStackProps, 'children'> {
|
|
|
39
40
|
* />
|
|
40
41
|
* ```
|
|
41
42
|
*/
|
|
42
|
-
export function CustomerSelect({
|
|
43
|
+
export function CustomerSelect<Doc>(props: CustomerSelectProps<Doc>) {
|
|
44
|
+
return props.traits ? <CustomerSelectView {...props} /> : <CustomerSelectFromContext {...props} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function CustomerSelectFromContext<Doc>(props: CustomerSelectProps<Doc>) {
|
|
48
|
+
return <CustomerSelectView {...props} traits={useCustomerTraits()} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function CustomerSelectView<Doc>({
|
|
43
52
|
customers,
|
|
44
53
|
selected,
|
|
45
54
|
onSelect,
|
|
46
55
|
onSearch,
|
|
47
56
|
placeholder = 'Search customers...',
|
|
48
57
|
className,
|
|
58
|
+
traits,
|
|
49
59
|
...props
|
|
50
|
-
}: CustomerSelectProps) {
|
|
51
|
-
const traits = useCustomerTraits();
|
|
60
|
+
}: CustomerSelectProps<Doc>) {
|
|
52
61
|
|
|
53
62
|
// Map selected customer to Select primitive's Option format
|
|
54
63
|
const selectedOption = selected && traits
|
|
@@ -56,7 +65,7 @@ export function CustomerSelect({
|
|
|
56
65
|
: undefined;
|
|
57
66
|
|
|
58
67
|
// Build a lookup so we can resolve the original customer doc on selection
|
|
59
|
-
const customerById = new Map<string,
|
|
68
|
+
const customerById = new Map<string, Doc>();
|
|
60
69
|
if (traits) {
|
|
61
70
|
for (const customer of customers) {
|
|
62
71
|
customerById.set(traits.getId(customer), customer);
|
|
@@ -78,7 +87,7 @@ export function CustomerSelect({
|
|
|
78
87
|
{selected && traits && (
|
|
79
88
|
<Select.Trigger asChild>
|
|
80
89
|
<View className="rounded-lg border border-primary bg-primary/5 px-3 py-2">
|
|
81
|
-
<CustomerCard doc={selected} />
|
|
90
|
+
<CustomerCard doc={selected} traits={traits} />
|
|
82
91
|
</View>
|
|
83
92
|
</Select.Trigger>
|
|
84
93
|
)}
|
|
@@ -89,6 +98,7 @@ export function CustomerSelect({
|
|
|
89
98
|
{customers.map((customer, index) => {
|
|
90
99
|
const id = traits?.getId(customer) ?? String(index);
|
|
91
100
|
const label = traits?.getName(customer) ?? '';
|
|
101
|
+
const email = traits?.getEmail(customer);
|
|
92
102
|
return (
|
|
93
103
|
<Select.Item
|
|
94
104
|
key={id}
|
|
@@ -96,9 +106,9 @@ export function CustomerSelect({
|
|
|
96
106
|
label={label}
|
|
97
107
|
asChild
|
|
98
108
|
>
|
|
99
|
-
<
|
|
100
|
-
<CustomerCard doc={customer} />
|
|
101
|
-
</
|
|
109
|
+
<Pressable accessibilityLabel={email ? `${label}, ${email}` : label} className="border-b border-border px-3 py-2">
|
|
110
|
+
<CustomerCard doc={customer} traits={traits} />
|
|
111
|
+
</Pressable>
|
|
102
112
|
</Select.Item>
|
|
103
113
|
);
|
|
104
114
|
})}
|
package/src/customer/index.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { CustomerCard, type CustomerCardProps } from './customer-card';
|
|
|
2
2
|
export { CustomerSelect, type CustomerSelectProps } from './customer-select';
|
|
3
3
|
export { CustomerForm, type CustomerFormProps, type CustomerFormValues } from './customer-form';
|
|
4
4
|
export { CustomerOrderHistory, type CustomerOrderHistoryProps } from './customer-order-history';
|
|
5
|
+
export { CustomerPicker, type CustomerPickerProps } from './customer-picker';
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { FlatList, View } from 'react-native';
|
|
3
3
|
import { cn } from '@tallyui/theme';
|
|
4
4
|
import { useProductTraits } from '@tallyui/core';
|
|
5
5
|
import { VStack, type VStackProps } from '../ui';
|
|
6
6
|
|
|
7
|
+
// Limit initial mounts for a 2,000-product catalogue to eight rows.
|
|
8
|
+
const INITIAL_ROWS = 8;
|
|
9
|
+
// Keep each render batch small for a 2,000-product catalogue.
|
|
10
|
+
const BATCH_ROWS = 8;
|
|
11
|
+
// Keep nearby rows mounted without rendering the whole 2,000-product catalogue.
|
|
12
|
+
const WINDOW_SIZE = 5;
|
|
13
|
+
|
|
7
14
|
export interface ProductGridProps extends Omit<VStackProps, 'children'> {
|
|
8
15
|
/** Array of product documents to render */
|
|
9
16
|
items: any[];
|
|
@@ -27,10 +34,9 @@ export interface ProductGridProps extends Omit<VStackProps, 'children'> {
|
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
|
-
* A
|
|
37
|
+
* A virtualized product grid with optional search and filter slots.
|
|
31
38
|
*
|
|
32
|
-
* Uses
|
|
33
|
-
* FlatList numColumns, which keeps things predictable in both native and web.
|
|
39
|
+
* Uses FlatList numColumns to render rows near the viewport on native and web.
|
|
34
40
|
*
|
|
35
41
|
* ```tsx
|
|
36
42
|
* <ProductGrid
|
|
@@ -70,13 +76,21 @@ export function ProductGrid({
|
|
|
70
76
|
{filterSlot && <View className="px-3 pb-2">{filterSlot}</View>}
|
|
71
77
|
|
|
72
78
|
{hasItems ? (
|
|
73
|
-
<
|
|
74
|
-
{
|
|
75
|
-
|
|
79
|
+
<FlatList
|
|
80
|
+
key={numColumns}
|
|
81
|
+
data={visibleItems}
|
|
82
|
+
numColumns={numColumns}
|
|
83
|
+
keyExtractor={(item, index) => String(item.id ?? index)}
|
|
84
|
+
contentContainerClassName="p-1"
|
|
85
|
+
initialNumToRender={INITIAL_ROWS}
|
|
86
|
+
maxToRenderPerBatch={BATCH_ROWS}
|
|
87
|
+
windowSize={WINDOW_SIZE}
|
|
88
|
+
renderItem={({ item, index }) => (
|
|
89
|
+
<View style={{ width: `${100 / numColumns}%` }} className="p-1">
|
|
76
90
|
{renderItem(item, index)}
|
|
77
91
|
</View>
|
|
78
|
-
)
|
|
79
|
-
|
|
92
|
+
)}
|
|
93
|
+
/>
|
|
80
94
|
) : (
|
|
81
95
|
emptyState ?? null
|
|
82
96
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { AccessibilityInfo, Animated, View } from 'react-native';
|
|
3
3
|
import type { useRegisterSession } from '@tallyui/pos';
|
|
4
|
-
import { countVariance, varianceText } from '@tallyui/pos';
|
|
4
|
+
import { countVariance, TAX_ROUNDING_MIXED_NOTE, varianceText } from '@tallyui/pos';
|
|
5
5
|
|
|
6
6
|
import { formatMoney, minorUnitDigits } from '@tallyui/core';
|
|
7
7
|
import { Button } from '../ui/button';
|
|
@@ -136,6 +136,12 @@ export function ClosureSheet({ register, currency, onPrint, onDone }: ClosureShe
|
|
|
136
136
|
)}
|
|
137
137
|
{/* Not a figure: shows who approved the close even while blind. */}
|
|
138
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
|
+
)}
|
|
139
145
|
{!!error && (
|
|
140
146
|
<Text testID="closure-print-error" className="text-destructive">
|
|
141
147
|
{error}
|
package/src/sale/catalogue.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
2
|
import { Pressable, Text, View } from 'react-native';
|
|
3
|
-
import type
|
|
3
|
+
import { useStockOverlaid, useStockOverlayAsOf, type ProductTraits } from '@tallyui/core';
|
|
4
4
|
import { catalogueEntries, findEntryByCode, searchProducts, variantPriceLabel, type CatalogueEntry } from '@tallyui/pos';
|
|
5
5
|
import { ProductGrid, ProductImage, ProductPrice, ProductStockBadge, ProductTitle } from '../product';
|
|
6
6
|
import { SearchInput } from '../input';
|
|
@@ -36,12 +36,14 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
36
36
|
products: Doc[];
|
|
37
37
|
traits: ProductTraits<Doc>;
|
|
38
38
|
currency: string;
|
|
39
|
+
/** Receives the chosen product and variant with the nearest ConnectorProvider's stock overlay applied (a copy). */
|
|
39
40
|
onSelect: (entry: CatalogueEntry<Doc>) => void;
|
|
40
41
|
statusText?: string;
|
|
41
42
|
/** Rendered at the end of the status line, e.g. a register control at phone width; the row renders even without `statusText`. */
|
|
42
43
|
statusAccessory?: ReactNode;
|
|
43
44
|
lastSyncedAt: Date | null;
|
|
44
|
-
/** The last completed stock reconcile pass, persisted across restarts
|
|
45
|
+
/** The last completed stock reconcile pass, persisted across restarts. The "stock as of" time shown is the
|
|
46
|
+
* latest of this, the provider's stockOverlayAsOf (when valid) and lastSyncedAt; null and omitted alike. */
|
|
45
47
|
lastStockCheckAt?: Date | null;
|
|
46
48
|
/** Whether to show a 12- or 24-hour clock in the stock-as-of time; undefined keeps the locale default. A platform
|
|
47
49
|
* reads this off its own device APIs (e.g. expo-localization's getCalendars()) and passes it in. */
|
|
@@ -51,7 +53,13 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
51
53
|
* in its unfocused wedge listener — not this prop's job. */
|
|
52
54
|
minCodeLength?: number;
|
|
53
55
|
}) {
|
|
54
|
-
|
|
56
|
+
// Idempotent (the adapter's overlay returns just the fields the stock map sets), so an app that already
|
|
57
|
+
// merges the overlay itself keeps working, and can drop its own merge.
|
|
58
|
+
const shown = useStockOverlaid(products) as Doc[];
|
|
59
|
+
const overlayAsOf = useStockOverlayAsOf();
|
|
60
|
+
const parsedOverlayAsOf = overlayAsOf ? new Date(overlayAsOf) : undefined;
|
|
61
|
+
const validOverlayAsOf = parsedOverlayAsOf && !isNaN(parsedOverlayAsOf.getTime()) ? parsedOverlayAsOf : undefined;
|
|
62
|
+
const stockAsOf = laterOf(laterOf(lastStockCheckAt, validOverlayAsOf), lastSyncedAt);
|
|
55
63
|
const [query, setQuery] = useState('');
|
|
56
64
|
// The chooser is live (medusapos ADR 0007): it holds only the chosen product's id and derives its choices
|
|
57
65
|
// from the current entries below, so an open chooser shows each reconcile pass as it lands.
|
|
@@ -59,8 +67,8 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
|
|
|
59
67
|
const [width, setWidth] = useState(0);
|
|
60
68
|
// ProductGrid has 4 px padding on each side of the content and each cell.
|
|
61
69
|
const columns = Math.max(2, Math.min(6, Math.floor((width - 8) / (MIN_TILE_WIDTH + 8))));
|
|
62
|
-
const entries = useMemo(() => catalogueEntries(
|
|
63
|
-
const results = useMemo(() => searchProducts(
|
|
70
|
+
const entries = useMemo(() => catalogueEntries(shown, traits), [shown, traits]);
|
|
71
|
+
const results = useMemo(() => searchProducts(shown, query, traits), [shown, query, traits]);
|
|
64
72
|
const choices = useMemo(() => (chooserId === null ? []
|
|
65
73
|
: entries.filter((entry) => traits.getId(entry.product) === chooserId)), [entries, chooserId, traits]);
|
|
66
74
|
// A product that leaves the catalogue (a resync, or no longer sellable) closes its chooser.
|
package/src/sale/orders-list.tsx
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
|
-
import { formatMoney } from '@tallyui/core';
|
|
4
|
-
import { needsAttention, type PosOrder } from '@tallyui/pos';
|
|
3
|
+
import { formatMoney, knownWarnings } from '@tallyui/core';
|
|
4
|
+
import { needsAttention, type OutboxState, type PosOrder } from '@tallyui/pos';
|
|
5
5
|
|
|
6
6
|
const STATUS_LABEL = { pending: 'Waiting to sync', applied: 'Synced', rejected: 'Not accepted' };
|
|
7
|
+
/** `figures_mismatch`'s labels; a field a newer store sends that isn't here is shown by its raw name. */
|
|
8
|
+
const FIGURE_LABELS = new Map([['subtotalMinor', 'Subtotal'], ['taxMinor', 'Tax'], ['discountMinor', 'Discount']]);
|
|
9
|
+
/** A rejected sale's line in the cashier's words, by the store's error code (#269). The store's own message goes to the
|
|
10
|
+
* sync log (the outbox logs it), never here. */
|
|
11
|
+
const REFUSAL_SENTENCES = new Map([
|
|
12
|
+
['invalid_payload', "The online store refused this sale: this till sent it in a form the store can't read. Ask the store owner to look at the till's sync log."],
|
|
13
|
+
['unsupported_version', "The online store refused this sale: the store's software is older than this till's. Ask the store owner to update the POS plugin."],
|
|
14
|
+
['idempotency_mismatch', "The online store has a different sale under this sale's number. Don't send it again; ask the store owner to compare the two."],
|
|
15
|
+
['store_configuration', 'The online store refused this sale: a setting on the store needs changing. Once the store owner fixes it, press Retry.'],
|
|
16
|
+
['platform_error', "The online store refused this sale. Ask the store owner to look at the till's sync log."],
|
|
17
|
+
['internal_error', "The online store hit a fault in its POS plugin and refused this sale. Ask the store owner to look at the till's sync log."],
|
|
18
|
+
['insufficient_stock', "The online store refused this sale: it doesn't have enough stock of one of the items."],
|
|
19
|
+
['unsupported_tax_mode', "The online store refused this sale: its tax settings can't take a sale like this one. Ask the store owner to check them."],
|
|
20
|
+
['unknown_variant', 'The online store refused this sale: that product variation no longer exists.'],
|
|
21
|
+
['invalid_quantity', "The online store refused this sale: a quantity or a discount on it isn't allowed."],
|
|
22
|
+
['underpaid', 'The online store refused this sale: the payments add up to less than the total.'],
|
|
23
|
+
['unsupported_currency', "The online store refused this sale: the store doesn't take this currency."],
|
|
24
|
+
]);
|
|
25
|
+
// Any other code, or none: platform_error's sentence, as the generic fallback (approved by the Front desk, 2026-09-30).
|
|
26
|
+
const refusalSentence = (code: string | undefined) => REFUSAL_SENTENCES.get(code ?? '') ?? REFUSAL_SENTENCES.get('platform_error')!;
|
|
7
27
|
|
|
8
28
|
/** Keeps an unparseable value as is, like medusapos's date util, since `Intl` throws on an invalid date. */
|
|
9
29
|
function defaultFormatDate(iso: string) {
|
|
@@ -13,9 +33,11 @@ function defaultFormatDate(iso: string) {
|
|
|
13
33
|
|
|
14
34
|
/** The "Needs attention" (when not empty) and "Recent" orders, lifted from medusapos/app's orders screen (ADR-052).
|
|
15
35
|
* `onRetry` is the outbox's `requeue`; `formatDate` replaces the app's date util; `footer` is the app's own content
|
|
16
|
-
* (medusapos: the feedback link). The router and session redirect stay in the app.
|
|
17
|
-
|
|
36
|
+
* (medusapos: the feedback link). The router and session redirect stay in the app. `stuck` is the outbox's
|
|
37
|
+
* `state.stuck` (`useOrderOutbox`): those pending orders need attention too, each with its own entry's reason and since. */
|
|
38
|
+
export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, footer, stuck }: {
|
|
18
39
|
orders: PosOrder[]; onRetry(orderIds: string[]): Promise<number>; formatDate?: (iso: string) => string; footer?: ReactNode;
|
|
40
|
+
stuck?: OutboxState['stuck'];
|
|
19
41
|
}) {
|
|
20
42
|
const retrying = useRef(new Set<string>());
|
|
21
43
|
const [retryingIds, setRetryingIds] = useState(new Set<string>());
|
|
@@ -26,21 +48,33 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
|
|
|
26
48
|
setRetryingIds(new Set(retrying.current));
|
|
27
49
|
}, [orders]);
|
|
28
50
|
return <ScrollView className="flex-1 bg-background p-4">
|
|
29
|
-
{[{ title: 'Needs attention', orders: needsAttention(orders) }, { title: 'Recent', orders }].filter((section) => section.title === 'Recent' || section.orders.length > 0).map((section) => (
|
|
51
|
+
{[{ title: 'Needs attention', orders: needsAttention(orders, { stuckCommandIds: stuck?.commandIds }) }, { title: 'Recent', orders }].filter((section) => section.title === 'Recent' || section.orders.length > 0).map((section) => (
|
|
30
52
|
<View key={section.title} className="mb-6 gap-3">
|
|
31
53
|
<Text accessibilityRole="header" className="text-xl font-semibold text-foreground">{section.title}</Text>
|
|
32
54
|
{section.orders.map((order) => {
|
|
33
55
|
const count = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
56
|
+
// The order's own stuck entry: its clock and latest reason, not the earliest across all stuck orders.
|
|
57
|
+
const stuckEntry = order.syncStatus === 'pending' ? stuck?.orders.find((entry) => entry.commandId === order.commandId) : undefined;
|
|
34
58
|
return (
|
|
35
59
|
<View key={order.id} className="gap-1 rounded-md border border-border bg-card p-3">
|
|
36
60
|
<Text className="text-foreground">{order.serverRefs?.displayId ? `Order #${order.serverRefs.displayId} · ` : ''}{count} {count === 1 ? 'item' : 'items'}</Text>
|
|
37
61
|
<Text className="text-muted-foreground">{formatDate(order.createdAt)} · {formatMoney({ amount: order.totalMinor, currency: order.currency })} · {STATUS_LABEL[order.syncStatus]}</Text>
|
|
38
|
-
{
|
|
62
|
+
{stuckEntry ? <Text className="text-destructive">
|
|
63
|
+
{/* The same words for any reason, with no reason code; the hour as the status line has it (numeric, #245). */}
|
|
64
|
+
{`Hasn't reached the online store since ${new Date(stuckEntry.since).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}.`}
|
|
65
|
+
</Text> : null}
|
|
66
|
+
{/* The code's sentence alone: the cashier sees no error code and never the store's message. */}
|
|
67
|
+
{order.syncStatus === 'rejected' ? <Text className="text-destructive">{refusalSentence(order.error?.code)}</Text> : null}
|
|
39
68
|
{/* A late sale (ADR-032) needs no Retry of its own; a rejected one still gets its Retry below. */}
|
|
40
69
|
{order.lateSessionId !== undefined ? <Text className="text-foreground">Taken after the register closed. It is not in that register's closure.</Text> : null}
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
70
|
+
{order.localWarnings?.map((warning, index) => <Text key={index} className="text-foreground">
|
|
71
|
+
{warning.code === 'customer_omitted'
|
|
72
|
+
? `The customer's ${warning.field} couldn't be sent to the store, so the order isn't linked to them.`
|
|
73
|
+
: "The terminal's payment reference couldn't be kept; the payment is recorded without it."}
|
|
74
|
+
</Text>)}
|
|
75
|
+
{/* idempotency_mismatch isn't requeueable (the outbox's NOT_REQUEUEABLE): its sentence says not to send it again, and no Retry. */}
|
|
76
|
+
{section.title === 'Needs attention' && order.syncStatus === 'rejected' && order.error?.code !== 'idempotency_mismatch'
|
|
77
|
+
? <Pressable accessibilityRole="button" disabled={retryingIds.has(order.id)} onPress={async () => {
|
|
44
78
|
if (retrying.current.has(order.id)) return;
|
|
45
79
|
retrying.current.add(order.id);
|
|
46
80
|
setRetryingIds(new Set(retrying.current));
|
|
@@ -51,9 +85,17 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
|
|
|
51
85
|
}} className="rounded-md bg-primary px-4 py-2">
|
|
52
86
|
<Text className="text-center font-semibold text-primary-foreground">Retry</Text>
|
|
53
87
|
</Pressable> : null}
|
|
54
|
-
{order.warnings
|
|
88
|
+
{knownWarnings(order.warnings).map((warning, index) => <View key={index} className="border-l-4 border-warning pl-2"><Text className="text-foreground">
|
|
55
89
|
{warning.code === 'insufficient_stock'
|
|
56
90
|
? `Stock short by ${warning.quantity} for ${order.lines.find((line) => line.variantId === warning.variantId)?.name}`
|
|
91
|
+
: warning.code === 'tax_rate_mismatch'
|
|
92
|
+
? `Tax at ${(warning.ratePpm / 10000).toLocaleString(undefined, { maximumFractionDigits: 4 })}%: store ${formatMoney({ amount: warning.serverMinor, currency: order.currency })} vs POS ${formatMoney({ amount: warning.expectedMinor, currency: order.currency })}`
|
|
93
|
+
: warning.code === 'customer_ignored'
|
|
94
|
+
? `The online store didn't recognise the customer on this sale, so it was saved as a guest sale. Customer id: ${warning.customerId}.`
|
|
95
|
+
: warning.code === 'figures_mismatch'
|
|
96
|
+
? `The online store worked out different figures for this sale. ${warning.fields.map(({ field, tillMinor, serverMinor }) => `${FIGURE_LABELS.get(field) ?? field}: till ${formatMoney({ amount: tillMinor, currency: order.currency })}, store ${formatMoney({ amount: serverMinor, currency: order.currency })}.`).join(' ')}`
|
|
97
|
+
: warning.bridgeMinor !== undefined
|
|
98
|
+
? `Store calculated ${formatMoney({ amount: warning.serverMinor, currency: order.currency })}; a rounding line of ${warning.bridgeMinor > 0 ? '+' : ''}${formatMoney({ amount: warning.bridgeMinor, currency: order.currency })} brought it to ${formatMoney({ amount: warning.expectedMinor, currency: order.currency })}`
|
|
57
99
|
: `Store total ${formatMoney({ amount: warning.serverMinor, currency: order.currency })} vs POS ${formatMoney({ amount: warning.expectedMinor, currency: order.currency })}`}
|
|
58
100
|
</Text></View>)}
|
|
59
101
|
</View>
|
package/src/sale/receipt.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { Pressable, Text, View } from 'react-native';
|
|
3
3
|
import { formatMoney } from '@tallyui/core';
|
|
4
|
-
import { buildReceiptData, type
|
|
4
|
+
import { buildReceiptData, type SentOrder } from '@tallyui/pos';
|
|
5
5
|
import { injectPrintStyle } from './print-style';
|
|
6
6
|
import { discountLabel } from './discount-form';
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ const defaultFormatDate = (iso: string) => new Intl.DateTimeFormat(undefined, {
|
|
|
11
11
|
* TV6a's `Cart`), outbox strip and date util, so `Receipt` stays platform-neutral (ADR-052). */
|
|
12
12
|
export function Receipt({ order, store, cashier, registerId, newSale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`,
|
|
13
13
|
topInset = 0, formatDate = defaultFormatDate }: {
|
|
14
|
-
order:
|
|
14
|
+
order: SentOrder; store: { name: string; address?: string }; cashier: string; registerId: string; newSale: () => void;
|
|
15
15
|
taxLabel?: (ratePpm: number) => string; topInset?: number; formatDate?: (iso: string) => string;
|
|
16
16
|
}) {
|
|
17
17
|
useEffect(injectPrintStyle, []);
|
|
@@ -34,6 +34,7 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
|
|
|
34
34
|
<Text className="text-foreground">Order {receipt.header.orderNumber.slice(-8)}</Text>
|
|
35
35
|
<Text className="text-muted-foreground">{formatDate(receipt.header.date)}</Text>
|
|
36
36
|
<Text className="text-muted-foreground">Cashier: {receipt.header.cashier}</Text>
|
|
37
|
+
{receipt.header.customer && <Text className="text-muted-foreground">Customer: {receipt.header.customer}</Text>}
|
|
37
38
|
{receipt.lineItems.map((line, index) => <View key={index}>
|
|
38
39
|
<Text className="text-foreground">{line.name}</Text>
|
|
39
40
|
{/* Before any discount, then the line's own discounts as sub-rows (TallyUI ADR-063); they add up to the subtotal. */}
|