@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.
Files changed (65) hide show
  1. package/AGENTS.md +352 -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_billing.tsx +344 -0
  8. package/examples/tpl_calendar.tsx +288 -0
  9. package/examples/tpl_callsheet.tsx +481 -0
  10. package/examples/tpl_convert.tsx +490 -0
  11. package/examples/tpl_crm_desk.tsx +541 -0
  12. package/examples/tpl_dashboard.tsx +554 -0
  13. package/examples/tpl_detail.tsx +232 -0
  14. package/examples/tpl_directory.tsx +263 -0
  15. package/examples/tpl_dispatch.tsx +289 -0
  16. package/examples/tpl_dossier.tsx +431 -0
  17. package/examples/tpl_intake.tsx +206 -0
  18. package/examples/tpl_inventory.tsx +299 -0
  19. package/examples/tpl_order.tsx +483 -0
  20. package/examples/tpl_pick.tsx +240 -0
  21. package/examples/tpl_quick.tsx +210 -0
  22. package/examples/tpl_reconcile.tsx +275 -0
  23. package/examples/tpl_record.tsx +301 -0
  24. package/examples/tpl_record_plain.tsx +154 -0
  25. package/examples/tpl_rollup.tsx +300 -0
  26. package/examples/tpl_run.tsx +235 -0
  27. package/examples/tpl_settings.tsx +178 -0
  28. package/examples/tpl_shifts.tsx +421 -0
  29. package/examples/tpl_stock.tsx +387 -0
  30. package/examples/tpl_timeline.tsx +244 -0
  31. package/examples/tpl_tower.tsx +356 -0
  32. package/examples/tpl_wizard.tsx +223 -0
  33. package/package.json +12 -2
  34. package/src/bar_chart.tsx +5 -0
  35. package/src/combobox.tsx +33 -8
  36. package/src/control_surface.ts +8 -0
  37. package/src/form_date_picker.tsx +2 -0
  38. package/src/form_picker.tsx +1 -0
  39. package/src/form_switch.tsx +1 -0
  40. package/src/form_text_input.tsx +2 -0
  41. package/src/icon.tsx +2 -0
  42. package/src/icon_button.tsx +5 -2
  43. package/src/index.css +6 -3
  44. package/src/inline_date_picker.tsx +111 -0
  45. package/src/inline_edit.tsx +238 -0
  46. package/src/inline_number_input.tsx +70 -0
  47. package/src/inline_select.tsx +92 -0
  48. package/src/inline_text_input.tsx +71 -0
  49. package/src/inline_time_picker.tsx +64 -0
  50. package/src/line_chart.tsx +4 -0
  51. package/src/link.tsx +32 -0
  52. package/src/list_item.tsx +5 -0
  53. package/src/number_input.tsx +12 -1
  54. package/src/page_content.tsx +5 -0
  55. package/src/picker.tsx +4 -1
  56. package/src/popover.tsx +10 -1
  57. package/src/pressable_row.tsx +4 -1
  58. package/src/radio_picker.tsx +3 -1
  59. package/src/section_heading.tsx +43 -29
  60. package/src/segmented_control.tsx +3 -2
  61. package/src/tabs.tsx +4 -2
  62. package/src/tag_input.tsx +202 -0
  63. package/src/text.tsx +1 -1
  64. package/src/time_picker.tsx +15 -3
  65. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,235 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors, type ColorName } from "@lotics/ui/colors";
