@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.
Files changed (54) hide show
  1. package/AGENTS.md +323 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/combobox.tsx +22 -6
  35. package/src/form_date_picker.tsx +2 -0
  36. package/src/form_picker.tsx +1 -0
  37. package/src/form_switch.tsx +1 -0
  38. package/src/form_text_input.tsx +2 -0
  39. package/src/icon.tsx +2 -0
  40. package/src/icon_button.tsx +5 -2
  41. package/src/inline_date_picker.tsx +110 -0
  42. package/src/inline_edit.tsx +228 -0
  43. package/src/inline_number_input.tsx +70 -0
  44. package/src/inline_select.tsx +91 -0
  45. package/src/inline_text_input.tsx +71 -0
  46. package/src/inline_time_picker.tsx +64 -0
  47. package/src/line_chart.tsx +4 -0
  48. package/src/list_item.tsx +5 -0
  49. package/src/number_input.tsx +12 -1
  50. package/src/page_content.tsx +5 -0
  51. package/src/section_heading.tsx +43 -29
  52. package/src/tag_input.tsx +202 -0
  53. package/src/time_picker.tsx +15 -3
  54. package/src/tooltip.tsx +19 -0
@@ -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
+ }