@lotics/ui 3.6.0 → 4.1.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 +352 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_billing.tsx +344 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +12 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +33 -8
- package/src/control_surface.ts +8 -0
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/index.css +6 -3
- package/src/inline_date_picker.tsx +111 -0
- package/src/inline_edit.tsx +238 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +92 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/link.tsx +32 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/picker.tsx +4 -1
- package/src/popover.tsx +10 -1
- package/src/pressable_row.tsx +4 -1
- package/src/radio_picker.tsx +3 -1
- package/src/section_heading.tsx +43 -29
- package/src/segmented_control.tsx +3 -2
- package/src/tabs.tsx +4 -2
- package/src/tag_input.tsx +202 -0
- package/src/text.tsx +1 -1
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { Button } from "@lotics/ui/button";
|
|
6
|
+
import { Card, CardBody, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
|
+
import { Badge } from "@lotics/ui/badge";
|
|
8
|
+
import { Link } from "@lotics/ui/link";
|
|
9
|
+
import { Icon } from "@lotics/ui/icon";
|
|
10
|
+
import { Divider } from "@lotics/ui/divider";
|
|
11
|
+
import { NumberInput } from "@lotics/ui/number_input";
|
|
12
|
+
import { Picker } from "@lotics/ui/picker";
|
|
13
|
+
import type { PickerOption } from "@lotics/ui/picker";
|
|
14
|
+
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
15
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
16
|
+
import { Alert } from "@lotics/ui/alert";
|
|
17
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Template · Billing & invoicing — the charges→invoice→collect flow as ONE
|
|
21
|
+
// banded card, not a stack of disconnected cards. It's a single document, so it
|
|
22
|
+
// reads as one: the bill-to, each invoice, the collect total, and the deposit
|
|
23
|
+
// are BANDS separated by hairline Dividers. The INVOICE DOCUMENT is the unit —
|
|
24
|
+
// each band is anchored by its own title + status badge and owns its editable
|
|
25
|
+
// charge lines (amount + how it's paid), its live total, and its issue action,
|
|
26
|
+
// so a charge never lives apart from the document it bills on. Issuing a real
|
|
27
|
+
// e-invoice is irreversible and needs a bill-to tax ID, so it's gated inline
|
|
28
|
+
// (never a dead end) and confirmed in a Dialog. A refundable DEPOSIT is its own
|
|
29
|
+
// (tinted) band — collected separately, never folded into the total due.
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
type Method = "cash" | "transfer" | "card";
|
|
33
|
+
const METHODS: PickerOption<Method>[] = [
|
|
34
|
+
{ value: "cash", label: "Cash" },
|
|
35
|
+
{ value: "transfer", label: "Bank transfer" },
|
|
36
|
+
{ value: "card", label: "Card" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
interface Charge {
|
|
40
|
+
key: string;
|
|
41
|
+
label: string;
|
|
42
|
+
/** The expected list price — offered as a one-tap fill while the line is unset. */
|
|
43
|
+
standard: number;
|
|
44
|
+
amount: number;
|
|
45
|
+
method: Method | "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Invoice {
|
|
49
|
+
key: string;
|
|
50
|
+
title: string;
|
|
51
|
+
charges: Charge[];
|
|
52
|
+
/** Lookup code once issued to the e-invoice provider; "" = not issued. */
|
|
53
|
+
ref: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const INITIAL: Invoice[] = [
|
|
57
|
+
{
|
|
58
|
+
key: "service",
|
|
59
|
+
title: "Service",
|
|
60
|
+
ref: "",
|
|
61
|
+
charges: [
|
|
62
|
+
{ key: "labor", label: "Labor", standard: 1_200_000, amount: 1_200_000, method: "cash" },
|
|
63
|
+
{ key: "parts", label: "Parts", standard: 0, amount: 0, method: "" },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: "inspection",
|
|
68
|
+
title: "Inspection",
|
|
69
|
+
ref: "",
|
|
70
|
+
charges: [{ key: "insp", label: "Inspection fee", standard: 150_000, amount: 150_000, method: "" }],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: "disposal",
|
|
74
|
+
title: "Disposal",
|
|
75
|
+
ref: "INV-2026-0414",
|
|
76
|
+
charges: [{ key: "disp", label: "Disposal fee", standard: 80_000, amount: 80_000, method: "transfer" }],
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
type Status = "none" | "draft" | "issued";
|
|
81
|
+
const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
|
|
82
|
+
const invoiceStatus = (inv: Invoice): Status => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
|
|
83
|
+
const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
|
|
84
|
+
|
|
85
|
+
/** One charge: label, an amount that reads blank until entered (a one-tap
|
|
86
|
+
* Standard fill + "Set 0" while unset), and the payment method — which turns
|
|
87
|
+
* required the moment the line carries an amount. */
|
|
88
|
+
function ChargeLine({
|
|
89
|
+
charge, onAmount, onMethod, disabled,
|
|
90
|
+
}: {
|
|
91
|
+
charge: Charge;
|
|
92
|
+
onAmount: (v: number) => void;
|
|
93
|
+
onMethod: (m: Method) => void;
|
|
94
|
+
disabled: boolean;
|
|
95
|
+
}) {
|
|
96
|
+
const unset = charge.amount <= 0;
|
|
97
|
+
const needsMethod = charge.amount > 0 && !charge.method;
|
|
98
|
+
return (
|
|
99
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, flexWrap: "wrap" }}>
|
|
100
|
+
<Text size="sm" color="muted" style={{ width: 116, paddingTop: 10 }}>{charge.label}</Text>
|
|
101
|
+
<View style={{ flexGrow: 1, flexBasis: 130, gap: 4 }}>
|
|
102
|
+
<NumberInput
|
|
103
|
+
value={unset ? null : charge.amount}
|
|
104
|
+
onValueChange={(v) => onAmount(v ?? 0)}
|
|
105
|
+
min={0}
|
|
106
|
+
disabled={disabled}
|
|
107
|
+
accessibilityLabel={`${charge.label} amount`}
|
|
108
|
+
/>
|
|
109
|
+
{unset && !disabled ? (
|
|
110
|
+
<View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap" }}>
|
|
111
|
+
{charge.standard > 0 ? (
|
|
112
|
+
<Button title={`Standard ${formatMoney(charge.standard)}`} color="secondary" onPress={() => onAmount(charge.standard)} />
|
|
113
|
+
) : null}
|
|
114
|
+
<Button title="Set 0" color="secondary" onPress={() => onAmount(0)} />
|
|
115
|
+
</View>
|
|
116
|
+
) : null}
|
|
117
|
+
</View>
|
|
118
|
+
<View style={{ flexBasis: 168, gap: 4 }}>
|
|
119
|
+
<Picker
|
|
120
|
+
options={METHODS}
|
|
121
|
+
value={charge.method || null}
|
|
122
|
+
onValueChange={onMethod}
|
|
123
|
+
placeholder="How paid…"
|
|
124
|
+
disabled={disabled || unset}
|
|
125
|
+
accessibilityLabel={`Payment method · ${charge.label}`}
|
|
126
|
+
/>
|
|
127
|
+
{needsMethod ? <Text size="xs" color="danger">Choose a method</Text> : null}
|
|
128
|
+
</View>
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One invoice document, rendered as a BAND inside the single billing card: a
|
|
134
|
+
* title + status header, its charge lines, and a total + issue row. The leading
|
|
135
|
+
* Divider sets it off from the band above without a second card. */
|
|
136
|
+
function InvoiceBand({
|
|
137
|
+
inv, taxId, onAmount, onMethod, onIssue,
|
|
138
|
+
}: {
|
|
139
|
+
inv: Invoice;
|
|
140
|
+
taxId: string;
|
|
141
|
+
onAmount: (chKey: string, v: number) => void;
|
|
142
|
+
onMethod: (chKey: string, m: Method) => void;
|
|
143
|
+
onIssue: (inv: Invoice) => void;
|
|
144
|
+
}) {
|
|
145
|
+
const total = invoiceTotal(inv);
|
|
146
|
+
const status = invoiceStatus(inv);
|
|
147
|
+
const missing = missingMethods(inv);
|
|
148
|
+
const canIssue = total > 0 && missing.length === 0 && !!taxId;
|
|
149
|
+
const blockReason = !taxId ? "Add the customer's tax ID to issue." : missing.length ? "Choose a payment method for every charged line." : "";
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
<Divider />
|
|
153
|
+
<CardBody style={{ gap: 10 }}>
|
|
154
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
155
|
+
<Text weight="semibold">{inv.title}</Text>
|
|
156
|
+
<View style={{ flex: 1 }} />
|
|
157
|
+
{status === "issued" ? (
|
|
158
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
159
|
+
<Badge variant="dot" label="Issued" color="emerald" />
|
|
160
|
+
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
161
|
+
</View>
|
|
162
|
+
) : status === "draft" ? (
|
|
163
|
+
<Badge variant="dot" label="Draft" color="amber" />
|
|
164
|
+
) : (
|
|
165
|
+
<Badge variant="dot" label="Nothing to bill" color="zinc" />
|
|
166
|
+
)}
|
|
167
|
+
</View>
|
|
168
|
+
{inv.charges.map((c) => (
|
|
169
|
+
<ChargeLine
|
|
170
|
+
key={c.key}
|
|
171
|
+
charge={c}
|
|
172
|
+
disabled={status === "issued"}
|
|
173
|
+
onAmount={(v) => onAmount(c.key, v)}
|
|
174
|
+
onMethod={(m) => onMethod(c.key, m)}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
{total > 0 ? (
|
|
178
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap", paddingTop: 2 }}>
|
|
179
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>
|
|
180
|
+
Total <Text weight="semibold" color="default" tabular>{formatMoney(total)}</Text>
|
|
181
|
+
</Text>
|
|
182
|
+
{status === "issued" ? (
|
|
183
|
+
<>
|
|
184
|
+
<Button title="Re-issue" color="secondary" disabled={!canIssue} onPress={() => onIssue(inv)} />
|
|
185
|
+
</>
|
|
186
|
+
) : (
|
|
187
|
+
<Button title="Issue invoice" color="primary" disabled={!canIssue} onPress={() => onIssue(inv)} />
|
|
188
|
+
)}
|
|
189
|
+
</View>
|
|
190
|
+
) : null}
|
|
191
|
+
{total > 0 && status !== "issued" && !canIssue ? <Text size="xs" color="muted">{blockReason}</Text> : null}
|
|
192
|
+
</CardBody>
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function TplBilling() {
|
|
198
|
+
const [invoices, setInvoices] = useState<Invoice[]>(INITIAL);
|
|
199
|
+
// The bill-to: issuing an e-invoice needs a tax ID. Toggle it to see the gate.
|
|
200
|
+
const [taxId, setTaxId] = useState("0312456780");
|
|
201
|
+
const [deposit, setDeposit] = useState(0);
|
|
202
|
+
const [confirm, setConfirm] = useState<Invoice | null>(null);
|
|
203
|
+
const seq = useState(() => ({ n: 414 }))[0];
|
|
204
|
+
|
|
205
|
+
const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
|
|
206
|
+
setInvoices((prev) =>
|
|
207
|
+
prev.map((inv) =>
|
|
208
|
+
inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) },
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const grandTotal = invoices.reduce((s, inv) => s + invoiceTotal(inv), 0);
|
|
213
|
+
const allMissing = invoices.flatMap(missingMethods);
|
|
214
|
+
const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
|
|
215
|
+
|
|
216
|
+
const issue = (inv: Invoice) => {
|
|
217
|
+
seq.n += 1;
|
|
218
|
+
const ref = `INV-2026-${String(seq.n).padStart(4, "0")}`;
|
|
219
|
+
setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
|
|
220
|
+
setConfirm(null);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const printReceipt = () => {
|
|
224
|
+
if (allMissing.length > 0) {
|
|
225
|
+
Alert.alert(
|
|
226
|
+
"Missing payment method",
|
|
227
|
+
`Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`,
|
|
228
|
+
[{ text: "OK" }],
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
237
|
+
<View style={{ width: "100%", maxWidth: 640, alignSelf: "center", gap: 16 }}>
|
|
238
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
239
|
+
<Text size="xl" weight="semibold">Billing</Text>
|
|
240
|
+
<Badge label="Order #4821" color="zinc" />
|
|
241
|
+
</View>
|
|
242
|
+
|
|
243
|
+
{/* ONE banded card — bill-to · each invoice · collect · deposit */}
|
|
244
|
+
<Card style={{ padding: 0 }}>
|
|
245
|
+
<CardHeader>
|
|
246
|
+
<CardHeaderTitle info="One document: the bill-to, each invoice, the total, and the deposit are bands of the same card.">
|
|
247
|
+
Phí & hóa đơn
|
|
248
|
+
</CardHeaderTitle>
|
|
249
|
+
<CardHeaderMeta>{formatMoney(grandTotal)}</CardHeaderMeta>
|
|
250
|
+
</CardHeader>
|
|
251
|
+
|
|
252
|
+
{/* bill-to band — issuing needs a tax ID; the toggle shows the gate */}
|
|
253
|
+
<CardBody>
|
|
254
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
255
|
+
<Icon name="building-2" size={16} color={colors.zinc[500]} />
|
|
256
|
+
<Text weight="medium">Harbor Freight Lines</Text>
|
|
257
|
+
<Text size="sm" color="muted">·</Text>
|
|
258
|
+
{taxId ? (
|
|
259
|
+
<Text size="sm" color="muted" tabular>MST {taxId}</Text>
|
|
260
|
+
) : (
|
|
261
|
+
<Text size="sm" color="danger">No tax ID — required to issue</Text>
|
|
262
|
+
)}
|
|
263
|
+
<View style={{ flex: 1 }} />
|
|
264
|
+
<Button title={taxId ? "Remove tax ID" : "Add tax ID"} color="muted" onPress={() => setTaxId((t) => (t ? "" : "0312456780"))} />
|
|
265
|
+
</View>
|
|
266
|
+
</CardBody>
|
|
267
|
+
|
|
268
|
+
{/* invoice bands */}
|
|
269
|
+
{invoices.map((inv) => (
|
|
270
|
+
<InvoiceBand
|
|
271
|
+
key={inv.key}
|
|
272
|
+
inv={inv}
|
|
273
|
+
taxId={taxId}
|
|
274
|
+
onAmount={(chKey, v) => patchCharge(inv.key, chKey, { amount: v })}
|
|
275
|
+
onMethod={(chKey, m) => patchCharge(inv.key, chKey, { method: m })}
|
|
276
|
+
onIssue={setConfirm}
|
|
277
|
+
/>
|
|
278
|
+
))}
|
|
279
|
+
|
|
280
|
+
{/* collect band */}
|
|
281
|
+
<Divider />
|
|
282
|
+
<CardBody style={{ gap: 10 }}>
|
|
283
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
284
|
+
<Text weight="semibold" style={{ flex: 1 }}>Tổng thu</Text>
|
|
285
|
+
<Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
|
|
286
|
+
</View>
|
|
287
|
+
{allMissing.length > 0 ? (
|
|
288
|
+
<Callout tone="warning">
|
|
289
|
+
<CalloutText>
|
|
290
|
+
{allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
|
|
291
|
+
</CalloutText>
|
|
292
|
+
</Callout>
|
|
293
|
+
) : null}
|
|
294
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
295
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
|
|
296
|
+
<Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
|
|
297
|
+
</View>
|
|
298
|
+
</CardBody>
|
|
299
|
+
|
|
300
|
+
{/* deposit band — tinted + labelled separate, never part of the total */}
|
|
301
|
+
<Divider />
|
|
302
|
+
<CardBody style={{ backgroundColor: colors.zinc[50], gap: 8 }}>
|
|
303
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
304
|
+
<Text weight="semibold">Deposit</Text>
|
|
305
|
+
<Badge label="Collected separately" color="zinc" />
|
|
306
|
+
</View>
|
|
307
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
|
308
|
+
<Text size="sm" color="muted" style={{ width: 116 }}>Refundable</Text>
|
|
309
|
+
<View style={{ flexGrow: 1, flexBasis: 130 }}>
|
|
310
|
+
<NumberInput value={deposit || null} onValueChange={(v) => setDeposit(v ?? 0)} min={0} accessibilityLabel="Deposit amount" />
|
|
311
|
+
</View>
|
|
312
|
+
<Button
|
|
313
|
+
title="Deposit receipt"
|
|
314
|
+
color="secondary"
|
|
315
|
+
disabled={deposit <= 0}
|
|
316
|
+
onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
|
|
317
|
+
/>
|
|
318
|
+
</View>
|
|
319
|
+
</CardBody>
|
|
320
|
+
</Card>
|
|
321
|
+
</View>
|
|
322
|
+
|
|
323
|
+
{/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
|
|
324
|
+
<Dialog width={460} open={confirm !== null} onOpenChange={(o) => { if (!o) setConfirm(null); }}>
|
|
325
|
+
<DialogHeader>
|
|
326
|
+
<DialogHeaderTitle>{confirm?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
|
|
327
|
+
</DialogHeader>
|
|
328
|
+
<View style={{ paddingHorizontal: 24, paddingVertical: 12 }}>
|
|
329
|
+
<Callout tone="warning">
|
|
330
|
+
<CalloutText>
|
|
331
|
+
{confirm
|
|
332
|
+
? `Issue a real e-invoice for "${confirm.title}" (${formatMoney(invoiceTotal(confirm))}) to the provider. This writes a lookup code to the record and can't be undone.`
|
|
333
|
+
: ""}
|
|
334
|
+
</CalloutText>
|
|
335
|
+
</Callout>
|
|
336
|
+
</View>
|
|
337
|
+
<DialogFooter>
|
|
338
|
+
<Button title="Cancel" color="secondary" onPress={() => setConfirm(null)} />
|
|
339
|
+
<Button title="Issue" color="primary" onPress={() => confirm && issue(confirm)} />
|
|
340
|
+
</DialogFooter>
|
|
341
|
+
</Dialog>
|
|
342
|
+
</ScrollView>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, solid, type ColorName } from "@lotics/ui/colors";
|
|
5
|
+
import { ActionMenu } from "@lotics/ui/action_menu";
|
|
6
|
+
import { Badge } from "@lotics/ui/badge";
|
|
7
|
+
import { Button } from "@lotics/ui/button";
|
|
8
|
+
import { Card, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
|
|
9
|
+
import { Divider } from "@lotics/ui/divider";
|
|
10
|
+
import { Drawer } from "@lotics/ui/drawer";
|
|
11
|
+
import { ListItem } from "@lotics/ui/list_item";
|
|
12
|
+
import {
|
|
13
|
+
CalendarView,
|
|
14
|
+
addDays,
|
|
15
|
+
isSameDay,
|
|
16
|
+
startOfWeek,
|
|
17
|
+
viewTitle,
|
|
18
|
+
type CalendarEvent,
|
|
19
|
+
} from "@lotics/ui/calendar";
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Template · Calendar & planning — the delivery/schedule desk. One real
|
|
23
|
+
// CalendarView (week grid) over the current week's deliveries / reconciliations /
|
|
24
|
+
// customer visits, then a "Today" agenda card listing today's three slots.
|
|
25
|
+
//
|
|
26
|
+
// Grammar: zinc-50 canvas · header band (title + week label + Today) ·
|
|
27
|
+
// calendar card at a real height · agenda card of pressable rows. Every slot
|
|
28
|
+
// is a door: press (grid event or agenda row) opens the sequenced workspace
|
|
29
|
+
// Drawer; agenda rows also carry the ⋯ quick-actions menu. Event colors carry
|
|
30
|
+
// meaning: blue = delivery, emerald = reconciliation, amber = customer.
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
interface AgendaMeta {
|
|
34
|
+
diaDiem: string;
|
|
35
|
+
trangThai: string;
|
|
36
|
+
mau: ColorName;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Mock data anchored to the real current week so the page is evergreen.
|
|
40
|
+
const NOW = new Date();
|
|
41
|
+
const TODAY = addDays(NOW, 0); // start of today
|
|
42
|
+
const WEEK_START = startOfWeek(TODAY, 1);
|
|
43
|
+
const TODAY_IDX = Math.round((TODAY.getTime() - WEEK_START.getTime()) / 86_400_000);
|
|
44
|
+
// Four weekday slots for the rest of the week — never colliding with today,
|
|
45
|
+
// so the agenda below always shows exactly today's three slots.
|
|
46
|
+
const SLOTS = [0, 1, 2, 3, 4, 5].filter((d) => d !== TODAY_IDX).slice(0, 4);
|
|
47
|
+
|
|
48
|
+
function evt(
|
|
49
|
+
id: string,
|
|
50
|
+
day: Date,
|
|
51
|
+
h: number,
|
|
52
|
+
m: number,
|
|
53
|
+
durMin: number,
|
|
54
|
+
title: string,
|
|
55
|
+
color: string,
|
|
56
|
+
data: AgendaMeta,
|
|
57
|
+
): CalendarEvent<AgendaMeta> {
|
|
58
|
+
const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, m);
|
|
59
|
+
return { id, title, start, end: new Date(start.getTime() + durMin * 60_000), color, data };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const EVENTS: CalendarEvent<AgendaMeta>[] = [
|
|
63
|
+
// Today — the dispatch desk's three slots.
|
|
64
|
+
evt("hn-1", TODAY, 8, 0, 90, "Deliver APEX PLASTICS — VTD063518", solid("blue"), {
|
|
65
|
+
diaDiem: "Eastport industrial zone",
|
|
66
|
+
trangThai: "Truck loaded",
|
|
67
|
+
mau: "blue",
|
|
68
|
+
}),
|
|
69
|
+
evt("hn-2", TODAY, 10, 30, 60, "Reconcile delivery notes — HANDAN", solid("emerald"), {
|
|
70
|
+
diaDiem: "Head office",
|
|
71
|
+
trangThai: "Prepared",
|
|
72
|
+
mau: "emerald",
|
|
73
|
+
}),
|
|
74
|
+
evt("hn-3", TODAY, 15, 0, 60, "Customer sample review — VITTORIA", solid("amber"), {
|
|
75
|
+
diaDiem: "Factory sample room",
|
|
76
|
+
trangThai: "Awaiting confirmation",
|
|
77
|
+
mau: "amber",
|
|
78
|
+
}),
|
|
79
|
+
// The rest of the week.
|
|
80
|
+
evt("t-1", addDays(WEEK_START, SLOTS[0]), 8, 0, 90, "Deliver KOMASPEC — blanks 675×325", solid("blue"), {
|
|
81
|
+
diaDiem: "Northgate industrial park",
|
|
82
|
+
trangThai: "Truck loaded",
|
|
83
|
+
mau: "blue",
|
|
84
|
+
}),
|
|
85
|
+
evt("t-2", addDays(WEEK_START, SLOTS[1]), 14, 0, 60, "Reconcile delivery notes — NEWTECONS", solid("emerald"), {
|
|
86
|
+
diaDiem: "Head office",
|
|
87
|
+
trangThai: "Prepared",
|
|
88
|
+
mau: "emerald",
|
|
89
|
+
}),
|
|
90
|
+
evt("t-3", addDays(WEEK_START, SLOTS[2]), 10, 0, 60, "Kick off the VITTORIA order", solid("amber"), {
|
|
91
|
+
diaDiem: "Online meeting",
|
|
92
|
+
trangThai: "Awaiting confirmation",
|
|
93
|
+
mau: "amber",
|
|
94
|
+
}),
|
|
95
|
+
evt("t-4", addDays(WEEK_START, SLOTS[3]), 8, 30, 90, "Deliver BRIGHTCELL BATTERIES — TS1250", solid("blue"), {
|
|
96
|
+
diaDiem: "Brightcell plant, Eastport",
|
|
97
|
+
trangThai: "Truck loaded",
|
|
98
|
+
mau: "blue",
|
|
99
|
+
}),
|
|
100
|
+
// All-week banner: an internal task spanning two mid-week days.
|
|
101
|
+
{
|
|
102
|
+
id: "kk-1",
|
|
103
|
+
title: "Q2 stock count",
|
|
104
|
+
start: addDays(WEEK_START, 2),
|
|
105
|
+
end: addDays(WEEK_START, 4),
|
|
106
|
+
allDay: true,
|
|
107
|
+
color: colors.zinc[500],
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const CAL_LABELS = {
|
|
112
|
+
today: "Today",
|
|
113
|
+
month: "Month",
|
|
114
|
+
week: "Week",
|
|
115
|
+
day: "Day",
|
|
116
|
+
previous: "Previous",
|
|
117
|
+
next: "Next",
|
|
118
|
+
allDay: "all day",
|
|
119
|
+
more: (n: number) => `+${n} more`,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const p2 = (n: number) => String(n).padStart(2, "0");
|
|
123
|
+
const fmtTime = (d: Date) => `${p2(d.getHours())}:${p2(d.getMinutes())}`;
|
|
124
|
+
const fmtDate = (d: Date) => `${p2(d.getDate())}/${p2(d.getMonth() + 1)}`;
|
|
125
|
+
|
|
126
|
+
// The drawer sequences over the week's timed slots in chronological order —
|
|
127
|
+
// the dispatcher's triage rhythm: open one, ◀ ▶ / ←→ through the rest. The
|
|
128
|
+
// all-day banner opens the same drawer but sits outside the sequence.
|
|
129
|
+
const SEQUENCE = EVENTS.filter((e) => !e.allDay).sort(
|
|
130
|
+
(a, b) => a.start.getTime() - b.start.getTime(),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Quick operations for a schedule slot — the ⋯ door on each agenda row
|
|
134
|
+
// (press = workspace drawer; ⋯ = these). Destructive last.
|
|
135
|
+
const SLOT_ACTIONS = [
|
|
136
|
+
{ key: "sua", label: "Edit slot", icon: "pencil" as const, onPress: () => {} },
|
|
137
|
+
{ key: "nhac", label: "Remind owner", icon: "bell" as const, onPress: () => {} },
|
|
138
|
+
{ key: "huy", label: "Cancel slot", icon: "trash" as const, danger: true, onPress: () => {} },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// Compact slot workspace — identity + key facts + the two real actions.
|
|
142
|
+
function SlotWorkspace({ e }: { e: CalendarEvent<AgendaMeta> }) {
|
|
143
|
+
const thoiGian = e.allDay
|
|
144
|
+
? `${fmtDate(e.start)} – ${e.end ? fmtDate(e.end) : ""} · all day`
|
|
145
|
+
: `${fmtDate(e.start)} · ${fmtTime(e.start)} – ${e.end ? fmtTime(e.end) : ""}`;
|
|
146
|
+
const rows: [string, React.ReactNode][] = [
|
|
147
|
+
["Time", <Text key="tg" size="sm" tabular>{thoiGian}</Text>],
|
|
148
|
+
...(e.data
|
|
149
|
+
? ([
|
|
150
|
+
["Location", <Text key="dd" size="sm">{e.data.diaDiem}</Text>],
|
|
151
|
+
["Status", <Badge key="tt" label={e.data.trangThai} color={e.data.mau} />],
|
|
152
|
+
] as [string, React.ReactNode][])
|
|
153
|
+
: []),
|
|
154
|
+
];
|
|
155
|
+
return (
|
|
156
|
+
<>
|
|
157
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 10 }}>
|
|
158
|
+
{rows.map(([label, value], i) => (
|
|
159
|
+
<View key={label} style={{ gap: 10 }}>
|
|
160
|
+
{i > 0 ? <Divider /> : null}
|
|
161
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
|
|
162
|
+
<Text size="sm" color="muted" style={{ width: 104 }}>{label}</Text>
|
|
163
|
+
{value}
|
|
164
|
+
</View>
|
|
165
|
+
</View>
|
|
166
|
+
))}
|
|
167
|
+
</ScrollView>
|
|
168
|
+
<View
|
|
169
|
+
style={{
|
|
170
|
+
borderTopWidth: 1,
|
|
171
|
+
borderTopColor: colors.border,
|
|
172
|
+
paddingHorizontal: 20,
|
|
173
|
+
paddingVertical: 14,
|
|
174
|
+
flexDirection: "row",
|
|
175
|
+
alignItems: "center",
|
|
176
|
+
justifyContent: "flex-end",
|
|
177
|
+
gap: 12,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<Button title="Edit slot" color="secondary" onPress={() => {}} />
|
|
181
|
+
<Button title="Open record" color="primary" onPress={() => {}} />
|
|
182
|
+
</View>
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function TplCalendar() {
|
|
188
|
+
// "Today" remounts the calendar — CalendarView owns date+view internally,
|
|
189
|
+
// so a fresh mount is the supported way to jump back to the current week.
|
|
190
|
+
const [calKey, setCalKey] = useState(0);
|
|
191
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
192
|
+
|
|
193
|
+
const openEvent = EVENTS.find((e) => e.id === openId) ?? null;
|
|
194
|
+
const seqIndex = openEvent ? SEQUENCE.findIndex((e) => e.id === openEvent.id) : -1;
|
|
195
|
+
|
|
196
|
+
const todayEvents = EVENTS.filter((e) => !e.allDay && isSameDay(e.start, TODAY)).sort(
|
|
197
|
+
(a, b) => a.start.getTime() - b.start.getTime(),
|
|
198
|
+
);
|
|
199
|
+
const todayLabel = new Intl.DateTimeFormat("vi", {
|
|
200
|
+
weekday: "long",
|
|
201
|
+
day: "numeric",
|
|
202
|
+
month: "long",
|
|
203
|
+
}).format(TODAY);
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<View style={{ flex: 1 }}>
|
|
207
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
208
|
+
<View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
|
|
209
|
+
{/* header band */}
|
|
210
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
211
|
+
<View style={{ gap: 2, flex: 1 }}>
|
|
212
|
+
<Text size="xl" weight="semibold">Calendar & planning</Text>
|
|
213
|
+
<Text size="sm" color="muted">This week — deliveries, reconciliations and customer visits</Text>
|
|
214
|
+
</View>
|
|
215
|
+
<Text size="sm" color="muted" tabular>
|
|
216
|
+
{viewTitle("week", TODAY, 1, "vi")}
|
|
217
|
+
</Text>
|
|
218
|
+
<Button title="Today" color="secondary" onPress={() => setCalKey((k) => k + 1)} />
|
|
219
|
+
</View>
|
|
220
|
+
|
|
221
|
+
{/* the calendar — real week grid, internal toolbar + scroll */}
|
|
222
|
+
<Card style={{ padding: 0, height: 520, overflow: "hidden" }}>
|
|
223
|
+
<CalendarView<AgendaMeta>
|
|
224
|
+
key={calKey}
|
|
225
|
+
events={EVENTS}
|
|
226
|
+
defaultView="week"
|
|
227
|
+
defaultDate={TODAY}
|
|
228
|
+
weekStartsOn={1}
|
|
229
|
+
locale="vi"
|
|
230
|
+
labels={CAL_LABELS}
|
|
231
|
+
onEventPress={(e) => setOpenId(e.id)}
|
|
232
|
+
/>
|
|
233
|
+
</Card>
|
|
234
|
+
|
|
235
|
+
{/* today — agenda */}
|
|
236
|
+
<Card style={{ padding: 0 }}>
|
|
237
|
+
<CardHeader>
|
|
238
|
+
<CardHeaderTitle>Today</CardHeaderTitle>
|
|
239
|
+
<CardHeaderMeta>{`${todayLabel} · ${todayEvents.length} slots`}</CardHeaderMeta>
|
|
240
|
+
</CardHeader>
|
|
241
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 6 }}>
|
|
242
|
+
{todayEvents.map((e) => (
|
|
243
|
+
<ListItem
|
|
244
|
+
key={e.id}
|
|
245
|
+
left={
|
|
246
|
+
<Text size="sm" weight="medium" tabular style={{ width: 104 }}>
|
|
247
|
+
{fmtTime(e.start)} – {e.end ? fmtTime(e.end) : ""}
|
|
248
|
+
</Text>
|
|
249
|
+
}
|
|
250
|
+
title={e.title}
|
|
251
|
+
description={e.data?.diaDiem}
|
|
252
|
+
selected={e.id === openId}
|
|
253
|
+
onPress={() => setOpenId(e.id)}
|
|
254
|
+
right={
|
|
255
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
256
|
+
{e.data ? <Badge label={e.data.trangThai} color={e.data.mau} /> : null}
|
|
257
|
+
<ActionMenu items={SLOT_ACTIONS} accessibilityLabel={`Actions for ${e.title}`} />
|
|
258
|
+
</View>
|
|
259
|
+
}
|
|
260
|
+
/>
|
|
261
|
+
))}
|
|
262
|
+
</View>
|
|
263
|
+
</Card>
|
|
264
|
+
</View>
|
|
265
|
+
</ScrollView>
|
|
266
|
+
|
|
267
|
+
{/* the shared slot workspace — body keyed by event id; ◀ ▶ / ←→ step
|
|
268
|
+
through the week's timed slots in start-time order */}
|
|
269
|
+
{openEvent ? (
|
|
270
|
+
<Drawer
|
|
271
|
+
open
|
|
272
|
+
onOpenChange={(o) => !o && setOpenId(null)}
|
|
273
|
+
title={openEvent.title}
|
|
274
|
+
width={480}
|
|
275
|
+
onPrev={seqIndex > 0 ? () => setOpenId(SEQUENCE[seqIndex - 1].id) : undefined}
|
|
276
|
+
onNext={
|
|
277
|
+
seqIndex >= 0 && seqIndex < SEQUENCE.length - 1
|
|
278
|
+
? () => setOpenId(SEQUENCE[seqIndex + 1].id)
|
|
279
|
+
: undefined
|
|
280
|
+
}
|
|
281
|
+
position={seqIndex >= 0 ? `${seqIndex + 1}/${SEQUENCE.length}` : undefined}
|
|
282
|
+
>
|
|
283
|
+
<SlotWorkspace key={openEvent.id} e={openEvent} />
|
|
284
|
+
</Drawer>
|
|
285
|
+
) : null}
|
|
286
|
+
</View>
|
|
287
|
+
);
|
|
288
|
+
}
|