@lotics/ui 3.6.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +323 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +22 -6
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, solid } from "@lotics/ui/colors";
|
|
5
|
+
import { ActionMenu } from "@lotics/ui/action_menu";
|
|
6
|
+
import { Alert } from "@lotics/ui/alert";
|
|
7
|
+
import { Badge } from "@lotics/ui/badge";
|
|
8
|
+
import { Button } from "@lotics/ui/button";
|
|
9
|
+
import { Card, CardBody, CardFooter, CardHeader, CardHeaderMeta } from "@lotics/ui/card";
|
|
10
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
11
|
+
import { Drawer } from "@lotics/ui/drawer";
|
|
12
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
13
|
+
import { Divider } from "@lotics/ui/divider";
|
|
14
|
+
import { FormField } from "@lotics/ui/form_field";
|
|
15
|
+
import { Icon } from "@lotics/ui/icon";
|
|
16
|
+
import { KPICard } from "@lotics/ui/kpi_card";
|
|
17
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
18
|
+
import { NumberInput } from "@lotics/ui/number_input";
|
|
19
|
+
import { Peek } from "@lotics/ui/peek";
|
|
20
|
+
import { PressableHighlight } from "@lotics/ui/pressable_highlight";
|
|
21
|
+
import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
|
|
22
|
+
import { Screen } from "@lotics/ui/screen_router";
|
|
23
|
+
import { StepProgress } from "@lotics/ui/step_progress";
|
|
24
|
+
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
25
|
+
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
26
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
27
|
+
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// App template · Order management — the KD03 pipeline desk, recreated in the
|
|
30
|
+
// harness with mock data so the UI can be polished in isolation and the
|
|
31
|
+
// result copied back into the app. This page IS the design spec.
|
|
32
|
+
//
|
|
33
|
+
// Grammar: full-bleed zinc-50 canvas · one header band · KPI strip · stage pills ·
|
|
34
|
+
// queue of work cards (CardHeader: identity · CardBody: product + stage
|
|
35
|
+
// StepProgress · CardFooter: KPICards → ONE action). The queue pill IS the
|
|
36
|
+
// macro pipeline position — cards carry no second pipeline indicator.
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const CONG_DOAN = ["Blanking", "Cutting", "Die-cut", "Printing", "Gluing", "Inbound", "Outbound"];
|
|
40
|
+
|
|
41
|
+
interface MockOrder {
|
|
42
|
+
id: string;
|
|
43
|
+
khach: string;
|
|
44
|
+
ten: string;
|
|
45
|
+
queue: "lenh" | "sx" | "giao" | "doichieu";
|
|
46
|
+
cd?: number; // stage index
|
|
47
|
+
cdDays?: number;
|
|
48
|
+
can: string;
|
|
49
|
+
sl: string;
|
|
50
|
+
giaTri: number;
|
|
51
|
+
overdue?: boolean;
|
|
52
|
+
daGiao?: string;
|
|
53
|
+
khop?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ORDERS: MockOrder[] = [
|
|
57
|
+
{ id: "SR-2026-0081", khach: "ATLAS COMPONENTS", ten: "Carton blank sheet 675×325 (HPKC00000005)", queue: "lenh", can: "10/04", sl: "2,747", giaTri: 8_982_690, overdue: true },
|
|
58
|
+
{ id: "SR-2026-0082", khach: "ATLAS COMPONENTS", ten: "Full Cover Box 2.4MPa 1470×290×168", queue: "lenh", can: "10/04", sl: "1,162", giaTri: 62_829_340, overdue: true },
|
|
59
|
+
{ id: "SR-2026-0022", khach: "CRESTLINE FURNITURE", ten: "TOP COVER · Long brace (11 pcs/set)", queue: "sx", cd: 5, cdDays: 3, can: "10/06", sl: "5,000", giaTri: 4_000_000, overdue: true },
|
|
60
|
+
{ id: "SR-2026-0026", khach: "VITTORIA ACCESSORIES", ten: "Accessory carton box 400×300×250", queue: "sx", cd: 3, cdDays: 1, can: "13/06", sl: "2,500", giaTri: 22_500_000 },
|
|
61
|
+
{ id: "SR-2026-0019", khach: "KING LUN PLASTICS", ten: "Inner box VTD063518 475×165×108", queue: "sx", cd: 1, cdDays: 0, can: "12/06", sl: "3,000", giaTri: 23_400_000 },
|
|
62
|
+
{ id: "SR-2026-0027", khach: "BRIGHTCELL BATTERIES", ten: "TS1250 Phoenix box 371×191×124", queue: "giao", can: "16/06", sl: "500", giaTri: 4_250_000, daGiao: "0" },
|
|
63
|
+
{ id: "SR-2026-0042", khach: "MERIDIAN CONSTRUCTION", ten: "Carton box NEW 2668 500×300×200", queue: "doichieu", can: "28/05", sl: "1,800", giaTri: 25_200_000, daGiao: "1,800", khop: true },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const QUEUES = [
|
|
67
|
+
{ id: "lenh", label: "Issue order", hint: "New orders — generate the production order for the contract shop" },
|
|
68
|
+
{ id: "sx", label: "Production", hint: "Shop-floor stages — nudge anything stuck over 2 days" },
|
|
69
|
+
{ id: "giao", label: "Delivery", hint: "Dispatch plans the trucks — track delivered quantity" },
|
|
70
|
+
{ id: "doichieu", label: "Reconciliation", hint: "Match delivered vs the delivery note — when it matches, hand to Accounting" },
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
// Peek dossiers — the detail BEHIND each inline reference. A name on a card
|
|
74
|
+
// answers "who"; the peek answers "who, how to reach them, what they owe"
|
|
75
|
+
// without leaving the queue.
|
|
76
|
+
const KHACH_HO_SO: Record<string, { nganh: string; lienHe: string; dienThoai: string; doanhThu6T: number; congNo: number }> = {
|
|
77
|
+
"ATLAS COMPONENTS": { nganh: "Export machining", lienHe: "Sarah Chen — Procurement", dienThoai: "(206) 555-0147", doanhThu6T: 312_400_000, congNo: 86_200_000 },
|
|
78
|
+
"CRESTLINE FURNITURE": { nganh: "Export furniture", lienHe: "David Park — Logistics", dienThoai: "(415) 555-0228", doanhThu6T: 121_500_000, congNo: 64_200_000 },
|
|
79
|
+
"VITTORIA ACCESSORIES": { nganh: "Bicycle accessories", lienHe: "Maria Lopez — Purchasing", dienThoai: "(312) 555-0193", doanhThu6T: 154_700_000, congNo: 0 },
|
|
80
|
+
"KING LUN PLASTICS": { nganh: "Household plastics", lienHe: "James Wu — Sourcing", dienThoai: "(646) 555-0162", doanhThu6T: 248_900_000, congNo: 52_800_000 },
|
|
81
|
+
"BRIGHTCELL BATTERIES": { nganh: "Electrical — batteries", lienHe: "Emma Davis — Materials", dienThoai: "(503) 555-0184", doanhThu6T: 186_200_000, congNo: 86_400_000 },
|
|
82
|
+
"MERIDIAN CONSTRUCTION": { nganh: "Construction", lienHe: "Daniel Reed — Projects", dienThoai: "(212) 555-0119", doanhThu6T: 98_300_000, congNo: 0 },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function PeekRow({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
|
|
86
|
+
return (
|
|
87
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
88
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
|
|
89
|
+
<Text size="sm" color={danger ? "danger" : "default"} tabular>{value}</Text>
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function KhachPeek({ ten }: { ten: string }) {
|
|
95
|
+
const hs = KHACH_HO_SO[ten];
|
|
96
|
+
return (
|
|
97
|
+
<View style={{ gap: 10 }}>
|
|
98
|
+
<View style={{ gap: 1 }}>
|
|
99
|
+
<Text size="sm" weight="semibold">{ten}</Text>
|
|
100
|
+
<Text size="xs" color="muted">{hs.nganh}</Text>
|
|
101
|
+
</View>
|
|
102
|
+
<Divider />
|
|
103
|
+
<PeekRow label="Contact" value={hs.lienHe} />
|
|
104
|
+
<PeekRow label="Phone" value={hs.dienThoai} />
|
|
105
|
+
<PeekRow label="Revenue (6 months)" value={formatMoney(hs.doanhThu6T)} />
|
|
106
|
+
<PeekRow label="Outstanding balance" value={hs.congNo > 0 ? formatMoney(hs.congNo) : "None"} danger={hs.congNo > 0} />
|
|
107
|
+
<Divider />
|
|
108
|
+
<Button title="Open customer record" color="secondary" onPress={() => {}} />
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The order's workspace — every detail, editable where it should be. Opened
|
|
114
|
+
// from the order id on any card; sequenced over the visible queue.
|
|
115
|
+
function OrderWorkspace({ o }: { o: MockOrder }) {
|
|
116
|
+
const [note, setNote] = useState("");
|
|
117
|
+
// The card's footer facts are read-only TRIAGE — editing lives HERE, in the
|
|
118
|
+
// workspace. Keyed by order id at the call site, so these reset per order.
|
|
119
|
+
const [can, setCan] = useState(o.can);
|
|
120
|
+
const [sl, setSl] = useState(o.sl);
|
|
121
|
+
const stuck = o.cdDays !== undefined && o.cdDays > 2;
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 14 }}>
|
|
125
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
126
|
+
<Badge label={QUEUES.find((q) => q.id === o.queue)?.label ?? ""} color="blue" />
|
|
127
|
+
{o.overdue ? <Badge label="Overdue" color="red" /> : null}
|
|
128
|
+
</View>
|
|
129
|
+
<Text size="sm">{o.ten}</Text>
|
|
130
|
+
{/* the SAME stage band as the card — full-width bar + caption below,
|
|
131
|
+
danger when stuck; card and drawer read identically */}
|
|
132
|
+
{o.queue === "sx" && o.cd !== undefined ? (
|
|
133
|
+
<StepProgress
|
|
134
|
+
steps={CONG_DOAN}
|
|
135
|
+
current={o.cd}
|
|
136
|
+
color={solid("emerald")}
|
|
137
|
+
captionBelow
|
|
138
|
+
label={stuck ? `Stuck ${o.cdDays}d · ${CONG_DOAN[o.cd]}` : undefined}
|
|
139
|
+
captionTone={stuck ? "danger" : "default"}
|
|
140
|
+
/>
|
|
141
|
+
) : null}
|
|
142
|
+
<Divider />
|
|
143
|
+
{/* customer = a reference (Peek); the two editable facts are real
|
|
144
|
+
inputs here; value/delivered stay read-only — value is contractual */}
|
|
145
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 28 }}>
|
|
146
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>Customer</Text>
|
|
147
|
+
<Peek accessibilityLabel={`Customer record ${o.khach}`} content={<KhachPeek ten={o.khach} />} align="end">
|
|
148
|
+
<Text size="sm" weight="medium">{o.khach}</Text>
|
|
149
|
+
</Peek>
|
|
150
|
+
</View>
|
|
151
|
+
<View style={{ flexDirection: "row", gap: 12 }}>
|
|
152
|
+
<FormField label="Needed by" style={{ flex: 1 }}>
|
|
153
|
+
<TextInputField value={can} onChangeText={setCan} inputMode="numeric" placeholder="dd/mm" />
|
|
154
|
+
</FormField>
|
|
155
|
+
<FormField label="Quantity" style={{ flex: 1 }}>
|
|
156
|
+
<TextInputField value={sl} onChangeText={setSl} inputMode="numeric" />
|
|
157
|
+
</FormField>
|
|
158
|
+
</View>
|
|
159
|
+
<View style={{ gap: 10 }}>
|
|
160
|
+
<PeekRow label="Value" value={formatMoney(o.giaTri)} />
|
|
161
|
+
{o.daGiao !== undefined ? <PeekRow label="Delivered" value={o.daGiao} /> : null}
|
|
162
|
+
</View>
|
|
163
|
+
<Divider />
|
|
164
|
+
<FormField label="Internal note" optional optionalLabel="Optional">
|
|
165
|
+
<TextInputField value={note} onChangeText={setNote} placeholder="Visible to the whole desk…" />
|
|
166
|
+
</FormField>
|
|
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
|
+
gap: 12,
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>The queue card stays read-only — edit here</Text>
|
|
180
|
+
<Button title="Open full record" color="secondary" onPress={() => {}} />
|
|
181
|
+
<Button title="Save changes" color="primary" onPress={() => {}} />
|
|
182
|
+
</View>
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Stage gates, tiered by weight. Most transitions are one click (never add
|
|
188
|
+
// friction without need); a transition that NEEDS input gets a popover form
|
|
189
|
+
// anchored to its button (context stays visible); the exception/destructive
|
|
190
|
+
// path gets a Dialog (full attention, explicit framing).
|
|
191
|
+
|
|
192
|
+
function XuatKhoGuard({ o }: { o: MockOrder }) {
|
|
193
|
+
const [sl, setSl] = useState<number | null>(() => Number(o.sl.replace(/,/g, "")));
|
|
194
|
+
return (
|
|
195
|
+
<Popover side="top" align="end">
|
|
196
|
+
<PopoverTrigger>
|
|
197
|
+
<Button title="Confirm outbound" color="primary" />
|
|
198
|
+
</PopoverTrigger>
|
|
199
|
+
<PopoverContent style={{ width: 300 }} disableBodyScroll>
|
|
200
|
+
<View style={{ gap: 12 }}>
|
|
201
|
+
<View style={{ gap: 2 }}>
|
|
202
|
+
<Text size="sm" weight="semibold">Confirm outbound</Text>
|
|
203
|
+
<Text size="xs" color="muted">Record the warehouse's actual count — it may differ from the ordered quantity.</Text>
|
|
204
|
+
</View>
|
|
205
|
+
<FormField label="Outbound quantity">
|
|
206
|
+
<NumberInput value={sl} onValueChange={setSl} min={0} accessibilityLabel="Outbound quantity" />
|
|
207
|
+
</FormField>
|
|
208
|
+
</View>
|
|
209
|
+
{/* commit action right — Action Layout */}
|
|
210
|
+
<PopoverFooter>
|
|
211
|
+
<Button title="Confirm" color="primary" onPress={() => {}} />
|
|
212
|
+
</PopoverFooter>
|
|
213
|
+
</PopoverContent>
|
|
214
|
+
</Popover>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function LechGuard({ o }: { o: MockOrder }) {
|
|
219
|
+
// Dialog is CONTROLLED — the trigger button lives outside, owning open state.
|
|
220
|
+
const [open, setOpen] = useState(false);
|
|
221
|
+
const [lyDo, setLyDo] = useState("");
|
|
222
|
+
return (
|
|
223
|
+
<>
|
|
224
|
+
<Button title="Mismatch" color="muted" onPress={() => setOpen(true)} />
|
|
225
|
+
<Dialog width={460} open={open} onOpenChange={setOpen}>
|
|
226
|
+
<Screen route="">
|
|
227
|
+
<DialogHeader>
|
|
228
|
+
<DialogHeaderTitle>Report delivery mismatch — {o.id}</DialogHeaderTitle>
|
|
229
|
+
</DialogHeader>
|
|
230
|
+
<View style={{ padding: 24, gap: 14 }}>
|
|
231
|
+
<Text size="sm" color="muted">
|
|
232
|
+
Delivered quantity does not match the delivery note. The order moves to mismatch handling — Accounting
|
|
233
|
+
and Dispatch are both notified, and the balance stays unchanged until it is settled.
|
|
234
|
+
</Text>
|
|
235
|
+
<FormField label="Reason for mismatch">
|
|
236
|
+
<TextInputField value={lyDo} onChangeText={setLyDo} placeholder="e.g. 50 boxes short — dented during loading…" />
|
|
237
|
+
</FormField>
|
|
238
|
+
</View>
|
|
239
|
+
<DialogFooter>
|
|
240
|
+
<Button title="Cancel" color="secondary" onPress={() => setOpen(false)} />
|
|
241
|
+
<Button title="Confirm mismatch" color="danger" onPress={() => setOpen(false)} />
|
|
242
|
+
</DialogFooter>
|
|
243
|
+
</Screen>
|
|
244
|
+
</Dialog>
|
|
245
|
+
</>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function OrderCard({ o, onOpen }: { o: MockOrder; onOpen: () => void }) {
|
|
250
|
+
return (
|
|
251
|
+
<Card style={{ padding: 0 }}>
|
|
252
|
+
{/* the WHOLE card is the door to the order workspace (the header
|
|
253
|
+
chevron marks it). Nested pressables — the CTA, stage gates, the
|
|
254
|
+
customer Peek — claim their own presses via the responder system,
|
|
255
|
+
so they never open the drawer. The surface itself carries no
|
|
256
|
+
button role and no tab stop: a button must not contain interactive
|
|
257
|
+
descendants, so the ACCESSIBLE door is the order id inside — the
|
|
258
|
+
card around it is a mouse-only click extension. A pressable card
|
|
259
|
+
is not a text-selection surface → userSelect none. */}
|
|
260
|
+
<PressableHighlight onPress={onOpen} focusable={false} userSelect="none" style={{ borderRadius: 16 }}>
|
|
261
|
+
<CardHeader>
|
|
262
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Open order ${o.id}`} onPress={onOpen}>
|
|
263
|
+
<Text size="sm" weight="semibold" tabular>{o.id}</Text>
|
|
264
|
+
</Pressable>
|
|
265
|
+
{o.overdue ? <Badge label="Overdue" color="red" /> : null}
|
|
266
|
+
<View style={{ flex: 1 }} />
|
|
267
|
+
<Peek accessibilityLabel={`Customer record ${o.khach}`} content={<KhachPeek ten={o.khach} />} align="end">
|
|
268
|
+
<CardHeaderMeta>{o.khach}</CardHeaderMeta>
|
|
269
|
+
</Peek>
|
|
270
|
+
<ActionMenu
|
|
271
|
+
accessibilityLabel={`Actions for ${o.id}`}
|
|
272
|
+
items={[
|
|
273
|
+
{ key: "sua", label: "Edit order", icon: "pencil", onPress: () => {} },
|
|
274
|
+
{ key: "huy", label: "Cancel order", icon: "trash", danger: true, onPress: () => Alert.alert("Cancel order?", "This cancels the order and notifies the customer — it can't be undone.", [{ text: "Keep order", style: "cancel" }, { text: "Cancel order", style: "destructive", onPress: () => {} }]) },
|
|
275
|
+
]}
|
|
276
|
+
/>
|
|
277
|
+
<Icon name="chevron-right" size={16} color={colors.zinc[400]} />
|
|
278
|
+
</CardHeader>
|
|
279
|
+
<CardBody style={{ gap: 10 }}>
|
|
280
|
+
<Text size="sm" numberOfLines={1}>{o.ten}</Text>
|
|
281
|
+
{/* the queue pill already says the macro KD03 stage — the only
|
|
282
|
+
progress inside a card is the shop sub-stage. Full-width bar with
|
|
283
|
+
the stage in a caption BELOW it (matching the drawer); stuck turns
|
|
284
|
+
the caption danger — no floating badge that would shift the bar
|
|
285
|
+
and break card-to-card consistency. */}
|
|
286
|
+
{o.queue === "sx" && o.cd !== undefined ? (
|
|
287
|
+
<StepProgress
|
|
288
|
+
steps={CONG_DOAN}
|
|
289
|
+
current={o.cd}
|
|
290
|
+
color={solid("emerald")}
|
|
291
|
+
captionBelow
|
|
292
|
+
label={o.cdDays !== undefined && o.cdDays > 2 ? `Stuck ${o.cdDays}d · ${CONG_DOAN[o.cd]}` : undefined}
|
|
293
|
+
captionTone={o.cdDays !== undefined && o.cdDays > 2 ? "danger" : "default"}
|
|
294
|
+
/>
|
|
295
|
+
) : null}
|
|
296
|
+
</CardBody>
|
|
297
|
+
<CardFooter style={{ gap: 28 }}>
|
|
298
|
+
<KPICard label="Needed by" value={o.can} size="sm" tone={o.overdue ? "danger" : "default"} />
|
|
299
|
+
<KPICard label="Quantity" value={o.sl} size="sm" />
|
|
300
|
+
{o.daGiao !== undefined ? (
|
|
301
|
+
<KPICard label="Delivered" value={o.daGiao} size="sm" />
|
|
302
|
+
) : (
|
|
303
|
+
<KPICard label="Value" value={o.giaTri} format="currency" size="sm" />
|
|
304
|
+
)}
|
|
305
|
+
<View style={{ flex: 1 }} />
|
|
306
|
+
{o.queue === "lenh" ? <Button title="Generate order" color="primary" onPress={() => {}} /> : null}
|
|
307
|
+
{o.queue === "sx" && o.cd !== undefined ? (
|
|
308
|
+
o.cd === 5 ? (
|
|
309
|
+
// gated: leaving the shop floor records the real counted quantity
|
|
310
|
+
<XuatKhoGuard o={o} />
|
|
311
|
+
) : (
|
|
312
|
+
// ungated: advancing a stage needs nothing — one click
|
|
313
|
+
<Button title={`→ ${CONG_DOAN[o.cd + 1]}`} color="secondary" onPress={() => {}} />
|
|
314
|
+
)
|
|
315
|
+
) : null}
|
|
316
|
+
{o.queue === "giao" ? <Badge label="Awaiting delivery" color="blue" /> : null}
|
|
317
|
+
{o.queue === "doichieu" ? (
|
|
318
|
+
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
|
319
|
+
{o.khop ? <Badge label="Quantities match" color="emerald" /> : null}
|
|
320
|
+
<Button title="Mark matched" color="primary" onPress={() => {}} />
|
|
321
|
+
{/* exception path: a dialog — full attention, reason required */}
|
|
322
|
+
<LechGuard o={o} />
|
|
323
|
+
</View>
|
|
324
|
+
) : null}
|
|
325
|
+
</CardFooter>
|
|
326
|
+
</PressableHighlight>
|
|
327
|
+
</Card>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function AppOrders() {
|
|
332
|
+
const [tab, setTab] = useState<(typeof QUEUES)[number]["id"]>("sx");
|
|
333
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
334
|
+
const counts = Object.fromEntries(QUEUES.map((q) => [q.id, ORDERS.filter((o) => o.queue === q.id).length]));
|
|
335
|
+
const queue = ORDERS.filter((o) => o.queue === tab);
|
|
336
|
+
const def = QUEUES.find((q) => q.id === tab);
|
|
337
|
+
const quaHan = ORDERS.filter((o) => o.overdue).length;
|
|
338
|
+
// dd/mm → comparable day index (mock data lives in one year)
|
|
339
|
+
const dayIdx = (ddmm: string) => Number(ddmm.slice(3, 5)) * 31 + Number(ddmm.slice(0, 2));
|
|
340
|
+
const today = dayIdx("12/06");
|
|
341
|
+
const dueThisWeek = ORDERS.filter((o) => !o.overdue && dayIdx(o.can) >= today && dayIdx(o.can) <= today + 7).length;
|
|
342
|
+
const stuck = ORDERS.filter((o) => o.queue === "sx" && (o.cdDays ?? 0) > 2).length;
|
|
343
|
+
const openValue = ORDERS.reduce((sum, o) => sum + o.giaTri, 0);
|
|
344
|
+
|
|
345
|
+
// The workspace drawer sequences over the active queue's visible ordering.
|
|
346
|
+
const openOrder = ORDERS.find((o) => o.id === openId) ?? null;
|
|
347
|
+
const seqIndex = openOrder ? queue.findIndex((o) => o.id === openOrder.id) : -1;
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
351
|
+
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
|
352
|
+
{/* header band */}
|
|
353
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
354
|
+
<View style={{ gap: 2, flex: 1 }}>
|
|
355
|
+
<Text size="xl" weight="semibold">Order management</Text>
|
|
356
|
+
<Text size="sm" color="muted">KD03 process — issue order → production → delivery → reconciliation</Text>
|
|
357
|
+
</View>
|
|
358
|
+
</View>
|
|
359
|
+
|
|
360
|
+
{/* the strip carries cross-queue HEALTH — never the tab counts
|
|
361
|
+
(the pills already show those) */}
|
|
362
|
+
<KPIStrip
|
|
363
|
+
items={[
|
|
364
|
+
{ label: "Overdue", value: quaHan, format: "number", tone: quaHan > 0 ? "danger" : "default", info: "Orders past their needed-by date, across every queue. The red badge on each card marks them." },
|
|
365
|
+
{ label: "Due this week", value: dueThisWeek, format: "number", caption: "needed by within 7 days" },
|
|
366
|
+
{ label: "Stuck in production", value: stuck, format: "number", tone: stuck > 0 ? "warning" : "default", info: "Production orders sitting in the same stage for more than 2 days — nudge the shop." },
|
|
367
|
+
{ label: "Open value", value: openValue, format: "currency", compact: true, caption: "all unsettled orders" },
|
|
368
|
+
]}
|
|
369
|
+
/>
|
|
370
|
+
|
|
371
|
+
<ChipGroup
|
|
372
|
+
accessibilityLabel="Process step"
|
|
373
|
+
options={QUEUES.map((q) => ({ label: `${q.label} · ${counts[q.id]}`, value: q.id }))}
|
|
374
|
+
value={tab}
|
|
375
|
+
onValueChange={setTab}
|
|
376
|
+
/>
|
|
377
|
+
{def ? <Text size="xs" color="muted">{def.hint}</Text> : null}
|
|
378
|
+
|
|
379
|
+
<View style={{ gap: 10 }}>
|
|
380
|
+
{queue.length === 0 ? (
|
|
381
|
+
<Card style={{ padding: 0 }}>
|
|
382
|
+
<EmptyState message="This queue is clear" hint="Orders land here as they reach this step" />
|
|
383
|
+
</Card>
|
|
384
|
+
) : (
|
|
385
|
+
queue.map((o) => <OrderCard key={o.id} o={o} onOpen={() => setOpenId(o.id)} />)
|
|
386
|
+
)}
|
|
387
|
+
</View>
|
|
388
|
+
</View>
|
|
389
|
+
|
|
390
|
+
{openOrder ? (
|
|
391
|
+
<Drawer
|
|
392
|
+
open
|
|
393
|
+
onOpenChange={(open) => !open && setOpenId(null)}
|
|
394
|
+
title={openOrder.id}
|
|
395
|
+
width={520}
|
|
396
|
+
onPrev={seqIndex > 0 ? () => setOpenId(queue[seqIndex - 1].id) : undefined}
|
|
397
|
+
onNext={seqIndex >= 0 && seqIndex < queue.length - 1 ? () => setOpenId(queue[seqIndex + 1].id) : undefined}
|
|
398
|
+
position={seqIndex >= 0 ? `${seqIndex + 1}/${queue.length}` : undefined}
|
|
399
|
+
>
|
|
400
|
+
<OrderWorkspace key={openOrder.id} o={openOrder} />
|
|
401
|
+
</Drawer>
|
|
402
|
+
) : null}
|
|
403
|
+
</ScrollView>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { AllocationRow } from "@lotics/ui/allocation_row";
|
|
6
|
+
import { Badge } from "@lotics/ui/badge";
|
|
7
|
+
import { Button } from "@lotics/ui/button";
|
|
8
|
+
import { Card, CardFooter } from "@lotics/ui/card";
|
|
9
|
+
import { Divider } from "@lotics/ui/divider";
|
|
10
|
+
import { RemainderMeter } from "@lotics/ui/remainder_meter";
|
|
11
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
12
|
+
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// Template · Allocation / split — apply ONE source across MANY targets until the
|
|
15
|
+
// remainder is zero. Here: cash application — a received payment spread across a
|
|
16
|
+
// customer's open invoices (the same shape serves stock-to-orders, landed-cost,
|
|
17
|
+
// budget distribution). The inverse of the batch builder: batch SUMS parts up to
|
|
18
|
+
// a total; allocation SPLITS a fixed total DOWN, with a remainder that must hit
|
|
19
|
+
// zero. The RemainderMeter is the spine — under (left on account) · exact (apply)
|
|
20
|
+
// · over (blocked). Oldest-first auto-allocates; each row can be filled or typed.
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
interface Invoice {
|
|
24
|
+
id: string;
|
|
25
|
+
issued: string;
|
|
26
|
+
ageDays: number;
|
|
27
|
+
/** Outstanding amount — the cap this invoice can absorb. */
|
|
28
|
+
due: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PAYMENT = { amount: 24_500_000, from: "ATLAS COMPONENTS", ref: "TT-88412", date: "11/06" };
|
|
32
|
+
|
|
33
|
+
const INVOICES: Invoice[] = [
|
|
34
|
+
{ id: "INV-2026-0301", issued: "24/05", ageDays: 18, due: 8_200_000 },
|
|
35
|
+
{ id: "INV-2026-0305", issued: "31/05", ageDays: 11, due: 6_400_000 },
|
|
36
|
+
{ id: "INV-2026-0312", issued: "07/06", ageDays: 4, due: 11_250_000 },
|
|
37
|
+
{ id: "INV-2026-0318", issued: "09/06", ageDays: 2, due: 3_150_000 },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function TplAllocate() {
|
|
41
|
+
const [alloc, setAlloc] = useState<Record<string, number>>({});
|
|
42
|
+
|
|
43
|
+
const allocated = INVOICES.reduce((s, inv) => s + (alloc[inv.id] ?? 0), 0);
|
|
44
|
+
const remainder = PAYMENT.amount - allocated;
|
|
45
|
+
const over = remainder < 0;
|
|
46
|
+
|
|
47
|
+
// Oldest-first — fill the oldest invoices in full until the payment runs out.
|
|
48
|
+
const autoOldest = () => {
|
|
49
|
+
let left = PAYMENT.amount;
|
|
50
|
+
const next: Record<string, number> = {};
|
|
51
|
+
[...INVOICES].sort((a, b) => b.ageDays - a.ageDays).forEach((inv) => {
|
|
52
|
+
const take = Math.max(0, Math.min(inv.due, left));
|
|
53
|
+
next[inv.id] = take;
|
|
54
|
+
left -= take;
|
|
55
|
+
});
|
|
56
|
+
setAlloc(next);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const status = over
|
|
60
|
+
? `Reduce by ${formatMoney(-remainder)} — you can't apply more than was received`
|
|
61
|
+
: remainder > 0
|
|
62
|
+
? `${formatMoney(remainder)} will be left on the customer's account`
|
|
63
|
+
: "Payment fully applied across the selected invoices";
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
67
|
+
<View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
|
|
68
|
+
{/* header */}
|
|
69
|
+
<View style={{ gap: 2 }}>
|
|
70
|
+
<Text size="xl" weight="semibold">Apply payment</Text>
|
|
71
|
+
<Text size="sm" color="muted">Split one received payment across the customer's open invoices — oldest first, or line by line</Text>
|
|
72
|
+
</View>
|
|
73
|
+
|
|
74
|
+
{/* the source — the payment, and the remainder as you place it */}
|
|
75
|
+
<Card style={{ padding: 0 }}>
|
|
76
|
+
<View style={{ padding: 20, flexDirection: "row", alignItems: "flex-start", gap: 24, flexWrap: "wrap" }}>
|
|
77
|
+
<View style={{ gap: 2, flexGrow: 1, flexBasis: 220 }}>
|
|
78
|
+
<Text size="xs" color="muted" transform="uppercase">Payment received</Text>
|
|
79
|
+
<Text size="xxl" weight="semibold" tabular>{formatMoney(PAYMENT.amount)}</Text>
|
|
80
|
+
<Text size="sm" color="muted">{`${PAYMENT.from} · ref ${PAYMENT.ref} · ${PAYMENT.date}`}</Text>
|
|
81
|
+
</View>
|
|
82
|
+
<View style={{ gap: 10, flexGrow: 1, flexBasis: 300 }}>
|
|
83
|
+
<RemainderMeter total={PAYMENT.amount} allocated={allocated} format={formatMoney} />
|
|
84
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
85
|
+
<Button title="Oldest first" color="secondary" onPress={autoOldest} />
|
|
86
|
+
<Button title="Clear" color="muted" onPress={() => setAlloc({})} />
|
|
87
|
+
</View>
|
|
88
|
+
</View>
|
|
89
|
+
</View>
|
|
90
|
+
</Card>
|
|
91
|
+
|
|
92
|
+
{/* the targets — the open invoices, each absorbing part of the payment */}
|
|
93
|
+
<Card style={{ padding: 0 }}>
|
|
94
|
+
<View style={{ paddingHorizontal: 20, paddingVertical: 12 }}>
|
|
95
|
+
<Text size="xs" color="muted" transform="uppercase">{`Open invoices · ${INVOICES.length}`}</Text>
|
|
96
|
+
</View>
|
|
97
|
+
<Divider />
|
|
98
|
+
{INVOICES.map((inv, i) => (
|
|
99
|
+
<View key={inv.id}>
|
|
100
|
+
{i > 0 ? <Divider /> : null}
|
|
101
|
+
<AllocationRow
|
|
102
|
+
label={inv.id}
|
|
103
|
+
sublabel={`Issued ${inv.issued}`}
|
|
104
|
+
cap={inv.due}
|
|
105
|
+
value={alloc[inv.id] ?? 0}
|
|
106
|
+
onValueChange={(n) => setAlloc((prev) => ({ ...prev, [inv.id]: n }))}
|
|
107
|
+
format={formatMoney}
|
|
108
|
+
trailing={<Badge variant="dot" label={`${inv.ageDays}d`} color={inv.ageDays > 14 ? "red" : "zinc"} />}
|
|
109
|
+
/>
|
|
110
|
+
</View>
|
|
111
|
+
))}
|
|
112
|
+
<CardFooter>
|
|
113
|
+
<Text size="xs" color={over ? "danger" : "muted"} style={{ flex: 1 }}>{status}</Text>
|
|
114
|
+
<Button title="Apply payment" color="primary" disabled={over || allocated === 0} onPress={() => {}} />
|
|
115
|
+
</CardFooter>
|
|
116
|
+
</Card>
|
|
117
|
+
</View>
|
|
118
|
+
</ScrollView>
|
|
119
|
+
);
|
|
120
|
+
}
|