@lotics/ui 3.5.0 → 4.0.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/AGENTS.md +323 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/callout.tsx +50 -17
- package/src/combobox.tsx +22 -6
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { Button } from "@lotics/ui/button";
|
|
6
|
+
import { Card, CardBody, CardFooter, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
|
+
import { Divider } from "@lotics/ui/divider";
|
|
8
|
+
import { Badge } from "@lotics/ui/badge";
|
|
9
|
+
import { Icon } from "@lotics/ui/icon";
|
|
10
|
+
import { FormField } from "@lotics/ui/form_field";
|
|
11
|
+
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
12
|
+
import { Picker } from "@lotics/ui/picker";
|
|
13
|
+
import { Combobox } from "@lotics/ui/combobox";
|
|
14
|
+
import type { PickerOption } from "@lotics/ui/picker";
|
|
15
|
+
import { DatePicker } from "@lotics/ui/date_picker";
|
|
16
|
+
import { NumberInput } from "@lotics/ui/number_input";
|
|
17
|
+
import { DetailRow } from "@lotics/ui/detail_row";
|
|
18
|
+
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
19
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
20
|
+
import { SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
21
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
22
|
+
import { Screen } from "@lotics/ui/screen_router";
|
|
23
|
+
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
24
|
+
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
25
|
+
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
26
|
+
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
27
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// Template · Document entry — the real-world transactional form: a header in a
|
|
31
|
+
// responsive two-column grid, a RELATED record attached by FIND-OR-CREATE
|
|
32
|
+
// (`Combobox` allowCustom → existing match attaches, "Create…" opens a modal
|
|
33
|
+
// mini-form), and LINE ITEMS built with the create → preview → edit composition
|
|
34
|
+
// (add / duplicate / remove, live totals). The adhoc-button-heavy screen: every
|
|
35
|
+
// section earns its actions. Money via formatMoney; no new primitives.
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
interface Customer {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
code: string;
|
|
42
|
+
taxId: string;
|
|
43
|
+
contact: string;
|
|
44
|
+
city: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const KNOWN_CUSTOMERS: Customer[] = [
|
|
48
|
+
{ id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "031245678", contact: "Mara Lindqvist", city: "Gothenburg" },
|
|
49
|
+
{ id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam" },
|
|
50
|
+
{ id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore" },
|
|
51
|
+
{ id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "0309887611", contact: "Tom Becker", city: "Hamburg" },
|
|
52
|
+
{ id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp" },
|
|
53
|
+
{ id: "cus_06", name: "Meridian Retail Group", code: "KH-0309", taxId: "0301889234", contact: "Sara Cohen", city: "Tel Aviv" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const PRODUCTS: PickerOption[] = [
|
|
57
|
+
{ value: "p_box", label: "Corrugated box · B-flute" },
|
|
58
|
+
{ value: "p_pallet", label: "Export pallet · 1200×1000" },
|
|
59
|
+
{ value: "p_wrap", label: "Stretch wrap · 500mm" },
|
|
60
|
+
{ value: "p_strap", label: "PET strapping · 16mm" },
|
|
61
|
+
{ value: "p_label", label: "Thermal label roll · 100×150" },
|
|
62
|
+
{ value: "p_tape", label: "Reinforced tape · 48mm" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const UNIT_PRICE: Record<string, number> = {
|
|
66
|
+
p_box: 18500,
|
|
67
|
+
p_pallet: 245000,
|
|
68
|
+
p_wrap: 86000,
|
|
69
|
+
p_strap: 142000,
|
|
70
|
+
p_label: 39000,
|
|
71
|
+
p_tape: 21500,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const TERMS: PickerOption[] = [
|
|
75
|
+
{ value: "receipt", label: "Due on receipt" },
|
|
76
|
+
{ value: "15", label: "Net 15" },
|
|
77
|
+
{ value: "30", label: "Net 30" },
|
|
78
|
+
{ value: "45", label: "Net 45" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const WAREHOUSES: PickerOption[] = [
|
|
82
|
+
{ value: "north", label: "North DC" },
|
|
83
|
+
{ value: "central", label: "Central hub" },
|
|
84
|
+
{ value: "port", label: "Port cross-dock" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const PRIORITY: PickerOption[] = [
|
|
88
|
+
{ value: "standard", label: "Standard" },
|
|
89
|
+
{ value: "rush", label: "Rush" },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
interface Line {
|
|
93
|
+
id: string;
|
|
94
|
+
product: string;
|
|
95
|
+
qty: number;
|
|
96
|
+
price: number;
|
|
97
|
+
editing: boolean;
|
|
98
|
+
/** True until first Save — a freshly-added line, so Cancel discards it
|
|
99
|
+
* (there is no prior committed state to revert to). */
|
|
100
|
+
isNew: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const half = { flexGrow: 1, flexBasis: 240 } as const;
|
|
104
|
+
const full = { flexGrow: 1, flexBasis: "100%" } as const;
|
|
105
|
+
|
|
106
|
+
const productLabel = (value: string) => PRODUCTS.find((p) => p.value === value)?.label ?? "—";
|
|
107
|
+
|
|
108
|
+
export function TplOrder() {
|
|
109
|
+
const id = useRef(1);
|
|
110
|
+
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
111
|
+
|
|
112
|
+
// ── header
|
|
113
|
+
const [orderDate, setOrderDate] = useState("2026-06-15");
|
|
114
|
+
const [deliverBy, setDeliverBy] = useState("2026-06-22");
|
|
115
|
+
const [terms, setTerms] = useState("30");
|
|
116
|
+
const [warehouse, setWarehouse] = useState("central");
|
|
117
|
+
const [poNumber, setPoNumber] = useState("");
|
|
118
|
+
const [priority, setPriority] = useState("standard");
|
|
119
|
+
|
|
120
|
+
// ── find-or-create customer
|
|
121
|
+
const [created, setCreated] = useState<Customer[]>([]);
|
|
122
|
+
const [customer, setCustomer] = useState<Customer | null>(null);
|
|
123
|
+
// Swap mode: re-open the picker WITHOUT detaching the current customer, so
|
|
124
|
+
// Change is non-destructive — only an actual pick (or create) replaces it.
|
|
125
|
+
const [changing, setChanging] = useState(false);
|
|
126
|
+
const [creating, setCreating] = useState(false);
|
|
127
|
+
const [draft, setDraft] = useState({ name: "", taxId: "", contact: "", city: "" });
|
|
128
|
+
|
|
129
|
+
// ── line items
|
|
130
|
+
const [lines, setLines] = useState<Line[]>([
|
|
131
|
+
{ id: "ln_1", product: "p_box", qty: 240, price: UNIT_PRICE.p_box, editing: false, isNew: false },
|
|
132
|
+
{ id: "ln_2", product: "p_wrap", qty: 30, price: UNIT_PRICE.p_wrap, editing: false, isNew: false },
|
|
133
|
+
]);
|
|
134
|
+
// Pre-edit snapshots so Cancel can revert an existing line's values.
|
|
135
|
+
const [backup, setBackup] = useState<Record<string, Line>>({});
|
|
136
|
+
|
|
137
|
+
// ── attachments
|
|
138
|
+
const [files, setFiles] = useState<DisplayFile[]>([]);
|
|
139
|
+
const [activeFile, setActiveFile] = useState<number | null>(null);
|
|
140
|
+
|
|
141
|
+
const customerOptions: PickerOption<string, Customer>[] = [...KNOWN_CUSTOMERS, ...created].map((c) => ({
|
|
142
|
+
value: c.id,
|
|
143
|
+
label: c.name,
|
|
144
|
+
data: c,
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const onPickCustomer = (opt: PickerOption<string, Customer>) => {
|
|
148
|
+
const match = [...KNOWN_CUSTOMERS, ...created].find((c) => c.id === opt.value);
|
|
149
|
+
if (match) {
|
|
150
|
+
setCustomer(match);
|
|
151
|
+
setChanging(false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// The custom row: opt.value is the raw query the rep typed. Open the create
|
|
155
|
+
// form pre-filled with it — find-or-create's "create" branch.
|
|
156
|
+
setDraft({ name: opt.value, taxId: "", contact: "", city: "" });
|
|
157
|
+
setCreating(true);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const createCustomer = () => {
|
|
161
|
+
const c: Customer = {
|
|
162
|
+
id: nextId("cus"),
|
|
163
|
+
name: draft.name.trim() || "New customer",
|
|
164
|
+
code: `KH-${String(310 + created.length + 1).padStart(4, "0")}`,
|
|
165
|
+
taxId: draft.taxId.trim(),
|
|
166
|
+
contact: draft.contact.trim(),
|
|
167
|
+
city: draft.city.trim(),
|
|
168
|
+
};
|
|
169
|
+
setCreated((prev) => [...prev, c]);
|
|
170
|
+
setCustomer(c);
|
|
171
|
+
setCreating(false);
|
|
172
|
+
setChanging(false);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const patchLine = (lid: string, patch: Partial<Line>) =>
|
|
176
|
+
setLines((prev) => prev.map((l) => (l.id === lid ? { ...l, ...patch } : l)));
|
|
177
|
+
const removeLine = (lid: string) => setLines((prev) => prev.filter((l) => l.id !== lid));
|
|
178
|
+
const dropBackup = (lid: string) =>
|
|
179
|
+
setBackup((b) => {
|
|
180
|
+
const next = { ...b };
|
|
181
|
+
delete next[lid];
|
|
182
|
+
return next;
|
|
183
|
+
});
|
|
184
|
+
const addLine = () =>
|
|
185
|
+
setLines((prev) => [...prev, { id: nextId("ln"), product: "", qty: 1, price: 0, editing: true, isNew: true }]);
|
|
186
|
+
const beginEdit = (l: Line) => {
|
|
187
|
+
setBackup((b) => ({ ...b, [l.id]: l }));
|
|
188
|
+
patchLine(l.id, { editing: true });
|
|
189
|
+
};
|
|
190
|
+
const saveLine = (lid: string) => {
|
|
191
|
+
dropBackup(lid);
|
|
192
|
+
patchLine(lid, { editing: false, isNew: false });
|
|
193
|
+
};
|
|
194
|
+
const cancelLine = (l: Line) => {
|
|
195
|
+
if (l.isNew) {
|
|
196
|
+
removeLine(l.id);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const prior = backup[l.id];
|
|
200
|
+
if (prior) setLines((prev) => prev.map((x) => (x.id === l.id ? { ...prior, editing: false } : x)));
|
|
201
|
+
dropBackup(l.id);
|
|
202
|
+
};
|
|
203
|
+
const duplicateLine = (l: Line) =>
|
|
204
|
+
setLines((prev) => {
|
|
205
|
+
const at = prev.findIndex((x) => x.id === l.id);
|
|
206
|
+
const copy = { ...l, id: nextId("ln"), editing: false, isNew: false };
|
|
207
|
+
return [...prev.slice(0, at + 1), copy, ...prev.slice(at + 1)];
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const subtotal = lines.reduce((sum, l) => sum + l.qty * l.price, 0);
|
|
211
|
+
const tax = Math.round(subtotal * 0.1);
|
|
212
|
+
const total = subtotal + tax;
|
|
213
|
+
const ready = customer !== null && lines.length > 0 && lines.every((l) => l.product !== "" && !l.editing);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
217
|
+
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
|
218
|
+
{/* header band */}
|
|
219
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
220
|
+
<Text size="xl" weight="semibold">New order</Text>
|
|
221
|
+
<Badge label="Draft" color="zinc" />
|
|
222
|
+
<View style={{ flex: 1 }} />
|
|
223
|
+
<Text size="sm" color="muted" tabular>SO-2026-0418</Text>
|
|
224
|
+
</View>
|
|
225
|
+
|
|
226
|
+
{/* order details — the two-column responsive grid */}
|
|
227
|
+
<Card style={{ padding: 0 }}>
|
|
228
|
+
<CardHeader>
|
|
229
|
+
<CardHeaderTitle info="Defaults pull from the customer once attached; adjust per order. Short related fields pair as two columns, long values take the full row.">
|
|
230
|
+
Order details
|
|
231
|
+
</CardHeaderTitle>
|
|
232
|
+
</CardHeader>
|
|
233
|
+
<CardBody>
|
|
234
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
|
|
235
|
+
<FormField label="Order date" style={half}>
|
|
236
|
+
<DatePicker value={orderDate} onValueChange={setOrderDate} locale="en-US" />
|
|
237
|
+
</FormField>
|
|
238
|
+
<FormField label="Requested delivery" style={half}>
|
|
239
|
+
<DatePicker value={deliverBy} onValueChange={setDeliverBy} locale="en-US" />
|
|
240
|
+
</FormField>
|
|
241
|
+
<FormField label="Payment terms" style={half}>
|
|
242
|
+
<Picker options={TERMS} value={terms} onValueChange={setTerms} placeholder="Select terms" accessibilityLabel="Payment terms" />
|
|
243
|
+
</FormField>
|
|
244
|
+
<FormField label="Fulfil from" style={half}>
|
|
245
|
+
<Picker options={WAREHOUSES} value={warehouse} onValueChange={setWarehouse} placeholder="Select warehouse" accessibilityLabel="Fulfil from" />
|
|
246
|
+
</FormField>
|
|
247
|
+
<FormField label="PO number" optional optionalLabel="Optional" style={half}>
|
|
248
|
+
<TextInputField value={poNumber} onChangeText={setPoNumber} placeholder="Buyer's reference" />
|
|
249
|
+
</FormField>
|
|
250
|
+
<FormField label="Priority" style={half}>
|
|
251
|
+
<Picker options={PRIORITY} value={priority} onValueChange={setPriority} placeholder="Select priority" accessibilityLabel="Priority" />
|
|
252
|
+
</FormField>
|
|
253
|
+
</View>
|
|
254
|
+
</CardBody>
|
|
255
|
+
</Card>
|
|
256
|
+
|
|
257
|
+
{/* customer — find-or-create */}
|
|
258
|
+
<Card style={{ padding: 0 }}>
|
|
259
|
+
<CardHeader>
|
|
260
|
+
<CardHeaderTitle info="Search the customer book to attach an existing record, or create one inline without leaving the order.">
|
|
261
|
+
Customer
|
|
262
|
+
</CardHeaderTitle>
|
|
263
|
+
{customer ? <CardHeaderMeta>{customer.code}</CardHeaderMeta> : null}
|
|
264
|
+
</CardHeader>
|
|
265
|
+
<CardBody>
|
|
266
|
+
{customer && !changing ? (
|
|
267
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
|
|
268
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
269
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
270
|
+
<Icon name="building-2" size={16} color={colors.zinc[500]} />
|
|
271
|
+
<Text weight="medium">{customer.name}</Text>
|
|
272
|
+
{created.some((c) => c.id === customer.id) ? <Badge label="New" color="emerald" /> : null}
|
|
273
|
+
</View>
|
|
274
|
+
<DetailRow label="Tax ID" labelWidth={96}><Text size="sm" tabular>{customer.taxId || "—"}</Text></DetailRow>
|
|
275
|
+
<DetailRow label="Contact" labelWidth={96}><Text size="sm">{customer.contact || "—"}</Text></DetailRow>
|
|
276
|
+
<DetailRow label="City" labelWidth={96}><Text size="sm">{customer.city || "—"}</Text></DetailRow>
|
|
277
|
+
</View>
|
|
278
|
+
<Button title="Change" color="muted" icon="pencil" onPress={() => setChanging(true)} />
|
|
279
|
+
</View>
|
|
280
|
+
) : (
|
|
281
|
+
<View style={{ gap: 10 }}>
|
|
282
|
+
{customer ? (
|
|
283
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
284
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>
|
|
285
|
+
Replacing <Text size="xs" weight="medium" color="default">{customer.name}</Text> — pick another or create a new one
|
|
286
|
+
</Text>
|
|
287
|
+
<Button title="Cancel" color="muted" onPress={() => setChanging(false)} />
|
|
288
|
+
</View>
|
|
289
|
+
) : null}
|
|
290
|
+
<Combobox
|
|
291
|
+
icon="search"
|
|
292
|
+
options={customerOptions}
|
|
293
|
+
onValueChange={onPickCustomer}
|
|
294
|
+
getOptionDescription={(o) => (o.data ? `${o.data.code} · ${o.data.city}` : undefined)}
|
|
295
|
+
reflectSelection={false}
|
|
296
|
+
allowCustom
|
|
297
|
+
customOptionPlacement="top"
|
|
298
|
+
customOptionLabel={(q) => `Create new customer “${q}”`}
|
|
299
|
+
placeholder="Search customers by name…"
|
|
300
|
+
emptyText="No customer matches — type a name to create one"
|
|
301
|
+
accessibilityLabel="Attach customer"
|
|
302
|
+
autoFocus={changing}
|
|
303
|
+
/>
|
|
304
|
+
{!customer ? (
|
|
305
|
+
<Text size="xs" color="muted">
|
|
306
|
+
No match? Pick “Create new customer …” to add it without leaving this order.
|
|
307
|
+
</Text>
|
|
308
|
+
) : null}
|
|
309
|
+
</View>
|
|
310
|
+
)}
|
|
311
|
+
</CardBody>
|
|
312
|
+
</Card>
|
|
313
|
+
|
|
314
|
+
{/* line items — create → preview → edit, each line its own card; the
|
|
315
|
+
heading is grouped with its cards at a tighter gap so it hugs them. */}
|
|
316
|
+
<View style={{ gap: 12 }}>
|
|
317
|
+
<SectionHeading>
|
|
318
|
+
<SectionHeadingTitle>Line items</SectionHeadingTitle>
|
|
319
|
+
<Button title="Add line" color="primary" icon="plus" onPress={addLine} />
|
|
320
|
+
</SectionHeading>
|
|
321
|
+
|
|
322
|
+
{lines.length === 0 ? (
|
|
323
|
+
<Card>
|
|
324
|
+
<EmptyState icon="receipt" message="No line items yet" hint="Add the products and quantities for this order" action={<Button title="Add line" color="primary" icon="plus" onPress={addLine} />} />
|
|
325
|
+
</Card>
|
|
326
|
+
) : (
|
|
327
|
+
lines.map((l) => (
|
|
328
|
+
<Card key={l.id} style={{ padding: 0 }}>
|
|
329
|
+
{l.editing ? (
|
|
330
|
+
<>
|
|
331
|
+
<CardBody>
|
|
332
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
|
|
333
|
+
<FormField label="Product" style={full}>
|
|
334
|
+
<Picker
|
|
335
|
+
options={PRODUCTS}
|
|
336
|
+
value={l.product}
|
|
337
|
+
onValueChange={(v) => patchLine(l.id, { product: v, price: l.price || UNIT_PRICE[v] || 0 })}
|
|
338
|
+
placeholder="Select a product"
|
|
339
|
+
accessibilityLabel="Product"
|
|
340
|
+
/>
|
|
341
|
+
</FormField>
|
|
342
|
+
<FormField label="Quantity" style={half}>
|
|
343
|
+
<NumberInput value={l.qty} onValueChange={(v) => patchLine(l.id, { qty: v ?? 0 })} min={0} accessibilityLabel="Quantity" />
|
|
344
|
+
</FormField>
|
|
345
|
+
<FormField label="Unit price" style={half}>
|
|
346
|
+
<NumberInput value={l.price} onValueChange={(v) => patchLine(l.id, { price: v ?? 0 })} min={0} accessibilityLabel="Unit price" />
|
|
347
|
+
</FormField>
|
|
348
|
+
</View>
|
|
349
|
+
<Text size="sm" color="muted">Line total <Text weight="medium" color="default">{formatMoney(l.qty * l.price)}</Text></Text>
|
|
350
|
+
</CardBody>
|
|
351
|
+
<CardFooter>
|
|
352
|
+
{l.isNew ? null : (
|
|
353
|
+
<Button title="Remove" color="danger-secondary" icon="trash" onPress={() => removeLine(l.id)} />
|
|
354
|
+
)}
|
|
355
|
+
<View style={{ flex: 1 }} />
|
|
356
|
+
<Button title="Cancel" color="secondary" onPress={() => cancelLine(l)} />
|
|
357
|
+
<Button title="Save line" color="primary" disabled={l.product === ""} onPress={() => saveLine(l.id)} />
|
|
358
|
+
</CardFooter>
|
|
359
|
+
</>
|
|
360
|
+
) : (
|
|
361
|
+
<>
|
|
362
|
+
<CardBody>
|
|
363
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
364
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
365
|
+
<Text weight="medium">{productLabel(l.product)}</Text>
|
|
366
|
+
<Text size="sm" color="muted" tabular>{l.qty} × {formatMoney(l.price)}</Text>
|
|
367
|
+
</View>
|
|
368
|
+
<Text weight="medium" tabular>{formatMoney(l.qty * l.price)}</Text>
|
|
369
|
+
</View>
|
|
370
|
+
</CardBody>
|
|
371
|
+
<CardFooter>
|
|
372
|
+
<Button title="Duplicate" color="muted" icon="copy" onPress={() => duplicateLine(l)} />
|
|
373
|
+
<Button title="Remove" color="danger-secondary" icon="trash" onPress={() => removeLine(l.id)} />
|
|
374
|
+
<View style={{ flex: 1 }} />
|
|
375
|
+
<Button title="Edit" color="secondary" icon="pencil" onPress={() => beginEdit(l)} />
|
|
376
|
+
</CardFooter>
|
|
377
|
+
</>
|
|
378
|
+
)}
|
|
379
|
+
</Card>
|
|
380
|
+
))
|
|
381
|
+
)}
|
|
382
|
+
</View>
|
|
383
|
+
|
|
384
|
+
{/* summary — totals + attachment + the Action-Layout commit bar */}
|
|
385
|
+
<Card style={{ padding: 0 }}>
|
|
386
|
+
<CardHeader>
|
|
387
|
+
<CardHeaderTitle>Summary</CardHeaderTitle>
|
|
388
|
+
<CardHeaderMeta>{lines.length} {lines.length === 1 ? "line" : "lines"}</CardHeaderMeta>
|
|
389
|
+
</CardHeader>
|
|
390
|
+
<CardBody>
|
|
391
|
+
<DetailRow label="Subtotal" labelWidth={120}><Text tabular>{formatMoney(subtotal)}</Text></DetailRow>
|
|
392
|
+
<DetailRow label="VAT (10%)" labelWidth={120}><Text tabular color="muted">{formatMoney(tax)}</Text></DetailRow>
|
|
393
|
+
<Divider />
|
|
394
|
+
<DetailRow label="Total" labelWidth={120}><Text weight="semibold" size="lg" tabular>{formatMoney(total)}</Text></DetailRow>
|
|
395
|
+
</CardBody>
|
|
396
|
+
<Divider />
|
|
397
|
+
<CardBody style={{ gap: 12 }}>
|
|
398
|
+
<Text size="xs" color="muted" transform="uppercase">Attachments</Text>
|
|
399
|
+
{files.length > 0 ? (
|
|
400
|
+
<FileThumbnailGrid
|
|
401
|
+
files={files}
|
|
402
|
+
itemSize={84}
|
|
403
|
+
onFilePress={(f) => setActiveFile(files.findIndex((x) => x.id === f.id))}
|
|
404
|
+
onRemove={(id) => setFiles((prev) => prev.filter((x) => x.id !== id))}
|
|
405
|
+
/>
|
|
406
|
+
) : null}
|
|
407
|
+
<FileDropzone
|
|
408
|
+
height={100}
|
|
409
|
+
label="Drop the signed quote or PO"
|
|
410
|
+
hint="or click to browse · PDF, images"
|
|
411
|
+
dropLabel="Release to attach"
|
|
412
|
+
accept="application/pdf,image/*"
|
|
413
|
+
accessibilityLabel="Attach documents"
|
|
414
|
+
onFiles={(dropped) =>
|
|
415
|
+
setFiles((prev) => [
|
|
416
|
+
...prev,
|
|
417
|
+
...dropped.map((f, i) => ({
|
|
418
|
+
id: `${f.name}-${prev.length + i}`,
|
|
419
|
+
filename: f.name,
|
|
420
|
+
mimeType: f.type || "application/octet-stream",
|
|
421
|
+
url: URL.createObjectURL(f),
|
|
422
|
+
})),
|
|
423
|
+
])
|
|
424
|
+
}
|
|
425
|
+
/>
|
|
426
|
+
</CardBody>
|
|
427
|
+
{!ready ? (
|
|
428
|
+
<CardBody>
|
|
429
|
+
<Callout tone="info">
|
|
430
|
+
<CalloutText>
|
|
431
|
+
{customer === null
|
|
432
|
+
? "Attach a customer and add at least one line to create the order."
|
|
433
|
+
: lines.some((l) => l.editing)
|
|
434
|
+
? "Save every line item before creating the order."
|
|
435
|
+
: "Add at least one line item to create the order."}
|
|
436
|
+
</CalloutText>
|
|
437
|
+
</Callout>
|
|
438
|
+
</CardBody>
|
|
439
|
+
) : null}
|
|
440
|
+
<CardFooter>
|
|
441
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>
|
|
442
|
+
{customer ? `${customer.name} · ${lines.length} ${lines.length === 1 ? "line" : "lines"} · ${formatMoney(total)}` : "No customer attached yet"}
|
|
443
|
+
</Text>
|
|
444
|
+
<Button title="Cancel" color="muted" onPress={() => {}} />
|
|
445
|
+
<Button title="Create order" color="primary" disabled={!ready} onPress={() => {}} />
|
|
446
|
+
</CardFooter>
|
|
447
|
+
</Card>
|
|
448
|
+
</View>
|
|
449
|
+
|
|
450
|
+
{/* find-or-create: the "create" branch — a focused modal mini-form */}
|
|
451
|
+
<Dialog width={520} open={creating} onOpenChange={setCreating}>
|
|
452
|
+
<Screen route="">
|
|
453
|
+
<DialogHeader>
|
|
454
|
+
<DialogHeaderTitle>New customer</DialogHeaderTitle>
|
|
455
|
+
</DialogHeader>
|
|
456
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 8, gap: 4 }}>
|
|
457
|
+
<Callout tone="info"><CalloutText>Creating here attaches the customer to this order and adds it to the customer book.</CalloutText></Callout>
|
|
458
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16, paddingTop: 12 }}>
|
|
459
|
+
<FormField label="Customer name" style={full}>
|
|
460
|
+
<TextInputField value={draft.name} onChangeText={(v) => setDraft((d) => ({ ...d, name: v }))} placeholder="Legal name" autoFocus />
|
|
461
|
+
</FormField>
|
|
462
|
+
<FormField label="Tax ID" style={half}>
|
|
463
|
+
<TextInputField value={draft.taxId} onChangeText={(v) => setDraft((d) => ({ ...d, taxId: v }))} placeholder="10 or 13 digits" inputMode="numeric" />
|
|
464
|
+
</FormField>
|
|
465
|
+
<FormField label="City" style={half}>
|
|
466
|
+
<TextInputField value={draft.city} onChangeText={(v) => setDraft((d) => ({ ...d, city: v }))} placeholder="City" />
|
|
467
|
+
</FormField>
|
|
468
|
+
<FormField label="Contact person" optional optionalLabel="Optional" style={full}>
|
|
469
|
+
<TextInputField value={draft.contact} onChangeText={(v) => setDraft((d) => ({ ...d, contact: v }))} placeholder="Who receives quotes" />
|
|
470
|
+
</FormField>
|
|
471
|
+
</View>
|
|
472
|
+
</View>
|
|
473
|
+
<DialogFooter>
|
|
474
|
+
<Button title="Cancel" color="secondary" onPress={() => setCreating(false)} />
|
|
475
|
+
<Button title="Create and attach" color="primary" disabled={draft.name.trim() === ""} onPress={createCustomer} />
|
|
476
|
+
</DialogFooter>
|
|
477
|
+
</Screen>
|
|
478
|
+
</Dialog>
|
|
479
|
+
|
|
480
|
+
<FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} captionHint="ESC to close · ←/→ to move" />
|
|
481
|
+
</ScrollView>
|
|
482
|
+
);
|
|
483
|
+
}
|