@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,355 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, solid } from "@lotics/ui/colors";
|
|
5
|
+
import { Accordion, AccordionContent, AccordionHeader } from "@lotics/ui/accordion";
|
|
6
|
+
import { ActionMenu } from "@lotics/ui/action_menu";
|
|
7
|
+
import { Avatar } from "@lotics/ui/avatar";
|
|
8
|
+
import { Badge } from "@lotics/ui/badge";
|
|
9
|
+
import { Button } from "@lotics/ui/button";
|
|
10
|
+
import { Card } from "@lotics/ui/card";
|
|
11
|
+
import { Divider } from "@lotics/ui/divider";
|
|
12
|
+
import { Drawer } from "@lotics/ui/drawer";
|
|
13
|
+
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
14
|
+
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
15
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
16
|
+
import { Picker } from "@lotics/ui/picker";
|
|
17
|
+
import { Tabs } from "@lotics/ui/tabs";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Template · Attendance. The daily attendance desk: who is in, who
|
|
21
|
+
// is late, who is out, plus the weekly per-person grid. Emerald = presence;
|
|
22
|
+
// badge colors carry the status meaning (amber late / red absent / blue leave).
|
|
23
|
+
//
|
|
24
|
+
// Grammar: zinc-50 canvas · header + month Picker · KPIStrip (four counts) ·
|
|
25
|
+
// Tabs Today / This week. "Today" = one banded roster Card — each row
|
|
26
|
+
// press-opens the employee workspace Drawer (◀ ▶ sequencing) and carries a
|
|
27
|
+
// ⋯ ActionMenu with the day's quick fixes. "This week" = per-person Accordion
|
|
28
|
+
// rows: five day markers, expanding to the day-by-day detail in place.
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
type TrangThai = "co_mat" | "di_muon" | "vang" | "nghi_phep";
|
|
32
|
+
type NgayCong = "du" | "muon" | "vang" | "phep";
|
|
33
|
+
|
|
34
|
+
interface NhanVien {
|
|
35
|
+
id: string;
|
|
36
|
+
ten: string;
|
|
37
|
+
chucVu: string;
|
|
38
|
+
vao?: string;
|
|
39
|
+
ra?: string;
|
|
40
|
+
trangThai: TrangThai;
|
|
41
|
+
/** Minutes late — only for di_muon rows; feeds the badge label. */
|
|
42
|
+
muonPhut?: number;
|
|
43
|
+
/** Hours worked today — only once the person has checked out. */
|
|
44
|
+
tongGio?: string;
|
|
45
|
+
/** T2 → T6 of the current week. */
|
|
46
|
+
tuan: NgayCong[];
|
|
47
|
+
tongTuan: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NHAN_VIEN: NhanVien[] = [
|
|
51
|
+
{ id: "NV-01", ten: "Daniel Reed", chucVu: "Plant supervisor", vao: "07:52", ra: "17:05", trangThai: "co_mat", tongGio: "9,2h", tuan: ["du", "du", "du", "du", "du"], tongTuan: "42,1h" },
|
|
52
|
+
{ id: "NV-02", ten: "Tessa Tran", chucVu: "Accountant", vao: "07:58", trangThai: "co_mat", tuan: ["du", "du", "muon", "du", "du"], tongTuan: "40,6h" },
|
|
53
|
+
{ id: "NV-03", ten: "Martin Pike", chucVu: "Sales rep", vao: "08:12", trangThai: "di_muon", muonPhut: 12, tuan: ["muon", "du", "du", "muon", "muon"], tongTuan: "39,8h" },
|
|
54
|
+
{ id: "NV-04", ten: "Helen Lee", chucVu: "Warehouse keeper", vao: "07:45", ra: "17:02", trangThai: "co_mat", tongGio: "9,3h", tuan: ["du", "du", "du", "du", "du"], tongTuan: "41,9h" },
|
|
55
|
+
{ id: "NV-05", ten: "Nolan Hayes", chucVu: "Delivery driver", trangThai: "vang", tuan: ["du", "vang", "du", "du", "vang"], tongTuan: "24,3h" },
|
|
56
|
+
{ id: "NV-06", ten: "Maria Vu", chucVu: "HR & admin", trangThai: "nghi_phep", tuan: ["du", "du", "phep", "phep", "phep"], tongTuan: "16,4h" },
|
|
57
|
+
{ id: "NV-07", ten: "Bruno Dale", chucVu: "Machine operator", vao: "07:49", trangThai: "co_mat", tuan: ["du", "du", "du", "du", "du"], tongTuan: "42,0h" },
|
|
58
|
+
{ id: "NV-08", ten: "Nina Burke", chucVu: "Sales rep", vao: "08:27", trangThai: "di_muon", muonPhut: 27, tuan: ["du", "muon", "du", "du", "muon"], tongTuan: "40,2h" },
|
|
59
|
+
{ id: "NV-09", ten: "Theo Nash", chucVu: "Maintenance", vao: "07:56", ra: "16:45", trangThai: "co_mat", tongGio: "8,8h", tuan: ["du", "du", "du", "du", "du"], tongTuan: "41,7h" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const TRANG_THAI: Record<TrangThai, { label: string; color: "emerald" | "amber" | "red" | "blue" }> = {
|
|
63
|
+
co_mat: { label: "Present", color: "emerald" },
|
|
64
|
+
di_muon: { label: "Late", color: "amber" },
|
|
65
|
+
vang: { label: "Absent", color: "red" },
|
|
66
|
+
nghi_phep: { label: "On leave", color: "blue" },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const NGAY_CONG: Record<NgayCong, { bg: string; border: string; label: string }> = {
|
|
70
|
+
du: { bg: solid("emerald"), border: solid("emerald"), label: "Full day" },
|
|
71
|
+
muon: { bg: solid("amber"), border: solid("amber"), label: "Late" },
|
|
72
|
+
vang: { bg: colors.white, border: solid("red"), label: "Absent" },
|
|
73
|
+
phep: { bg: solid("blue"), border: solid("blue"), label: "On leave" },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const THANG = [
|
|
77
|
+
{ label: "April 2026", value: "2026-04" },
|
|
78
|
+
{ label: "May 2026", value: "2026-05" },
|
|
79
|
+
{ label: "June 2026", value: "2026-06" },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const TAB = [
|
|
83
|
+
{ label: "Today", value: "hom_nay" },
|
|
84
|
+
{ label: "This week", value: "tuan_nay" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const NGAY_TUAN = ["T2", "T3", "T4", "T5", "T6"];
|
|
88
|
+
|
|
89
|
+
const rowPad = { paddingHorizontal: 20, flexDirection: "row" as const, alignItems: "center" as const, gap: 12 };
|
|
90
|
+
|
|
91
|
+
// Week-view widths (the Accordion grid: day cells + week total + disclosure).
|
|
92
|
+
// The Today roster uses COLUMNS below, rendered by Table.
|
|
93
|
+
const W = { tong: 72, cell: 22, chevron: 28 };
|
|
94
|
+
|
|
95
|
+
const COLUMNS: TableColumn[] = [
|
|
96
|
+
{ key: "ten", label: "Employee", flex: 1, sortable: true },
|
|
97
|
+
{ key: "vao", label: "In – out", width: 112, align: "right", sortable: true },
|
|
98
|
+
{ key: "trangThai", label: "Status", width: 124, sortable: true },
|
|
99
|
+
{ key: "tongGio", label: "Hours", width: 72, align: "right", sortable: true },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function badgeLabel(nv: NhanVien): string {
|
|
103
|
+
const tt = TRANG_THAI[nv.trangThai];
|
|
104
|
+
return nv.trangThai === "di_muon" && nv.muonPhut !== undefined ? `${tt.label} ${nv.muonPhut}ph` : tt.label;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One weekday marker — filled means worked, hollow red ring means absent. */
|
|
108
|
+
function DayCell({ ngay }: { ngay: NgayCong }) {
|
|
109
|
+
const s = NGAY_CONG[ngay];
|
|
110
|
+
return (
|
|
111
|
+
<View style={{ width: W.cell, alignItems: "center" }}>
|
|
112
|
+
<View style={{ width: 13, height: 13, borderRadius: 4, backgroundColor: s.bg, borderWidth: 1, borderColor: s.border }} />
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The day's quick fixes for one person — the ⋯ door; the row press is the workspace door. */
|
|
118
|
+
function HomNayMenu({ nv }: { nv: NhanVien }) {
|
|
119
|
+
return (
|
|
120
|
+
<ActionMenu
|
|
121
|
+
accessibilityLabel={`Actions for ${nv.ten}`}
|
|
122
|
+
items={[
|
|
123
|
+
{ key: "sua_gio", label: "Adjust hours", icon: "pencil", onPress: () => {} },
|
|
124
|
+
{ key: "nghi_phep", label: "Record leave", icon: "calendar-off", onPress: () => {} },
|
|
125
|
+
{ key: "nhac", label: "Remind to clock in", icon: "bell", disabled: nv.vao !== undefined, onPress: () => {} },
|
|
126
|
+
{ key: "danh_dau_vang", label: "Mark absent", icon: "ban", danger: true, disabled: nv.trangThai === "vang", onPress: () => {} },
|
|
127
|
+
]}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean; onPress: () => void }) {
|
|
133
|
+
const tt = TRANG_THAI[nv.trangThai];
|
|
134
|
+
return (
|
|
135
|
+
<TableRow
|
|
136
|
+
onPress={onPress}
|
|
137
|
+
selected={selected}
|
|
138
|
+
minHeight={64}
|
|
139
|
+
accessibilityLabel={`Open ${nv.ten}'s day`}
|
|
140
|
+
trailing={<HomNayMenu nv={nv} />}
|
|
141
|
+
>
|
|
142
|
+
<TableCell>
|
|
143
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
144
|
+
<Avatar name={nv.ten} size={36} />
|
|
145
|
+
<View style={{ flex: 1, gap: 2, minWidth: 0 }}>
|
|
146
|
+
<Text weight="medium" numberOfLines={1}>{nv.ten}</Text>
|
|
147
|
+
<Text size="sm" color="zinc-500" numberOfLines={1}>{nv.chucVu}</Text>
|
|
148
|
+
</View>
|
|
149
|
+
</View>
|
|
150
|
+
</TableCell>
|
|
151
|
+
<TableCell>
|
|
152
|
+
<Text size="sm" tabular>{`${nv.vao ?? "—"} – ${nv.ra ?? "—"}`}</Text>
|
|
153
|
+
</TableCell>
|
|
154
|
+
<TableCell>
|
|
155
|
+
<Badge variant="dot" label={badgeLabel(nv)} color={tt.color} />
|
|
156
|
+
</TableCell>
|
|
157
|
+
<TableCell>
|
|
158
|
+
<Text size="sm" weight="medium" tabular>{nv.tongGio ?? "—"}</Text>
|
|
159
|
+
</TableCell>
|
|
160
|
+
</TableRow>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Week row — read-only drill-down: expands in place to the day-by-day detail. */
|
|
165
|
+
function TuanRow({ nv }: { nv: NhanVien }) {
|
|
166
|
+
return (
|
|
167
|
+
<Accordion>
|
|
168
|
+
<AccordionHeader accessibilityLabel={`Week detail for ${nv.ten}`}>
|
|
169
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
170
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{nv.ten}</Text>
|
|
171
|
+
<Text size="xs" color="muted" numberOfLines={1}>{nv.chucVu}</Text>
|
|
172
|
+
</View>
|
|
173
|
+
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
174
|
+
{nv.tuan.map((ngay, i) => <DayCell key={NGAY_TUAN[i]} ngay={ngay} />)}
|
|
175
|
+
</View>
|
|
176
|
+
<View style={{ width: W.tong, alignItems: "flex-end" }}>
|
|
177
|
+
<Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
|
|
178
|
+
</View>
|
|
179
|
+
</AccordionHeader>
|
|
180
|
+
<AccordionContent>
|
|
181
|
+
{nv.tuan.map((ngay, i) => (
|
|
182
|
+
<View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 32 }}>
|
|
183
|
+
<Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
|
|
184
|
+
<DayCell ngay={ngay} />
|
|
185
|
+
<Text size="sm">{NGAY_CONG[ngay].label}</Text>
|
|
186
|
+
</View>
|
|
187
|
+
))}
|
|
188
|
+
</AccordionContent>
|
|
189
|
+
</Accordion>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function KVRow({ label, value }: { label: string; value: string }) {
|
|
194
|
+
return (
|
|
195
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
|
|
196
|
+
<Text size="sm" color="muted" style={{ width: 140 }}>{label}</Text>
|
|
197
|
+
<Text size="sm" tabular>{value}</Text>
|
|
198
|
+
</View>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The drawer body — compact: identity + today's facts + the week at a glance.
|
|
203
|
+
// Keyed by nv.id from the caller so it resets when ◀ ▶ steps.
|
|
204
|
+
function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
|
|
205
|
+
const tt = TRANG_THAI[nv.trangThai];
|
|
206
|
+
return (
|
|
207
|
+
<>
|
|
208
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 20 }}>
|
|
209
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
210
|
+
<Avatar name={nv.ten} size={40} />
|
|
211
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
212
|
+
<Text size="sm" color="muted">{nv.chucVu}</Text>
|
|
213
|
+
</View>
|
|
214
|
+
<Badge label={badgeLabel(nv)} color={tt.color} />
|
|
215
|
+
</View>
|
|
216
|
+
<View style={{ gap: 6 }}>
|
|
217
|
+
<KVRow label="Clock-in" value={nv.vao ?? "Not clocked"} />
|
|
218
|
+
<KVRow label="Clock-out" value={nv.ra ?? "Not clocked"} />
|
|
219
|
+
<KVRow label="Hours today" value={nv.tongGio ?? "—"} />
|
|
220
|
+
</View>
|
|
221
|
+
<Divider />
|
|
222
|
+
<View style={{ gap: 10 }}>
|
|
223
|
+
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
224
|
+
<View style={{ flex: 1 }}>
|
|
225
|
+
<Text size="xs" color="muted" transform="uppercase">This week</Text>
|
|
226
|
+
</View>
|
|
227
|
+
<Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
|
|
228
|
+
</View>
|
|
229
|
+
<View style={{ gap: 6 }}>
|
|
230
|
+
{nv.tuan.map((ngay, i) => (
|
|
231
|
+
<View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
|
|
232
|
+
<Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
|
|
233
|
+
<DayCell ngay={ngay} />
|
|
234
|
+
<Text size="sm">{NGAY_CONG[ngay].label}</Text>
|
|
235
|
+
</View>
|
|
236
|
+
))}
|
|
237
|
+
</View>
|
|
238
|
+
</View>
|
|
239
|
+
</ScrollView>
|
|
240
|
+
{/* pinned footer — the workspace's one primary fix action */}
|
|
241
|
+
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: 20, paddingVertical: 14, flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
242
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>Missed punches get fixed here — every change is audited.</Text>
|
|
243
|
+
<Button title="Adjust hours" color="primary" onPress={() => {}} />
|
|
244
|
+
</View>
|
|
245
|
+
</>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function TplAttendance() {
|
|
250
|
+
const [tab, setTab] = useState<string>("hom_nay");
|
|
251
|
+
const [thang, setThang] = useState<string>("2026-06");
|
|
252
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
253
|
+
const [sort, setSort] = useState<SortState | null>(null);
|
|
254
|
+
const onSort = (key: string) => setSort(cycleSort(sort, key));
|
|
255
|
+
|
|
256
|
+
const dem = (tt: TrangThai) => NHAN_VIEN.filter((nv) => nv.trangThai === tt).length;
|
|
257
|
+
const eyebrow = (label: string) => <Text size="xs" color="muted" transform="uppercase">{label}</Text>;
|
|
258
|
+
|
|
259
|
+
// The Today roster, ordered by the active sort (Hours sorts numerically).
|
|
260
|
+
const sorted = sortBy(NHAN_VIEN, sort, (nv, key) =>
|
|
261
|
+
key === "vao" ? (nv.vao ?? "")
|
|
262
|
+
: key === "trangThai" ? TRANG_THAI[nv.trangThai].label
|
|
263
|
+
: key === "tongGio" ? parseFloat((nv.tongGio ?? "0").replace(",", "."))
|
|
264
|
+
: nv.ten.toLowerCase(),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// The drawer sequences over the roster's visible (sorted) ordering.
|
|
268
|
+
const openNv = NHAN_VIEN.find((nv) => nv.id === openId) ?? null;
|
|
269
|
+
const seqIndex = openNv ? sorted.findIndex((nv) => nv.id === openNv.id) : -1;
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
273
|
+
<View style={{ width: "100%", maxWidth: 920, alignSelf: "center", gap: 16 }}>
|
|
274
|
+
{/* header band */}
|
|
275
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
276
|
+
<View style={{ gap: 2, flex: 1 }}>
|
|
277
|
+
<Text size="xl" weight="semibold">Attendance</Text>
|
|
278
|
+
<Text size="sm" color="muted">Today, Friday 12/06 — in/out times for 9 employees</Text>
|
|
279
|
+
</View>
|
|
280
|
+
<Picker
|
|
281
|
+
options={THANG}
|
|
282
|
+
value={thang}
|
|
283
|
+
onValueChange={setThang}
|
|
284
|
+
placeholder="Pick a month"
|
|
285
|
+
style={{ width: 150 }}
|
|
286
|
+
/>
|
|
287
|
+
</View>
|
|
288
|
+
|
|
289
|
+
<KPIStrip
|
|
290
|
+
items={[
|
|
291
|
+
{ label: "Present", value: dem("co_mat"), format: "number" },
|
|
292
|
+
{ label: "Late", value: dem("di_muon"), format: "number" },
|
|
293
|
+
{ label: "Absent", value: dem("vang"), format: "number", tone: "danger" },
|
|
294
|
+
{ label: "On leave", value: dem("nghi_phep"), format: "number" },
|
|
295
|
+
]}
|
|
296
|
+
/>
|
|
297
|
+
|
|
298
|
+
<Tabs accessibilityLabel="Attendance scope" options={TAB} selectedTab={tab} onSelectTab={setTab} />
|
|
299
|
+
|
|
300
|
+
{tab === "hom_nay" ? (
|
|
301
|
+
<Card style={{ padding: 0 }}>
|
|
302
|
+
<Table columns={COLUMNS} trailing={28} sort={sort} onSort={onSort}>
|
|
303
|
+
{sorted.map((nv) => (
|
|
304
|
+
<HomNayRow key={nv.id} nv={nv} selected={nv.id === openId} onPress={() => setOpenId(nv.id)} />
|
|
305
|
+
))}
|
|
306
|
+
</Table>
|
|
307
|
+
</Card>
|
|
308
|
+
) : (
|
|
309
|
+
<Card style={{ padding: 0 }}>
|
|
310
|
+
<View style={[rowPad, { paddingVertical: 10 }]}>
|
|
311
|
+
<View style={{ flex: 1 }}>{eyebrow("Employee")}</View>
|
|
312
|
+
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
313
|
+
{NGAY_TUAN.map((d) => (
|
|
314
|
+
<View key={d} style={{ width: W.cell, alignItems: "center" }}>{eyebrow(d)}</View>
|
|
315
|
+
))}
|
|
316
|
+
</View>
|
|
317
|
+
<View style={{ width: W.tong, alignItems: "flex-end" }}>{eyebrow("Week total")}</View>
|
|
318
|
+
<View style={{ width: W.chevron - 12 }} />
|
|
319
|
+
</View>
|
|
320
|
+
<View style={{ paddingHorizontal: 20 }}>
|
|
321
|
+
{NHAN_VIEN.map((nv) => (
|
|
322
|
+
<View key={nv.id}>
|
|
323
|
+
<Divider />
|
|
324
|
+
<TuanRow nv={nv} />
|
|
325
|
+
</View>
|
|
326
|
+
))}
|
|
327
|
+
</View>
|
|
328
|
+
<Divider />
|
|
329
|
+
{/* legend band — what each marker means */}
|
|
330
|
+
<View style={[rowPad, { paddingVertical: 12, gap: 18 }]}>
|
|
331
|
+
{(Object.keys(NGAY_CONG) as NgayCong[]).map((k) => (
|
|
332
|
+
<View key={k} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
333
|
+
<DayCell ngay={k} />
|
|
334
|
+
<Text size="xs" color="muted">{NGAY_CONG[k].label}</Text>
|
|
335
|
+
</View>
|
|
336
|
+
))}
|
|
337
|
+
</View>
|
|
338
|
+
</Card>
|
|
339
|
+
)}
|
|
340
|
+
</View>
|
|
341
|
+
|
|
342
|
+
{/* the employee workspace — body keyed by id so state resets on ◀ ▶ / ←→ steps */}
|
|
343
|
+
{openNv ? (
|
|
344
|
+
<Drawer
|
|
345
|
+
open onOpenChange={(o) => !o && setOpenId(null)} title={openNv.ten} width={440}
|
|
346
|
+
onPrev={seqIndex > 0 ? () => setOpenId(NHAN_VIEN[seqIndex - 1].id) : undefined}
|
|
347
|
+
onNext={seqIndex >= 0 && seqIndex < NHAN_VIEN.length - 1 ? () => setOpenId(NHAN_VIEN[seqIndex + 1].id) : undefined}
|
|
348
|
+
position={seqIndex >= 0 ? `${seqIndex + 1}/${NHAN_VIEN.length}` : undefined}
|
|
349
|
+
>
|
|
350
|
+
<NhanVienWorkspace key={openNv.id} nv={openNv} />
|
|
351
|
+
</Drawer>
|
|
352
|
+
) : null}
|
|
353
|
+
</ScrollView>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { Badge } from "@lotics/ui/badge";
|
|
6
|
+
import { Button } from "@lotics/ui/button";
|
|
7
|
+
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
8
|
+
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
9
|
+
import { DetailRow } from "@lotics/ui/detail_row";
|
|
10
|
+
import { Divider } from "@lotics/ui/divider";
|
|
11
|
+
import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
|
|
12
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
13
|
+
import { IconButton } from "@lotics/ui/icon_button";
|
|
14
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
15
|
+
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
16
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Template · Batch builder — the work COMPOSES a new record from parts:
|
|
20
|
+
// tick source rows, watch the draft build with running totals, create the
|
|
21
|
+
// aggregate (here: uninvoiced deliveries → one invoice; same shape for
|
|
22
|
+
// payment runs, shipment consolidation, PO bundling). The grouping rule —
|
|
23
|
+
// one invoice, one customer — is enforced at the checkbox: once the first
|
|
24
|
+
// row is ticked, other customers' rows disable with the reason visible.
|
|
25
|
+
// The draft panel is the live preview; Create resolves the batch and the
|
|
26
|
+
// source rows leave the pool.
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface Delivery {
|
|
30
|
+
id: string;
|
|
31
|
+
customer: string;
|
|
32
|
+
description: string;
|
|
33
|
+
date: string;
|
|
34
|
+
ageDays: number;
|
|
35
|
+
amount: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DELIVERIES: Delivery[] = [
|
|
39
|
+
{ id: "DL-0214", customer: "ATLAS COMPONENTS", description: "Carton blank sheet 675×325 — 2,747 pcs", date: "27/05", ageDays: 16, amount: 8_982_690 },
|
|
40
|
+
{ id: "DL-0218", customer: "ATLAS COMPONENTS", description: "Full cover box 1470×290×168 — 580 pcs", date: "02/06", ageDays: 10, amount: 31_414_670 },
|
|
41
|
+
{ id: "DL-0223", customer: "ATLAS COMPONENTS", description: "Full cover box 1470×290×168 — 582 pcs", date: "09/06", ageDays: 3, amount: 31_414_670 },
|
|
42
|
+
{ id: "DL-0216", customer: "CRESTLINE FURNITURE", description: "TOP COVER long brace — 2,500 sets", date: "30/05", ageDays: 13, amount: 2_000_000 },
|
|
43
|
+
{ id: "DL-0221", customer: "CRESTLINE FURNITURE", description: "TOP COVER long brace — 2,500 sets", date: "06/06", ageDays: 6, amount: 2_000_000 },
|
|
44
|
+
{ id: "DL-0219", customer: "KING LUN PLASTICS", description: "Inner box VTD063518 — 3,000 pcs", date: "04/06", ageDays: 8, amount: 23_400_000 },
|
|
45
|
+
{ id: "DL-0224", customer: "VITTORIA ACCESSORIES", description: "Accessory carton 400×300×250 — 1,250 pcs", date: "10/06", ageDays: 2, amount: 11_250_000 },
|
|
46
|
+
{ id: "DL-0225", customer: "VITTORIA ACCESSORIES", description: "Accessory carton 400×300×250 — 1,250 pcs", date: "11/06", ageDays: 1, amount: 11_250_000 },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const VAT_RATE = 0.08;
|
|
50
|
+
|
|
51
|
+
export function TplBatch() {
|
|
52
|
+
const [pool, setPool] = useState<Delivery[]>(DELIVERIES);
|
|
53
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
54
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
55
|
+
|
|
56
|
+
const picked = pool.filter((d) => selected.has(d.id));
|
|
57
|
+
// The grouping rule: the first ticked row locks the batch to its customer.
|
|
58
|
+
const lockedCustomer = picked[0]?.customer ?? null;
|
|
59
|
+
|
|
60
|
+
const toggle = (id: string, on: boolean) =>
|
|
61
|
+
setSelected((prev) => { const next = new Set(prev); if (on) next.add(id); else next.delete(id); return next; });
|
|
62
|
+
|
|
63
|
+
const customers = [...new Set(pool.map((d) => d.customer))];
|
|
64
|
+
const subtotal = picked.reduce((s, d) => s + d.amount, 0);
|
|
65
|
+
const vat = Math.round(subtotal * VAT_RATE);
|
|
66
|
+
const oldest = pool.reduce((m, d) => Math.max(m, d.ageDays), 0);
|
|
67
|
+
|
|
68
|
+
const create = () => {
|
|
69
|
+
setPool((prev) => prev.filter((d) => !selected.has(d.id)));
|
|
70
|
+
setSelected(new Set());
|
|
71
|
+
setOpenId(null);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const open = pool.find((d) => d.id === openId) ?? null;
|
|
75
|
+
const seqIndex = open ? pool.findIndex((d) => d.id === open.id) : -1;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
79
|
+
<View style={{ width: "100%", maxWidth: 1060, alignSelf: "center", gap: 16 }}>
|
|
80
|
+
{/* header band */}
|
|
81
|
+
<View style={{ gap: 2 }}>
|
|
82
|
+
<Text size="xl" weight="semibold">Invoice builder</Text>
|
|
83
|
+
<Text size="sm" color="muted">Tick deliveries, watch the draft build, create one invoice — one customer per batch</Text>
|
|
84
|
+
</View>
|
|
85
|
+
|
|
86
|
+
<KPIStrip
|
|
87
|
+
items={[
|
|
88
|
+
{ label: "Uninvoiced deliveries", value: pool.length, format: "number" },
|
|
89
|
+
{ label: "Value uninvoiced", value: pool.reduce((s, d) => s + d.amount, 0), format: "currency", compact: true, info: "Goods delivered but not yet billed — every day here is working capital lent to the customer for free." },
|
|
90
|
+
{ label: "Customers awaiting", value: customers.length, format: "number", caption: "one invoice each" },
|
|
91
|
+
{ label: "Oldest delivery", value: oldest, format: "number", tone: oldest > 14 ? "danger" : "default", caption: "days unbilled" },
|
|
92
|
+
]}
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
<View style={{ flexDirection: "row", gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
|
|
96
|
+
{/* the source pool, grouped by the batching key */}
|
|
97
|
+
<Card style={{ padding: 0, flexGrow: 2, flexBasis: 540 }}>
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardHeaderTitle info="One invoice covers one customer: the first ticked row locks the batch, and other customers' rows disable until you clear it.">
|
|
100
|
+
Uninvoiced deliveries
|
|
101
|
+
</CardHeaderTitle>
|
|
102
|
+
</CardHeader>
|
|
103
|
+
{pool.length === 0 ? (
|
|
104
|
+
<EmptyState message="Everything is billed" hint="The pool refills as deliveries complete" />
|
|
105
|
+
) : (
|
|
106
|
+
customers.map((customer) => {
|
|
107
|
+
const rows = pool.filter((d) => d.customer === customer);
|
|
108
|
+
const blocked = lockedCustomer !== null && lockedCustomer !== customer;
|
|
109
|
+
const allPicked = rows.every((d) => selected.has(d.id));
|
|
110
|
+
return (
|
|
111
|
+
<View key={customer}>
|
|
112
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 20, paddingTop: 14, paddingBottom: 6 }}>
|
|
113
|
+
<CheckboxInput
|
|
114
|
+
accessibilityLabel={`Select all deliveries for ${customer}`}
|
|
115
|
+
checked={allPicked && rows.length > 0}
|
|
116
|
+
indeterminate={!allPicked && rows.some((d) => selected.has(d.id))}
|
|
117
|
+
disabled={blocked}
|
|
118
|
+
onChange={(on) => setSelected((prev) => {
|
|
119
|
+
const next = new Set(prev);
|
|
120
|
+
rows.forEach((d) => { if (on) next.add(d.id); else next.delete(d.id); });
|
|
121
|
+
return next;
|
|
122
|
+
})}
|
|
123
|
+
/>
|
|
124
|
+
<Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>{customer}</Text>
|
|
125
|
+
{blocked ? <Text size="xs" color="muted">{`batch locked to ${lockedCustomer}`}</Text> : null}
|
|
126
|
+
</View>
|
|
127
|
+
<View style={{ paddingHorizontal: 20, paddingBottom: 10, gap: 2 }}>
|
|
128
|
+
{rows.map((d) => (
|
|
129
|
+
<PressableRow key={d.id} variant="inset" marked={selected.has(d.id)} onPress={() => setOpenId(d.id)} selected={open?.id === d.id} style={{ gap: 10 }}>
|
|
130
|
+
<CheckboxInput
|
|
131
|
+
accessibilityLabel={`Select ${d.id}`}
|
|
132
|
+
checked={selected.has(d.id)}
|
|
133
|
+
disabled={blocked}
|
|
134
|
+
onChange={(on) => toggle(d.id, on)}
|
|
135
|
+
/>
|
|
136
|
+
<Pressable
|
|
137
|
+
accessibilityRole="button"
|
|
138
|
+
accessibilityLabel={`Open delivery ${d.id}`}
|
|
139
|
+
onPress={() => setOpenId(d.id)}
|
|
140
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 44 }}
|
|
141
|
+
>
|
|
142
|
+
<Text size="sm" weight="medium" tabular style={{ width: 70 }}>{d.id}</Text>
|
|
143
|
+
<Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{d.description}</Text>
|
|
144
|
+
<Text size="sm" color={d.ageDays > 14 ? "danger" : "muted"} tabular style={{ width: 44 }}>{`${d.ageDays}d`}</Text>
|
|
145
|
+
<Text size="sm" tabular align="right" style={{ width: 104 }}>{formatMoney(d.amount)}</Text>
|
|
146
|
+
</Pressable>
|
|
147
|
+
</PressableRow>
|
|
148
|
+
))}
|
|
149
|
+
</View>
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
})
|
|
153
|
+
)}
|
|
154
|
+
<CardFooter>
|
|
155
|
+
<Text size="xs" color="muted" tabular style={{ flex: 1 }}>
|
|
156
|
+
{`${pool.length} deliveries · ${formatMoney(pool.reduce((s, d) => s + d.amount, 0), { compact: true })} unbilled`}
|
|
157
|
+
</Text>
|
|
158
|
+
</CardFooter>
|
|
159
|
+
</Card>
|
|
160
|
+
|
|
161
|
+
{/* the draft — a live preview of the record being composed */}
|
|
162
|
+
<Card style={{ padding: 0, flexGrow: 1, flexBasis: 320 }}>
|
|
163
|
+
<CardHeader>
|
|
164
|
+
<CardHeaderTitle info="The invoice being composed — lines, totals and VAT update as you tick. Create resolves the batch; the deliveries leave the pool.">
|
|
165
|
+
Draft invoice
|
|
166
|
+
</CardHeaderTitle>
|
|
167
|
+
</CardHeader>
|
|
168
|
+
{picked.length === 0 ? (
|
|
169
|
+
<EmptyState message="No lines yet" hint="Tick deliveries on the left to start the draft" />
|
|
170
|
+
) : (
|
|
171
|
+
<>
|
|
172
|
+
<View style={{ paddingHorizontal: 20, paddingVertical: 14, gap: 10 }}>
|
|
173
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
174
|
+
<Badge label={lockedCustomer ?? ""} color="blue" />
|
|
175
|
+
<Text size="xs" color="muted" tabular>{`${picked.length} lines`}</Text>
|
|
176
|
+
</View>
|
|
177
|
+
<View style={{ gap: 4 }}>
|
|
178
|
+
{picked.map((d) => (
|
|
179
|
+
<View key={d.id} style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 28 }}>
|
|
180
|
+
<Text size="sm" color="muted" tabular style={{ width: 70 }}>{d.id}</Text>
|
|
181
|
+
<Text size="xs" color="muted" numberOfLines={1} style={{ flex: 1 }}>{d.date}</Text>
|
|
182
|
+
<Text size="sm" tabular align="right">{formatMoney(d.amount)}</Text>
|
|
183
|
+
<IconButton icon="x" accessibilityLabel={`Remove ${d.id} from the draft`} onPress={() => toggle(d.id, false)} />
|
|
184
|
+
</View>
|
|
185
|
+
))}
|
|
186
|
+
</View>
|
|
187
|
+
<Divider />
|
|
188
|
+
<DetailRow label="Subtotal"><Text size="sm" tabular>{formatMoney(subtotal)}</Text></DetailRow>
|
|
189
|
+
<DetailRow label={`VAT ${Math.round(VAT_RATE * 100)}%`}><Text size="sm" tabular>{formatMoney(vat)}</Text></DetailRow>
|
|
190
|
+
<Divider />
|
|
191
|
+
<DetailRow label="Total"><Text size="md" weight="semibold" tabular>{formatMoney(subtotal + vat)}</Text></DetailRow>
|
|
192
|
+
</View>
|
|
193
|
+
<CardFooter>
|
|
194
|
+
<Button title="Clear" color="muted" onPress={() => setSelected(new Set())} />
|
|
195
|
+
<View style={{ flex: 1 }} />
|
|
196
|
+
<Button title="Create invoice" color="primary" onPress={create} />
|
|
197
|
+
</CardFooter>
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</Card>
|
|
201
|
+
</View>
|
|
202
|
+
</View>
|
|
203
|
+
|
|
204
|
+
{open !== null ? (
|
|
205
|
+
<Drawer
|
|
206
|
+
open
|
|
207
|
+
onOpenChange={(o) => !o && setOpenId(null)}
|
|
208
|
+
title={open.id}
|
|
209
|
+
width={440}
|
|
210
|
+
onPrev={seqIndex > 0 ? () => setOpenId(pool[seqIndex - 1].id) : undefined}
|
|
211
|
+
onNext={seqIndex >= 0 && seqIndex < pool.length - 1 ? () => setOpenId(pool[seqIndex + 1].id) : undefined}
|
|
212
|
+
position={seqIndex >= 0 ? `${seqIndex + 1}/${pool.length}` : undefined}
|
|
213
|
+
>
|
|
214
|
+
<View key={open.id} style={{ flex: 1, padding: 24, gap: 14 }}>
|
|
215
|
+
<Badge label={open.customer} color="blue" />
|
|
216
|
+
<Text size="sm">{open.description}</Text>
|
|
217
|
+
<View style={{ gap: 6 }}>
|
|
218
|
+
<DetailRow label="Delivered"><Text size="sm" tabular>{open.date}</Text></DetailRow>
|
|
219
|
+
<Divider />
|
|
220
|
+
<DetailRow label="Days unbilled">
|
|
221
|
+
<Text size="sm" tabular color={open.ageDays > 14 ? "danger" : "default"}>{`${open.ageDays} days`}</Text>
|
|
222
|
+
</DetailRow>
|
|
223
|
+
<Divider />
|
|
224
|
+
<DetailRow label="Amount"><Text size="sm" tabular>{formatMoney(open.amount)}</Text></DetailRow>
|
|
225
|
+
</View>
|
|
226
|
+
</View>
|
|
227
|
+
<DrawerFooter>
|
|
228
|
+
<Button title="Open delivery record" color="primary" onPress={() => {}} />
|
|
229
|
+
</DrawerFooter>
|
|
230
|
+
</Drawer>
|
|
231
|
+
) : null}
|
|
232
|
+
</ScrollView>
|
|
233
|
+
);
|
|
234
|
+
}
|