@tallyui/components 3.3.0 → 3.4.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 +29 -2
- package/dist/index.js +443 -303
- package/package.json +9 -9
- package/src/index.ts +1 -0
- package/src/sale/cart.tsx +32 -6
- package/src/sale/charge-form.tsx +71 -0
- package/src/sale/index.ts +1 -0
- package/src/sale/receipt.tsx +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/components",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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.
|
|
31
|
-
"@tallyui/primitives": "^3.
|
|
32
|
-
"@tallyui/theme": "^3.
|
|
30
|
+
"@tallyui/core": "^3.4.0",
|
|
31
|
+
"@tallyui/primitives": "^3.4.0",
|
|
32
|
+
"@tallyui/theme": "^3.4.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.
|
|
39
|
+
"@tallyui/pos": "3.4.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@tallyui/connector-medusa": "3.
|
|
43
|
-
"@tallyui/connector-vendure": "3.
|
|
44
|
-
"@tallyui/connector-woocommerce": "3.
|
|
45
|
-
"@tallyui/mock-api": "3.
|
|
42
|
+
"@tallyui/connector-medusa": "3.4.0",
|
|
43
|
+
"@tallyui/connector-vendure": "3.4.0",
|
|
44
|
+
"@tallyui/connector-woocommerce": "3.4.0",
|
|
45
|
+
"@tallyui/mock-api": "3.4.0",
|
|
46
46
|
"@types/react": ">=18",
|
|
47
47
|
"react": "^19.2.4",
|
|
48
48
|
"react-native-svg": "15.12.1",
|
package/src/index.ts
CHANGED
|
@@ -156,6 +156,7 @@ export {
|
|
|
156
156
|
export {
|
|
157
157
|
Cart, CartBar, Tender, SplitTender, DiscountForm, DiscountChips, parseDiscount, discountLabel,
|
|
158
158
|
PriceForm, parsePrice,
|
|
159
|
+
ChargeForm, type ChargeFormProps,
|
|
159
160
|
Catalogue, formatStockSyncTime, Receipt, injectPrintStyle, SyncStatus, OrdersList, orderReference,
|
|
160
161
|
ParkedSales, type ParkedSalesProps,
|
|
161
162
|
} from './sale';
|
package/src/sale/cart.tsx
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { formatMoney } from '@tallyui/core';
|
|
3
4
|
import { buildReceiptData } from '@tallyui/pos';
|
|
4
5
|
import type { useSale } from '@tallyui/pos';
|
|
5
6
|
import { CartLine, CartLineActions, CartPanel, CartTotal } from '../cart';
|
|
6
7
|
import { DiscountChips, DiscountForm } from './discount-form';
|
|
7
8
|
import { PriceForm } from './price-form';
|
|
9
|
+
import { ChargeForm } from './charge-form';
|
|
8
10
|
|
|
9
11
|
/** `taxLabel` names each tax row; VAT isn't universal, so a platform passes its own (medusapos passes `VAT ${rate}%`). */
|
|
10
|
-
export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`, canEditPrice = false, onPriceChange }:
|
|
12
|
+
export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`, canEditPrice = false, onPriceChange, taxClasses }:
|
|
11
13
|
{ sale: ReturnType<typeof useSale>; taxLabel?: (ratePpm: number) => string;
|
|
14
|
+
taxClasses?: ReadonlyArray<{ id: string; label: string }>;
|
|
12
15
|
/** Shows a Price action on each line. Pass the till's own permission; the control isn't shown without it. */
|
|
13
16
|
canEditPrice?: boolean;
|
|
14
17
|
/** Called after a price is applied, for the app's audit log. The reason is kept only here. */
|
|
@@ -24,8 +27,9 @@ export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10
|
|
|
24
27
|
const display = (lineId: string) => order.display.lines.find((line) => line.lineId === lineId)!;
|
|
25
28
|
const orderAmounts = order.discounts.length === 1
|
|
26
29
|
? [{ discountId: order.discounts[0].id, amountMinor: order.display.orderDiscountMinor }] : [];
|
|
27
|
-
// One open form: a line's discount or price,
|
|
28
|
-
const [form, setForm] = useState<{ type: 'discount'; lineId: string | null } | { type: 'price'; lineId: string } | null>(null);
|
|
30
|
+
// One open form: a line's discount or price, the order's discount (lineId null), or a charge.
|
|
31
|
+
const [form, setForm] = useState<{ type: 'discount'; lineId: string | null } | { type: 'price'; lineId: string } | { type: 'charge' } | null>(null);
|
|
32
|
+
const chargesOffered = (sale.capabilities?.orderCreate ?? 0) >= 5;
|
|
29
33
|
const discountForm = (lineId: string | null, title: string) => form?.type === 'discount' && form.lineId === lineId
|
|
30
34
|
? <DiscountForm key={lineId ?? 'order'} title={title} currency={order.currency} onClose={() => setForm(null)}
|
|
31
35
|
onApply={(discount) => sale.applyDiscount(lineId, discount)} /> : null;
|
|
@@ -59,13 +63,35 @@ export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10
|
|
|
59
63
|
</>;
|
|
60
64
|
return <CartPanel dataSet={{ print: 'hide' }} items={order.lineItems} renderItem={renderItem}
|
|
61
65
|
emptyState={<Text testID="cart-empty" className="px-3 py-2 text-muted-foreground">Scan or tap a product to start a sale.</Text>}
|
|
62
|
-
afterItems={
|
|
66
|
+
afterItems={<>
|
|
67
|
+
{(['fees', 'shipping'] as const).map((kind) => order[kind]?.map((charge, index) =>
|
|
68
|
+
<View key={charge.id} testID={`cart-${kind === 'fees' ? 'fee' : 'shipping'}-${index}`} className="flex-row items-center gap-3 px-3 py-2">
|
|
69
|
+
<Text className="flex-1 text-foreground">{charge.name}</Text>
|
|
70
|
+
<Text className="text-foreground">{formatMoney(money(order.display[kind]!.find((row) => row.id === charge.id)!.amountMinor))}</Text>
|
|
71
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Remove ${charge.name}`}
|
|
72
|
+
onPress={() => kind === 'fees' ? sale.removeFee(charge.id) : sale.removeShipping(charge.id)}
|
|
73
|
+
className="rounded-md border border-border bg-card px-3 py-2 min-h-11 items-center justify-center">
|
|
74
|
+
<Text className="text-foreground">Remove</Text></Pressable>
|
|
75
|
+
</View>))}
|
|
76
|
+
{order.lineItems.length || chargesOffered ? <View className="gap-3 px-3 py-2">
|
|
77
|
+
{order.lineItems.length ? <>
|
|
63
78
|
<DiscountChips discounts={order.discounts} amounts={orderAmounts} currency={order.currency} onRemove={sale.removeDiscount}
|
|
64
79
|
prefix="Order discount" />
|
|
65
|
-
{form?.lineId === null ? discountForm(null, 'Order discount') : <Pressable accessibilityRole="button"
|
|
80
|
+
{form?.type === 'discount' && form.lineId === null ? discountForm(null, 'Order discount') : <Pressable accessibilityRole="button"
|
|
66
81
|
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">
|
|
67
82
|
<Text className="text-foreground">Order discount</Text></Pressable>}
|
|
68
|
-
|
|
83
|
+
</> : null}
|
|
84
|
+
{chargesOffered ? form?.type === 'charge' ? <ChargeForm currency={order.currency} lineTax={sale.capabilities?.lineTax}
|
|
85
|
+
taxClasses={taxClasses} shippingTaxFromStore={sale.capabilities?.taxRounding?.granularity === 'woocommerce'}
|
|
86
|
+
onClose={() => setForm(null)} onApply={({ kind, name, amountMinor, taxStatus, taxClass }) => {
|
|
87
|
+
const result = kind === 'custom' ? sale.addCustomLine({ name, priceMinor: amountMinor, taxStatus, taxClass })
|
|
88
|
+
: kind === 'fee' ? sale.addFee({ name, amountMinor, taxStatus, taxClass })
|
|
89
|
+
: sale.addShipping({ name, amountMinor, taxStatus, taxClass });
|
|
90
|
+
return typeof result === 'string' ? result : null;
|
|
91
|
+
}} /> : <Pressable accessibilityRole="button" onPress={() => setForm({ type: 'charge' })}
|
|
92
|
+
className="self-start rounded-md border border-border bg-card px-4 py-2 min-h-11 justify-center">
|
|
93
|
+
<Text className="text-foreground">Add charge</Text></Pressable> : null}
|
|
94
|
+
</View> : null}</>}
|
|
69
95
|
footer={<View testID="cart-footer" className="gap-3 pb-4">
|
|
70
96
|
{/* Exclusive: subtotal − discount + tax = total. Inclusive: subtotal − discount = total, the tax "incl.", not added. */}
|
|
71
97
|
<CartTotal subtotal={money(totals.subtotalMinor)} discount={money(totals.discountMinor)} total={money(totals.totalMinor)}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { parsePrice } from './price-form';
|
|
4
|
+
|
|
5
|
+
export interface ChargeFormProps {
|
|
6
|
+
currency: string;
|
|
7
|
+
lineTax?: { none: boolean; classes: boolean };
|
|
8
|
+
taxClasses?: ReadonlyArray<{ id: string; label: string }>;
|
|
9
|
+
shippingTaxFromStore?: boolean;
|
|
10
|
+
onApply: (charge: { kind: 'fee' | 'shipping' | 'custom'; name: string; amountMinor: number;
|
|
11
|
+
taxStatus?: 'none'; taxClass?: string }) => string | null;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const button = 'rounded-md border border-border px-3 py-2 min-h-11 items-center justify-center';
|
|
16
|
+
|
|
17
|
+
/** `onApply` returns a refusal to show inline, or null once applied. */
|
|
18
|
+
export function ChargeForm({ currency, lineTax, taxClasses, shippingTaxFromStore, onApply, onClose }: ChargeFormProps) {
|
|
19
|
+
const [kind, setKind] = useState<'fee' | 'shipping' | 'custom'>('fee');
|
|
20
|
+
const [name, setName] = useState('');
|
|
21
|
+
const [text, setText] = useState('');
|
|
22
|
+
const [noTax, setNoTax] = useState(false);
|
|
23
|
+
const [taxClass, setTaxClass] = useState('');
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
const storeShipping = kind === 'shipping' && shippingTaxFromStore;
|
|
26
|
+
function apply() {
|
|
27
|
+
const parsed = parsePrice(text, currency);
|
|
28
|
+
const refused = !name.trim() ? 'Enter a name.' : typeof parsed === 'string' ? parsed : onApply({
|
|
29
|
+
kind, name: name.trim(), amountMinor: parsed,
|
|
30
|
+
taxStatus: !storeShipping && lineTax?.none && noTax ? 'none' : undefined,
|
|
31
|
+
taxClass: !storeShipping && lineTax?.classes && taxClasses?.length ? taxClass || undefined : undefined,
|
|
32
|
+
});
|
|
33
|
+
setError(refused);
|
|
34
|
+
if (refused === null) onClose();
|
|
35
|
+
}
|
|
36
|
+
return <View testID="charge-form" role="group" accessibilityLabel="Add charge" className="gap-2 px-3 py-2">
|
|
37
|
+
<Text className="font-semibold text-foreground">Add charge</Text>
|
|
38
|
+
<View className="flex-row gap-2">
|
|
39
|
+
{(['fee', 'shipping', 'custom'] as const).map((option) => <Pressable key={option} accessibilityRole="button"
|
|
40
|
+
accessibilityState={{ selected: kind === option }} aria-selected={kind === option} onPress={() => setKind(option)}
|
|
41
|
+
className={`${button} ${kind === option ? 'bg-primary' : 'bg-card'}`}>
|
|
42
|
+
<Text className={kind === option ? 'text-primary-foreground' : 'text-foreground'}>
|
|
43
|
+
{option === 'fee' ? 'Fee' : option === 'shipping' ? 'Shipping' : 'Custom item'}</Text>
|
|
44
|
+
</Pressable>)}
|
|
45
|
+
</View>
|
|
46
|
+
<TextInput accessibilityLabel="Name" placeholder="Name" value={name} onChangeText={setName}
|
|
47
|
+
className="min-w-0 rounded-md border border-border bg-background px-3 py-2 text-foreground" />
|
|
48
|
+
<TextInput accessibilityLabel="Amount" value={text} onChangeText={setText} onSubmitEditing={apply}
|
|
49
|
+
inputMode="decimal" placeholder={currency}
|
|
50
|
+
className="min-w-0 rounded-md border border-border bg-background px-3 py-2 text-foreground" />
|
|
51
|
+
{storeShipping ? <Text className="text-muted-foreground">Shipping is taxed at the store's shipping tax class.</Text> : <>
|
|
52
|
+
{lineTax?.none ? <Pressable accessibilityRole="switch" accessibilityLabel="No tax"
|
|
53
|
+
accessibilityState={{ checked: noTax }} aria-checked={noTax} onPress={() => setNoTax(!noTax)} className={`${button} ${noTax ? 'bg-primary' : 'bg-card'}`}>
|
|
54
|
+
<Text className={noTax ? 'text-primary-foreground' : 'text-foreground'}>No tax</Text></Pressable> : null}
|
|
55
|
+
{lineTax?.classes && taxClasses?.length ? <View role="group" accessibilityLabel="Tax class" className="flex-row flex-wrap gap-2">
|
|
56
|
+
{[{ id: '', label: 'No class' }, ...taxClasses].map((option) => <Pressable key={option.id} accessibilityRole="button"
|
|
57
|
+
accessibilityState={{ selected: taxClass === option.id }} aria-selected={taxClass === option.id} onPress={() => setTaxClass(option.id)}
|
|
58
|
+
className={`${button} ${taxClass === option.id ? 'bg-primary' : 'bg-card'}`}>
|
|
59
|
+
<Text className={taxClass === option.id ? 'text-primary-foreground' : 'text-foreground'}>{option.label}</Text>
|
|
60
|
+
</Pressable>)}
|
|
61
|
+
</View> : null}
|
|
62
|
+
</>}
|
|
63
|
+
{error ? <Text accessibilityRole="alert" className="text-destructive">{error}</Text> : null}
|
|
64
|
+
<View className="flex-row gap-2">
|
|
65
|
+
<Pressable accessibilityRole="button" onPress={apply} className={`${button} bg-primary`}>
|
|
66
|
+
<Text className="font-semibold text-primary-foreground">Apply</Text></Pressable>
|
|
67
|
+
<Pressable accessibilityRole="button" onPress={onClose} className={`${button} bg-card`}>
|
|
68
|
+
<Text className="text-foreground">Cancel</Text></Pressable>
|
|
69
|
+
</View>
|
|
70
|
+
</View>;
|
|
71
|
+
}
|
package/src/sale/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { Tender } from './tender';
|
|
|
4
4
|
export { SplitTender } from './split-tender';
|
|
5
5
|
export { DiscountForm, DiscountChips, parseDiscount, discountLabel } from './discount-form';
|
|
6
6
|
export { PriceForm, parsePrice } from './price-form';
|
|
7
|
+
export { ChargeForm, type ChargeFormProps } from './charge-form';
|
|
7
8
|
export { Catalogue, formatStockSyncTime } from './catalogue';
|
|
8
9
|
export { Receipt } from './receipt';
|
|
9
10
|
export { orderReference } from './order-reference';
|
package/src/sale/receipt.tsx
CHANGED
|
@@ -50,6 +50,8 @@ export function Receipt({ order, posOrder, store, cashier, registerId, newSale,
|
|
|
50
50
|
</View>)}
|
|
51
51
|
{/* Each discount appears once in the totals: line discounts are itemised under their lines and summed once here;
|
|
52
52
|
the order discount appears once. order.display's figures (ADR-063; medusapos ADR 0008). */}
|
|
53
|
+
{receipt.fees.map((charge, index) => row(charge.name, money(charge.amountMinor), false, index))}
|
|
54
|
+
{receipt.shipping.map((charge, index) => row(charge.name, money(charge.amountMinor), false, index))}
|
|
53
55
|
{row('Subtotal', money(totals.subtotalMinor))}
|
|
54
56
|
{lineDiscountMinor > 0 ? row('Line discounts', `−${money(lineDiscountMinor)}`) : null}
|
|
55
57
|
{receipt.orderDiscountMinor > 0 ? row('Order discount', `−${money(receipt.orderDiscountMinor)}`) : null}
|