@lotics/ui 46.3.0 → 46.8.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 +14 -1
- package/docs/catalog.md +118 -6
- package/docs/composition.md +26 -0
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +90 -29
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1027 -0
- package/package.json +261 -259
- package/src/accordion.tsx +7 -1
- package/src/board.tsx +611 -0
- package/src/card.tsx +7 -1
- package/src/charge_lines.tsx +373 -0
- package/src/chip_group.tsx +57 -1
- package/src/file_row.tsx +98 -5
- package/src/icon.tsx +6 -0
- package/src/inline_edit.tsx +54 -10
- package/src/inline_number_input.tsx +5 -1
- package/src/inline_text_input.tsx +1 -1
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/reference_field.tsx +36 -13
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { Pressable, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { Alert } from "@lotics/ui/alert";
|
|
5
|
+
import { Button } from "@lotics/ui/button";
|
|
6
|
+
import { IconButton } from "@lotics/ui/icon_button";
|
|
7
|
+
import { Link } from "@lotics/ui/link";
|
|
8
|
+
import { TextLink } from "@lotics/ui/text_link";
|
|
9
|
+
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
10
|
+
import { PageContent } from "@lotics/ui/page_content";
|
|
11
|
+
import { Section, SectionHeading, SectionHeadingTitle, Subsection } from "@lotics/ui/section_heading";
|
|
12
|
+
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
13
|
+
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
14
|
+
import { SummaryLine } from "@lotics/ui/summary_line";
|
|
15
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
16
|
+
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
17
|
+
import { FileBadge } from "@lotics/ui/file_badge";
|
|
18
|
+
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
19
|
+
import { type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
20
|
+
import { InlineButton } from "@lotics/ui/inline_button";
|
|
21
|
+
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
22
|
+
import { InlineFiles } from "@lotics/ui/inline_files";
|
|
23
|
+
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
24
|
+
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
25
|
+
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
26
|
+
import { ChargeLine, ChargeLines } from "@lotics/ui/charge_lines";
|
|
27
|
+
import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
|
|
28
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
29
|
+
import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
|
|
30
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
31
|
+
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
32
|
+
import type { PickerOption } from "@lotics/ui/picker";
|
|
33
|
+
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// Template, Money — THE money surface: one record's money, in every shape it
|
|
36
|
+
// takes. Four kinds of screen live here and they are not interchangeable:
|
|
37
|
+
//
|
|
38
|
+
// FEES — the DETAILED view, both directions (charge/cost), each fee with
|
|
39
|
+
// its party, due date, supplier original and paid state. A
|
|
40
|
+
// register whose every row expands in place to its full detail.
|
|
41
|
+
// BILLING — the invoice DOCUMENTS: per-invoice charge bands, the closing
|
|
42
|
+
// `Ledger` statement, the deposit and the receipt.
|
|
43
|
+
// CHARGES — `ChargeLines`, the editable band you PRICE into: priced lines,
|
|
44
|
+
// flat amounts, mixed kinds, locked, empty, and the narrow fork.
|
|
45
|
+
//
|
|
46
|
+
// The split that governs the page: `ChargeLines` is what you are PRICING and
|
|
47
|
+
// `Ledger` is what you are READING — a band you type into versus a statement
|
|
48
|
+
// you check. Both keep every figure on one right-aligned tabular column,
|
|
49
|
+
// because a money column that lands on more edges than it has kinds cannot be
|
|
50
|
+
// added up by eye.
|
|
51
|
+
//
|
|
52
|
+
// Fees and Billing are lifted from `tpl_record`, where they are two sections of
|
|
53
|
+
// a much larger record surface; here they are the subject rather than a part of
|
|
54
|
+
// one, and the charge bands below them show the shapes a record's money takes
|
|
55
|
+
// that no single scenario exercises at once.
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** ONE ₫ formatter for the page — a template that spells its home currency two
|
|
59
|
+
* ways teaches two ways. `formatMoney` is the kit's, so a table cell and a
|
|
60
|
+
* charge line cannot disagree about digit grouping. */
|
|
61
|
+
const money = (v: number | null) => (v == null ? "" : formatMoney(v));
|
|
62
|
+
/** A SECOND currency, for the one band that is denominated in it. A band formats
|
|
63
|
+
* its own money (`ChargeLines.formatMoney`), which is exactly what lets a
|
|
64
|
+
* foreign-currency charge sit on the same page as the domestic ones. */
|
|
65
|
+
const eur = (n: number) => `€${n.toLocaleString("en-IE", { minimumFractionDigits: 2 })}`;
|
|
66
|
+
|
|
67
|
+
type Method = "cash" | "transfer" | "card";
|
|
68
|
+
const METHODS: PickerOption<Method>[] = [
|
|
69
|
+
{ value: "cash", label: "Cash" },
|
|
70
|
+
{ value: "transfer", label: "Bank transfer" },
|
|
71
|
+
{ value: "card", label: "Card" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Fake persistence for the inline editors — real apps await their mutation. */
|
|
75
|
+
function persist<T>(set: (v: T) => void) {
|
|
76
|
+
return (v: T) =>
|
|
77
|
+
new Promise<void>((resolve) => {
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
set(v);
|
|
80
|
+
resolve();
|
|
81
|
+
}, 350);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
|
|
86
|
+
const MOCK_PHOTO_URL =
|
|
87
|
+
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM5M2M1ZmQnLz48Y2lyY2xlIGN4PScyNDAnIGN5PSc4MCcgcj0nNDAnIGZpbGw9JyNmZWY5YzMnLz48L3N2Zz4=";
|
|
88
|
+
|
|
89
|
+
// ── the FEES ledger — the DETAILED money view (both directions), distinct
|
|
90
|
+
// from Billing's invoice DOCUMENTS: every fee the record incurs or charges,
|
|
91
|
+
// with its party, due date, proof reference and paid state.
|
|
92
|
+
//
|
|
93
|
+
// The drill-down law for a CHILD COLLECTION inside a record: the row EXPANDS
|
|
94
|
+
// (`TableRow` `detail` + `expanded`), it does not open a drawer. A register
|
|
95
|
+
// screen's row still docks a workspace Drawer — on a list screen there is
|
|
96
|
+
// nowhere else for the detail to go — but here the record page IS the context
|
|
97
|
+
// the drawer was recreating, so docking one charged for navigation twice and
|
|
98
|
+
// took the surrounding rows away to show nine fields.
|
|
99
|
+
//
|
|
100
|
+
// This is also why the ledger is not simply WIDER. All nine fields as columns
|
|
101
|
+
// demand 946px + 112 gap + 40 gutter = 1098px against a reading column a third
|
|
102
|
+
// of that; over budget `table_fit` drops droppable columns by priority until
|
|
103
|
+
// the rest fit. Widening the register does not display VAT / Invoice no / Note
|
|
104
|
+
// — it drops them silently. And `note` is prose: it has no column width at any
|
|
105
|
+
// measure.
|
|
106
|
+
type FeeDirection = "charge" | "cost";
|
|
107
|
+
/** A picked `File` as the kit's `DisplayFile`. A real host uploads and returns
|
|
108
|
+
* the stored object; the object URL stands in for that here. */
|
|
109
|
+
let feeDocSeq = 0;
|
|
110
|
+
const asDisplayFile = (f: File): DisplayFile => ({
|
|
111
|
+
id: `fee-doc-u${(feeDocSeq += 1)}`,
|
|
112
|
+
filename: f.name,
|
|
113
|
+
mimeType: f.type || "application/octet-stream",
|
|
114
|
+
url: URL.createObjectURL(f),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
interface Fee {
|
|
118
|
+
id: string;
|
|
119
|
+
name: string;
|
|
120
|
+
/** charge = billed to the customer, cost = paid out to a vendor. */
|
|
121
|
+
direction: FeeDirection;
|
|
122
|
+
party: string;
|
|
123
|
+
amount: number;
|
|
124
|
+
vat: number | null;
|
|
125
|
+
due: string;
|
|
126
|
+
invoiceNo: string;
|
|
127
|
+
/** The supplier's original, filed against THIS fee. A number is what the field
|
|
128
|
+
* claimed; these are what actually arrived, and the gap between them is the
|
|
129
|
+
* work a payables desk does. */
|
|
130
|
+
docs: DisplayFile[];
|
|
131
|
+
paid: boolean;
|
|
132
|
+
note: string;
|
|
133
|
+
}
|
|
134
|
+
let feeSeq = 4;
|
|
135
|
+
const FEE_SEED: Fee[] = [
|
|
136
|
+
{ id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", docs: [], paid: true, note: "" },
|
|
137
|
+
{ id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", docs: [{ id: "fee-doc-1", filename: "NH-2044-invoice.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], paid: false, note: "Last-mile to the port" },
|
|
138
|
+
// UNPAID with a number claimed and nothing filed — the state the status column
|
|
139
|
+
// exists to surface. It was `paid: true`, which meant "Awaiting invoice" could
|
|
140
|
+
// never render and the design could not be seen at all.
|
|
141
|
+
{ id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", docs: [], paid: false, note: "" },
|
|
142
|
+
// A PHOTO of an invoice, not a PDF — the case the badge exists for. A supplier
|
|
143
|
+
// who sends a phone snap has not sent a tax document, and the colour says so
|
|
144
|
+
// before anyone opens it. A fixture of nothing but PDFs makes the badge look
|
|
145
|
+
// like decoration, because the one thing it carries never varies.
|
|
146
|
+
{ id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "HF-8821", docs: [{ id: "fee-doc-2", filename: "storage-invoice-photo.jpg", mimeType: "image/jpeg", url: MOCK_PHOTO_URL }], paid: false, note: "3 extra days at Central hub" },
|
|
147
|
+
];
|
|
148
|
+
// `priority` = the mobile contract: Party then Type drop first on a narrow
|
|
149
|
+
// container, Amount + Status survive with the identity; at the floor the rows
|
|
150
|
+
// STACK label-over-value — and the expansion always carries the full fee.
|
|
151
|
+
// THREE columns, not five — the register's grammar. A column per FACT spends
|
|
152
|
+
// width on separation the reader never asked for: the party belongs to the fee's
|
|
153
|
+
// identity and the payment state belongs to its amount, so each rides line 2 of
|
|
154
|
+
// the cell it qualifies. Grouping bought 246px (Party 150 + Status 96), which is
|
|
155
|
+
// more than the document column costs — the table gained information and lost
|
|
156
|
+
// two columns.
|
|
157
|
+
const FEE_COLUMNS: TableColumn[] = [
|
|
158
|
+
{ key: "fee", label: "Fee", flex: 1 },
|
|
159
|
+
{ key: "amount", label: "Amount", width: 104, align: "right", priority: 1 },
|
|
160
|
+
{ key: "invoice", label: "Invoice", width: 116, priority: 2 },
|
|
161
|
+
];
|
|
162
|
+
const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
|
|
163
|
+
|
|
164
|
+
interface Charge {
|
|
165
|
+
key: string;
|
|
166
|
+
label: string;
|
|
167
|
+
/** The expected list price — shown as ghost text while the line is unset. */
|
|
168
|
+
standard: number;
|
|
169
|
+
amount: number;
|
|
170
|
+
method: Method | "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface Invoice {
|
|
174
|
+
key: string;
|
|
175
|
+
title: string;
|
|
176
|
+
charges: Charge[];
|
|
177
|
+
/** Lookup code once issued to the e-invoice provider; "" = not issued. */
|
|
178
|
+
ref: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const BILLING_INITIAL: Invoice[] = [
|
|
182
|
+
{
|
|
183
|
+
// Issued AND multi-line, which is the row that exercises the precedence: `peek` wins,
|
|
184
|
+
// so its lookup link renders INSIDE the popover and the flat `reference` is ignored.
|
|
185
|
+
// Storage is the mirror case (one charge, issued) and takes the flat link instead.
|
|
186
|
+
key: "delivery",
|
|
187
|
+
title: "Delivery",
|
|
188
|
+
ref: "INV-0029",
|
|
189
|
+
charges: [
|
|
190
|
+
{ key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
|
|
191
|
+
{ key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
key: "handling",
|
|
196
|
+
title: "Handling",
|
|
197
|
+
ref: "",
|
|
198
|
+
charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
// Seeded ISSUED, so the section renders both states at rest: the Re-issue path, the
|
|
202
|
+
// band's lookup link, and the ledger's trailing `reference`. A fixture where nothing
|
|
203
|
+
// has happened yet only ever exercises the first half of a flow.
|
|
204
|
+
key: "storage",
|
|
205
|
+
title: "Storage",
|
|
206
|
+
ref: "INV-0031",
|
|
207
|
+
charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
/** A settled credit against the record — the statement's third side, and its only line. */
|
|
212
|
+
const CREDIT = { key: "cn-0031", label: "Credit note CN-0031", amount: 120_000, meta: "Storage waived, 3 days" };
|
|
213
|
+
|
|
214
|
+
const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
|
|
215
|
+
const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
|
|
216
|
+
|
|
217
|
+
function InvoiceParticulars({ inv }: { inv: Invoice }) {
|
|
218
|
+
return (
|
|
219
|
+
<View style={{ gap: 8, padding: 12, minWidth: 240 }}>
|
|
220
|
+
<Text size="xs" color="muted">{inv.title}</Text>
|
|
221
|
+
{inv.charges.map((c) => (
|
|
222
|
+
<View key={c.key} style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
|
|
223
|
+
<Text size="sm" style={{ flexGrow: 1, flexShrink: 1 }}>{c.label}</Text>
|
|
224
|
+
<Text size="sm" tabular color={c.amount > 0 ? undefined : "muted"}>
|
|
225
|
+
{c.amount > 0 ? formatMoney(c.amount) : "—"}
|
|
226
|
+
</Text>
|
|
227
|
+
</View>
|
|
228
|
+
))}
|
|
229
|
+
{inv.ref ? (
|
|
230
|
+
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
231
|
+
) : null}
|
|
232
|
+
</View>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── the CHARGE BANDS — `ChargeLines`, the write side. One seed feeds the wide
|
|
237
|
+
// band and the narrow one, because the fork is a statement about the BAND's
|
|
238
|
+
// container and nothing else: the same data, the same handlers, two widths.
|
|
239
|
+
interface PricedLine {
|
|
240
|
+
key: string;
|
|
241
|
+
label: string;
|
|
242
|
+
meta?: string;
|
|
243
|
+
quantity: number | null;
|
|
244
|
+
unitPrice: number | null;
|
|
245
|
+
}
|
|
246
|
+
const PRICED_SEED: PricedLine[] = [
|
|
247
|
+
{ key: "runs", label: "Delivery runs", meta: "Distance band 101–200 km", quantity: 45, unitPrice: 4_000 },
|
|
248
|
+
{ key: "cleaning", label: "Site cleaning", quantity: 1, unitPrice: 30_000 },
|
|
249
|
+
];
|
|
250
|
+
/** The rate card's ceiling — the figure the one-tap verb fills in. */
|
|
251
|
+
const CEILING_RATE = 4_200;
|
|
252
|
+
const lineAmount = (l: PricedLine) => (l.quantity ?? 0) * (l.unitPrice ?? 0);
|
|
253
|
+
|
|
254
|
+
export function TplMoney() {
|
|
255
|
+
// ONE clock per render. Two rows each calling `new Date()` can land on
|
|
256
|
+
// opposite sides of midnight and disagree about what "today" is.
|
|
257
|
+
const now = new Date();
|
|
258
|
+
const words = useLoticsLocale();
|
|
259
|
+
// The countdown for any date field on this record: "2 days overdue" / "Today"
|
|
260
|
+
// / "5 days left", routed to the annotation slot its urgency earns
|
|
261
|
+
// (error / warning / description) — @lotics/ui/deadline owns the wording and
|
|
262
|
+
// the thresholds so this screen cannot disagree with the register beside it.
|
|
263
|
+
const dueAnnotation = (iso: string, open: boolean) =>
|
|
264
|
+
open && iso !== "" ? deadlineAnnotation(daysUntil(new Date(iso), now), words.deadline) : {};
|
|
265
|
+
|
|
266
|
+
// ── the fees ledger — a register whose rows EXPAND (create-then-refine: Add
|
|
267
|
+
// appends a blank fee already open; every field refines in the expansion)
|
|
268
|
+
const [fees, setFees] = useState<Fee[]>(FEE_SEED);
|
|
269
|
+
// Which fee is expanded — ONE at a time, the caller's rule (`TableRow.expanded`
|
|
270
|
+
// is controlled precisely so this is a decision and not a default). Two open
|
|
271
|
+
// 380px details would push the third row off screen, and a register that can't
|
|
272
|
+
// be scanned has stopped being a register.
|
|
273
|
+
const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
|
|
274
|
+
const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
|
|
275
|
+
const addFee = () => {
|
|
276
|
+
const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", docs: [], paid: false, note: "" };
|
|
277
|
+
setFees((prev) => [...prev, f]);
|
|
278
|
+
setFeeView({ kind: "edit", id: f.id });
|
|
279
|
+
};
|
|
280
|
+
const deleteFee = (f: Fee) => {
|
|
281
|
+
Alert.alert(`Remove ${f.name || "this fee"}?`, "The fee comes off the record's ledger. This can't be undone.", [
|
|
282
|
+
{ text: "Cancel", style: "cancel" },
|
|
283
|
+
{ text: "Remove", style: "destructive", onPress: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null); } },
|
|
284
|
+
]);
|
|
285
|
+
};
|
|
286
|
+
// The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
|
|
287
|
+
/**
|
|
288
|
+
* MONEY state only — it rides under the amount it is about.
|
|
289
|
+
*
|
|
290
|
+
* "Awaiting docs" lived here while the document had no column of its own, and
|
|
291
|
+
* it was a conflation: whether a supplier's original has arrived is not a fact
|
|
292
|
+
* about the money, and putting it in the money's state meant a fee could not
|
|
293
|
+
* report both at once. Now the Invoice column carries the document and this
|
|
294
|
+
* carries the payment, and each says one thing.
|
|
295
|
+
*/
|
|
296
|
+
const feeStatus = (f: Fee): { text: string; danger: boolean } =>
|
|
297
|
+
f.paid ? { text: "Paid", danger: false }
|
|
298
|
+
: feeOverdue(f) ? { text: "Overdue", danger: true }
|
|
299
|
+
: f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false }
|
|
300
|
+
: { text: "Unpaid", danger: false };
|
|
301
|
+
|
|
302
|
+
// The gallery is mounted only while a file is being looked at. The row's link
|
|
303
|
+
// opens the fee's OWN originals — the number in that cell is what they were
|
|
304
|
+
// filed against, so the preview a reader asks for by pressing it is that fee's
|
|
305
|
+
// paperwork and not the record's whole pile.
|
|
306
|
+
const [preview, setPreview] = useState<{ files: DisplayFile[]; index: number } | null>(null);
|
|
307
|
+
|
|
308
|
+
// ── billing — the invoice documents
|
|
309
|
+
const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
|
|
310
|
+
const [deposit, setDeposit] = useState(0);
|
|
311
|
+
const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
|
|
312
|
+
const seq = useRef(414);
|
|
313
|
+
|
|
314
|
+
const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
|
|
315
|
+
setInvoices((prev) =>
|
|
316
|
+
prev.map((inv) =>
|
|
317
|
+
inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) },
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
/** Inline-editor persistence for one charge cell. */
|
|
322
|
+
const saveCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
|
|
323
|
+
new Promise<void>((resolve) => {
|
|
324
|
+
setTimeout(() => {
|
|
325
|
+
patchCharge(invKey, chKey, patch);
|
|
326
|
+
resolve();
|
|
327
|
+
}, 350);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Resolved from the CURRENT invoices every render — the confirm dialog quotes the
|
|
331
|
+
// total that will actually be billed, including a charge that landed after the press.
|
|
332
|
+
const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
|
|
333
|
+
const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
|
|
334
|
+
// A charge carrying a METHOD has been paid — the statement below is derived from
|
|
335
|
+
// that, not from a second "paid" flag somebody has to keep in step with it. The
|
|
336
|
+
// record already knows; asking again is how the two answers start disagreeing.
|
|
337
|
+
//
|
|
338
|
+
// ONE list, and both the rows and the subtotal read it. Deriving them from two
|
|
339
|
+
// separate predicates is how a ledger comes to disagree with itself: filter the
|
|
340
|
+
// rows on `method && amount > 0` while summing on `method` alone and any charge
|
|
341
|
+
// that fails only the second test lands in the total with no line to explain it.
|
|
342
|
+
// The component cannot catch that — it renders the `total` it is handed.
|
|
343
|
+
const receipts = invoices.flatMap((inv) =>
|
|
344
|
+
inv.charges.filter((c) => c.method !== "" && c.amount > 0).map((c) => ({ inv, charge: c })),
|
|
345
|
+
);
|
|
346
|
+
const received = receipts.reduce((sum, r) => sum + r.charge.amount, 0);
|
|
347
|
+
// Three sides now, so the total states the arithmetic rather than a difference of two:
|
|
348
|
+
// what was billed, less what was credited, less what arrived.
|
|
349
|
+
const outstanding = grandTotal - CREDIT.amount - received;
|
|
350
|
+
const allMissing = invoices.flatMap(missingMethods);
|
|
351
|
+
|
|
352
|
+
const issue = (inv: Invoice) => {
|
|
353
|
+
seq.current += 1;
|
|
354
|
+
const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
|
|
355
|
+
setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
|
|
356
|
+
setConfirmIssueKey(null);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const printReceipt = () => {
|
|
360
|
+
if (allMissing.length > 0) {
|
|
361
|
+
Alert.alert(
|
|
362
|
+
"Missing payment method",
|
|
363
|
+
`Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`,
|
|
364
|
+
[{ text: "OK" }],
|
|
365
|
+
);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// ── the charge bands
|
|
372
|
+
const [pricedLines, setPricedLines] = useState<PricedLine[]>(PRICED_SEED);
|
|
373
|
+
const patchLine = (key: string, p: Partial<PricedLine>) =>
|
|
374
|
+
setPricedLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...p } : l)));
|
|
375
|
+
const removeLine = (key: string) => setPricedLines((prev) => prev.filter((l) => l.key !== key));
|
|
376
|
+
const pricedTotal = pricedLines.reduce((s, l) => s + lineAmount(l), 0);
|
|
377
|
+
|
|
378
|
+
const [handling, setHandling] = useState<number | null>(0);
|
|
379
|
+
const [clearance, setClearance] = useState<number | null>(1_200);
|
|
380
|
+
const [handlingMethod, setHandlingMethod] = useState<Method | null>(null);
|
|
381
|
+
|
|
382
|
+
const [mixedQty, setMixedQty] = useState<number | null>(45);
|
|
383
|
+
const [mixedRate, setMixedRate] = useState<number | null>(4_000);
|
|
384
|
+
const [shortfall, setShortfall] = useState<number | null>(50_000);
|
|
385
|
+
|
|
386
|
+
/** One priced band, rendered twice — see the narrow section for why. */
|
|
387
|
+
const pricedBand = (
|
|
388
|
+
<ChargeLines
|
|
389
|
+
totalLabel="Total"
|
|
390
|
+
total={pricedTotal}
|
|
391
|
+
formatMoney={money}
|
|
392
|
+
empty={<EmptyState compact message="Nothing priced yet" hint="Add a charge to price it against the rate card." />}
|
|
393
|
+
/* The commit takes its OWN row at the band's end and sits on the left —
|
|
394
|
+
it belongs to the band, so the band draws it rather than the page
|
|
395
|
+
laying a button beside one. */
|
|
396
|
+
action={
|
|
397
|
+
<Button
|
|
398
|
+
color="primary"
|
|
399
|
+
title="Issue invoice"
|
|
400
|
+
disabled={pricedTotal <= 0}
|
|
401
|
+
onPress={() => Alert.alert("Issue invoice", `Issuing an invoice for ${money(pricedTotal)}.`, [{ text: "OK" }])}
|
|
402
|
+
/>
|
|
403
|
+
}
|
|
404
|
+
>
|
|
405
|
+
{pricedLines.map((l) => (
|
|
406
|
+
<ChargeLine
|
|
407
|
+
key={l.key}
|
|
408
|
+
label={l.label}
|
|
409
|
+
meta={l.meta}
|
|
410
|
+
quantity={l.quantity}
|
|
411
|
+
unitPrice={l.unitPrice}
|
|
412
|
+
onQuantityChange={(v) => patchLine(l.key, { quantity: v })}
|
|
413
|
+
onUnitPriceChange={(v) => patchLine(l.key, { unitPrice: v })}
|
|
414
|
+
/* the rate card's ceiling, one tap, BESIDE the field it fills */
|
|
415
|
+
/* Unconditional + `disabled`, never conditional: a verb passed only when it
|
|
416
|
+
has work makes the field narrower on the rows that carry it, so those rows
|
|
417
|
+
stop sharing this money column with the rest. */
|
|
418
|
+
unitPriceActions={
|
|
419
|
+
<InlineButton
|
|
420
|
+
title={money(CEILING_RATE)}
|
|
421
|
+
accessibilityLabel="Apply the ceiling rate"
|
|
422
|
+
disabled={l.key !== "runs" || l.unitPrice === CEILING_RATE}
|
|
423
|
+
onPress={() => patchLine(l.key, { unitPrice: CEILING_RATE })}
|
|
424
|
+
/>
|
|
425
|
+
}
|
|
426
|
+
action={<IconButton icon="x" size="sm" color="danger" tooltip="Remove this charge" onPress={() => removeLine(l.key)} />}
|
|
427
|
+
/>
|
|
428
|
+
))}
|
|
429
|
+
</ChargeLines>
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<PageContent
|
|
434
|
+
size="md"
|
|
435
|
+
title="Money"
|
|
436
|
+
description="Everything this record charges, costs, bills and collects."
|
|
437
|
+
>
|
|
438
|
+
<SectionStack>
|
|
439
|
+
<Section>
|
|
440
|
+
<SectionHeading>
|
|
441
|
+
<SectionHeadingTitle description="Every fee the record incurs or charges — its party, due date and paid state.">Fees</SectionHeadingTitle>
|
|
442
|
+
{/* THE SECTION'S ADD RIDES THE HEADING ROW, right edge — the one
|
|
443
|
+
place it can sit that does not MOVE. Under the register it sat
|
|
444
|
+
below the last row, so where a reader looks for "how do I add
|
|
445
|
+
one" depended on how many there already were: past a screenful
|
|
446
|
+
the verb is off-screen entirely, and on an empty list there is no
|
|
447
|
+
last row to sit under, so it had to be a SECOND button inside the
|
|
448
|
+
`EmptyState`. One verb, two renderings, neither findable without
|
|
449
|
+
scanning. The heading row is the section's control line — where a
|
|
450
|
+
section's meta and verbs sit, with the title growing to push its
|
|
451
|
+
siblings right — so the add belongs on it, in the same spot
|
|
452
|
+
whether the list holds nought or forty. */}
|
|
453
|
+
<Button title="Add fee" color="primary" onPress={addFee} />
|
|
454
|
+
</SectionHeading>
|
|
455
|
+
<SummaryLine
|
|
456
|
+
items={[
|
|
457
|
+
{ label: "collected", value: fees.filter((f) => f.direction === "charge" && f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true },
|
|
458
|
+
{ label: "to collect", value: fees.filter((f) => f.direction === "charge" && !f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true },
|
|
459
|
+
{ label: "to pay", value: fees.filter((f) => f.direction === "cost" && !f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true, tone: fees.some((f) => f.direction === "cost" && feeOverdue(f)) ? "warning" : undefined },
|
|
460
|
+
]}
|
|
461
|
+
/>
|
|
462
|
+
{/* The empty state takes NO `action` — the heading's Add is the only
|
|
463
|
+
one, and it is already on screen. Repeating the section's verb here
|
|
464
|
+
puts two buttons for one act in view at once, and makes the add MOVE
|
|
465
|
+
the moment the first row lands. The empty state says what the
|
|
466
|
+
section holds; the heading says how to fill it. */}
|
|
467
|
+
{fees.length === 0 ? (
|
|
468
|
+
<EmptyState icon="receipt" message="No fees on this record" hint="Add the first charge or cost — it expands ready to fill in." />
|
|
469
|
+
) : (
|
|
470
|
+
<Table columns={FEE_COLUMNS}>
|
|
471
|
+
{fees.map((f) => {
|
|
472
|
+
const st = feeStatus(f);
|
|
473
|
+
return (
|
|
474
|
+
<TableRow
|
|
475
|
+
key={f.id}
|
|
476
|
+
/* TOGGLE, unlike a peek's open-never-toggle: a popover
|
|
477
|
+
fights its own outside-press dismissal, an expansion has
|
|
478
|
+
none, so the row that opened it must also close it. */
|
|
479
|
+
onPress={() => setFeeView((cur) => (cur?.id === f.id ? null : { kind: "edit", id: f.id }))}
|
|
480
|
+
expanded={feeView?.id === f.id}
|
|
481
|
+
/* `— details`, the peek's convention: the door is a disclosure,
|
|
482
|
+
not a trip, so "Open" would name the wrong act. `PressDoor`
|
|
483
|
+
carries the state as aria-expanded. */
|
|
484
|
+
accessibilityLabel={`${f.name || "New fee"} — details`}
|
|
485
|
+
/* NO `selected`: `TableRow` already derives it —
|
|
486
|
+
`selected={selected || showDetail}` — so the expanded row
|
|
487
|
+
wears the wash without being told, and passing it here
|
|
488
|
+
would only restate the kit. */
|
|
489
|
+
detail={
|
|
490
|
+
/* The detail carries ALL NINE fields, including the three the
|
|
491
|
+
row already shows — because the row's cells are read-only
|
|
492
|
+
Text, and because tier 2 DROPS columns on a narrow
|
|
493
|
+
container. `table_fit` licenses that drop on the promise
|
|
494
|
+
that "the door opens the record where dropped values
|
|
495
|
+
live"; the expansion is now that door, so anything it
|
|
496
|
+
omitted would be unreachable, not merely inconvenient.
|
|
497
|
+
Table does not expose its fit result, so this cannot be
|
|
498
|
+
conditional — nor should it be. */
|
|
499
|
+
/* 24 (`SPACE.lg`), not the 12 this file uses for intra-block
|
|
500
|
+
gaps: the field grid's own row gap is `SPACE.md` (16), so
|
|
501
|
+
anything separating a DIFFERENT block has to beat it or
|
|
502
|
+
the verb below reads as one more field. Same rung logic
|
|
503
|
+
`DetailRow` uses for its stacked rows. */
|
|
504
|
+
<View style={{ gap: 24 }}>
|
|
505
|
+
{/* 150, the SAME width every field grid on this page uses.
|
|
506
|
+
The page's invariant is one pixel-aligned VALUE column,
|
|
507
|
+
and a `Table`'s detail is inset by `ROW_GUTTER` — which
|
|
508
|
+
is 0 — so this grid starts at the same x as Billing's
|
|
509
|
+
and takes the same label width. In a grid that IS
|
|
510
|
+
inset, the label width is 150 − inset. */}
|
|
511
|
+
<DetailTable labelWidth={150}>
|
|
512
|
+
<DetailRow label="Fee">
|
|
513
|
+
<InlineTextInput value={f.name} onSave={persist((v: string) => patchFee(f.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
|
|
514
|
+
</DetailRow>
|
|
515
|
+
<DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
|
|
516
|
+
<InlineSelect
|
|
517
|
+
value={f.direction}
|
|
518
|
+
options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
|
|
519
|
+
onSave={persist((v: FeeDirection) => patchFee(f.id, { direction: v }))}
|
|
520
|
+
accessibilityLabel="Fee type"
|
|
521
|
+
/>
|
|
522
|
+
</DetailRow>
|
|
523
|
+
<DetailRow label="Party">
|
|
524
|
+
<InlineTextInput value={f.party} onSave={persist((v: string) => patchFee(f.id, { party: v }))} placeholder="Who pays, or is paid…" accessibilityLabel="Party" />
|
|
525
|
+
</DetailRow>
|
|
526
|
+
<DetailRow label="Amount">
|
|
527
|
+
<InlineNumberInput value={f.amount || null} onSave={persist((v: number | null) => patchFee(f.id, { amount: v ?? 0 }))} min={0} format={money} placeholder="—" accessibilityLabel="Amount" />
|
|
528
|
+
</DetailRow>
|
|
529
|
+
<DetailRow label="VAT (%)">
|
|
530
|
+
<InlineNumberInput value={f.vat} onSave={persist((v: number | null) => patchFee(f.id, { vat: v }))} min={0} placeholder="—" accessibilityLabel="VAT percent" />
|
|
531
|
+
</DetailRow>
|
|
532
|
+
<DetailRow label="Due" {...dueAnnotation(f.due, !f.paid)}>
|
|
533
|
+
<InlineDatePicker value={f.due} onSave={persist((v: string) => patchFee(f.id, { due: v }))} locale="en-US" placeholder="Set a due date…" accessibilityLabel="Fee due date" />
|
|
534
|
+
</DetailRow>
|
|
535
|
+
<DetailRow label="Invoice no" description="The party's invoice or debit note">
|
|
536
|
+
<InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
|
|
537
|
+
</DetailRow>
|
|
538
|
+
{/* THE FILE, ON THE ROW THAT OWES IT — the shape a
|
|
539
|
+
payables desk actually works in: a table of
|
|
540
|
+
charges, each row expanding to the document that
|
|
541
|
+
justifies it. The register cell above can only
|
|
542
|
+
MARK whether a document is held; the managing
|
|
543
|
+
happens here.
|
|
544
|
+
|
|
545
|
+
`multiple`, because a supplier invoice arrives as
|
|
546
|
+
one PDF or as three scanned pages, and the second
|
|
547
|
+
page must not replace the first.
|
|
548
|
+
|
|
549
|
+
`blockedReason` when no number has been claimed:
|
|
550
|
+
there is nothing to file the scan AGAINST yet, and
|
|
551
|
+
a sentence naming that act beats a disabled button
|
|
552
|
+
that only says no. */}
|
|
553
|
+
<DetailRow label="Supplier original">
|
|
554
|
+
<InlineFiles
|
|
555
|
+
files={f.docs}
|
|
556
|
+
onAdd={(picked) => patchFee(f.id, { docs: [...f.docs, ...picked.map(asDisplayFile)] })}
|
|
557
|
+
onRemove={(doc) => patchFee(f.id, { docs: f.docs.filter((d) => d.id !== doc.id) })}
|
|
558
|
+
addLabel="Attach original"
|
|
559
|
+
addMoreLabel="Add page"
|
|
560
|
+
accept="application/pdf,image/*"
|
|
561
|
+
blockedReason={f.invoiceNo.trim() === "" ? "Enter the invoice number first — the scan is filed against it." : undefined}
|
|
562
|
+
/>
|
|
563
|
+
</DetailRow>
|
|
564
|
+
<DetailRow label="Status">
|
|
565
|
+
<InlineSelect
|
|
566
|
+
value={f.paid ? "paid" : "unpaid"}
|
|
567
|
+
options={[{ value: "unpaid", label: "Unpaid" }, { value: "paid", label: "Paid" }]}
|
|
568
|
+
onSave={persist((v: string) => patchFee(f.id, { paid: v === "paid" }))}
|
|
569
|
+
accessibilityLabel="Paid status"
|
|
570
|
+
/>
|
|
571
|
+
</DetailRow>
|
|
572
|
+
<DetailRow label="Note">
|
|
573
|
+
<InlineTextInput value={f.note} onSave={persist((v: string) => patchFee(f.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
|
|
574
|
+
</DetailRow>
|
|
575
|
+
</DetailTable>
|
|
576
|
+
{/* The destructive verb, LEFT — the peek footer's convention.
|
|
577
|
+
Solid `danger` per composition rule 9; no trash glyph,
|
|
578
|
+
because "Remove fee" already names the object the icon
|
|
579
|
+
was only decorating.
|
|
580
|
+
|
|
581
|
+
NO Divider. Every hairline a `Table` draws is full-bleed,
|
|
582
|
+
so one inset by ROW_GUTTER puts two hairline lengths in
|
|
583
|
+
the same vertical run — and it repeats what the expanded
|
|
584
|
+
row's wash already says. The row wrapper IS required: a
|
|
585
|
+
`Button` alone in a column View stretches full width. */}
|
|
586
|
+
<View style={{ flexDirection: "row" }}>
|
|
587
|
+
<Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
|
|
588
|
+
</View>
|
|
589
|
+
</View>
|
|
590
|
+
}
|
|
591
|
+
>
|
|
592
|
+
{/* IDENTITY over its PARTY — `tpl_item_list`'s customer cell
|
|
593
|
+
exactly: the thing you scan for on line 1, what qualifies
|
|
594
|
+
it on line 2, in one cell rather than two columns. */}
|
|
595
|
+
<TableCell>
|
|
596
|
+
<View style={{ gap: 2 }}>
|
|
597
|
+
<Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{f.name || "—"}</Text>
|
|
598
|
+
{/* Body size: the subject carries `medium`, which is what
|
|
599
|
+
makes this a pair rather than two peers. */}
|
|
600
|
+
<Text size="sm" color="muted" leading="tight" numberOfLines={1}>
|
|
601
|
+
{f.party || (f.direction === "charge" ? "Charge" : "Cost")}
|
|
602
|
+
</Text>
|
|
603
|
+
</View>
|
|
604
|
+
</TableCell>
|
|
605
|
+
{/* AMOUNT over its PAYMENT state — the register's fee cell,
|
|
606
|
+
same stack, same rungs. The state qualifies the number it
|
|
607
|
+
sits under, so it needs no column and no repeated label. */}
|
|
608
|
+
<TableCell>
|
|
609
|
+
<View style={{ gap: 2, alignItems: "flex-end" }}>
|
|
610
|
+
<Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text>
|
|
611
|
+
<Text size="xs" tabular numberOfLines={1} color={st.danger ? "danger" : "muted"}>{st.text}</Text>
|
|
612
|
+
</View>
|
|
613
|
+
</TableCell>
|
|
614
|
+
{/* THE DOCUMENT, with a column of its own at last.
|
|
615
|
+
Held: a clip + the number, pressable to preview. Claimed
|
|
616
|
+
but not arrived: the number in WARNING tone — the fee
|
|
617
|
+
cannot be paid, and that now reads in the column the fact
|
|
618
|
+
belongs to instead of borrowing the money's state. Never
|
|
619
|
+
claimed (paid in cash on the spot): an em dash, because nothing is
|
|
620
|
+
owed and a permanent warning on every such row trains the
|
|
621
|
+
eye straight past the column. */}
|
|
622
|
+
<TableCell>
|
|
623
|
+
{f.invoiceNo.trim() === "" ? (
|
|
624
|
+
<Text size="sm" color="muted">—</Text>
|
|
625
|
+
) : f.docs.length === 0 ? (
|
|
626
|
+
<Text size="sm" color="muted" numberOfLines={1}>{f.invoiceNo}</Text>
|
|
627
|
+
) : (
|
|
628
|
+
<Pressable
|
|
629
|
+
onPress={() => setPreview({ files: f.docs, index: 0 })}
|
|
630
|
+
accessibilityRole="button"
|
|
631
|
+
accessibilityLabel={`Open invoice ${f.invoiceNo}`}
|
|
632
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
|
|
633
|
+
>
|
|
634
|
+
<FileBadge mimeType={f.docs[0].mimeType} size={22} />
|
|
635
|
+
<TextLink size="sm">{f.invoiceNo}</TextLink>
|
|
636
|
+
</Pressable>
|
|
637
|
+
)}
|
|
638
|
+
</TableCell>
|
|
639
|
+
</TableRow>
|
|
640
|
+
);
|
|
641
|
+
})}
|
|
642
|
+
</Table>
|
|
643
|
+
)}
|
|
644
|
+
</Section>
|
|
645
|
+
|
|
646
|
+
{/* BILLING — invoice documents on the record, in the inline vocabulary.
|
|
647
|
+
The issue action lives at each band's end. In `tpl_record` a
|
|
648
|
+
record-level premise (no customer, no valid tax ID) also reads here
|
|
649
|
+
as ONE section-scoped Callout, because the action-gating law is: a
|
|
650
|
+
not-ready CTA is DISABLED and a PROBLEM reads as a co-located
|
|
651
|
+
Callout at its scope, once. This page holds no customer, so the only
|
|
652
|
+
problems it can state are band-local — and those ride the CHARGE ROW
|
|
653
|
+
that has them (`warning` on the line missing its method), which is
|
|
654
|
+
both smaller than a callout and more precise than one: it names the
|
|
655
|
+
offender instead of counting offenders. Never prose beside a CTA. */}
|
|
656
|
+
<Section>
|
|
657
|
+
<SectionHeading>
|
|
658
|
+
<SectionHeadingTitle description="Each invoice owns its charge lines — a charge never lives apart from the document it bills on.">Billing</SectionHeadingTitle>
|
|
659
|
+
</SectionHeading>
|
|
660
|
+
|
|
661
|
+
{/* EVERYTHING VISIBLE, and the chrome is what shrinks.
|
|
662
|
+
Three attempts landed here. Inline BANDS were "spread" — but the
|
|
663
|
+
spread was never the data: four charge lines across three invoices
|
|
664
|
+
is nothing. Each band carried a `SubsectionHeading`, its own
|
|
665
|
+
callout and its own action row, so the CHROME was three times the
|
|
666
|
+
content. A register then hid those four lines behind three
|
|
667
|
+
expansions, which trades a real fault for a worse one: you can no
|
|
668
|
+
longer see what you are paying for.
|
|
669
|
+
So the lines all stay, and each invoice costs ONE line of chrome —
|
|
670
|
+
its name, its total, its act. No heading rung, no band callout, no
|
|
671
|
+
separate action row. */}
|
|
672
|
+
<SubsectionStack>
|
|
673
|
+
{invoices.map((inv) => {
|
|
674
|
+
const missing = missingMethods(inv);
|
|
675
|
+
const total = invoiceTotal(inv);
|
|
676
|
+
const ready = total > 0 && missing.length === 0;
|
|
677
|
+
return (
|
|
678
|
+
<Subsection key={inv.key}>
|
|
679
|
+
{/* the invoice's ONE line of chrome: what it is, what it comes
|
|
680
|
+
to, and its reference once issued. No indent under it — the
|
|
681
|
+
lead line's weight and its total already mark the group, and
|
|
682
|
+
the `SubsectionStack` beat separates one from the next, so an
|
|
683
|
+
indent would only push every charge off the page's left edge
|
|
684
|
+
for nothing. */}
|
|
685
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
686
|
+
<Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>{inv.title}</Text>
|
|
687
|
+
<Text size="sm" tabular color={total > 0 ? "default" : "muted"}>{total > 0 ? money(total) : "—"}</Text>
|
|
688
|
+
{/* HELD OPEN whether or not this invoice has been issued.
|
|
689
|
+
The reference is a trailing verb, and a trailing verb
|
|
690
|
+
that shows on some rows and not others drags the figure
|
|
691
|
+
beside it: three invoice totals then sit on three edges
|
|
692
|
+
and stop sharing the column the `Ledger` below closes.
|
|
693
|
+
Same law as the fixed action slot in `ChargeLines`. */}
|
|
694
|
+
<View style={{ width: 64, alignItems: "flex-start" }}>
|
|
695
|
+
{inv.ref ? (
|
|
696
|
+
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
697
|
+
) : null}
|
|
698
|
+
</View>
|
|
699
|
+
</View>
|
|
700
|
+
<DetailTable labelWidth={150} minValueWidth={320}>
|
|
701
|
+
{inv.charges.map((c) => (
|
|
702
|
+
<DetailRow
|
|
703
|
+
key={c.key}
|
|
704
|
+
label={c.label}
|
|
705
|
+
warning={c.amount > 0 && !c.method ? "Choose how this was paid" : undefined}
|
|
706
|
+
>
|
|
707
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
|
|
708
|
+
<View style={{ flexGrow: 1, flexBasis: 150 }}>
|
|
709
|
+
<InlineNumberInput
|
|
710
|
+
value={c.amount || null}
|
|
711
|
+
onSave={(v) => saveCharge(inv.key, c.key, { amount: v ?? 0 })}
|
|
712
|
+
min={0}
|
|
713
|
+
format={money}
|
|
714
|
+
placeholder="—"
|
|
715
|
+
accessibilityLabel={`Amount for ${c.label}`}
|
|
716
|
+
/* The list price, one tap, ON the field it fills —
|
|
717
|
+
and this is the fork from `ChargeLines`, which
|
|
718
|
+
puts the same verb BESIDE the field. The
|
|
719
|
+
discriminator is what the field sits in: here it
|
|
720
|
+
is a form row whose value column grows, so a verb
|
|
721
|
+
on the field's own surface moves nothing. In a
|
|
722
|
+
right-aligned money COLUMN it would eat the
|
|
723
|
+
field's width and slide the figure off the
|
|
724
|
+
column its neighbours are on. */
|
|
725
|
+
actions={
|
|
726
|
+
<InlineButton
|
|
727
|
+
title={money(c.standard)}
|
|
728
|
+
accessibilityLabel={`Use the standard ${c.label} price`}
|
|
729
|
+
disabled={c.amount > 0 || c.standard <= 0}
|
|
730
|
+
onPress={() => saveCharge(inv.key, c.key, { amount: c.standard })}
|
|
731
|
+
/>
|
|
732
|
+
}
|
|
733
|
+
/>
|
|
734
|
+
</View>
|
|
735
|
+
<View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
|
|
736
|
+
<InlineSelect
|
|
737
|
+
value={c.method === "" ? null : c.method}
|
|
738
|
+
onSave={(m) => saveCharge(inv.key, c.key, { method: m })}
|
|
739
|
+
options={METHODS}
|
|
740
|
+
placeholder="How paid…"
|
|
741
|
+
disabled={c.amount <= 0}
|
|
742
|
+
accessibilityLabel={`Payment method for ${c.label}`}
|
|
743
|
+
/>
|
|
744
|
+
</View>
|
|
745
|
+
</View>
|
|
746
|
+
</DetailRow>
|
|
747
|
+
))}
|
|
748
|
+
{/* AFTER the lines it bills — a committing act goes where its
|
|
749
|
+
effect lands, which is the end of what it commits, never
|
|
750
|
+
above it. On the control column like every other CTA on
|
|
751
|
+
the page (the form-action alignment law). */}
|
|
752
|
+
<DetailRow label="">
|
|
753
|
+
<View style={{ flexDirection: "row" }}>
|
|
754
|
+
<Button
|
|
755
|
+
title={inv.ref ? "Re-issue" : "Issue invoice"}
|
|
756
|
+
color={inv.ref ? "secondary" : "primary"}
|
|
757
|
+
disabled={!ready}
|
|
758
|
+
onPress={() => setConfirmIssueKey(inv.key)}
|
|
759
|
+
/>
|
|
760
|
+
</View>
|
|
761
|
+
</DetailRow>
|
|
762
|
+
</DetailTable>
|
|
763
|
+
</Subsection>
|
|
764
|
+
);
|
|
765
|
+
})}
|
|
766
|
+
</SubsectionStack>
|
|
767
|
+
|
|
768
|
+
{/* THE CLOSING STATEMENT — `Ledger`, the money grammar, and the answer to
|
|
769
|
+
the one question the bands above cannot give: what does this record come
|
|
770
|
+
to, and how much of it has arrived. Each invoice states its own total on
|
|
771
|
+
its lead line, but three totals down the page are three facts the reader
|
|
772
|
+
has to add up, and "is anything still owed" was nowhere on the section.
|
|
773
|
+
|
|
774
|
+
It is a STATEMENT, not another band: no `Subsection`, no heading rung. It
|
|
775
|
+
closes the invoices, so it sits directly under them.
|
|
776
|
+
|
|
777
|
+
THREE sides, which is what makes the total state arithmetic rather than a
|
|
778
|
+
difference: billed, less credited, less received. Money coming back is
|
|
779
|
+
NEGATIVE (the component renders "− ") and `success`-toned, because a
|
|
780
|
+
receipt and a credit are both the good outcome; `zeroLabel` gives a settled
|
|
781
|
+
record "Paid in full" instead of a proud zero.
|
|
782
|
+
|
|
783
|
+
The DEPOSIT is deliberately absent. It is refundable — never part of the
|
|
784
|
+
total to collect — so folding it in would make the closing figure answer a
|
|
785
|
+
different question than the one it is labelled with. A ledger earns trust
|
|
786
|
+
by summing exactly what its label claims.
|
|
787
|
+
|
|
788
|
+
A row opens where there is something to open, and the two ways are not
|
|
789
|
+
interchangeable. `peek` floats an invoice's PARTICULARS — the charges behind
|
|
790
|
+
its one figure — and the lookup link lives INSIDE that popover, never beside
|
|
791
|
+
the trigger, because a button inside a button is invalid. `reference` is the
|
|
792
|
+
flat alternative for a row with nothing to expand: one charge IS its own
|
|
793
|
+
particulars, so Storage carries a trailing link instead of a door.
|
|
794
|
+
|
|
795
|
+
The `Adjustments` group holds ONE row and therefore no `total`: a sum over a
|
|
796
|
+
single line states the same fact twice, at two weights. That is the honest
|
|
797
|
+
fixture rather than a tidy one — real groups are built from data, so some
|
|
798
|
+
arrive with one row. */}
|
|
799
|
+
<Ledger formatValue={money}>
|
|
800
|
+
<LedgerGroup label="Invoiced" total={grandTotal}>
|
|
801
|
+
{invoices.map((inv) => {
|
|
802
|
+
/* NO meta on this side. It briefly carried "awaiting payment method",
|
|
803
|
+
and that was `meta` doing two jobs: on the Received rows it holds a
|
|
804
|
+
neutral QUALIFIER (how the money arrived), here it held a PROBLEM.
|
|
805
|
+
Two unlike things in one treatment is what makes a caption read as
|
|
806
|
+
inconsistent — the size was never the fault.
|
|
807
|
+
The problem also had a home already: the charge row above carries
|
|
808
|
+
`warning="Choose how this was paid"`, on the line with the editor that
|
|
809
|
+
fixes it. A statement's job is the arithmetic, and the arithmetic
|
|
810
|
+
already says it — `Outstanding` IS the unpaid money. Saying it again in
|
|
811
|
+
weaker words adds a second voice for one fact. */
|
|
812
|
+
/* A door where there are PARTICULARS, and the count that decides it is the
|
|
813
|
+
charges, not the priced ones. An invoice whose second line is unpriced is
|
|
814
|
+
exactly the row whose figure looks too small for it, which is the question
|
|
815
|
+
the popover answers — gating on `amount > 0` would hide the door precisely
|
|
816
|
+
when it is most useful. */
|
|
817
|
+
return (
|
|
818
|
+
<LedgerRow
|
|
819
|
+
key={inv.key}
|
|
820
|
+
label={inv.title}
|
|
821
|
+
value={invoiceTotal(inv)}
|
|
822
|
+
peek={inv.charges.length > 1 ? <InvoiceParticulars inv={inv} /> : undefined}
|
|
823
|
+
reference={inv.ref ? { label: inv.ref, onPress: () => {} } : undefined}
|
|
824
|
+
/>
|
|
825
|
+
);
|
|
826
|
+
})}
|
|
827
|
+
</LedgerGroup>
|
|
828
|
+
<LedgerGroup label="Adjustments">
|
|
829
|
+
<LedgerRow label={CREDIT.label} meta={CREDIT.meta} value={-CREDIT.amount} tone="success" />
|
|
830
|
+
</LedgerGroup>
|
|
831
|
+
{received > 0 ? (
|
|
832
|
+
<LedgerGroup label="Received" total={-received}>
|
|
833
|
+
{receipts.map(({ inv, charge }) => (
|
|
834
|
+
<LedgerRow
|
|
835
|
+
key={`${inv.key}-${charge.key}`}
|
|
836
|
+
label={charge.label}
|
|
837
|
+
meta={METHODS.find((m) => m.value === charge.method)?.label}
|
|
838
|
+
value={-charge.amount}
|
|
839
|
+
tone="success"
|
|
840
|
+
/>
|
|
841
|
+
))}
|
|
842
|
+
</LedgerGroup>
|
|
843
|
+
) : null}
|
|
844
|
+
<LedgerTotal label="Outstanding" value={outstanding} tone={outstanding > 0 ? "danger" : "default"} zeroLabel="Paid in full" />
|
|
845
|
+
</Ledger>
|
|
846
|
+
|
|
847
|
+
{/* ONE action row for the section, on the control column. The deposit
|
|
848
|
+
is a field of the record, not a band of its own — its receipt is a
|
|
849
|
+
second act on the same row. */}
|
|
850
|
+
<DetailTable labelWidth={150} minValueWidth={320}>
|
|
851
|
+
<DetailRow label="Deposit" description="Refundable — never part of the total to collect">
|
|
852
|
+
<InlineNumberInput
|
|
853
|
+
value={deposit || null}
|
|
854
|
+
onSave={persist((v: number | null) => setDeposit(v ?? 0))}
|
|
855
|
+
min={0}
|
|
856
|
+
format={money}
|
|
857
|
+
placeholder="—"
|
|
858
|
+
accessibilityLabel="Deposit amount"
|
|
859
|
+
actions={<InlineButton title="Receipt" accessibilityLabel="Deposit receipt" disabled={deposit <= 0} onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])} />}
|
|
860
|
+
/>
|
|
861
|
+
</DetailRow>
|
|
862
|
+
<DetailRow label="">
|
|
863
|
+
<View style={{ flexDirection: "row" }}>
|
|
864
|
+
<Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
|
|
865
|
+
</View>
|
|
866
|
+
</DetailRow>
|
|
867
|
+
</DetailTable>
|
|
868
|
+
</Section>
|
|
869
|
+
|
|
870
|
+
<Section>
|
|
871
|
+
<SectionHeading>
|
|
872
|
+
<SectionHeadingTitle description="Quantity × unit price derives the amount, and the amount is never typed. The arithmetic reads across one line and ends at the amount: hang the derived figure on a second line and every row doubles in height while the answer lands in no column, so the total below closes nothing.">
|
|
873
|
+
Priced lines
|
|
874
|
+
</SectionHeadingTitle>
|
|
875
|
+
</SectionHeading>
|
|
876
|
+
{/* `unitPriceActions` puts the standard rate one tap away, BESIDE the
|
|
877
|
+
field it fills — inside, the verb eats the field's width and the
|
|
878
|
+
figure slides left, so a row with a one-tap rate and a row without
|
|
879
|
+
stop sharing a column. */}
|
|
880
|
+
{pricedBand}
|
|
881
|
+
</Section>
|
|
882
|
+
|
|
883
|
+
<Section>
|
|
884
|
+
<SectionHeading>
|
|
885
|
+
<SectionHeadingTitle description="Omit the quantity and unit price and the AMOUNT becomes the editable figure — a fee agreed as one number rather than computed from a rate. A line's problem sits beside the control that fixes it, never as a callout for the whole band.">
|
|
886
|
+
Flat charges
|
|
887
|
+
</SectionHeadingTitle>
|
|
888
|
+
</SectionHeading>
|
|
889
|
+
{/* `extra` carries a second control that belongs to the charge itself
|
|
890
|
+
(how it was paid), so it rides the charge's own line instead of
|
|
891
|
+
living in a separate table.
|
|
892
|
+
|
|
893
|
+
A band formats its own money: this one is denominated in euro while
|
|
894
|
+
every other band on the page is in ₫, and neither has to know about
|
|
895
|
+
the other. */}
|
|
896
|
+
<ChargeLines
|
|
897
|
+
totalLabel="Invoice total"
|
|
898
|
+
total={(handling ?? 0) + (clearance ?? 0)}
|
|
899
|
+
formatMoney={eur}
|
|
900
|
+
action={
|
|
901
|
+
<Button
|
|
902
|
+
color="primary"
|
|
903
|
+
title="Issue invoice"
|
|
904
|
+
disabled={!handlingMethod}
|
|
905
|
+
onPress={() => Alert.alert("Issue invoice", `Issuing an invoice for ${eur((handling ?? 0) + (clearance ?? 0))}.`, [{ text: "OK" }])}
|
|
906
|
+
/>
|
|
907
|
+
}
|
|
908
|
+
>
|
|
909
|
+
<ChargeLine
|
|
910
|
+
label="Handling"
|
|
911
|
+
amount={handling}
|
|
912
|
+
onAmountChange={setHandling}
|
|
913
|
+
amountActions={
|
|
914
|
+
<InlineButton
|
|
915
|
+
title={eur(250)}
|
|
916
|
+
accessibilityLabel="Use the standard handling price"
|
|
917
|
+
disabled={(handling ?? 0) > 0}
|
|
918
|
+
onPress={() => setHandling(250)}
|
|
919
|
+
/>
|
|
920
|
+
}
|
|
921
|
+
warning={(handling ?? 0) > 0 && !handlingMethod ? "Choose how this was paid" : undefined}
|
|
922
|
+
extra={
|
|
923
|
+
<InlineSelect
|
|
924
|
+
value={handlingMethod}
|
|
925
|
+
onSave={setHandlingMethod}
|
|
926
|
+
options={METHODS}
|
|
927
|
+
placeholder="How paid…"
|
|
928
|
+
disabled={(handling ?? 0) <= 0}
|
|
929
|
+
accessibilityLabel="Payment method for handling"
|
|
930
|
+
/>
|
|
931
|
+
}
|
|
932
|
+
/>
|
|
933
|
+
<ChargeLine label="Customs clearance" amount={clearance} onAmountChange={setClearance} />
|
|
934
|
+
</ChargeLines>
|
|
935
|
+
</Section>
|
|
936
|
+
|
|
937
|
+
<Section>
|
|
938
|
+
<SectionHeading>
|
|
939
|
+
<SectionHeadingTitle description="A band mixing a priced line with a flat one still lands every figure on ONE amount column, which is the whole reason the two shapes share a component rather than each app inventing its own row.">
|
|
940
|
+
Mixed kinds
|
|
941
|
+
</SectionHeadingTitle>
|
|
942
|
+
</SectionHeading>
|
|
943
|
+
<ChargeLines totalLabel="Total" total={(mixedQty ?? 0) * (mixedRate ?? 0) + (shortfall ?? 0)} formatMoney={money}>
|
|
944
|
+
<ChargeLine label="Delivery runs" quantity={mixedQty} unitPrice={mixedRate} onQuantityChange={setMixedQty} onUnitPriceChange={setMixedRate} />
|
|
945
|
+
<ChargeLine label="Shortfall recovered" amount={shortfall} onAmountChange={setShortfall} />
|
|
946
|
+
</ChargeLines>
|
|
947
|
+
</Section>
|
|
948
|
+
|
|
949
|
+
{/* ITS OWN SECTION, not a second band under the one above. A band closes
|
|
950
|
+
on its total, so the next band's first line arriving 8px under that
|
|
951
|
+
total reads as another charge in the same band — two bands stacked
|
|
952
|
+
inside one heading have nothing between them that says where one
|
|
953
|
+
stops. The section beat and the heading are what separate them. */}
|
|
954
|
+
<Section>
|
|
955
|
+
<SectionHeading>
|
|
956
|
+
<SectionHeadingTitle description="Locked turns the editors into values once the money is collected — the band stops being a form and becomes the record of what was charged, with no verb slot drawn.">
|
|
957
|
+
Settled
|
|
958
|
+
</SectionHeadingTitle>
|
|
959
|
+
</SectionHeading>
|
|
960
|
+
<ChargeLines totalLabel="Collected" total={190_000} formatMoney={money}>
|
|
961
|
+
<ChargeLine label="Delivery runs" quantity={40} unitPrice={4_000} locked />
|
|
962
|
+
<ChargeLine label="Site cleaning" quantity={1} unitPrice={30_000} locked />
|
|
963
|
+
</ChargeLines>
|
|
964
|
+
</Section>
|
|
965
|
+
|
|
966
|
+
<Section>
|
|
967
|
+
<SectionHeading>
|
|
968
|
+
<SectionHeadingTitle description="An empty band is a real state, not an error: a record that has not been priced. The total still closes the column, so the shape a reader learns does not change the moment there is data.">
|
|
969
|
+
Nothing priced yet
|
|
970
|
+
</SectionHeadingTitle>
|
|
971
|
+
</SectionHeading>
|
|
972
|
+
<ChargeLines
|
|
973
|
+
totalLabel="Total"
|
|
974
|
+
total={0}
|
|
975
|
+
formatMoney={money}
|
|
976
|
+
empty={<EmptyState compact message="Nothing charged on this record" hint="Add the first charge to price it against the rate card." />}
|
|
977
|
+
/>
|
|
978
|
+
</Section>
|
|
979
|
+
|
|
980
|
+
<Section>
|
|
981
|
+
<SectionHeading>
|
|
982
|
+
<SectionHeadingTitle description="Below the band's fork width the name takes its own line and the arithmetic sits under it, still ending on the amount column. That is a two-line row ON PURPOSE, which is a different thing from a derived value that fell off the end of a one-line row — the tell is that the amounts still share an edge with the total.">
|
|
983
|
+
The narrow fork
|
|
984
|
+
</SectionHeadingTitle>
|
|
985
|
+
</SectionHeading>
|
|
986
|
+
{/* The fork is measured on the BAND's own container, never the window:
|
|
987
|
+
this shape lives in drawers as often as on pages. Which is why the
|
|
988
|
+
box below is a fixed 360 — it is the SAME band as the priced
|
|
989
|
+
section above, same state, same handlers, rendered in a narrower
|
|
990
|
+
container. Edit either and both move. */}
|
|
991
|
+
<View style={{ width: 360 }}>{pricedBand}</View>
|
|
992
|
+
</Section>
|
|
993
|
+
</SectionStack>
|
|
994
|
+
|
|
995
|
+
{/* issuing an e-invoice is irreversible — confirm in a Dialog */}
|
|
996
|
+
<Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssueKey(null); }}>
|
|
997
|
+
<DialogHeader>
|
|
998
|
+
<DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
|
|
999
|
+
</DialogHeader>
|
|
1000
|
+
<View style={{ paddingHorizontal: 24, paddingVertical: 12 }}>
|
|
1001
|
+
<Callout tone="warning">
|
|
1002
|
+
<CalloutText>
|
|
1003
|
+
{confirmIssue
|
|
1004
|
+
? confirmIssue.ref
|
|
1005
|
+
? `Re-issue "${confirmIssue.title}" (${formatMoney(invoiceTotal(confirmIssue))}) with the current charges. A NEW lookup code replaces ${confirmIssue.ref}; this can't be undone.`
|
|
1006
|
+
: `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.`
|
|
1007
|
+
: ""}
|
|
1008
|
+
</CalloutText>
|
|
1009
|
+
</Callout>
|
|
1010
|
+
</View>
|
|
1011
|
+
<DialogFooter>
|
|
1012
|
+
<Button title="Cancel" color="secondary" onPress={() => setConfirmIssueKey(null)} />
|
|
1013
|
+
<Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
|
|
1014
|
+
</DialogFooter>
|
|
1015
|
+
</Dialog>
|
|
1016
|
+
|
|
1017
|
+
{/* Mounted only while open — its per-file hooks want a stable list. */}
|
|
1018
|
+
{preview ? (
|
|
1019
|
+
<FileGalleryModal
|
|
1020
|
+
files={preview.files}
|
|
1021
|
+
activeIndex={preview.index}
|
|
1022
|
+
onIndexChange={(i) => setPreview((p) => (i == null || !p ? null : { ...p, index: i }))}
|
|
1023
|
+
/>
|
|
1024
|
+
) : null}
|
|
1025
|
+
</PageContent>
|
|
1026
|
+
);
|
|
1027
|
+
}
|