@lotics/ui 8.0.0 → 9.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 +171 -68
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +16 -13
- package/examples/tpl_tasks.tsx +15 -28
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +8 -1
- package/src/capture_row.tsx +59 -0
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/detail_row.tsx +137 -10
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +21 -0
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/suggestion_chip.tsx +47 -0
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_wizard.tsx +0 -223
|
@@ -0,0 +1,1354 @@
|
|
|
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 { Divider } from "@lotics/ui/divider";
|
|
7
|
+
import { Badge } from "@lotics/ui/badge";
|
|
8
|
+
import { Link } from "@lotics/ui/link";
|
|
9
|
+
import { Icon } from "@lotics/ui/icon";
|
|
10
|
+
import { Alert } from "@lotics/ui/alert";
|
|
11
|
+
import type { PickerOption } from "@lotics/ui/picker";
|
|
12
|
+
import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
|
|
13
|
+
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
14
|
+
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
15
|
+
import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
16
|
+
import { MenuButton } from "@lotics/ui/menu_button";
|
|
17
|
+
import { Modal, ModalBody, ModalHeader } from "@lotics/ui/modal";
|
|
18
|
+
import { useSectionNav } from "@lotics/ui/use_section_nav";
|
|
19
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
20
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
21
|
+
import { RecordSummary } from "@lotics/ui/record_summary";
|
|
22
|
+
import { CheckCircle } from "@lotics/ui/check_circle";
|
|
23
|
+
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
24
|
+
import { FilterChip } from "@lotics/ui/filter_chip";
|
|
25
|
+
import { CaptureRow } from "@lotics/ui/capture_row";
|
|
26
|
+
import { Checklist, ChecklistRow } from "@lotics/ui/checklist";
|
|
27
|
+
import type { ActionMenuItem } from "@lotics/ui/action_menu";
|
|
28
|
+
import { SuggestionChip } from "@lotics/ui/suggestion_chip";
|
|
29
|
+
import { OptionList } from "@lotics/ui/option_list";
|
|
30
|
+
import { MemberChip } from "@lotics/ui/member_chip";
|
|
31
|
+
import { InlineStatic } from "@lotics/ui/inline_static";
|
|
32
|
+
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
33
|
+
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
34
|
+
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
35
|
+
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
36
|
+
import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
|
|
37
|
+
import type { MemberSelectMember } from "@lotics/ui/member_select";
|
|
38
|
+
import { Switch } from "@lotics/ui/switch";
|
|
39
|
+
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
40
|
+
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
41
|
+
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
42
|
+
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
43
|
+
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
44
|
+
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
// Template · Record — THE record surface, create-then-refine taken all the way:
|
|
47
|
+
// "New order" is ONE CLICK (no dialog, no form) — it creates a fresh Draft and
|
|
48
|
+
// everything is refined here, in place. Top → bottom: a `RecordSummary` header
|
|
49
|
+
// (identity · status · the to-collect metric) over its key-fact `DetailTable`,
|
|
50
|
+
// a Details `DetailTable`, the CUSTOMER section (TWO states: attached = the
|
|
51
|
+
// read-only-first card — Tax ID / Contact / City edit inline, and the Fetch
|
|
52
|
+
// button fills contact + city from the tax-ID registry, un-gating invoicing;
|
|
53
|
+
// Remove detaches the link → the find-or-create search, whose custom row opens
|
|
54
|
+
// the create Dialog), BILLING — charges grouped into issuable invoice
|
|
55
|
+
// documents, every value in the inline vocabulary (amount chips with the list
|
|
56
|
+
// price as ghost text, "How paid…" select chips), the issue action in each
|
|
57
|
+
// band's header, ONE section-level callout explaining what blocks issuing, the
|
|
58
|
+
// collect total, and a separate refundable deposit — and FILES (drop to add,
|
|
59
|
+
// gallery preview, delete). The lifecycle is the HANDOFF CHAIN below —
|
|
60
|
+
// per-desk task checklists inform each stage's handoff CTA (never blocking);
|
|
61
|
+
// there is no separate submit step. Also the settings shape:
|
|
62
|
+
// Preferences switch rows + the `DangerZone`.
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
interface Customer {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
code: string;
|
|
69
|
+
taxId: string;
|
|
70
|
+
contact: string;
|
|
71
|
+
city: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Atlas ships WITHOUT a tax ID — attach it to see the billing gate + the
|
|
75
|
+
// inline Tax ID fix-up in the Customer section.
|
|
76
|
+
const KNOWN_CUSTOMERS: Customer[] = [
|
|
77
|
+
{ id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg" },
|
|
78
|
+
{ id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam" },
|
|
79
|
+
{ id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore" },
|
|
80
|
+
{ id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg" },
|
|
81
|
+
{ id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const TAX_ID_RE = /^\d{10}(\d{3})?$/;
|
|
85
|
+
|
|
86
|
+
// HANDOFF is a STAGE TRANSITION on the shared record — never a message or a
|
|
87
|
+
// task. Each department owns its sections; the handoff CTA moves the record to
|
|
88
|
+
// the next desk (spine + stage = handoff) — open tasks warn, they never block.
|
|
89
|
+
type Stage = "sales" | "operations" | "accounting" | "closed";
|
|
90
|
+
type Desk = Exclude<Stage, "closed">;
|
|
91
|
+
const DESKS: { key: Desk; label: string; color: "blue" | "amber" | "violet" }[] = [
|
|
92
|
+
{ key: "sales", label: "Sales", color: "blue" },
|
|
93
|
+
{ key: "operations", label: "Operations", color: "amber" },
|
|
94
|
+
{ key: "accounting", label: "Accounting", color: "violet" },
|
|
95
|
+
];
|
|
96
|
+
const STAGES: { key: Stage; label: string; color: "blue" | "amber" | "violet" | "emerald" }[] = [
|
|
97
|
+
...DESKS,
|
|
98
|
+
{ key: "closed", label: "Closed", color: "emerald" },
|
|
99
|
+
];
|
|
100
|
+
const stageOf = (st: Stage) => STAGES.find((x) => x.key === st) ?? STAGES[0];
|
|
101
|
+
|
|
102
|
+
// The handoff is MANAGED as tasks: each desk's checklist INFORMS its handoff
|
|
103
|
+
// CTA (open tasks warn and carry over; the CTA never blocks) — actionable,
|
|
104
|
+
// unlike a decorative progress strip.
|
|
105
|
+
interface StageTask {
|
|
106
|
+
id: string;
|
|
107
|
+
label: string;
|
|
108
|
+
stage: Desk;
|
|
109
|
+
done: boolean;
|
|
110
|
+
assignee: string | null;
|
|
111
|
+
}
|
|
112
|
+
// Common-but-OPTIONAL tasks per desk — in a real app, the record type's
|
|
113
|
+
// configured checklist. A NEW record starts with an EMPTY list (tasks truly
|
|
114
|
+
// vary); these commons wait as dismissible suggestion CHIPS under each desk
|
|
115
|
+
// (tap to materialize, ✕ to dismiss) — pills, never rows, so a
|
|
116
|
+
// suggestion can't be mistaken for a task. MANDATORY tasks are a different
|
|
117
|
+
// thing entirely: the app seeds those via a workflow on create — no human
|
|
118
|
+
// types them.
|
|
119
|
+
const SUGGESTED_TASKS: Record<Desk, string[]> = {
|
|
120
|
+
sales: ["Confirm pricing with the customer", "Attach the signed quote", "Verify the customer's tax ID"],
|
|
121
|
+
operations: ["Book the carrier", "Attach the delivery documents", "Confirm the delivery window"],
|
|
122
|
+
accounting: ["Issue every invoice", "Reconcile the receipts"],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Monotonic id for user-added tasks — a length-derived id would collide after
|
|
126
|
+
// a removal (delete then add) and break keys + patch-by-id.
|
|
127
|
+
let taskSeq = 0;
|
|
128
|
+
const newTaskId = () => `t_${(taskSeq += 1)}`;
|
|
129
|
+
|
|
130
|
+
// The in-flight demo record's existing checklist (a NEW record gets []).
|
|
131
|
+
const TASK_SEEDS: StageTask[] = [
|
|
132
|
+
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01" },
|
|
133
|
+
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01" },
|
|
134
|
+
{ id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null },
|
|
135
|
+
{ id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02" },
|
|
136
|
+
{ id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null },
|
|
137
|
+
{ id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03" },
|
|
138
|
+
{ id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/** The owner tag a section heading wears — WHO works this section. */
|
|
142
|
+
function OwnerTag({ stage }: { stage: Desk }) {
|
|
143
|
+
const meta = stageOf(stage);
|
|
144
|
+
return <Badge variant="dot" label={meta.label} color={meta.color} />;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The assignable roster — an app feeds `useMembers()` here.
|
|
148
|
+
const TEAM: MemberSelectMember[] = [
|
|
149
|
+
{ id: "mem_01", name: "Sarah Chen" },
|
|
150
|
+
{ id: "mem_02", name: "David Park" },
|
|
151
|
+
{ id: "mem_03", name: "Maria Lopez" },
|
|
152
|
+
{ id: "mem_04", name: "James Walker" },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
// The company registry the Fetch button consults (mocked): tax ID → the
|
|
156
|
+
// company's registered contact + city. Unknown-but-valid IDs still resolve so
|
|
157
|
+
// the fill is always demoable.
|
|
158
|
+
const REGISTRY: Record<string, { contact: string; city: string }> = {
|
|
159
|
+
"0312456780": { contact: "Mara Lindqvist", city: "Gothenburg" },
|
|
160
|
+
"0312998820": { contact: "Diego Alvarez", city: "Rotterdam" },
|
|
161
|
+
"0301557742": { contact: "Priya Nair", city: "Singapore" },
|
|
162
|
+
"0312004455": { contact: "Lena Fischer", city: "Antwerp" },
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const lookupRegistry = (taxId: string) =>
|
|
166
|
+
new Promise<{ contact: string; city: string }>((resolve) => {
|
|
167
|
+
setTimeout(() => resolve(REGISTRY[taxId] ?? { contact: "Accounts desk", city: "Ho Chi Minh City" }), 700);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const TERMS = [
|
|
171
|
+
{ value: "receipt", label: "Due on receipt" },
|
|
172
|
+
{ value: "15", label: "Net 15" },
|
|
173
|
+
{ value: "30", label: "Net 30" },
|
|
174
|
+
{ value: "45", label: "Net 45" },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const WAREHOUSES = [
|
|
178
|
+
{ value: "north", label: "North DC" },
|
|
179
|
+
{ value: "central", label: "Central hub" },
|
|
180
|
+
{ value: "port", label: "Port cross-dock" },
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const PRIORITY = [
|
|
184
|
+
{ value: "standard", label: "Standard" },
|
|
185
|
+
{ value: "rush", label: "Rush" },
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const PRIORITY_DOT: Record<string, string> = {
|
|
189
|
+
standard: colors.zinc[400],
|
|
190
|
+
rush: colors.amber[500],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// ── billing — charges grouped into issuable invoice DOCUMENTS on this record
|
|
194
|
+
type Method = "cash" | "transfer" | "card";
|
|
195
|
+
const METHODS: PickerOption<Method>[] = [
|
|
196
|
+
{ value: "cash", label: "Cash" },
|
|
197
|
+
{ value: "transfer", label: "Bank transfer" },
|
|
198
|
+
{ value: "card", label: "Card" },
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
interface Charge {
|
|
202
|
+
key: string;
|
|
203
|
+
label: string;
|
|
204
|
+
/** The expected list price — shown as ghost text while the line is unset. */
|
|
205
|
+
standard: number;
|
|
206
|
+
amount: number;
|
|
207
|
+
method: Method | "";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface Invoice {
|
|
211
|
+
key: string;
|
|
212
|
+
title: string;
|
|
213
|
+
charges: Charge[];
|
|
214
|
+
/** Lookup code once issued to the e-invoice provider; "" = not issued. */
|
|
215
|
+
ref: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const BILLING_INITIAL: Invoice[] = [
|
|
219
|
+
{
|
|
220
|
+
key: "delivery",
|
|
221
|
+
title: "Delivery",
|
|
222
|
+
ref: "",
|
|
223
|
+
charges: [
|
|
224
|
+
{ key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
|
|
225
|
+
{ key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
key: "handling",
|
|
230
|
+
title: "Handling",
|
|
231
|
+
ref: "",
|
|
232
|
+
charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
key: "storage",
|
|
236
|
+
title: "Storage",
|
|
237
|
+
ref: "",
|
|
238
|
+
charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
/** A fresh record has nothing billed — the invoice shapes with zeroed lines. */
|
|
243
|
+
const freshInvoices = (): Invoice[] =>
|
|
244
|
+
BILLING_INITIAL.map((inv) => ({ ...inv, ref: "", charges: inv.charges.map((c) => ({ ...c, amount: 0, method: "" as const })) }));
|
|
245
|
+
|
|
246
|
+
type InvoiceState = "none" | "draft" | "issued";
|
|
247
|
+
const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
|
|
248
|
+
const invoiceStatus = (inv: Invoice): InvoiceState => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
|
|
249
|
+
const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
|
|
250
|
+
|
|
251
|
+
const money = (v: number | null) => (v == null ? "" : formatMoney(v));
|
|
252
|
+
|
|
253
|
+
/** Fake persistence for the inline editors — real apps await their mutation. */
|
|
254
|
+
function persist<T>(set: (v: T) => void) {
|
|
255
|
+
return (v: T) =>
|
|
256
|
+
new Promise<void>((resolve) => {
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
set(v);
|
|
259
|
+
resolve();
|
|
260
|
+
}, 350);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** One invoice document — a hairline-set BAND: a title line, its charge lines
|
|
265
|
+
* as inline rows (amount chip with the list price as ghost text + a one-tap
|
|
266
|
+
* "Standard …" suggestion pill while unset; the payment method turns required
|
|
267
|
+
* the moment the line carries an amount), then the ACTION ROW at the bottom
|
|
268
|
+
* right: the hint naming exactly what's missing → the status dot → Issue.
|
|
269
|
+
* No per-band total — the collect band below owns the number. An ISSUED
|
|
270
|
+
* invoice keeps its lines editable — fees change — and offers Re-issue (a new
|
|
271
|
+
* lookup code replaces the old, confirmed in the same Dialog). */
|
|
272
|
+
function InvoiceBand({
|
|
273
|
+
inv, gateReason, onAmount, onMethod, onIssue,
|
|
274
|
+
}: {
|
|
275
|
+
inv: Invoice;
|
|
276
|
+
/** The customer-level block ("Attach a customer…" / "Add the tax ID…"),
|
|
277
|
+
* resolved by the parent — "" when the customer side is ready. */
|
|
278
|
+
gateReason: string;
|
|
279
|
+
onAmount: (chKey: string, v: number) => Promise<void>;
|
|
280
|
+
onMethod: (chKey: string, m: Method) => Promise<void>;
|
|
281
|
+
onIssue: (inv: Invoice) => void;
|
|
282
|
+
}) {
|
|
283
|
+
const total = invoiceTotal(inv);
|
|
284
|
+
const state = invoiceStatus(inv);
|
|
285
|
+
const missing = missingMethods(inv);
|
|
286
|
+
const ready = total > 0 && missing.length === 0 && gateReason === "";
|
|
287
|
+
// What stands between this invoice and Issue — band-local blockers first
|
|
288
|
+
// (fixable right here), then the customer-level gate.
|
|
289
|
+
const blockReason =
|
|
290
|
+
total === 0
|
|
291
|
+
? "Enter the fees to issue."
|
|
292
|
+
: missing.length > 0
|
|
293
|
+
? "Choose a payment method for every charged line."
|
|
294
|
+
: gateReason;
|
|
295
|
+
return (
|
|
296
|
+
<>
|
|
297
|
+
<Divider />
|
|
298
|
+
<View style={{ gap: 8, paddingVertical: 2 }}>
|
|
299
|
+
<Text weight="semibold">{inv.title}</Text>
|
|
300
|
+
{/* two editors per cell → stack earlier than a single-editor table */}
|
|
301
|
+
<DetailTable labelWidth={150} minValueWidth={320}>
|
|
302
|
+
{inv.charges.map((c) => (
|
|
303
|
+
<DetailRow key={c.key} label={c.label}>
|
|
304
|
+
<View style={{ gap: 4 }}>
|
|
305
|
+
{/* amount + method WRAP on narrow — a fixed 160 method next to a
|
|
306
|
+
flex amount crushes the amount chip to nothing on phones */}
|
|
307
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
|
|
308
|
+
<View style={{ flexGrow: 1, flexBasis: 150 }}>
|
|
309
|
+
<InlineNumberInput
|
|
310
|
+
value={c.amount || null}
|
|
311
|
+
onSave={(v) => onAmount(c.key, v ?? 0)}
|
|
312
|
+
min={0}
|
|
313
|
+
format={money}
|
|
314
|
+
placeholder={c.standard > 0 ? money(c.standard) : "—"}
|
|
315
|
+
accessibilityLabel={`${c.label} amount`}
|
|
316
|
+
/>
|
|
317
|
+
</View>
|
|
318
|
+
<View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
|
|
319
|
+
<InlineSelect
|
|
320
|
+
value={c.method === "" ? null : c.method}
|
|
321
|
+
onSave={(m) => onMethod(c.key, m)}
|
|
322
|
+
options={METHODS}
|
|
323
|
+
placeholder="How paid…"
|
|
324
|
+
disabled={c.amount <= 0}
|
|
325
|
+
accessibilityLabel={`Payment method · ${c.label}`}
|
|
326
|
+
/>
|
|
327
|
+
</View>
|
|
328
|
+
</View>
|
|
329
|
+
{/* the one-tap suggestion: fill the list price while unset */}
|
|
330
|
+
{c.amount <= 0 && c.standard > 0 ? (
|
|
331
|
+
<View style={{ flexDirection: "row" }}>
|
|
332
|
+
<Button title={`Standard ${money(c.standard)}`} color="secondary" onPress={() => onAmount(c.key, c.standard)} />
|
|
333
|
+
</View>
|
|
334
|
+
) : null}
|
|
335
|
+
</View>
|
|
336
|
+
</DetailRow>
|
|
337
|
+
))}
|
|
338
|
+
</DetailTable>
|
|
339
|
+
{/* the band's action row — bottom right: why-not → status → the action */}
|
|
340
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 6 }}>
|
|
341
|
+
{state === "issued" ? (
|
|
342
|
+
<>
|
|
343
|
+
<Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 180 }}>
|
|
344
|
+
{ready ? "" : blockReason}
|
|
345
|
+
</Text>
|
|
346
|
+
<Badge variant="dot" label="Issued" color="emerald" />
|
|
347
|
+
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
348
|
+
<Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
|
|
349
|
+
</>
|
|
350
|
+
) : (
|
|
351
|
+
<>
|
|
352
|
+
<Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 180 }}>
|
|
353
|
+
{blockReason}
|
|
354
|
+
</Text>
|
|
355
|
+
<Badge
|
|
356
|
+
variant="dot"
|
|
357
|
+
label={state === "draft" ? "Draft" : "Nothing to bill"}
|
|
358
|
+
color={state === "draft" ? "amber" : "zinc"}
|
|
359
|
+
/>
|
|
360
|
+
<Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
|
|
361
|
+
</>
|
|
362
|
+
)}
|
|
363
|
+
</View>
|
|
364
|
+
</View>
|
|
365
|
+
</>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function TplRecord() {
|
|
370
|
+
const id = useRef(1);
|
|
371
|
+
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
372
|
+
|
|
373
|
+
// ── the record — seeded mid-flight so every state is visible
|
|
374
|
+
const [code, setCode] = useState("RC-2026-0418");
|
|
375
|
+
const [stage, setStage] = useState<Stage>("sales");
|
|
376
|
+
// The customer book is STATE: the attached customer's tax ID edits inline in
|
|
377
|
+
// the Customer section (that's what un-gates invoicing).
|
|
378
|
+
const [customers, setCustomers] = useState<Customer[]>(KNOWN_CUSTOMERS);
|
|
379
|
+
const [customerId, setCustomerId] = useState<string | null>("cus_02");
|
|
380
|
+
const customer = customers.find((c) => c.id === customerId) ?? null;
|
|
381
|
+
|
|
382
|
+
// ── facts + details (all inline-persisted)
|
|
383
|
+
const [orderDate, setOrderDate] = useState("2026-06-15");
|
|
384
|
+
const [deliverBy, setDeliverBy] = useState("2026-06-22");
|
|
385
|
+
const [terms, setTerms] = useState("30");
|
|
386
|
+
const [priority, setPriority] = useState("standard");
|
|
387
|
+
const [warehouse, setWarehouse] = useState("central");
|
|
388
|
+
const [reference, setReference] = useState("");
|
|
389
|
+
const [weight, setWeight] = useState<number | null>(1250);
|
|
390
|
+
const [salesOwner, setSalesOwner] = useState<string | null>("mem_01");
|
|
391
|
+
|
|
392
|
+
// ── customer section — TWO states: attached (read-only-first card, Remove
|
|
393
|
+
// detaches) or empty (the find-or-create search). The "create" branch opens
|
|
394
|
+
// a focused Dialog; non-null draft = the dialog is open.
|
|
395
|
+
const [custDraft, setCustDraft] = useState<{ name: string; taxId: string; contact: string; city: string } | null>(null);
|
|
396
|
+
// The attached card RESTS read-only; Edit switches its fields to inline chips.
|
|
397
|
+
const [editingCustomer, setEditingCustomer] = useState(false);
|
|
398
|
+
// The Fetch button's in-flight state (the registry lookup).
|
|
399
|
+
const [fetching, setFetching] = useState(false);
|
|
400
|
+
|
|
401
|
+
// ── billing — the bill-to tax ID comes off the CUSTOMER
|
|
402
|
+
const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
|
|
403
|
+
const [deposit, setDeposit] = useState(0);
|
|
404
|
+
const [confirmIssue, setConfirmIssue] = useState<Invoice | null>(null);
|
|
405
|
+
const seq = useRef(414);
|
|
406
|
+
const fileSeq = useRef(0);
|
|
407
|
+
const taxId = customer?.taxId ?? "";
|
|
408
|
+
const taxIdValid = TAX_ID_RE.test(taxId);
|
|
409
|
+
|
|
410
|
+
// ── files (ids from a monotonic counter — name+index would collide after a
|
|
411
|
+
// removal and break React keys + the gallery index)
|
|
412
|
+
const [files, setFiles] = useState<DisplayFile[]>([]);
|
|
413
|
+
const [activeFile, setActiveFile] = useState<number | null>(null);
|
|
414
|
+
|
|
415
|
+
// ── the handoff tasks — the current desk's checklist gates its handoff
|
|
416
|
+
const [tasks, setTasks] = useState<StageTask[]>(TASK_SEEDS);
|
|
417
|
+
const [newTask, setNewTask] = useState("");
|
|
418
|
+
const toggleTask = (tid: string, done: boolean) =>
|
|
419
|
+
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, done } : t)));
|
|
420
|
+
const renameTask = (tid: string, label: string) =>
|
|
421
|
+
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, label } : t)));
|
|
422
|
+
// The capture desk: adds land on the current desk; a CLOSED record's adds
|
|
423
|
+
// land on the last desk (the stage never gates task editing).
|
|
424
|
+
const captureDesk: Desk = stage === "closed" ? "accounting" : stage;
|
|
425
|
+
const addTask = () => {
|
|
426
|
+
const label = newTask.trim();
|
|
427
|
+
if (!label) return;
|
|
428
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null }]);
|
|
429
|
+
setNewTask("");
|
|
430
|
+
};
|
|
431
|
+
const addSuggested = (desk: Desk, label: string) =>
|
|
432
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null }]);
|
|
433
|
+
const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
|
|
434
|
+
const moveTask = (tid: string, desk: Desk) =>
|
|
435
|
+
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
|
|
436
|
+
const nextDeskOf = (desk: Desk): Desk | null =>
|
|
437
|
+
desk === "sales" ? "operations" : desk === "operations" ? "accounting" : null;
|
|
438
|
+
const [dismissed, setDismissed] = useState<string[]>([]);
|
|
439
|
+
const assignTask = (tid: string, member: string | null) =>
|
|
440
|
+
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
|
|
441
|
+
// The Task-list grammar: a clearable Group-by + filter chips derive the
|
|
442
|
+
// sections; empty groups drop (except desks — the journey stays visible).
|
|
443
|
+
const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
|
|
444
|
+
const [assigneeFilter, setAssigneeFilter] = useState<string[]>([]);
|
|
445
|
+
const [statusFilter, setStatusFilter] = useState<"open" | "done" | null>(null);
|
|
446
|
+
const taskPool = tasks.filter((t) => {
|
|
447
|
+
if (assigneeFilter.length > 0 && !assigneeFilter.includes(t.assignee ?? "none")) return false;
|
|
448
|
+
if (statusFilter === "open" && t.done) return false;
|
|
449
|
+
if (statusFilter === "done" && !t.done) return false;
|
|
450
|
+
return true;
|
|
451
|
+
});
|
|
452
|
+
const suggesting = assigneeFilter.length === 0 && statusFilter === null;
|
|
453
|
+
const taskSections: { key: string; head: React.ReactNode; items: StageTask[]; desk: Desk | null; ghosts: string[] }[] =
|
|
454
|
+
taskGroup === "desk"
|
|
455
|
+
? DESKS.map((st) => ({
|
|
456
|
+
key: st.key,
|
|
457
|
+
head: <Badge variant="dot" label={st.label} color={st.color} />,
|
|
458
|
+
items: taskPool.filter((t) => t.stage === st.key),
|
|
459
|
+
desk: st.key,
|
|
460
|
+
ghosts: suggesting ? SUGGESTED_TASKS[st.key].filter((l) => !tasks.some((t) => t.label === l) && !dismissed.includes(l)) : [],
|
|
461
|
+
}))
|
|
462
|
+
: taskGroup === "assignee"
|
|
463
|
+
? [...TEAM.map((m) => ({ key: m.id, head: <MemberChip name={m.name ?? m.id} />, items: taskPool.filter((t) => t.assignee === m.id), desk: null, ghosts: [] })),
|
|
464
|
+
{ key: "none", head: <Text size="xs" weight="semibold" color="muted">Unassigned</Text>, items: taskPool.filter((t) => t.assignee == null), desk: null, ghosts: [] },
|
|
465
|
+
].filter((g) => g.items.length > 0)
|
|
466
|
+
: taskGroup === "status"
|
|
467
|
+
? [
|
|
468
|
+
{ key: "open", head: <Text size="xs" weight="semibold" color="muted">Open</Text>, items: taskPool.filter((t) => !t.done), desk: null, ghosts: [] },
|
|
469
|
+
{ key: "done", head: <Text size="xs" weight="semibold" color="muted">Done</Text>, items: taskPool.filter((t) => t.done), desk: null, ghosts: [] },
|
|
470
|
+
].filter((g) => g.items.length > 0)
|
|
471
|
+
: [{ key: "all", head: null, items: taskPool, desk: null, ghosts: [] }];
|
|
472
|
+
const remaining = tasks.filter((t) => t.stage === stage && !t.done).length;
|
|
473
|
+
|
|
474
|
+
// ── preferences
|
|
475
|
+
const [notifyCustomer, setNotifyCustomer] = useState(true);
|
|
476
|
+
const [partialDelivery, setPartialDelivery] = useState(false);
|
|
477
|
+
|
|
478
|
+
const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
|
|
479
|
+
value: c.id,
|
|
480
|
+
label: c.name,
|
|
481
|
+
data: c,
|
|
482
|
+
}));
|
|
483
|
+
|
|
484
|
+
const patchCustomer = (cid: string, patch: Partial<Customer>) =>
|
|
485
|
+
setCustomers((prev) => prev.map((c) => (c.id === cid ? { ...c, ...patch } : c)));
|
|
486
|
+
|
|
487
|
+
// Find-or-create resolution: an existing pick attaches; the custom row opens
|
|
488
|
+
// the create Dialog, prefilled with the query.
|
|
489
|
+
const onPickCustomer = (opt: PickerOption<string, Customer>) => {
|
|
490
|
+
if (customers.some((c) => c.id === opt.value)) {
|
|
491
|
+
setCustomerId(opt.value);
|
|
492
|
+
setEditingCustomer(false);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
setCustDraft({ name: opt.value, taxId: "", contact: "", city: "" });
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const taxIdError =
|
|
499
|
+
custDraft && custDraft.taxId && !TAX_ID_RE.test(custDraft.taxId.trim()) ? "Tax ID is 10 or 13 digits" : undefined;
|
|
500
|
+
|
|
501
|
+
const createAndAttach = () => {
|
|
502
|
+
if (!custDraft) return;
|
|
503
|
+
const c: Customer = {
|
|
504
|
+
id: nextId("cus"),
|
|
505
|
+
name: custDraft.name.trim(),
|
|
506
|
+
code: `KH-${String(300 + id.current).padStart(4, "0")}`,
|
|
507
|
+
taxId: custDraft.taxId.trim(),
|
|
508
|
+
contact: custDraft.contact.trim(),
|
|
509
|
+
city: custDraft.city.trim(),
|
|
510
|
+
};
|
|
511
|
+
setCustomers((prev) => [...prev, c]);
|
|
512
|
+
setCustomerId(c.id);
|
|
513
|
+
setCustDraft(null);
|
|
514
|
+
setEditingCustomer(false);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// The registry lookup — fills Contact + City off the customer's tax ID.
|
|
518
|
+
const fetchDetails = async (c: Customer) => {
|
|
519
|
+
setFetching(true);
|
|
520
|
+
const details = await lookupRegistry(c.taxId.trim());
|
|
521
|
+
patchCustomer(c.id, details);
|
|
522
|
+
setFetching(false);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// The same lookup for the create Dialog — fills the draft's fields.
|
|
526
|
+
const fetchIntoDraft = async () => {
|
|
527
|
+
if (!custDraft) return;
|
|
528
|
+
setFetching(true);
|
|
529
|
+
const details = await lookupRegistry(custDraft.taxId.trim());
|
|
530
|
+
setCustDraft((d) => (d ? { ...d, ...details } : d));
|
|
531
|
+
setFetching(false);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// "New order" is ONE CLICK — a fresh Draft, refined entirely on this surface.
|
|
535
|
+
const newOrder = () => {
|
|
536
|
+
setCode(`RC-2026-0${420 + (id.current += 1)}`);
|
|
537
|
+
setStage("sales");
|
|
538
|
+
setCustomerId(null);
|
|
539
|
+
setCustDraft(null);
|
|
540
|
+
setEditingCustomer(false);
|
|
541
|
+
setOrderDate("2026-07-04");
|
|
542
|
+
setDeliverBy("");
|
|
543
|
+
setTerms("30");
|
|
544
|
+
setPriority("standard");
|
|
545
|
+
setWarehouse("central");
|
|
546
|
+
setReference("");
|
|
547
|
+
setWeight(null);
|
|
548
|
+
setSalesOwner(null);
|
|
549
|
+
setInvoices(freshInvoices());
|
|
550
|
+
setDeposit(0);
|
|
551
|
+
setFiles([]);
|
|
552
|
+
setTasks([]);
|
|
553
|
+
setDismissed([]);
|
|
554
|
+
setNewTask("");
|
|
555
|
+
setNotifyCustomer(true);
|
|
556
|
+
setPartialDelivery(false);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
|
|
560
|
+
setInvoices((prev) =>
|
|
561
|
+
prev.map((inv) =>
|
|
562
|
+
inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) },
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
/** Inline-editor persistence for one charge cell. */
|
|
567
|
+
const saveCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
|
|
568
|
+
new Promise<void>((resolve) => {
|
|
569
|
+
setTimeout(() => {
|
|
570
|
+
patchCharge(invKey, chKey, patch);
|
|
571
|
+
resolve();
|
|
572
|
+
}, 350);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
|
|
576
|
+
const allMissing = invoices.flatMap(missingMethods);
|
|
577
|
+
const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
|
|
578
|
+
|
|
579
|
+
const issue = (inv: Invoice) => {
|
|
580
|
+
seq.current += 1;
|
|
581
|
+
const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
|
|
582
|
+
setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
|
|
583
|
+
setConfirmIssue(null);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const printReceipt = () => {
|
|
587
|
+
if (allMissing.length > 0) {
|
|
588
|
+
Alert.alert(
|
|
589
|
+
"Missing payment method",
|
|
590
|
+
`Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`,
|
|
591
|
+
[{ text: "OK" }],
|
|
592
|
+
);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// Open tasks INFORM the handoff, they never block it — the count warns and
|
|
599
|
+
// carries over to the next desk (or stays open on the closed record).
|
|
600
|
+
const taskHint =
|
|
601
|
+
stage === "accounting"
|
|
602
|
+
? `${remaining} open ${remaining === 1 ? "task stays" : "tasks stay"} open on the closed record.`
|
|
603
|
+
: `${remaining} open ${remaining === 1 ? "task carries" : "tasks carry"} over to the next desk.`;
|
|
604
|
+
const gate =
|
|
605
|
+
stage === "sales"
|
|
606
|
+
? { cta: "Hand off to Operations", next: "operations" as Stage }
|
|
607
|
+
: stage === "operations"
|
|
608
|
+
? { cta: "Hand off to Accounting", next: "accounting" as Stage }
|
|
609
|
+
: stage === "accounting"
|
|
610
|
+
? { cta: "Close record", next: "closed" as Stage }
|
|
611
|
+
: null;
|
|
612
|
+
const prevStage: Stage | null = stage === "operations" ? "sales" : stage === "accounting" ? "operations" : null;
|
|
613
|
+
|
|
614
|
+
// ── the outline rail — jump to a section instead of scrolling the long page
|
|
615
|
+
const SECTIONS = [
|
|
616
|
+
{ key: "details", label: "Details", icon: "file-text" },
|
|
617
|
+
{ key: "customer", label: "Customer", icon: "building-2" },
|
|
618
|
+
{ key: "billing", label: "Billing", icon: "credit-card" },
|
|
619
|
+
{ key: "files", label: "Files", icon: "folder-closed" },
|
|
620
|
+
{ key: "tasks", label: "Tasks", icon: "list-checks" },
|
|
621
|
+
{ key: "preferences", label: "Preferences", icon: "sliders-horizontal" },
|
|
622
|
+
] as const;
|
|
623
|
+
const nav = useSectionNav(["details", "customer", "billing", "files", "tasks", "preferences"] as const);
|
|
624
|
+
// Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
|
|
625
|
+
// it opens a full-page section picker (Escape / the close control dismiss).
|
|
626
|
+
const [sectionsOpen, setSectionsOpen] = useState(false);
|
|
627
|
+
const [pageWidth, setPageWidth] = useState<number | null>(null);
|
|
628
|
+
const wide = pageWidth != null && pageWidth >= 760;
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
// Measure-then-reveal: hidden until the width is known, so the rail/bar
|
|
632
|
+
// choice never visibly reshuffles the page on first paint.
|
|
633
|
+
<View style={{ flex: 1, flexDirection: wide ? "row" : "column", backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
|
|
634
|
+
{pageWidth == null ? null : wide ? (
|
|
635
|
+
<View style={{ width: 208, borderRightWidth: 1, borderRightColor: colors.zinc[200], padding: 8 }}>
|
|
636
|
+
<View style={{ gap: 2 }}>
|
|
637
|
+
{SECTIONS.map((sec) => (
|
|
638
|
+
<MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
|
|
639
|
+
))}
|
|
640
|
+
</View>
|
|
641
|
+
</View>
|
|
642
|
+
) : (
|
|
643
|
+
// Narrow: a pinned bar names the CURRENT section (the spy keeps it
|
|
644
|
+
// honest while scrolling); tapping opens the full-page section picker —
|
|
645
|
+
// no tab strip the thumb has to scroll.
|
|
646
|
+
<View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: 12, paddingVertical: 6, flexDirection: "row" }}>
|
|
647
|
+
<Button
|
|
648
|
+
title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
|
|
649
|
+
color="secondary"
|
|
650
|
+
icon="list-collapse"
|
|
651
|
+
onPress={() => setSectionsOpen(true)}
|
|
652
|
+
/>
|
|
653
|
+
</View>
|
|
654
|
+
)}
|
|
655
|
+
<ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
|
|
656
|
+
<View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 32 }}>
|
|
657
|
+
{/* app bar — CREATE is one click; the surface is where everything is refined */}
|
|
658
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 10, rowGap: 8 }}>
|
|
659
|
+
<Text size="sm" color="muted">Records <Text size="sm" color="muted">/</Text> <Text size="sm" weight="medium" color="default" tabular>{code}</Text></Text>
|
|
660
|
+
<View style={{ flex: 1 }} />
|
|
661
|
+
<Button title="New record" color="secondary" icon="plus" onPress={newOrder} />
|
|
662
|
+
</View>
|
|
663
|
+
|
|
664
|
+
<RecordSummary
|
|
665
|
+
title={code}
|
|
666
|
+
subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(" · ") : "No customer"}
|
|
667
|
+
status={<Badge label={stageOf(stage).label} color={stageOf(stage).color} />}
|
|
668
|
+
metric={{ label: "To collect", value: formatMoney(grandTotal), tone: stage === "closed" ? "success" : "default" }}
|
|
669
|
+
/>
|
|
670
|
+
|
|
671
|
+
{/* the header's key facts — a DetailTable with the SAME columns as the
|
|
672
|
+
Details table below (trailingWidth reserved even though no fact has
|
|
673
|
+
a trailing), so the two parts sit on one page-wide grid */}
|
|
674
|
+
<DetailTable labelWidth={150} trailingWidth={88}>
|
|
675
|
+
<DetailRow label="Sales owner">
|
|
676
|
+
<InlineMemberSelect
|
|
677
|
+
members={TEAM}
|
|
678
|
+
value={salesOwner}
|
|
679
|
+
onSave={persist(setSalesOwner)}
|
|
680
|
+
placeholder="Assign…"
|
|
681
|
+
accessibilityLabel="Sales owner"
|
|
682
|
+
/>
|
|
683
|
+
</DetailRow>
|
|
684
|
+
<DetailRow label="Opened">
|
|
685
|
+
<InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
|
|
686
|
+
</DetailRow>
|
|
687
|
+
<DetailRow label="Due">
|
|
688
|
+
<InlineDatePicker value={deliverBy} onSave={persist(setDeliverBy)} locale="en-US" accessibilityLabel="Due date" placeholder="Set a due date…" />
|
|
689
|
+
</DetailRow>
|
|
690
|
+
<DetailRow label="Payment terms">
|
|
691
|
+
<InlineSelect value={terms} options={TERMS} onSave={persist(setTerms)} accessibilityLabel="Payment terms" />
|
|
692
|
+
</DetailRow>
|
|
693
|
+
</DetailTable>
|
|
694
|
+
|
|
695
|
+
<Divider />
|
|
696
|
+
|
|
697
|
+
{/* ONE DetailTable = the record's field grid: label · editor · trailing
|
|
698
|
+
columns set once on the parent, every row aligned like a table —
|
|
699
|
+
editors share one width even when only some rows carry a trailing
|
|
700
|
+
action / badge; read-only values are InlineStatic in the same
|
|
701
|
+
column, aligned to the pixel */}
|
|
702
|
+
<View onLayout={nav.register("details")}>
|
|
703
|
+
<Section>
|
|
704
|
+
<SectionHeading>
|
|
705
|
+
<SectionHeadingTitle description="Every value edits in place — click a chip, change it, it saves on its own.">Details</SectionHeadingTitle>
|
|
706
|
+
<OwnerTag stage="sales" />
|
|
707
|
+
</SectionHeading>
|
|
708
|
+
<DetailTable labelWidth={150} trailingWidth={88}>
|
|
709
|
+
<DetailRow label="Reference" trailing={<Button title="Copy" color="secondary" onPress={() => {}} />}>
|
|
710
|
+
<InlineTextInput value={reference} onSave={persist(setReference)} placeholder="Add buyer's reference…" accessibilityLabel="Reference" />
|
|
711
|
+
</DetailRow>
|
|
712
|
+
<DetailRow label="Priority">
|
|
713
|
+
<InlineSelect
|
|
714
|
+
value={priority}
|
|
715
|
+
onSave={persist(setPriority)}
|
|
716
|
+
options={PRIORITY}
|
|
717
|
+
accessibilityLabel="Priority"
|
|
718
|
+
renderOptionContent={(o) => (
|
|
719
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
720
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PRIORITY_DOT[o.value] }} />
|
|
721
|
+
<Text size="sm">{o.label}</Text>
|
|
722
|
+
</View>
|
|
723
|
+
)}
|
|
724
|
+
/>
|
|
725
|
+
</DetailRow>
|
|
726
|
+
<DetailRow label="Location">
|
|
727
|
+
<InlineSelect value={warehouse} onSave={persist(setWarehouse)} options={WAREHOUSES} accessibilityLabel="Location" />
|
|
728
|
+
</DetailRow>
|
|
729
|
+
<DetailRow label="Total weight">
|
|
730
|
+
<InlineNumberInput
|
|
731
|
+
value={weight}
|
|
732
|
+
onSave={persist(setWeight)}
|
|
733
|
+
min={0}
|
|
734
|
+
format={(v) => (v == null ? "" : `${v.toLocaleString("en-US")} kg`)}
|
|
735
|
+
placeholder="Add the weight…"
|
|
736
|
+
accessibilityLabel="Total weight"
|
|
737
|
+
/>
|
|
738
|
+
</DetailRow>
|
|
739
|
+
<DetailRow label="Record ID" trailing={<Badge label="System" color="zinc" />}>
|
|
740
|
+
<InlineStatic value={code} tabular />
|
|
741
|
+
</DetailRow>
|
|
742
|
+
</DetailTable>
|
|
743
|
+
</Section>
|
|
744
|
+
</View>
|
|
745
|
+
|
|
746
|
+
<Divider />
|
|
747
|
+
|
|
748
|
+
{/* CUSTOMER — TWO states, no swap mode. Attached: the read-only-first
|
|
749
|
+
card (identity + editable fields — Tax ID with the registry Fetch
|
|
750
|
+
that fills Contact + City, which are inline-editable too); Remove
|
|
751
|
+
detaches the LINK (the customer stays in the book) and drops you to
|
|
752
|
+
the empty state. Empty: the find-or-create search; the custom row
|
|
753
|
+
opens the create Dialog. */}
|
|
754
|
+
<View onLayout={nav.register("customer")}>
|
|
755
|
+
<Section>
|
|
756
|
+
<SectionHeading>
|
|
757
|
+
<SectionHeadingTitle description="The bill-to. Its tax ID gates invoicing below — add it here.">Customer</SectionHeadingTitle>
|
|
758
|
+
<OwnerTag stage="sales" />
|
|
759
|
+
{customer && !editingCustomer ? (
|
|
760
|
+
<Button title="Edit" color="secondary" icon="pencil" onPress={() => setEditingCustomer(true)} />
|
|
761
|
+
) : null}
|
|
762
|
+
</SectionHeading>
|
|
763
|
+
{customer ? (
|
|
764
|
+
<View style={{ gap: 10 }}>
|
|
765
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
766
|
+
<Icon name="building-2" size={16} color={colors.zinc[500]} />
|
|
767
|
+
<Text weight="medium">{customer.name}</Text>
|
|
768
|
+
<Text size="xs" color="muted" tabular>{customer.code}</Text>
|
|
769
|
+
</View>
|
|
770
|
+
{editingCustomer ? (
|
|
771
|
+
<DetailTable labelWidth={150} trailingWidth={88}>
|
|
772
|
+
<DetailRow
|
|
773
|
+
label="Tax ID"
|
|
774
|
+
trailing={
|
|
775
|
+
<Button
|
|
776
|
+
title="Fetch"
|
|
777
|
+
color="secondary"
|
|
778
|
+
loading={fetching}
|
|
779
|
+
disabled={!TAX_ID_RE.test(customer.taxId.trim())}
|
|
780
|
+
onPress={() => fetchDetails(customer)}
|
|
781
|
+
/>
|
|
782
|
+
}
|
|
783
|
+
>
|
|
784
|
+
<InlineTextInput
|
|
785
|
+
value={customer.taxId}
|
|
786
|
+
onSave={(v: string) => {
|
|
787
|
+
if (v.trim() !== "" && !TAX_ID_RE.test(v.trim())) throw new Error("Enter a 10- or 13-digit tax ID.");
|
|
788
|
+
return persist((next: string) => patchCustomer(customer.id, { taxId: next.trim() }))(v);
|
|
789
|
+
}}
|
|
790
|
+
placeholder="Add tax ID…"
|
|
791
|
+
accessibilityLabel="Tax ID"
|
|
792
|
+
/>
|
|
793
|
+
</DetailRow>
|
|
794
|
+
<DetailRow label="Contact">
|
|
795
|
+
<InlineTextInput
|
|
796
|
+
value={customer.contact}
|
|
797
|
+
onSave={persist((v: string) => patchCustomer(customer.id, { contact: v }))}
|
|
798
|
+
placeholder="Add contact…"
|
|
799
|
+
accessibilityLabel="Contact"
|
|
800
|
+
/>
|
|
801
|
+
</DetailRow>
|
|
802
|
+
<DetailRow label="City">
|
|
803
|
+
<InlineTextInput
|
|
804
|
+
value={customer.city}
|
|
805
|
+
onSave={persist((v: string) => patchCustomer(customer.id, { city: v }))}
|
|
806
|
+
placeholder="Add city…"
|
|
807
|
+
accessibilityLabel="City"
|
|
808
|
+
/>
|
|
809
|
+
</DetailRow>
|
|
810
|
+
</DetailTable>
|
|
811
|
+
) : (
|
|
812
|
+
// At REST the card is read-only — flat InlineStatic values in
|
|
813
|
+
// the SAME columns, so entering Edit never shifts the grid.
|
|
814
|
+
<DetailTable labelWidth={150} trailingWidth={88}>
|
|
815
|
+
<DetailRow label="Tax ID">
|
|
816
|
+
<InlineStatic value={customer.taxId || "—"} tabular />
|
|
817
|
+
</DetailRow>
|
|
818
|
+
<DetailRow label="Contact">
|
|
819
|
+
<InlineStatic value={customer.contact || "—"} />
|
|
820
|
+
</DetailRow>
|
|
821
|
+
<DetailRow label="City">
|
|
822
|
+
<InlineStatic value={customer.city || "—"} />
|
|
823
|
+
</DetailRow>
|
|
824
|
+
</DetailTable>
|
|
825
|
+
)}
|
|
826
|
+
{/* the card's closing row — Remove (destructive) low-left at
|
|
827
|
+
rest; while editing, Done commits out at the bottom right */}
|
|
828
|
+
{editingCustomer ? (
|
|
829
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
|
|
830
|
+
<Button title="Done" color="primary" onPress={() => setEditingCustomer(false)} />
|
|
831
|
+
</View>
|
|
832
|
+
) : (
|
|
833
|
+
<View style={{ flexDirection: "row" }}>
|
|
834
|
+
<Button title="Remove" color="danger" icon="x" onPress={() => setCustomerId(null)} />
|
|
835
|
+
</View>
|
|
836
|
+
)}
|
|
837
|
+
</View>
|
|
838
|
+
) : (
|
|
839
|
+
<View style={{ gap: 10 }}>
|
|
840
|
+
<Combobox
|
|
841
|
+
options={customerOptions}
|
|
842
|
+
onValueChange={onPickCustomer}
|
|
843
|
+
getOptionDescription={(o) => (o.data ? `${o.data.code} · ${o.data.city}` : undefined)}
|
|
844
|
+
reflectSelection={false}
|
|
845
|
+
allowCustom
|
|
846
|
+
customOptionPlacement="top"
|
|
847
|
+
customOptionLabel={(q) => `Create new customer “${q}”`}
|
|
848
|
+
>
|
|
849
|
+
<ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
|
|
850
|
+
<ComboboxContent emptyText="No customer matches — type a name to create one" />
|
|
851
|
+
</Combobox>
|
|
852
|
+
<Text size="xs" color="muted">
|
|
853
|
+
No match? Pick “Create new customer …” to add one without leaving the order.
|
|
854
|
+
</Text>
|
|
855
|
+
</View>
|
|
856
|
+
)}
|
|
857
|
+
</Section>
|
|
858
|
+
</View>
|
|
859
|
+
|
|
860
|
+
<Divider />
|
|
861
|
+
|
|
862
|
+
{/* BILLING — invoice documents on the record, in the inline vocabulary.
|
|
863
|
+
The issue action lives in each band's header; ONE section-level
|
|
864
|
+
callout explains what blocks issuing (no per-band totals, no
|
|
865
|
+
per-band reasons — the collect band owns the number). */}
|
|
866
|
+
<View onLayout={nav.register("billing")}>
|
|
867
|
+
<Section>
|
|
868
|
+
<SectionHeading>
|
|
869
|
+
<SectionHeadingTitle description="Each invoice owns its charge lines — a charge never lives apart from the document it bills on.">Billing</SectionHeadingTitle>
|
|
870
|
+
<OwnerTag stage="accounting" />
|
|
871
|
+
</SectionHeading>
|
|
872
|
+
|
|
873
|
+
{stage === "sales" || stage === "operations" ? (
|
|
874
|
+
<Callout tone="info">
|
|
875
|
+
<CalloutText>Billing opens when the record reaches Accounting — hand off above.</CalloutText>
|
|
876
|
+
</Callout>
|
|
877
|
+
) : customer === null ? (
|
|
878
|
+
<Callout tone="warning">
|
|
879
|
+
<CalloutText>Attach a customer above to issue invoices.</CalloutText>
|
|
880
|
+
</Callout>
|
|
881
|
+
) : !taxIdValid ? (
|
|
882
|
+
<Callout tone="warning">
|
|
883
|
+
<CalloutText>{customer.name} has no valid tax ID — add it in the Customer section to issue invoices.</CalloutText>
|
|
884
|
+
</Callout>
|
|
885
|
+
) : null}
|
|
886
|
+
|
|
887
|
+
<View style={{ gap: 10 }}>
|
|
888
|
+
{invoices.map((inv) => (
|
|
889
|
+
<InvoiceBand
|
|
890
|
+
key={inv.key}
|
|
891
|
+
inv={inv}
|
|
892
|
+
gateReason={
|
|
893
|
+
stage === "sales" || stage === "operations"
|
|
894
|
+
? "Billing opens at the Accounting stage."
|
|
895
|
+
: customer === null
|
|
896
|
+
? "Attach a customer to issue."
|
|
897
|
+
: !taxIdValid
|
|
898
|
+
? "Add the customer's tax ID to issue."
|
|
899
|
+
: ""
|
|
900
|
+
}
|
|
901
|
+
onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
|
|
902
|
+
onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
|
|
903
|
+
onIssue={setConfirmIssue}
|
|
904
|
+
/>
|
|
905
|
+
))}
|
|
906
|
+
</View>
|
|
907
|
+
|
|
908
|
+
{/* collect band */}
|
|
909
|
+
<Divider />
|
|
910
|
+
<View style={{ gap: 10 }}>
|
|
911
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
912
|
+
<Text weight="semibold" style={{ flex: 1 }}>Total to collect</Text>
|
|
913
|
+
<Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
|
|
914
|
+
</View>
|
|
915
|
+
{allMissing.length > 0 ? (
|
|
916
|
+
<Callout tone="warning">
|
|
917
|
+
<CalloutText>
|
|
918
|
+
{allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
|
|
919
|
+
</CalloutText>
|
|
920
|
+
</Callout>
|
|
921
|
+
) : null}
|
|
922
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
923
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
|
|
924
|
+
<Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
|
|
925
|
+
</View>
|
|
926
|
+
</View>
|
|
927
|
+
|
|
928
|
+
{/* deposit band — labelled separate, never part of the total */}
|
|
929
|
+
<Divider />
|
|
930
|
+
<View style={{ gap: 8 }}>
|
|
931
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
932
|
+
<Text weight="semibold">Deposit</Text>
|
|
933
|
+
<Badge label="Collected separately" color="zinc" />
|
|
934
|
+
</View>
|
|
935
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap", minHeight: 40 }}>
|
|
936
|
+
<Text size="sm" color="muted" style={{ width: 150 }}>Refundable</Text>
|
|
937
|
+
<View style={{ flexGrow: 1, flexBasis: 160 }}>
|
|
938
|
+
<InlineNumberInput
|
|
939
|
+
value={deposit || null}
|
|
940
|
+
onSave={persist((v: number | null) => setDeposit(v ?? 0))}
|
|
941
|
+
min={0}
|
|
942
|
+
format={money}
|
|
943
|
+
placeholder="—"
|
|
944
|
+
accessibilityLabel="Deposit amount"
|
|
945
|
+
/>
|
|
946
|
+
</View>
|
|
947
|
+
<Button
|
|
948
|
+
title="Deposit receipt"
|
|
949
|
+
color="secondary"
|
|
950
|
+
disabled={deposit <= 0}
|
|
951
|
+
onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
|
|
952
|
+
/>
|
|
953
|
+
</View>
|
|
954
|
+
</View>
|
|
955
|
+
</Section>
|
|
956
|
+
</View>
|
|
957
|
+
|
|
958
|
+
<Divider />
|
|
959
|
+
|
|
960
|
+
{/* FILES — the record's document CRUD: drop to add, click a thumbnail
|
|
961
|
+
to preview in the gallery, ✕ to delete. */}
|
|
962
|
+
<View onLayout={nav.register("files")}>
|
|
963
|
+
<Section>
|
|
964
|
+
<SectionHeading>
|
|
965
|
+
<SectionHeadingTitle description="The signed quote, the PO, photos — drop to add, click to preview.">Files</SectionHeadingTitle>
|
|
966
|
+
<OwnerTag stage="operations" />
|
|
967
|
+
{files.length > 0 ? <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta> : null}
|
|
968
|
+
</SectionHeading>
|
|
969
|
+
{files.length > 0 ? (
|
|
970
|
+
<FileThumbnailGrid
|
|
971
|
+
files={files}
|
|
972
|
+
itemSize={84}
|
|
973
|
+
onFilePress={(f) => setActiveFile(files.findIndex((x) => x.id === f.id))}
|
|
974
|
+
onRemove={(fid) => setFiles((prev) => prev.filter((x) => x.id !== fid))}
|
|
975
|
+
/>
|
|
976
|
+
) : null}
|
|
977
|
+
<FileDropzone
|
|
978
|
+
height={100}
|
|
979
|
+
label="Drop the signed quote or PO"
|
|
980
|
+
hint="or click to browse · PDF, images"
|
|
981
|
+
dropLabel="Release to attach"
|
|
982
|
+
accept="application/pdf,image/*"
|
|
983
|
+
accessibilityLabel="Attach documents"
|
|
984
|
+
onFiles={(dropped) =>
|
|
985
|
+
setFiles((prev) => [
|
|
986
|
+
...prev,
|
|
987
|
+
...dropped.map((f) => ({
|
|
988
|
+
id: `file_${(fileSeq.current += 1)}`,
|
|
989
|
+
filename: f.name,
|
|
990
|
+
mimeType: f.type || "application/octet-stream",
|
|
991
|
+
url: URL.createObjectURL(f),
|
|
992
|
+
})),
|
|
993
|
+
])
|
|
994
|
+
}
|
|
995
|
+
/>
|
|
996
|
+
</Section>
|
|
997
|
+
</View>
|
|
998
|
+
|
|
999
|
+
<Divider />
|
|
1000
|
+
|
|
1001
|
+
{/* TASKS — the handoff checklist, grouped by the desk that owns
|
|
1002
|
+
them; every row edits (the stage gates the handoff and Billing,
|
|
1003
|
+
never task editing). The CURRENT desk's open tasks warn beside
|
|
1004
|
+
the handoff CTA below. */}
|
|
1005
|
+
<View onLayout={nav.register("tasks")}>
|
|
1006
|
+
<Section>
|
|
1007
|
+
<SectionHeading>
|
|
1008
|
+
<SectionHeadingTitle description="Each desk clears its checklist to hand the record off.">Tasks</SectionHeadingTitle>
|
|
1009
|
+
{/* the same compact meter as the register's Tasks column */}
|
|
1010
|
+
{tasks.length > 0 ? (
|
|
1011
|
+
<View style={{ width: 120 }}>
|
|
1012
|
+
<ProgressBar compact value={tasks.filter((t) => t.done).length} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
|
|
1013
|
+
</View>
|
|
1014
|
+
) : null}
|
|
1015
|
+
</SectionHeading>
|
|
1016
|
+
{/* the Task-list toolbar grammar: a clearable Group-by FilterChip +
|
|
1017
|
+
filter chips — not a bespoke pill row */}
|
|
1018
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
|
1019
|
+
<FilterChip
|
|
1020
|
+
label="Group by"
|
|
1021
|
+
summary={taskGroup === "desk" ? "Desk" : taskGroup === "assignee" ? "Assignee" : taskGroup === "status" ? "Status" : undefined}
|
|
1022
|
+
onClear={() => setTaskGroup(null)}
|
|
1023
|
+
clearLabel="Clear grouping"
|
|
1024
|
+
>
|
|
1025
|
+
{({ close }) => (
|
|
1026
|
+
<OptionList
|
|
1027
|
+
search={{ mode: "none" }}
|
|
1028
|
+
options={[
|
|
1029
|
+
{ value: "desk", label: "Desk" },
|
|
1030
|
+
{ value: "assignee", label: "Assignee" },
|
|
1031
|
+
{ value: "status", label: "Status" },
|
|
1032
|
+
]}
|
|
1033
|
+
value={taskGroup ?? undefined}
|
|
1034
|
+
onValueChange={(v) => {
|
|
1035
|
+
if (v) setTaskGroup(v);
|
|
1036
|
+
close();
|
|
1037
|
+
}}
|
|
1038
|
+
onRequestClose={close}
|
|
1039
|
+
/>
|
|
1040
|
+
)}
|
|
1041
|
+
</FilterChip>
|
|
1042
|
+
<FilterChip
|
|
1043
|
+
label="Assignee"
|
|
1044
|
+
summary={assigneeFilter.length > 0 ? `${assigneeFilter.length}` : undefined}
|
|
1045
|
+
onClear={() => setAssigneeFilter([])}
|
|
1046
|
+
clearLabel="Clear assignee filter"
|
|
1047
|
+
>
|
|
1048
|
+
<OptionList
|
|
1049
|
+
search={{ mode: "none" }}
|
|
1050
|
+
multi
|
|
1051
|
+
options={[...TEAM.map((m) => ({ value: m.id, label: m.name ?? m.id })), { value: "none", label: "Unassigned" }]}
|
|
1052
|
+
value={assigneeFilter}
|
|
1053
|
+
onValueChange={setAssigneeFilter}
|
|
1054
|
+
/>
|
|
1055
|
+
</FilterChip>
|
|
1056
|
+
<FilterChip
|
|
1057
|
+
label="Status"
|
|
1058
|
+
summary={statusFilter === "open" ? "Open" : statusFilter === "done" ? "Done" : undefined}
|
|
1059
|
+
onClear={() => setStatusFilter(null)}
|
|
1060
|
+
clearLabel="Clear status filter"
|
|
1061
|
+
>
|
|
1062
|
+
{({ close }) => (
|
|
1063
|
+
<OptionList
|
|
1064
|
+
search={{ mode: "none" }}
|
|
1065
|
+
options={[
|
|
1066
|
+
{ value: "open", label: "Open" },
|
|
1067
|
+
{ value: "done", label: "Done" },
|
|
1068
|
+
]}
|
|
1069
|
+
value={statusFilter ?? undefined}
|
|
1070
|
+
onValueChange={(v) => {
|
|
1071
|
+
if (v) setStatusFilter(v);
|
|
1072
|
+
close();
|
|
1073
|
+
}}
|
|
1074
|
+
onRequestClose={close}
|
|
1075
|
+
/>
|
|
1076
|
+
)}
|
|
1077
|
+
</FilterChip>
|
|
1078
|
+
</View>
|
|
1079
|
+
{/* the capture row at the TOP — a new task lands on the capture desk
|
|
1080
|
+
(the current one; the last desk once closed) */}
|
|
1081
|
+
<CaptureRow value={newTask} onChangeText={setNewTask} onSubmit={addTask} placeholder={`Add a task for ${stageOf(captureDesk).label}…`} accessibilityLabel="Add a task" />
|
|
1082
|
+
<View style={{ gap: 14 }}>
|
|
1083
|
+
{taskSections.map((g) => {
|
|
1084
|
+
const desk = g.desk;
|
|
1085
|
+
return (
|
|
1086
|
+
<View key={g.key} style={{ gap: 6 }}>
|
|
1087
|
+
{g.head ? (
|
|
1088
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
1089
|
+
{g.head}
|
|
1090
|
+
{g.items.length > 0 ? (
|
|
1091
|
+
<Text size="xs" color="muted" tabular>{`${g.items.filter((t) => t.done).length}/${g.items.length}`}</Text>
|
|
1092
|
+
) : null}
|
|
1093
|
+
</View>
|
|
1094
|
+
) : null}
|
|
1095
|
+
{/* rows ride the Checklist compound — one geometry, EVERY
|
|
1096
|
+
desk's rows fully editable (the stage gates the handoff
|
|
1097
|
+
and Billing, never task editing — planning ahead on a
|
|
1098
|
+
later desk is normal work) */}
|
|
1099
|
+
<Checklist trailingWidth={156}>
|
|
1100
|
+
{g.items.map((t) => {
|
|
1101
|
+
const next = nextDeskOf(t.stage);
|
|
1102
|
+
const menuItems: ActionMenuItem[] = [
|
|
1103
|
+
...(next !== null
|
|
1104
|
+
? [{ key: "chuyen", label: `Move to ${stageOf(next).label}`, icon: "arrow-right" as const, onPress: () => moveTask(t.id, next) }]
|
|
1105
|
+
: []),
|
|
1106
|
+
{ key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => removeTask(t.id) },
|
|
1107
|
+
];
|
|
1108
|
+
return (
|
|
1109
|
+
<ChecklistRow
|
|
1110
|
+
key={t.id}
|
|
1111
|
+
control={
|
|
1112
|
+
<CheckCircle
|
|
1113
|
+
checked={t.done}
|
|
1114
|
+
onChange={(on) => toggleTask(t.id, on)}
|
|
1115
|
+
accessibilityLabel={t.label}
|
|
1116
|
+
/>
|
|
1117
|
+
}
|
|
1118
|
+
trailing={
|
|
1119
|
+
<InlineMemberSelect
|
|
1120
|
+
background="transparent"
|
|
1121
|
+
members={TEAM}
|
|
1122
|
+
value={t.assignee}
|
|
1123
|
+
onSave={(m) => assignTask(t.id, m)}
|
|
1124
|
+
placeholder="Assign…"
|
|
1125
|
+
accessibilityLabel={`Assignee · ${t.label}`}
|
|
1126
|
+
/>
|
|
1127
|
+
}
|
|
1128
|
+
menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
|
|
1129
|
+
>
|
|
1130
|
+
<InlineTextInput
|
|
1131
|
+
background="transparent"
|
|
1132
|
+
value={t.label}
|
|
1133
|
+
onSave={(v) => renameTask(t.id, v)}
|
|
1134
|
+
struck={t.done}
|
|
1135
|
+
accessibilityLabel="Task title"
|
|
1136
|
+
/>
|
|
1137
|
+
</ChecklistRow>
|
|
1138
|
+
);
|
|
1139
|
+
})}
|
|
1140
|
+
{/* SUGGESTIONS are pills, not rows — tap to materialize,
|
|
1141
|
+
✕ dismisses for this record; never counted */}
|
|
1142
|
+
{desk != null && g.ghosts.length > 0 ? (
|
|
1143
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, paddingTop: 2, paddingBottom: 4 }}>
|
|
1144
|
+
{g.ghosts.map((label) => (
|
|
1145
|
+
<SuggestionChip
|
|
1146
|
+
key={label}
|
|
1147
|
+
label={label}
|
|
1148
|
+
onAdd={() => addSuggested(desk, label)}
|
|
1149
|
+
onDismiss={() => setDismissed((prev) => (prev.includes(label) ? prev : [...prev, label]))}
|
|
1150
|
+
/>
|
|
1151
|
+
))}
|
|
1152
|
+
</View>
|
|
1153
|
+
) : null}
|
|
1154
|
+
</Checklist>
|
|
1155
|
+
</View>
|
|
1156
|
+
);
|
|
1157
|
+
})}
|
|
1158
|
+
</View>
|
|
1159
|
+
</Section>
|
|
1160
|
+
</View>
|
|
1161
|
+
|
|
1162
|
+
<Divider />
|
|
1163
|
+
|
|
1164
|
+
{/* THE HANDOFF GATE — the atomic boundary between departments, not a
|
|
1165
|
+
form and not a message: the record itself changes desks. Each stage
|
|
1166
|
+
names what it owes (the why-hint) before its CTA un-gates; the
|
|
1167
|
+
receiving department finds the record in ITS register. */}
|
|
1168
|
+
{gate ? (
|
|
1169
|
+
<View style={{ gap: 12 }}>
|
|
1170
|
+
{remaining > 0 ? (
|
|
1171
|
+
<Callout tone="info">
|
|
1172
|
+
<CalloutText>{taskHint}</CalloutText>
|
|
1173
|
+
</Callout>
|
|
1174
|
+
) : null}
|
|
1175
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8 }}>
|
|
1176
|
+
{prevStage ? (
|
|
1177
|
+
<Button title={`Back to ${stageOf(prevStage).label}`} color="muted" onPress={() => setStage(prevStage)} />
|
|
1178
|
+
) : null}
|
|
1179
|
+
<Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 160 }}>
|
|
1180
|
+
{`Handing off moves the record to ${stageOf(gate.next).label} — fields stay editable after.`}
|
|
1181
|
+
</Text>
|
|
1182
|
+
<Button title={gate.cta} color="primary" onPress={() => setStage(gate.next)} />
|
|
1183
|
+
</View>
|
|
1184
|
+
</View>
|
|
1185
|
+
) : (
|
|
1186
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8 }}>
|
|
1187
|
+
<Text size="xs" color="muted" style={{ flexGrow: 1, flexBasis: 160 }}>
|
|
1188
|
+
Closed on 04/07/2026 — every field still edits in place and saves on its own.
|
|
1189
|
+
</Text>
|
|
1190
|
+
<Button title="Reopen at Accounting" color="muted" onPress={() => setStage("accounting")} />
|
|
1191
|
+
</View>
|
|
1192
|
+
)}
|
|
1193
|
+
|
|
1194
|
+
<Divider />
|
|
1195
|
+
|
|
1196
|
+
{/* the settings shape: title + description left, a live control right,
|
|
1197
|
+
applied on change */}
|
|
1198
|
+
<View onLayout={nav.register("preferences")}>
|
|
1199
|
+
<Section>
|
|
1200
|
+
<SectionHeading>
|
|
1201
|
+
<SectionHeadingTitle description="Applied as you change them — no Save.">Preferences</SectionHeadingTitle>
|
|
1202
|
+
</SectionHeading>
|
|
1203
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
|
|
1204
|
+
<View style={{ flex: 1, gap: 1 }}>
|
|
1205
|
+
<Text size="sm">Notify the customer</Text>
|
|
1206
|
+
<Text size="xs" color="muted">Email changes to the primary contact</Text>
|
|
1207
|
+
</View>
|
|
1208
|
+
<Switch accessibilityLabel="Notify the customer" value={notifyCustomer} onChange={setNotifyCustomer} />
|
|
1209
|
+
</View>
|
|
1210
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
|
|
1211
|
+
<View style={{ flex: 1, gap: 1 }}>
|
|
1212
|
+
<Text size="sm">Allow partial delivery</Text>
|
|
1213
|
+
<Text size="xs" color="muted">Ship available lines before the rest arrive</Text>
|
|
1214
|
+
</View>
|
|
1215
|
+
<Switch accessibilityLabel="Allow partial delivery" value={partialDelivery} onChange={setPartialDelivery} />
|
|
1216
|
+
</View>
|
|
1217
|
+
</Section>
|
|
1218
|
+
</View>
|
|
1219
|
+
|
|
1220
|
+
<Divider />
|
|
1221
|
+
|
|
1222
|
+
<DangerZone
|
|
1223
|
+
title="Delete record"
|
|
1224
|
+
description="Permanently remove this record and its invoices, charges, and history. This can't be undone."
|
|
1225
|
+
>
|
|
1226
|
+
<Button title="Delete record" color="danger" onPress={() => {}} />
|
|
1227
|
+
</DangerZone>
|
|
1228
|
+
</View>
|
|
1229
|
+
|
|
1230
|
+
{/* the narrow-mode section picker — a full-page takeover: pick a section
|
|
1231
|
+
to jump there; Escape or the close control dismisses */}
|
|
1232
|
+
<Modal open={sectionsOpen} onClose={() => setSectionsOpen(false)}>
|
|
1233
|
+
<ModalHeader title="Sections" onClose={() => setSectionsOpen(false)} />
|
|
1234
|
+
<ModalBody>
|
|
1235
|
+
<View style={{ gap: 2 }}>
|
|
1236
|
+
{SECTIONS.map((sec) => (
|
|
1237
|
+
<MenuButton
|
|
1238
|
+
key={sec.key}
|
|
1239
|
+
icon={sec.icon}
|
|
1240
|
+
title={sec.label}
|
|
1241
|
+
selected={nav.activeKey === sec.key}
|
|
1242
|
+
onPress={() => {
|
|
1243
|
+
setSectionsOpen(false);
|
|
1244
|
+
nav.jumpTo(sec.key);
|
|
1245
|
+
}}
|
|
1246
|
+
/>
|
|
1247
|
+
))}
|
|
1248
|
+
</View>
|
|
1249
|
+
</ModalBody>
|
|
1250
|
+
</Modal>
|
|
1251
|
+
|
|
1252
|
+
{/* find-or-create's CREATE branch — a focused Dialog (4 fields is
|
|
1253
|
+
dialog-weight; an inline swap shifts the page). Essentials only:
|
|
1254
|
+
after Create & attach, the card's Fetch button fills the rest. */}
|
|
1255
|
+
<Dialog width={520} open={custDraft !== null} onOpenChange={(o) => { if (!o) setCustDraft(null); }}>
|
|
1256
|
+
<DialogHeader>
|
|
1257
|
+
<DialogHeaderTitle>New customer</DialogHeaderTitle>
|
|
1258
|
+
</DialogHeader>
|
|
1259
|
+
{/* the SAME inline-chip table as the edit card — create and edit are
|
|
1260
|
+
one vocabulary, only the commit differs (Create & attach) */}
|
|
1261
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 8 }}>
|
|
1262
|
+
<DetailTable labelWidth={150} trailingWidth={88}>
|
|
1263
|
+
<DetailRow label="Customer name">
|
|
1264
|
+
<InlineTextInput
|
|
1265
|
+
value={custDraft?.name ?? ""}
|
|
1266
|
+
onSave={(v) => {
|
|
1267
|
+
setCustDraft((d) => (d ? { ...d, name: v } : d));
|
|
1268
|
+
return Promise.resolve();
|
|
1269
|
+
}}
|
|
1270
|
+
placeholder="Add legal name…"
|
|
1271
|
+
accessibilityLabel="Customer name"
|
|
1272
|
+
/>
|
|
1273
|
+
</DetailRow>
|
|
1274
|
+
<DetailRow
|
|
1275
|
+
label="Tax ID"
|
|
1276
|
+
trailing={
|
|
1277
|
+
<Button
|
|
1278
|
+
title="Fetch"
|
|
1279
|
+
color="secondary"
|
|
1280
|
+
loading={fetching}
|
|
1281
|
+
disabled={!TAX_ID_RE.test((custDraft?.taxId ?? "").trim())}
|
|
1282
|
+
onPress={fetchIntoDraft}
|
|
1283
|
+
/>
|
|
1284
|
+
}
|
|
1285
|
+
>
|
|
1286
|
+
<InlineTextInput
|
|
1287
|
+
value={custDraft?.taxId ?? ""}
|
|
1288
|
+
onSave={(v) => {
|
|
1289
|
+
setCustDraft((d) => (d ? { ...d, taxId: v } : d));
|
|
1290
|
+
return Promise.resolve();
|
|
1291
|
+
}}
|
|
1292
|
+
placeholder="Add tax ID…"
|
|
1293
|
+
accessibilityLabel="Tax ID"
|
|
1294
|
+
/>
|
|
1295
|
+
</DetailRow>
|
|
1296
|
+
<DetailRow label="Contact">
|
|
1297
|
+
<InlineTextInput
|
|
1298
|
+
value={custDraft?.contact ?? ""}
|
|
1299
|
+
onSave={(v) => {
|
|
1300
|
+
setCustDraft((d) => (d ? { ...d, contact: v } : d));
|
|
1301
|
+
return Promise.resolve();
|
|
1302
|
+
}}
|
|
1303
|
+
placeholder="Add contact…"
|
|
1304
|
+
accessibilityLabel="Contact"
|
|
1305
|
+
/>
|
|
1306
|
+
</DetailRow>
|
|
1307
|
+
<DetailRow label="City">
|
|
1308
|
+
<InlineTextInput
|
|
1309
|
+
value={custDraft?.city ?? ""}
|
|
1310
|
+
onSave={(v) => {
|
|
1311
|
+
setCustDraft((d) => (d ? { ...d, city: v } : d));
|
|
1312
|
+
return Promise.resolve();
|
|
1313
|
+
}}
|
|
1314
|
+
placeholder="Add city…"
|
|
1315
|
+
accessibilityLabel="City"
|
|
1316
|
+
/>
|
|
1317
|
+
</DetailRow>
|
|
1318
|
+
</DetailTable>
|
|
1319
|
+
{taxIdError ? <Text size="xs" color="danger">{taxIdError}</Text> : null}
|
|
1320
|
+
<Text size="xs" color="muted">Created and attached to this order — Fetch fills contact + city from the tax-ID registry.</Text>
|
|
1321
|
+
</View>
|
|
1322
|
+
<DialogFooter>
|
|
1323
|
+
<Button title="Cancel" color="secondary" onPress={() => setCustDraft(null)} />
|
|
1324
|
+
<Button title="Create & attach" color="primary" disabled={(custDraft?.name.trim() ?? "") === "" || !!taxIdError} onPress={createAndAttach} />
|
|
1325
|
+
</DialogFooter>
|
|
1326
|
+
</Dialog>
|
|
1327
|
+
|
|
1328
|
+
<FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} />
|
|
1329
|
+
|
|
1330
|
+
{/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
|
|
1331
|
+
<Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssue(null); }}>
|
|
1332
|
+
<DialogHeader>
|
|
1333
|
+
<DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
|
|
1334
|
+
</DialogHeader>
|
|
1335
|
+
<View style={{ paddingHorizontal: 24, paddingVertical: 12 }}>
|
|
1336
|
+
<Callout tone="warning">
|
|
1337
|
+
<CalloutText>
|
|
1338
|
+
{confirmIssue
|
|
1339
|
+
? confirmIssue.ref
|
|
1340
|
+
? `Re-issue "${confirmIssue.title}" (${formatMoney(invoiceTotal(confirmIssue))}) with the current charges. A NEW lookup code replaces ${confirmIssue.ref}; this can't be undone.`
|
|
1341
|
+
: `Issue a real e-invoice for "${confirmIssue.title}" (${formatMoney(invoiceTotal(confirmIssue))}) to the provider. This writes a lookup code to the record and can't be undone.`
|
|
1342
|
+
: ""}
|
|
1343
|
+
</CalloutText>
|
|
1344
|
+
</Callout>
|
|
1345
|
+
</View>
|
|
1346
|
+
<DialogFooter>
|
|
1347
|
+
<Button title="Cancel" color="secondary" onPress={() => setConfirmIssue(null)} />
|
|
1348
|
+
<Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
|
|
1349
|
+
</DialogFooter>
|
|
1350
|
+
</Dialog>
|
|
1351
|
+
</ScrollView>
|
|
1352
|
+
</View>
|
|
1353
|
+
);
|
|
1354
|
+
}
|