@tallyui/components 1.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/LICENSE +21 -0
- package/dist/index.d.ts +1113 -83
- package/dist/index.js +4936 -353
- package/package.json +27 -5
- package/src/cart/cart-line-actions.tsx +40 -0
- package/src/cart/cart-line.tsx +18 -32
- package/src/cart/cart-note-input.tsx +46 -0
- package/src/cart/cart-panel.tsx +39 -26
- package/src/cart/cart-total.tsx +43 -47
- package/src/cart/discount-badge.tsx +19 -0
- package/src/cart/index.ts +4 -1
- package/src/checkout/cash-tendered.tsx +101 -0
- package/src/checkout/change-display.tsx +19 -0
- package/src/checkout/index.ts +3 -0
- package/src/checkout/order-summary-line.tsx +7 -8
- package/src/checkout/order-summary.tsx +8 -7
- package/src/checkout/payment-method-card.tsx +10 -9
- package/src/checkout/payment-selector.tsx +5 -5
- package/src/checkout/receipt-preview.tsx +78 -0
- package/src/customer/customer-card.tsx +16 -12
- package/src/customer/customer-form.tsx +72 -0
- package/src/customer/customer-order-history.tsx +34 -0
- package/src/customer/customer-picker.tsx +124 -0
- package/src/customer/customer-select.tsx +80 -31
- package/src/customer/index.ts +3 -0
- package/src/index.ts +114 -1
- package/src/input/filter-chip.tsx +85 -27
- package/src/input/index.ts +1 -0
- package/src/input/quantity-stepper.tsx +64 -0
- package/src/input/search-input.tsx +30 -7
- package/src/layout/checkout-layout.tsx +46 -0
- package/src/layout/index.ts +5 -0
- package/src/layout/live-tab-screen.tsx +54 -0
- package/src/layout/pos-layout.tsx +64 -0
- package/src/layout/settings-layout.tsx +58 -0
- package/src/layout/store-settings-choice-screen.tsx +142 -0
- package/src/order/index.ts +4 -0
- package/src/order/order-card.tsx +48 -0
- package/src/order/order-detail.tsx +82 -0
- package/src/order/order-list.tsx +39 -0
- package/src/order/order-status-badge.tsx +32 -0
- package/src/product/category-nav.tsx +70 -0
- package/src/product/index.ts +3 -0
- package/src/product/product-card.tsx +36 -8
- package/src/product/product-grid.tsx +46 -13
- package/src/product/product-image.tsx +28 -7
- package/src/product/product-list.tsx +44 -0
- package/src/product/product-price.tsx +77 -22
- package/src/product/product-sku.tsx +5 -5
- package/src/product/product-stock-badge.tsx +41 -28
- package/src/product/product-title.tsx +2 -3
- package/src/product/product-variant-picker.tsx +61 -0
- package/src/register/cash-count-input.tsx +85 -0
- package/src/register/closure-sheet.tsx +161 -0
- package/src/register/index.ts +12 -0
- package/src/register/movement-sheet.tsx +118 -0
- package/src/register/open-register-card.tsx +109 -0
- package/src/register/register-bar.helpers.ts +57 -0
- package/src/register/register-bar.tsx +85 -0
- package/src/register/register-column.tsx +117 -0
- package/src/register/register-count.tsx +330 -0
- package/src/register/register-open-close.tsx +52 -0
- package/src/register/register-panel.tsx +151 -0
- package/src/register/register-picker.tsx +60 -0
- package/src/register/register-summary.tsx +72 -0
- package/src/sale/cart-bar.tsx +18 -0
- package/src/sale/cart.tsx +66 -0
- package/src/sale/catalogue.tsx +139 -0
- package/src/sale/discount-form.tsx +85 -0
- package/src/sale/index.ts +9 -0
- package/src/sale/orders-list.tsx +108 -0
- package/src/sale/print-style.ts +11 -0
- package/src/sale/receipt.tsx +62 -0
- package/src/sale/sync-status.tsx +141 -0
- package/src/sale/tender.tsx +38 -0
- package/src/settings/connector-status.tsx +65 -0
- package/src/settings/index.ts +3 -0
- package/src/settings/settings-group.tsx +23 -0
- package/src/settings/settings-row.tsx +23 -0
- package/src/ui/accordion/index.tsx +59 -0
- package/src/ui/alert-dialog/index.tsx +91 -0
- package/src/ui/avatar/index.tsx +50 -0
- package/src/ui/badge/index.tsx +60 -0
- package/src/ui/button/index.tsx +90 -0
- package/src/ui/card/index.tsx +60 -0
- package/src/ui/checkbox/index.tsx +29 -0
- package/src/ui/collapsible/index.tsx +24 -0
- package/src/ui/combobox/index.tsx +98 -0
- package/src/ui/context-menu/index.tsx +159 -0
- package/src/ui/dialog/index.tsx +92 -0
- package/src/ui/dropdown-menu/index.tsx +161 -0
- package/src/ui/hover-card/index.tsx +31 -0
- package/src/ui/hstack/index.tsx +43 -0
- package/src/ui/icon/index.tsx +44 -0
- package/src/ui/icon/search-icon.tsx +9 -0
- package/src/ui/icon/svg-icon.tsx +36 -0
- package/src/ui/icon-button/index.tsx +58 -0
- package/src/ui/index.ts +99 -0
- package/src/ui/input/index.tsx +100 -0
- package/src/ui/label/index.tsx +26 -0
- package/src/ui/loader/index.tsx +37 -0
- package/src/ui/popover/index.tsx +31 -0
- package/src/ui/progress/index.tsx +28 -0
- package/src/ui/radio-group/index.tsx +38 -0
- package/src/ui/select/index.tsx +96 -0
- package/src/ui/separator/index.tsx +29 -0
- package/src/ui/slider/index.tsx +28 -0
- package/src/ui/switch/index.tsx +31 -0
- package/src/ui/table/index.tsx +59 -0
- package/src/ui/tabs/index.tsx +53 -0
- package/src/ui/text/index.tsx +36 -0
- package/src/ui/textarea/index.tsx +29 -0
- package/src/ui/toast/index.tsx +52 -0
- package/src/ui/toggle/index.tsx +69 -0
- package/src/ui/toggle-group/index.tsx +66 -0
- package/src/ui/tooltip/index.tsx +30 -0
- package/src/ui/vstack/index.tsx +43 -0
- package/src/uniwind-env.d.ts +9 -1
- package/src/__tests__/cart-line.test.tsx +0 -53
- package/src/__tests__/cart-panel.test.tsx +0 -55
- package/src/__tests__/cart-total.test.tsx +0 -60
- package/src/__tests__/customer-card.test.tsx +0 -47
- package/src/__tests__/customer-select.test.tsx +0 -33
- package/src/__tests__/filter-chip.test.tsx +0 -40
- package/src/__tests__/helpers.ts +0 -111
- package/src/__tests__/order-summary-line.test.tsx +0 -18
- package/src/__tests__/order-summary.test.tsx +0 -32
- package/src/__tests__/payment-method-card.test.tsx +0 -24
- package/src/__tests__/payment-selector.test.tsx +0 -23
- package/src/__tests__/product-card.test.tsx +0 -31
- package/src/__tests__/product-grid.test.tsx +0 -37
- package/src/__tests__/product-image.test.tsx +0 -54
- package/src/__tests__/product-price.test.tsx +0 -68
- package/src/__tests__/product-sku.test.tsx +0 -47
- package/src/__tests__/product-stock-badge.test.tsx +0 -58
- package/src/__tests__/product-title.test.tsx +0 -38
- package/src/__tests__/search-input.test.tsx +0 -22
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Pressable, Text } from 'react-native';
|
|
2
|
+
import { formatMoney } from '@tallyui/core';
|
|
3
|
+
import { buildReceiptData } from '@tallyui/pos';
|
|
4
|
+
import type { useSale } from '@tallyui/pos';
|
|
5
|
+
|
|
6
|
+
/** Phone mode's cart summary (medusapos ADR 0009): the whole bar is one button; the total is the cart's own Total row. */
|
|
7
|
+
export function CartBar({ sale, onOpen }: { sale: ReturnType<typeof useSale>; onOpen: () => void }) {
|
|
8
|
+
const { order } = sale;
|
|
9
|
+
const count = order.lineItems.reduce((sum, line) => sum + line.quantity, 0);
|
|
10
|
+
const total = formatMoney({ amount: buildReceiptData(order, { storeName: '' }).totals.totalMinor, currency: order.currency });
|
|
11
|
+
const items = `${count} ${count === 1 ? 'item' : 'items'}`;
|
|
12
|
+
return <Pressable accessibilityRole="button" accessibilityLabel={count ? `Open cart, ${items}, ${total}` : 'Cart is empty'}
|
|
13
|
+
disabled={!count} onPress={onOpen}
|
|
14
|
+
className={`min-h-14 mx-3 mb-3 flex-row items-center justify-between rounded-md bg-primary px-4 py-3 ${count ? '' : 'opacity-50'}`}>
|
|
15
|
+
<Text className="font-bold text-primary-foreground">{count ? `Cart · ${items}` : 'Cart is empty'}</Text>
|
|
16
|
+
{count ? <Text className="font-bold text-primary-foreground">{total} ›</Text> : null}
|
|
17
|
+
</Pressable>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { buildReceiptData } from '@tallyui/pos';
|
|
4
|
+
import type { useSale } from '@tallyui/pos';
|
|
5
|
+
import { CartLine, CartLineActions, CartPanel, CartTotal } from '../cart';
|
|
6
|
+
import { DiscountChips, DiscountForm } from './discount-form';
|
|
7
|
+
|
|
8
|
+
/** `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 }) {
|
|
11
|
+
const { order } = sale;
|
|
12
|
+
const money = (amount: number) => ({ amount, currency: order.currency });
|
|
13
|
+
// order.display's figures (TallyUI ADR-063), with its tax split by rate; never app arithmetic (medusapos ADR 0008).
|
|
14
|
+
const { totals } = buildReceiptData(order, { storeName: '' });
|
|
15
|
+
const taxRows = totals.taxLines.map((line) => ({ label: taxLabel(line.ratePpm), amount: money(line.amountMinor) }));
|
|
16
|
+
// Each line before its discounts, with its own discounts' amounts. The order discounts are one figure: a single
|
|
17
|
+
// order discount's chip carries it; several keep their own labels, as no single one has a display amount.
|
|
18
|
+
const display = (lineId: string) => order.display.lines.find((line) => line.lineId === lineId)!;
|
|
19
|
+
const orderAmounts = order.discounts.length === 1
|
|
20
|
+
? [{ 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
|
|
24
|
+
? <DiscountForm key={lineId ?? 'order'} title={title} currency={order.currency} onClose={() => setForm(null)}
|
|
25
|
+
onApply={(discount) => sale.applyDiscount(lineId, discount)} /> : null;
|
|
26
|
+
const pay = `min-h-12 flex-1 justify-center rounded-md bg-primary px-4 py-3 ${!order.lineItems.length ? 'opacity-50' : ''}`;
|
|
27
|
+
const renderItem = (line: typeof order.lineItems[number]) => <>
|
|
28
|
+
<CartLineActions actions={[{ id: 'discount', label: 'Discount', color: 'text-primary', onPress: () => setForm({ lineId: line.id }) }]}>
|
|
29
|
+
<CartLine name={line.name} quantity={line.quantity} unitPrice={money(line.unitPriceMinor)}
|
|
30
|
+
lineTotal={money(display(line.id).amountMinor)} />
|
|
31
|
+
</CartLineActions>
|
|
32
|
+
<DiscountChips discounts={line.discounts} amounts={display(line.id).discounts} currency={order.currency} onRemove={sale.removeDiscount} />
|
|
33
|
+
<View className="flex-row gap-4 px-3 py-2">
|
|
34
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Decrease ${line.name}`}
|
|
35
|
+
onPress={() => sale.setQuantity(line.id, line.quantity - 1)} 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">−</Text></Pressable>
|
|
36
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Increase ${line.name}`}
|
|
37
|
+
onPress={() => sale.setQuantity(line.id, line.quantity + 1)} 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">+</Text></Pressable>
|
|
38
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Remove ${line.name}`}
|
|
39
|
+
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
|
+
</View>
|
|
41
|
+
{discountForm(line.id, `Discount on ${line.name}`)}
|
|
42
|
+
</>;
|
|
43
|
+
return <CartPanel dataSet={{ print: 'hide' }} items={order.lineItems} renderItem={renderItem}
|
|
44
|
+
emptyState={<Text className="text-muted-foreground">Scan or tap a product to start a sale.</Text>}
|
|
45
|
+
afterItems={order.lineItems.length ? <View className="gap-3 px-3 py-2">
|
|
46
|
+
<DiscountChips discounts={order.discounts} amounts={orderAmounts} currency={order.currency} onRemove={sale.removeDiscount}
|
|
47
|
+
prefix="Order discount" />
|
|
48
|
+
{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">
|
|
50
|
+
<Text className="text-foreground">Order discount</Text></Pressable>}
|
|
51
|
+
</View> : undefined}
|
|
52
|
+
footer={<View testID="cart-footer" className="gap-3 pb-4">
|
|
53
|
+
{/* Exclusive: subtotal − discount + tax = total. Inclusive: subtotal − discount = total, the tax "incl.", not added. */}
|
|
54
|
+
<CartTotal subtotal={money(totals.subtotalMinor)} discount={money(totals.discountMinor)} total={money(totals.totalMinor)}
|
|
55
|
+
taxLines={taxRows} taxInclusive={totals.taxInclusive} />
|
|
56
|
+
{sale.error ? <Text accessibilityRole="alert" className="text-destructive">{sale.error}</Text> : null}
|
|
57
|
+
<View className="flex-row gap-3">
|
|
58
|
+
<Pressable accessibilityRole="button" disabled={!order.lineItems.length} onPress={() => sale.startTender('cash')} className={pay}>
|
|
59
|
+
<Text className="text-center font-semibold text-primary-foreground">Cash</Text>
|
|
60
|
+
</Pressable>
|
|
61
|
+
<Pressable accessibilityRole="button" disabled={!order.lineItems.length} onPress={() => sale.startTender('external')} className={pay}>
|
|
62
|
+
<Text className="text-center font-semibold text-primary-foreground">Card terminal</Text>
|
|
63
|
+
</Pressable>
|
|
64
|
+
</View>
|
|
65
|
+
</View>} />;
|
|
66
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { useStockOverlaid, useStockOverlayAsOf, type ProductTraits } from '@tallyui/core';
|
|
4
|
+
import { catalogueEntries, findEntryByCode, searchProducts, variantPriceLabel, type CatalogueEntry } from '@tallyui/pos';
|
|
5
|
+
import { ProductGrid, ProductImage, ProductPrice, ProductStockBadge, ProductTitle } from '../product';
|
|
6
|
+
import { SearchInput } from '../input';
|
|
7
|
+
import { VStack } from '../ui';
|
|
8
|
+
|
|
9
|
+
// Keep product names readable and touch targets at least 160 px wide where two columns fit.
|
|
10
|
+
const MIN_TILE_WIDTH = 160;
|
|
11
|
+
const STOCK_LABEL = {
|
|
12
|
+
in_stock: 'In Stock', out_of_stock: 'Out of Stock', backorder: 'On Backorder', unknown: 'Unknown',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// locale/hour12/now are injected (rather than read from the device or the clock inside this
|
|
16
|
+
// function) so the formatting is testable without the device; callers default them to the
|
|
17
|
+
// device's own settings and the current moment.
|
|
18
|
+
export function formatStockSyncTime(time: Date, locale?: string, hour12?: boolean, now: Date = new Date()): string {
|
|
19
|
+
const timeLabel = time.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12 });
|
|
20
|
+
// "Today" is decided in the device's own timezone: toDateString() reads the Date in local time,
|
|
21
|
+
// the same timezone the device clock and toLocaleTimeString above already use.
|
|
22
|
+
if (time.toDateString() === now.toDateString()) return timeLabel;
|
|
23
|
+
const dateLabel = time.toLocaleDateString(locale, { day: 'numeric', month: 'short' });
|
|
24
|
+
return `${dateLabel}, ${timeLabel}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The later of a session's own catalogue sync and a persisted stock check from a previous session
|
|
28
|
+
// (or restart), so an old persisted time never shadows a fresher sync.
|
|
29
|
+
function laterOf(a: Date | null | undefined, b: Date | null | undefined): Date | null {
|
|
30
|
+
if (!a) return b ?? null;
|
|
31
|
+
if (!b) return a;
|
|
32
|
+
return a.getTime() >= b.getTime() ? a : b;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, lastStockCheckAt, hour12, minCodeLength }: {
|
|
36
|
+
products: Doc[];
|
|
37
|
+
traits: ProductTraits<Doc>;
|
|
38
|
+
currency: string;
|
|
39
|
+
/** Receives the chosen product and variant with the nearest ConnectorProvider's stock overlay applied (a copy). */
|
|
40
|
+
onSelect: (entry: CatalogueEntry<Doc>) => void;
|
|
41
|
+
statusText?: string;
|
|
42
|
+
/** Rendered at the end of the status line, e.g. a register control at phone width; the row renders even without `statusText`. */
|
|
43
|
+
statusAccessory?: ReactNode;
|
|
44
|
+
lastSyncedAt: Date | null;
|
|
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. */
|
|
47
|
+
lastStockCheckAt?: Date | null;
|
|
48
|
+
/** Whether to show a 12- or 24-hour clock in the stock-as-of time; undefined keeps the locale default. A platform
|
|
49
|
+
* reads this off its own device APIs (e.g. expo-localization's getCalendars()) and passes it in. */
|
|
50
|
+
hour12?: boolean;
|
|
51
|
+
/** A till's scanner setting: below this length (after trimming), Enter leaves the typed text as a plain
|
|
52
|
+
* search instead of doing a code lookup. A scanner's timing threshold is the app's own concern,
|
|
53
|
+
* in its unfocused wedge listener — not this prop's job. */
|
|
54
|
+
minCodeLength?: number;
|
|
55
|
+
}) {
|
|
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);
|
|
63
|
+
const [query, setQuery] = useState('');
|
|
64
|
+
// The chooser is live (medusapos ADR 0007): it holds only the chosen product's id and derives its choices
|
|
65
|
+
// from the current entries below, so an open chooser shows each reconcile pass as it lands.
|
|
66
|
+
const [chooserId, setChooserId] = useState<string | null>(null);
|
|
67
|
+
const [width, setWidth] = useState(0);
|
|
68
|
+
// ProductGrid has 4 px padding on each side of the content and each cell.
|
|
69
|
+
const columns = Math.max(2, Math.min(6, Math.floor((width - 8) / (MIN_TILE_WIDTH + 8))));
|
|
70
|
+
const entries = useMemo(() => catalogueEntries(shown, traits), [shown, traits]);
|
|
71
|
+
const results = useMemo(() => searchProducts(shown, query, traits), [shown, query, traits]);
|
|
72
|
+
const choices = useMemo(() => (chooserId === null ? []
|
|
73
|
+
: entries.filter((entry) => traits.getId(entry.product) === chooserId)), [entries, chooserId, traits]);
|
|
74
|
+
// A product that leaves the catalogue (a resync, or no longer sellable) closes its chooser.
|
|
75
|
+
useEffect(() => { if (chooserId !== null && choices.length === 0) setChooserId(null); }, [chooserId, choices]);
|
|
76
|
+
|
|
77
|
+
function select(entry: CatalogueEntry<Doc>) {
|
|
78
|
+
onSelect(entry);
|
|
79
|
+
setChooserId(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<View className="flex-1" onLayout={(event) => setWidth(event.nativeEvent.layout.width)}>
|
|
84
|
+
<View className="gap-2 border-b border-border bg-card px-4 pb-3 pt-3">
|
|
85
|
+
<SearchInput value={query} onChangeText={setQuery} placeholder="Search or scan barcode / SKU" autoFocus
|
|
86
|
+
onSubmitEditing={() => {
|
|
87
|
+
if (minCodeLength && query.trim().length < minCodeLength) return;
|
|
88
|
+
const entry = findEntryByCode(entries, query);
|
|
89
|
+
if (entry) { select(entry); setQuery(''); }
|
|
90
|
+
}} />
|
|
91
|
+
{(statusText || statusAccessory) ? (
|
|
92
|
+
<View testID="catalogue-status-row" className="flex-row items-center gap-2">
|
|
93
|
+
{statusText ? <Text className="flex-1 text-xs text-muted-foreground" numberOfLines={statusAccessory ? 1 : undefined}>
|
|
94
|
+
{statusText}{query.trim() ? ` · ${results.length.toLocaleString()} matching` : ''}
|
|
95
|
+
</Text> : null}
|
|
96
|
+
{statusAccessory}
|
|
97
|
+
</View>
|
|
98
|
+
) : null}
|
|
99
|
+
{choices.length > 0 ? (
|
|
100
|
+
<View accessibilityLabel="Choose variant" className="gap-2">
|
|
101
|
+
{choices.map((entry) => (
|
|
102
|
+
<Pressable key={entry.variant.id} accessibilityRole="button" onPress={() => select(entry)}
|
|
103
|
+
className="gap-1 rounded-md border border-border p-3">
|
|
104
|
+
<Text className="font-semibold text-foreground">{entry.variant.title}</Text>
|
|
105
|
+
<Text className="text-muted-foreground">{entry.variant.sku}</Text>
|
|
106
|
+
<Text className="text-foreground">{variantPriceLabel(entry.variant, currency)}</Text>
|
|
107
|
+
<Text className="text-muted-foreground">{STOCK_LABEL[entry.variant.stock.status]} · {stockAsOf ? `as of ${formatStockSyncTime(stockAsOf, undefined, hour12)}` : 'not yet synced'}</Text>
|
|
108
|
+
</Pressable>
|
|
109
|
+
))}
|
|
110
|
+
<Pressable accessibilityRole="button" onPress={() => setChooserId(null)} className="rounded-md border border-border bg-card px-4 py-3">
|
|
111
|
+
<Text className="text-center text-foreground">Cancel</Text>
|
|
112
|
+
</Pressable>
|
|
113
|
+
</View>
|
|
114
|
+
) : null}
|
|
115
|
+
</View>
|
|
116
|
+
<ProductGrid items={results} numColumns={columns}
|
|
117
|
+
renderItem={(product: Doc) => (
|
|
118
|
+
// TallyUI's ProductCard has no children slot, so the tile is composed directly here
|
|
119
|
+
// (same structure/classes as ProductCard) to add the stock badge as a fourth child,
|
|
120
|
+
// inside the card and centred with the rest, rather than adding a slot to ProductCard (out of scope for TV6b).
|
|
121
|
+
<Pressable accessibilityRole="button" testID={`product-tile-${traits.getName(product)}`} onPress={() => {
|
|
122
|
+
const variants = entries.filter((entry) => entry.product === product);
|
|
123
|
+
if (variants.length === 1) select(variants[0]);
|
|
124
|
+
else setChooserId(traits.getId(product));
|
|
125
|
+
}}>
|
|
126
|
+
<VStack space="sm" className="items-center rounded-lg border border-border bg-card p-3">
|
|
127
|
+
<ProductImage doc={product} size={80} className="rounded-md" />
|
|
128
|
+
<ProductTitle doc={product} className="text-sm" numberOfLines={2} />
|
|
129
|
+
<ProductPrice doc={product} />
|
|
130
|
+
<ProductStockBadge doc={product} showAsOf={false} className="self-center" />
|
|
131
|
+
</VStack>
|
|
132
|
+
</Pressable>
|
|
133
|
+
)}
|
|
134
|
+
emptyState={<Text className="mt-10 text-center text-sm text-muted-foreground">
|
|
135
|
+
{query.trim() ? `No products match "${query.trim()}".` : 'No products yet.'}
|
|
136
|
+
</Text>} />
|
|
137
|
+
</View>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { formatMoney, minorUnitDigits, moneyFromDecimalString } from '@tallyui/core';
|
|
4
|
+
import type { AppliedDiscount, Discount } from '@tallyui/pos';
|
|
5
|
+
import { DiscountBadge } from '../cart';
|
|
6
|
+
|
|
7
|
+
/** The cashier's entry as a TallyUI Discount (a percent, or integer minor units), or the inline error to show. */
|
|
8
|
+
export function parseDiscount(type: Discount['type'], text: string, currency: string): Discount | string {
|
|
9
|
+
// A comma-locale keypad (inputMode="decimal") types "1,50": a single comma is the decimal separator.
|
|
10
|
+
const raw = text.trim();
|
|
11
|
+
const trimmed = raw.split(',').length === 2 ? raw.replace(',', '.') : raw;
|
|
12
|
+
if (!trimmed) return 'Enter a discount.';
|
|
13
|
+
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return 'Enter a number.';
|
|
14
|
+
const value = Number(trimmed);
|
|
15
|
+
if (value <= 0) return 'Enter a discount above 0.';
|
|
16
|
+
// At most 2 decimals in both modes: "1,000" or "1.000" may be a thousands separator, so it is refused, never read as 1.
|
|
17
|
+
const digits = type === 'percentage' ? 2 : Math.min(2, minorUnitDigits(currency));
|
|
18
|
+
if ((trimmed.split('.')[1]?.length ?? 0) > digits) return digits ? `Use at most ${digits} decimal places.` : 'Use a whole amount.';
|
|
19
|
+
if (type === 'percentage') return value > 100 ? 'A percentage can be at most 100.' : { type, value };
|
|
20
|
+
const money = moneyFromDecimalString(trimmed, currency);
|
|
21
|
+
return money ? { type, value: money.amount } : 'Enter a number.';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "10%", or a fixed discount's amount as it actually comes off (amountMinor), never the amount requested. */
|
|
25
|
+
export const discountLabel = (discount: AppliedDiscount, currency: string) => discount.type === 'percentage'
|
|
26
|
+
? `${discount.value}%` : formatMoney({ amount: discount.amountMinor, currency }) ?? String(discount.amountMinor);
|
|
27
|
+
|
|
28
|
+
const button = 'rounded-md border border-border px-3 py-2 min-h-11 items-center justify-center';
|
|
29
|
+
|
|
30
|
+
/** `onApply` returns a refusal to show inline (the capability gate), or null once applied. */
|
|
31
|
+
export function DiscountForm({ title, currency, onApply, onClose }: {
|
|
32
|
+
title: string; currency: string; onApply: (discount: Discount) => string | null; onClose: () => void;
|
|
33
|
+
}) {
|
|
34
|
+
const [type, setType] = useState<Discount['type']>('percentage');
|
|
35
|
+
const [text, setText] = useState('');
|
|
36
|
+
const [error, setError] = useState<string | null>(null);
|
|
37
|
+
function apply() {
|
|
38
|
+
const parsed = parseDiscount(type, text, currency);
|
|
39
|
+
const refused = typeof parsed === 'string' ? parsed : onApply(parsed);
|
|
40
|
+
setError(refused);
|
|
41
|
+
if (refused === null) onClose();
|
|
42
|
+
}
|
|
43
|
+
return <View role="group" accessibilityLabel={title} className="gap-2 px-3 py-2">
|
|
44
|
+
<Text className="font-semibold text-foreground">{title}</Text>
|
|
45
|
+
<View className="flex-row gap-2">
|
|
46
|
+
{(['percentage', 'fixed'] as const).map((option) => <Pressable key={option} accessibilityRole="button"
|
|
47
|
+
accessibilityState={{ selected: type === option }} onPress={() => setType(option)}
|
|
48
|
+
className={`${button} ${type === option ? 'bg-primary' : 'bg-card'}`}>
|
|
49
|
+
<Text className={type === option ? 'text-primary-foreground' : 'text-foreground'}>{option === 'percentage' ? 'Percent' : 'Amount'}</Text>
|
|
50
|
+
</Pressable>)}
|
|
51
|
+
<TextInput accessibilityLabel="Discount value" value={text} onChangeText={setText} onSubmitEditing={apply}
|
|
52
|
+
inputMode="decimal" placeholder={type === 'percentage' ? '%' : currency}
|
|
53
|
+
className="min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-foreground" />
|
|
54
|
+
</View>
|
|
55
|
+
{error ? <Text accessibilityRole="alert" className="text-destructive">{error}</Text> : null}
|
|
56
|
+
<View className="flex-row gap-2">
|
|
57
|
+
<Pressable accessibilityRole="button" onPress={apply} className={`${button} bg-primary`}>
|
|
58
|
+
<Text className="font-semibold text-primary-foreground">Apply</Text></Pressable>
|
|
59
|
+
<Pressable accessibilityRole="button" onPress={onClose} className={`${button} bg-card`}>
|
|
60
|
+
<Text className="text-foreground">Cancel</Text></Pressable>
|
|
61
|
+
</View>
|
|
62
|
+
</View>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Applied discounts as badges; pressing one removes it through TallyUI's builder. */
|
|
66
|
+
/** `prefix` names a chip that would otherwise read like a fee, e.g. "Order discount −€0.50". */
|
|
67
|
+
/** `amounts` are order.display's figures by discountId (TallyUI ADR-063): "10% −€0.40", or "−€0.40" for a fixed one. */
|
|
68
|
+
export function DiscountChips({ discounts, currency, onRemove, prefix, amounts = [] }: {
|
|
69
|
+
discounts: AppliedDiscount[]; currency: string; onRemove: (id: string) => void; prefix?: string;
|
|
70
|
+
amounts?: { discountId: string; amountMinor: number }[];
|
|
71
|
+
}) {
|
|
72
|
+
if (!discounts.length) return null;
|
|
73
|
+
return <View className="flex-row flex-wrap gap-2 px-3">
|
|
74
|
+
{discounts.map((discount) => {
|
|
75
|
+
const amount = amounts.find((row) => row.discountId === discount.id)?.amountMinor;
|
|
76
|
+
const own = discountLabel(discount, currency);
|
|
77
|
+
const label = [prefix, amount === undefined ? (prefix ? `−${own}` : own) : discount.type === 'percentage' ? own : null,
|
|
78
|
+
amount === undefined ? null : `−${formatMoney({ amount, currency })}`].filter(Boolean).join(' ');
|
|
79
|
+
return <Pressable key={discount.id} accessibilityRole="button" accessibilityLabel={`Remove discount ${label}`}
|
|
80
|
+
onPress={() => onRemove(discount.id)} className="min-h-11 flex-row items-center gap-1">
|
|
81
|
+
<DiscountBadge label={label} className="self-center" /><Text className="text-muted-foreground">✕</Text>
|
|
82
|
+
</Pressable>;
|
|
83
|
+
})}
|
|
84
|
+
</View>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Cart } from './cart';
|
|
2
|
+
export { CartBar } from './cart-bar';
|
|
3
|
+
export { Tender } from './tender';
|
|
4
|
+
export { DiscountForm, DiscountChips, parseDiscount, discountLabel } from './discount-form';
|
|
5
|
+
export { Catalogue, formatStockSyncTime } from './catalogue';
|
|
6
|
+
export { Receipt } from './receipt';
|
|
7
|
+
export { injectPrintStyle } from './print-style';
|
|
8
|
+
export { SyncStatus } from './sync-status';
|
|
9
|
+
export { OrdersList } from './orders-list';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
|
+
import { formatMoney, knownWarnings } from '@tallyui/core';
|
|
4
|
+
import { needsAttention, type OutboxState, type PosOrder } from '@tallyui/pos';
|
|
5
|
+
|
|
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')!;
|
|
27
|
+
|
|
28
|
+
/** Keeps an unparseable value as is, like medusapos's date util, since `Intl` throws on an invalid date. */
|
|
29
|
+
function defaultFormatDate(iso: string) {
|
|
30
|
+
const date = new Date(iso);
|
|
31
|
+
return Number.isNaN(date.getTime()) ? iso : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The "Needs attention" (when not empty) and "Recent" orders, lifted from medusapos/app's orders screen (ADR-052).
|
|
35
|
+
* `onRetry` is the outbox's `requeue`; `formatDate` replaces the app's date util; `footer` is the app's own content
|
|
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 }: {
|
|
39
|
+
orders: PosOrder[]; onRetry(orderIds: string[]): Promise<number>; formatDate?: (iso: string) => string; footer?: ReactNode;
|
|
40
|
+
stuck?: OutboxState['stuck'];
|
|
41
|
+
}) {
|
|
42
|
+
const retrying = useRef(new Set<string>());
|
|
43
|
+
const [retryingIds, setRetryingIds] = useState(new Set<string>());
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
for (const id of retrying.current) {
|
|
46
|
+
if (!orders.some((order) => order.id === id && order.syncStatus === 'rejected')) retrying.current.delete(id);
|
|
47
|
+
}
|
|
48
|
+
setRetryingIds(new Set(retrying.current));
|
|
49
|
+
}, [orders]);
|
|
50
|
+
return <ScrollView className="flex-1 bg-background p-4">
|
|
51
|
+
{[{ title: 'Needs attention', orders: needsAttention(orders, { stuckCommandIds: stuck?.commandIds }) }, { title: 'Recent', orders }].filter((section) => section.title === 'Recent' || section.orders.length > 0).map((section) => (
|
|
52
|
+
<View key={section.title} className="mb-6 gap-3">
|
|
53
|
+
<Text accessibilityRole="header" className="text-xl font-semibold text-foreground">{section.title}</Text>
|
|
54
|
+
{section.orders.map((order) => {
|
|
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;
|
|
58
|
+
return (
|
|
59
|
+
<View key={order.id} className="gap-1 rounded-md border border-border bg-card p-3">
|
|
60
|
+
<Text className="text-foreground">{order.serverRefs?.displayId ? `Order #${order.serverRefs.displayId} · ` : ''}{count} {count === 1 ? 'item' : 'items'}</Text>
|
|
61
|
+
<Text className="text-muted-foreground">{formatDate(order.createdAt)} · {formatMoney({ amount: order.totalMinor, currency: order.currency })} · {STATUS_LABEL[order.syncStatus]}</Text>
|
|
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}
|
|
68
|
+
{/* A late sale (ADR-032) needs no Retry of its own; a rejected one still gets its Retry below. */}
|
|
69
|
+
{order.lateSessionId !== undefined ? <Text className="text-foreground">Taken after the register closed. It is not in that register's closure.</Text> : null}
|
|
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 () => {
|
|
78
|
+
if (retrying.current.has(order.id)) return;
|
|
79
|
+
retrying.current.add(order.id);
|
|
80
|
+
setRetryingIds(new Set(retrying.current));
|
|
81
|
+
if (await onRetry([order.id]) === 0) {
|
|
82
|
+
retrying.current.delete(order.id);
|
|
83
|
+
setRetryingIds(new Set(retrying.current));
|
|
84
|
+
}
|
|
85
|
+
}} className="rounded-md bg-primary px-4 py-2">
|
|
86
|
+
<Text className="text-center font-semibold text-primary-foreground">Retry</Text>
|
|
87
|
+
</Pressable> : null}
|
|
88
|
+
{knownWarnings(order.warnings).map((warning, index) => <View key={index} className="border-l-4 border-warning pl-2"><Text className="text-foreground">
|
|
89
|
+
{warning.code === 'insufficient_stock'
|
|
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 })}`
|
|
99
|
+
: `Store total ${formatMoney({ amount: warning.serverMinor, currency: order.currency })} vs POS ${formatMoney({ amount: warning.expectedMinor, currency: order.currency })}`}
|
|
100
|
+
</Text></View>)}
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</View>
|
|
105
|
+
))}
|
|
106
|
+
{footer}
|
|
107
|
+
</ScrollView>;
|
|
108
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
// react-native-web supports dataSet; uniwind-env.d.ts already augments ViewProps with it (TV6a).
|
|
4
|
+
|
|
5
|
+
export function injectPrintStyle() {
|
|
6
|
+
if (Platform.OS !== 'web' || typeof document === 'undefined' || document.getElementById('pos-print-style')) return;
|
|
7
|
+
const style = document.createElement('style');
|
|
8
|
+
style.id = 'pos-print-style';
|
|
9
|
+
style.textContent = '@media print { [data-print="hide"] { display: none !important } }';
|
|
10
|
+
document.head.appendChild(style);
|
|
11
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { formatMoney } from '@tallyui/core';
|
|
4
|
+
import { buildReceiptData, type SentOrder } from '@tallyui/pos';
|
|
5
|
+
import { injectPrintStyle } from './print-style';
|
|
6
|
+
import { discountLabel } from './discount-form';
|
|
7
|
+
|
|
8
|
+
const defaultFormatDate = (iso: string) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
|
9
|
+
|
|
10
|
+
/** `store`/`taxLabel`/`topInset`/`formatDate` replace the app's Medusa settings, hard-coded VAT (default matches
|
|
11
|
+
* TV6a's `Cart`), outbox strip and date util, so `Receipt` stays platform-neutral (ADR-052). */
|
|
12
|
+
export function Receipt({ order, store, cashier, registerId, newSale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`,
|
|
13
|
+
topInset = 0, formatDate = defaultFormatDate }: {
|
|
14
|
+
order: SentOrder; store: { name: string; address?: string }; cashier: string; registerId: string; newSale: () => void;
|
|
15
|
+
taxLabel?: (ratePpm: number) => string; topInset?: number; formatDate?: (iso: string) => string;
|
|
16
|
+
}) {
|
|
17
|
+
useEffect(injectPrintStyle, []);
|
|
18
|
+
const receipt = buildReceiptData(order, {
|
|
19
|
+
storeName: store.name, storeAddress: store.address, cashier, register: registerId,
|
|
20
|
+
});
|
|
21
|
+
const money = (amount: number) => formatMoney({ amount, currency: receipt.currency }) ?? '';
|
|
22
|
+
const row = (label: string, amount: string, bold = false, key?: number) => <View key={key} accessibilityLabel={`${label}: ${amount}`} className="flex-row justify-between gap-4">
|
|
23
|
+
<Text className={`text-foreground ${bold ? 'font-semibold' : ''}`}>{label}</Text>
|
|
24
|
+
<Text className={`text-right text-foreground ${bold ? 'font-semibold' : ''}`}>{amount}</Text>
|
|
25
|
+
</View>;
|
|
26
|
+
const { totals } = receipt;
|
|
27
|
+
const taxRows = totals.taxLines.map((line, index) =>
|
|
28
|
+
row(`${totals.taxInclusive ? 'incl. ' : ''}${taxLabel(line.ratePpm)}`, money(line.amountMinor), false, index));
|
|
29
|
+
return <>
|
|
30
|
+
{topInset > 0 ? <View dataSet={{ print: 'hide' }} style={{ height: topInset, flexShrink: 0 }} /> : null}
|
|
31
|
+
<View className="w-full max-w-md self-center gap-3 p-4 bg-card">
|
|
32
|
+
<Text className="text-lg font-semibold text-foreground">{receipt.header.storeName}</Text>
|
|
33
|
+
{receipt.header.storeAddress ? <Text className="text-muted-foreground">{receipt.header.storeAddress}</Text> : null}
|
|
34
|
+
<Text className="text-foreground">Order {receipt.header.orderNumber.slice(-8)}</Text>
|
|
35
|
+
<Text className="text-muted-foreground">{formatDate(receipt.header.date)}</Text>
|
|
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>}
|
|
38
|
+
{receipt.lineItems.map((line, index) => <View key={index}>
|
|
39
|
+
<Text className="text-foreground">{line.name}</Text>
|
|
40
|
+
{/* Before any discount, then the line's own discounts as sub-rows (TallyUI ADR-063); they add up to the subtotal. */}
|
|
41
|
+
{row(`${line.quantity} × ${money(line.unitPriceMinor)}`, money(line.displayAmountMinor))}
|
|
42
|
+
{line.displayDiscounts.map((discount, i) => <View key={i} className="pl-4">{row(`${discountLabel(
|
|
43
|
+
order.lineItems[index].discounts[i], receipt.currency)} off`, `−${money(discount.amountMinor)}`)}</View>)}
|
|
44
|
+
</View>)}
|
|
45
|
+
{receipt.orderDiscountMinor > 0 ? row('Order discount', `−${money(receipt.orderDiscountMinor)}`) : null}
|
|
46
|
+
{/* order.display's figures (ADR-063; medusapos ADR 0008), in the cart's order. Exclusive: subtotal − discount + tax
|
|
47
|
+
= total; inclusive: subtotal − discount = total, and the tax is "incl.", not added. */}
|
|
48
|
+
{row('Subtotal', money(totals.subtotalMinor))}
|
|
49
|
+
{totals.discountMinor > 0 ? row('Discount', `−${money(totals.discountMinor)}`) : null}
|
|
50
|
+
{taxRows}
|
|
51
|
+
{row('Total', money(totals.totalMinor), true)}
|
|
52
|
+
{receipt.payments.map((payment, index) => row(payment.method === 'cash' ? 'Cash tendered' : 'Card terminal',
|
|
53
|
+
money(payment.amountMinor) + (payment.reference ? ` · ${payment.reference}` : ''), false, index))}
|
|
54
|
+
{row('Change', money(receipt.changeDueMinor))}
|
|
55
|
+
<View dataSet={{ print: 'hide' }} className="flex-row gap-4">
|
|
56
|
+
<Pressable accessibilityRole="button" onPress={() => { if (typeof window !== 'undefined' && typeof window.print === 'function') window.print(); }} className="rounded-md border border-border bg-card px-4 py-3">
|
|
57
|
+
<Text className="text-center text-foreground">Print receipt</Text>
|
|
58
|
+
</Pressable>
|
|
59
|
+
<Pressable accessibilityRole="button" onPress={newSale} className="rounded-md bg-primary px-4 py-3"><Text className="text-center font-semibold text-primary-foreground">New sale</Text></Pressable>
|
|
60
|
+
</View>
|
|
61
|
+
</View></>;
|
|
62
|
+
}
|