5
+ import { Badge } from "@lotics/ui/badge";
6
+ import { Button } from "@lotics/ui/button";
7
+ import { Card, CardFooter } from "@lotics/ui/card";
8
+ import { ChipGroup } from "@lotics/ui/chip_group";
9
+ import { CompletionState } from "@lotics/ui/completion_state";
10
+ import { DetailRow } from "@lotics/ui/detail_row";
11
+ import { Divider } from "@lotics/ui/divider";
12
+ import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
13
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
14
+ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
15
+ import { formatMoney } from "@lotics/ui/format_money";
16
+
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ // Template · Run & post — stage a bulk operation, PREVIEW what it will do row by
19
+ // row with a validation verdict, then commit the valid and HOLD the errors. Here:
20
+ // a billing run (generate the period's invoices); the same shape serves a payroll
21
+ // run, period-close postings, an MRP run, depreciation. Each row carries a status
22
+ // — ready · needs review (posts, flagged) · blocked (held) — with the reason
23
+ // inline; blockers are resolved in the drawer, which moves them back into the
24
+ // postable set. The footer "Post" is GATED: you can never post a blocked line, and
25
+ // the run can't apply more than it previews. CompletionState closes the run.
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ type RunStatus = "ready" | "review" | "blocked";
29
+
30
+ interface RunLine {
31
+ id: string;
32
+ customer: string;
33
+ ref: string;
34
+ amount: number;
35
+ status: RunStatus;
36
+ /** Why it's flagged / held — shown inline and in the drawer. */
37
+ issue?: string;
38
+ }
39
+
40
+ const STATUS: Record<RunStatus, { label: string; color: ColorName }> = {
41
+ ready: { label: "Ready", color: "emerald" },
42
+ review: { label: "Needs review", color: "amber" },
43
+ blocked: { label: "Blocked", color: "red" },
44
+ };
45
+
46
+ const LINES: RunLine[] = [
47
+ { id: "SO-4021", customer: "ATLAS COMPONENTS", ref: "Order SO-4021", amount: 31_414_670, status: "ready" },
48
+ { id: "SO-4024", customer: "Northwind Packaging", ref: "Order SO-4024", amount: 18_900_000, status: "ready" },
49
+ { id: "SO-4025", customer: "CRESTLINE FURNITURE", ref: "Order SO-4025", amount: 4_000_000, status: "review", issue: "PO number missing — invoice will post without it" },
50
+ { id: "SO-4027", customer: "King Lun Plastics", ref: "Order SO-4027", amount: 23_400_000, status: "ready" },
51
+ { id: "SO-4030", customer: "Vittoria Accessories", ref: "Order SO-4030", amount: 22_500_000, status: "review", issue: "Customer over credit limit by ₫8.4M" },
52
+ { id: "SO-4031", customer: "Harbor Industries", ref: "Order SO-4031", amount: 13_650_000, status: "blocked", issue: "Customer tax ID missing — cannot issue a tax invoice" },
53
+ { id: "SO-4033", customer: "Summit Logistics", ref: "Order SO-4033", amount: 9_180_000, status: "ready" },
54
+ { id: "SO-4035", customer: "Beacon Supplies", ref: "Order SO-4035", amount: 28_800_000, status: "blocked", issue: "Customer on credit hold — release the hold to invoice" },
55
+ { id: "SO-4036", customer: "Meridian Foods", ref: "Order SO-4036", amount: 16_320_000, status: "ready" },
56
+ ];
57
+
58
+ const FILTERS: { value: string; label: string }[] = [
59
+ { value: "all", label: "All" },
60
+ { value: "ready", label: "Ready" },
61
+ { value: "review", label: "Needs review" },
62
+ { value: "blocked", label: "Blocked" },
63
+ ];
64
+
65
+ const COLUMNS: TableColumn[] = [
66
+ { key: "customer", label: "Customer", flex: 1 },
67
+ { key: "amount", label: "Amount", width: 150, align: "right" },
68
+ { key: "status", label: "Verdict", width: 230 },
69
+ ];
70
+
71
+ function RunRow({ line, status, onPress }: { line: RunLine; status: RunStatus; onPress: () => void }) {
72
+ const meta = STATUS[status];
73
+ return (
74
+ <TableRow onPress={onPress} accessibilityLabel={`Inspect ${line.customer}`}>
75
+ <TableCell>
76
+ <View style={{ gap: 0 }}>
77
+ <Text size="sm" weight="medium" numberOfLines={1}>{line.customer}</Text>
78
+ <Text size="xs" color="muted" tabular>{line.ref}</Text>
79
+ </View>
80
+ </TableCell>
81
+ <TableCell>
82
+ <Text size="sm" tabular>{formatMoney(line.amount)}</Text>
83
+ </TableCell>
84
+ <TableCell>
85
+ <View style={{ gap: 2 }}>
86
+ <Badge variant="dot" label={meta.label} color={meta.color} />
87
+ {status !== "ready" && line.issue ? (
88
+ <Text size="xs" color={status === "blocked" ? "danger" : "muted"} numberOfLines={1}>{line.issue}</Text>
89
+ ) : null}
90
+ </View>
91
+ </TableCell>
92
+ </TableRow>
93
+ );
94
+ }
95
+
96
+ function InspectLine({ line, status, onResolve }: { line: RunLine; status: RunStatus; onResolve: () => void }) {
97
+ const meta = STATUS[status];
98
+ return (
99
+ <>
100
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
101
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
102
+ <Badge variant="dot" label={meta.label} color={meta.color} />
103
+ <Text size="xs" color="muted" tabular style={{ flex: 1, textAlign: "right" }}>{line.ref}</Text>
104
+ </View>
105
+ <Text size="md" weight="medium">{line.customer}</Text>
106
+ <View style={{ gap: 6 }}>
107
+ <DetailRow label="Invoice total"><Text size="sm" weight="semibold" tabular>{formatMoney(line.amount)}</Text></DetailRow>
108
+ <Divider />
109
+ <DetailRow label="Source"><Text size="sm" tabular>{line.ref}</Text></DetailRow>
110
+ </View>
111
+ {status !== "ready" && line.issue ? (
112
+ <View style={{ gap: 4, padding: 14, borderRadius: 10, backgroundColor: status === "blocked" ? colors.red[50] : colors.amber[50] }}>
113
+ <Text size="xs" weight="semibold" color={status === "blocked" ? "danger" : "default"} transform="uppercase">
114
+ {status === "blocked" ? "Blocked" : "Needs review"}
115
+ </Text>
116
+ <Text size="sm">{line.issue}</Text>
117
+ </View>
118
+ ) : (
119
+ <Text size="sm" color="muted">No issues — this invoice will post in the run.</Text>
120
+ )}
121
+ </ScrollView>
122
+ <DrawerFooter>
123
+ {status === "blocked" ? (
124
+ <Button title="Resolve & include" color="primary" onPress={onResolve} />
125
+ ) : (
126
+ <Button title="Open order record" color="primary" onPress={() => {}} />
127
+ )}
128
+ </DrawerFooter>
129
+ </>
130
+ );
131
+ }
132
+
133
+ export function TplRun() {
134
+ const [resolved, setResolved] = useState<Set<string>>(new Set());
135
+ const [filter, setFilter] = useState("all");
136
+ const [openId, setOpenId] = useState<string | null>(null);
137
+ const [posted, setPosted] = useState(false);
138
+
139
+ // A resolved blocker re-enters the run as ready; a review still posts.
140
+ const statusOf = (l: RunLine): RunStatus => (resolved.has(l.id) && l.status === "blocked" ? "ready" : l.status);
141
+
142
+ const counts = { ready: 0, review: 0, blocked: 0 } as Record<RunStatus, number>;
143
+ LINES.forEach((l) => { counts[statusOf(l)] += 1; });
144
+
145
+ const postable = LINES.filter((l) => statusOf(l) !== "blocked");
146
+ const blocked = LINES.filter((l) => statusOf(l) === "blocked");
147
+ const postValue = postable.reduce((s, l) => s + l.amount, 0);
148
+
149
+ const visible = LINES.filter((l) => filter === "all" || statusOf(l) === filter);
150
+ const open = openId === null ? null : LINES.find((l) => l.id === openId) ?? null;
151
+
152
+ if (posted) {
153
+ return (
154
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
155
+ <View style={{ width: "100%", maxWidth: 1000, alignSelf: "center" }}>
156
+ <Card style={{ padding: 0 }}>
157
+ <CompletionState
158
+ title="Billing run posted"
159
+ summary={`${postable.length} invoices · ${formatMoney(postValue)}${blocked.length ? ` · ${blocked.length} held for fixes` : ""}`}
160
+ >
161
+ <Button title="Start a new run" color="muted" onPress={() => { setPosted(false); setResolved(new Set()); setFilter("all"); }} />
162
+ <Button title="View posted invoices" color="primary" onPress={() => {}} />
163
+ </CompletionState>
164
+ </Card>
165
+ </View>
166
+ </ScrollView>
167
+ );
168
+ }
169
+
170
+ return (
171
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
172
+ <View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
173
+ {/* header */}
174
+ <View style={{ gap: 2 }}>
175
+ <Text size="xl" weight="semibold">June billing run</Text>
176
+ <Text size="sm" color="muted">Preview every invoice the run will create, clear the blockers, then post — blocked lines are held, never posted</Text>
177
+ </View>
178
+
179
+ {/* the run summary — the verdict breakdown the commit gates on */}
180
+ <KPIStrip
181
+ items={[
182
+ { label: "Ready", value: counts.ready, format: "number" },
183
+ { label: "Needs review", value: counts.review, format: "number", tone: counts.review > 0 ? "warning" : "default", info: "Soft issues — these post, but the flag is recorded (missing PO, over credit limit)." },
184
+ { label: "Blocked", value: counts.blocked, format: "number", tone: counts.blocked > 0 ? "danger" : "default", info: "Hard blockers — held out of the run until resolved (no tax ID, on credit hold)." },
185
+ { label: "Value to post", value: postValue, format: "currency", compact: true, caption: `${postable.length} invoices` },
186
+ ]}
187
+ />
188
+
189
+ {/* preview register — filter to the verdict you're working */}
190
+ <Card style={{ padding: 0 }}>
191
+ <View style={{ paddingHorizontal: 20, paddingVertical: 12 }}>
192
+ <ChipGroup
193
+ accessibilityLabel="Filter by verdict"
194
+ options={FILTERS.map((f) => ({
195
+ label: f.value === "all" ? `All · ${LINES.length}` : `${f.label} · ${counts[f.value as RunStatus]}`,
196
+ value: f.value,
197
+ }))}
198
+ value={filter}
199
+ onValueChange={setFilter}
200
+ />
201
+ </View>
202
+ <Table columns={COLUMNS}>
203
+ {visible.map((l) => (
204
+ <RunRow key={l.id} line={l} status={statusOf(l)} onPress={() => setOpenId(l.id)} />
205
+ ))}
206
+ </Table>
207
+ <CardFooter>
208
+ <Text size="xs" color={blocked.length > 0 ? "danger" : "muted"} style={{ flex: 1 }}>
209
+ {blocked.length > 0
210
+ ? `${blocked.length} blocked held back — resolve to include them`
211
+ : "All lines clear — nothing held back"}
212
+ </Text>
213
+ <Button
214
+ title={`Post ${postable.length} invoices`}
215
+ color="primary"
216
+ disabled={postable.length === 0}
217
+ onPress={() => setPosted(true)}
218
+ />
219
+ </CardFooter>
220
+ </Card>
221
+ </View>
222
+
223
+ {open !== null ? (
224
+ <Drawer open onOpenChange={(o) => !o && setOpenId(null)} title={open.customer} width={440}>
225
+ <InspectLine
226
+ key={open.id}
227
+ line={open}
228
+ status={statusOf(open)}
229
+ onResolve={() => { setResolved((prev) => new Set(prev).add(open.id)); setOpenId(null); }}
230
+ />
231
+ </Drawer>
232
+ ) : null}
233
+ </ScrollView>
234
+ );
235
+ }
@@ -0,0 +1,178 @@
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, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
7
+ import { Picker } from "@lotics/ui/picker";
8
+ import { Switch } from "@lotics/ui/switch";
9
+ import { Divider } from "@lotics/ui/divider";
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Template · Settings — the workspace settings screen; the Action Layout doc
13
+ // made visible. Three banded cards on the zinc-50 canvas:
14
+ // General → Divider-separated rows, label left / control right (text+Edit, Pickers)
15
+ // Notifications → title+description left, live Switch right
16
+ // Danger zone → separated red heading + description + ONE labeled
17
+ // danger button (never icon-only, never next to Save)
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ const NGON_NGU = [
21
+ { label: "Vietnamese", value: "vi" },
22
+ { label: "English", value: "en" },
23
+ ];
24
+
25
+ const MUI_GIO = [
26
+ { label: "(GMT+7) Ho Chi Minh City", value: "Asia/Ho_Chi_Minh" },
27
+ { label: "(GMT+7) Bangkok", value: "Asia/Bangkok" },
28
+ { label: "(GMT+8) Singapore", value: "Asia/Singapore" },
29
+ { label: "(GMT+9) Tokyo", value: "Asia/Tokyo" },
30
+ ];
31
+
32
+ const THONG_BAO = [
33
+ {
34
+ id: "donHang",
35
+ label: "New orders",
36
+ description: "Notify immediately when an order is created in this workspace.",
37
+ },
38
+ {
39
+ id: "nhacViec",
40
+ label: "Due-task reminders",
41
+ description: "Send an 8:00 AM reminder for tasks due today.",
42
+ },
43
+ {
44
+ id: "tomTat",
45
+ label: "Weekly digest",
46
+ description: "A summary of the whole team's activity, sent Monday morning.",
47
+ },
48
+ ] as const;
49
+
50
+ type ThongBaoId = (typeof THONG_BAO)[number]["id"];
51
+
52
+ /** One hairline settings row: label (+ optional description) left, control right. */
53
+ function SettingRow({
54
+ label,
55
+ description,
56
+ children,
57
+ }: {
58
+ label: string;
59
+ description?: string;
60
+ children: React.ReactNode;
61
+ }) {
62
+ return (
63
+ <View
64
+ style={{
65
+ paddingHorizontal: 20,
66
+ paddingVertical: 14,
67
+ flexDirection: "row",
68
+ alignItems: "center",
69
+ gap: 16,
70
+ }}
71
+ >
72
+ <View style={{ flex: 1, gap: 2 }}>
73
+ <Text size="sm" weight="medium">{label}</Text>
74
+ {description ? <Text size="xs" color="muted">{description}</Text> : null}
75
+ </View>
76
+ {children}
77
+ </View>
78
+ );
79
+ }
80
+
81
+ export function TplSettings() {
82
+ const [ngonNgu, setNgonNgu] = useState("vi");
83
+ const [muiGio, setMuiGio] = useState("Asia/Ho_Chi_Minh");
84
+ const [thongBao, setThongBao] = useState<Record<ThongBaoId, boolean>>({
85
+ donHang: true,
86
+ nhacViec: true,
87
+ tomTat: false,
88
+ });
89
+
90
+ return (
91
+ <ScrollView
92
+ style={{ flex: 1, backgroundColor: colors.zinc[50] }}
93
+ contentContainerStyle={{ padding: 28 }}
94
+ >
95
+ <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
96
+ {/* header band */}
97
+ <View style={{ gap: 2 }}>
98
+ <Text size="xl" weight="semibold">Settings</Text>
99
+ <Text size="sm" color="muted">
100
+ General settings, notifications and the workspace danger zone
101
+ </Text>
102
+ </View>
103
+
104
+ {/* General */}
105
+ <Card style={{ padding: 0 }}>
106
+ <CardHeader>
107
+ <CardHeaderTitle description="Display name, language and timezone applied to every member">
108
+ General
109
+ </CardHeaderTitle>
110
+ </CardHeader>
111
+ <SettingRow label="Workspace name">
112
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
113
+ <Text size="sm">Crestline Logistics</Text>
114
+ <Button title="Edit" color="muted" onPress={() => {}} />
115
+ </View>
116
+ </SettingRow>
117
+ <Divider />
118
+ <SettingRow label="Language" description="Interface language and outgoing email">
119
+ <Picker
120
+ options={NGON_NGU}
121
+ value={ngonNgu}
122
+ onValueChange={setNgonNgu}
123
+ style={{ width: 220 }}
124
+ />
125
+ </SettingRow>
126
+ <Divider />
127
+ <SettingRow label="Timezone" description="Used for due dates, calendars and reminders">
128
+ <Picker
129
+ options={MUI_GIO}
130
+ value={muiGio}
131
+ onValueChange={setMuiGio}
132
+ style={{ width: 220 }}
133
+ />
134
+ </SettingRow>
135
+ </Card>
136
+
137
+ {/* Notifications */}
138
+ <Card style={{ padding: 0 }}>
139
+ <CardHeader>
140
+ <CardHeaderTitle description="Pick what deserves a notification — everything else stays in-app">
141
+ Notifications
142
+ </CardHeaderTitle>
143
+ </CardHeader>
144
+ {THONG_BAO.map((tb, i) => (
145
+ <View key={tb.id}>
146
+ {i > 0 ? <Divider /> : null}
147
+ <SettingRow label={tb.label} description={tb.description}>
148
+ <Switch
149
+ accessibilityLabel={tb.label}
150
+ value={thongBao[tb.id]}
151
+ onChange={(value) => setThongBao((prev) => ({ ...prev, [tb.id]: value }))}
152
+ />
153
+ </SettingRow>
154
+ </View>
155
+ ))}
156
+ </Card>
157
+
158
+ {/* Danger zone — separated, explicit label, confirm before executing */}
159
+ <Card style={{ padding: 0 }}>
160
+ <CardHeader>
161
+ {/* bespoke title node: CardHeaderTitle has no danger color, and the
162
+ red heading is the point of this band */}
163
+ <View style={{ flex: 1, gap: 2 }}>
164
+ <Text size="sm" weight="semibold" color="danger">Danger zone</Text>
165
+ <Text size="xs" color="muted">
166
+ Permanently deletes the workspace with every table, record and attachment. This
167
+ cannot be undone.
168
+ </Text>
169
+ </View>
170
+ </CardHeader>
171
+ <CardFooter>
172
+ <Button title="Delete workspace" color="danger" onPress={() => {}} />
173
+ </CardFooter>
174
+ </Card>
175
+ </View>
176
+ </ScrollView>
177
+ );
178
+ }