@lotics/ui 46.3.0 → 46.8.1

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.
@@ -0,0 +1,257 @@
1
+ import { useMemo, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Avatar } from "@lotics/ui/avatar";
4
+ import { Board, BoardCard, BoardColumn } from "@lotics/ui/board";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { colors } from "@lotics/ui/colors";
7
+ import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
8
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
9
+ import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
10
+ import { FilterChip } from "@lotics/ui/filter_chip";
11
+ import { MemberChip } from "@lotics/ui/member_chip";
12
+ import { OptionBadge } from "@lotics/ui/option_badge";
13
+ import { OptionList } from "@lotics/ui/option_list";
14
+ import { SearchInput } from "@lotics/ui/search_input";
15
+ import { SummaryLine } from "@lotics/ui/summary_line";
16
+ import { Text } from "@lotics/ui/text";
17
+
18
+ /**
19
+ * THE column board — a surface whose subject is ADVANCING work between piles.
20
+ *
21
+ * ## Why this is not `tpl_task_board`
22
+ *
23
+ * They look adjacent and they are not. `tpl_task_board` is a `DataGrid`: bands
24
+ * of ROWS with an inline editor in every cell, for a reader who is COMPARING
25
+ * work and correcting its fields — a deadline here, an owner there. Nothing
26
+ * moves; the grid is a spreadsheet with opinions.
27
+ *
28
+ * This shape exists for the opposite act. The columns ARE the stages, the pile
29
+ * sizes are themselves the report, and the only thing the reader does is send a
30
+ * card onward. Reach for it when the question is *"what is stuck, and what moves
31
+ * next?"* rather than *"which of these is wrong?"*.
32
+ *
33
+ * Getting this backwards is not hypothetical: an app hand-rolled a 280px column
34
+ * because a reviewer read `tpl_task_board`'s NAME and told its author the kit
35
+ * already shipped a board. It did not. Read a template's source, never its name.
36
+ *
37
+ * ## What a board owes its reader
38
+ *
39
+ * - **The move is reachable by KEYBOARD.** Every card carries a control whose
40
+ * items name their destinations, so advancing work never requires a pointer.
41
+ * Drag is layered on the same declared list — the grip drags, the drop
42
+ * hit-tests the column under the pointer and is refused unless the card
43
+ * already declared it, so the two paths cannot disagree about what is legal.
44
+ * - **A column states its size as a PROP.** Never formatted into the label: the
45
+ * count is what makes a pile a report, and a number inside a heading is a
46
+ * second copy that goes stale.
47
+ * - **The BOARD scrolls sideways, never the page.** A wide shape earns its place
48
+ * by owning its own overflow; a page that scrolls horizontally has lost its
49
+ * left edge.
50
+ * - **A card is a DOOR.** Pressing it opens the record, exactly as a register
51
+ * row does — the board is another way into the same surface, never a second
52
+ * one. What a card SHOWS is the minimum for deciding whether to move it.
53
+ */
54
+
55
+ type Stage = "intake" | "review" | "approved" | "issued";
56
+
57
+ interface Doc {
58
+ id: string;
59
+ title: string;
60
+ ref: string;
61
+ owner: string;
62
+ due: string | null;
63
+ stage: Stage;
64
+ }
65
+
66
+ const STAGES: { key: Stage; label: string; color: "zinc" | "amber" | "blue" | "green" }[] = [
67
+ { key: "intake", label: "Received", color: "zinc" },
68
+ { key: "review", label: "In review", color: "amber" },
69
+ { key: "approved", label: "Approved", color: "blue" },
70
+ { key: "issued", label: "Issued", color: "green" },
71
+ ];
72
+
73
+ const SEED: Doc[] = [
74
+ { id: "d1", title: "Carrier rate agreement 2026", ref: "DOC-2026-0041", owner: "Trần Thị Ngọc Ánh", due: "2026-08-24", stage: "intake" },
75
+ { id: "d2", title: "Warehouse lease — Bay 4", ref: "DOC-2026-0042", owner: "Nguyễn Minh Đức", due: "2026-09-02", stage: "intake" },
76
+ { id: "d3", title: "Customs power of attorney", ref: "DOC-2026-0043", owner: "Lê Thu Hằng", due: null, stage: "intake" },
77
+ { id: "d4", title: "Insurance renewal — fleet", ref: "DOC-2026-0038", owner: "Trần Thị Ngọc Ánh", due: "2026-08-28", stage: "review" },
78
+ { id: "d5", title: "Subcontractor NDA", ref: "DOC-2026-0039", owner: "Phạm Thị Lan", due: "2026-09-15", stage: "review" },
79
+ { id: "d6", title: "Port handling addendum", ref: "DOC-2026-0035", owner: "Nguyễn Minh Đức", due: "2026-09-08", stage: "approved" },
80
+ { id: "d7", title: "Annual safety declaration", ref: "DOC-2026-0031", owner: "Lê Thu Hằng", due: "2026-08-19", stage: "issued" },
81
+ { id: "d8", title: "Depot access permit", ref: "DOC-2026-0029", owner: "Phạm Thị Lan", due: "2026-10-01", stage: "issued" },
82
+ ];
83
+
84
+ const OWNERS = [...new Set(SEED.map((d) => d.owner))].map((o) => ({ value: o, label: o }));
85
+
86
+ /** The template's "now", so the countdowns are stable in a gallery. */
87
+ const NOW = new Date("2026-08-30T00:00:00Z");
88
+ const DEADLINE_WORDS = {
89
+ overdue: (d: number) => `${d} day${d === 1 ? "" : "s"} overdue`,
90
+ today: "Due today",
91
+ tomorrow: "Due tomorrow",
92
+ inDays: (d: number) => `${d} days left`,
93
+ };
94
+ /** One helper, so a card and the record it opens cannot word the same date
95
+ * differently — the annotation carries its own tone with it. */
96
+ const dueProps = (iso: string, done: boolean) =>
97
+ done ? { color: "muted" as const, children: "Issued" } : (() => {
98
+ const a = deadlineAnnotation(daysUntil(new Date(iso), NOW), DEADLINE_WORDS);
99
+ const text = a.error ?? a.warning ?? a.description ?? "";
100
+ const color: "danger" | "warning" | "muted" = a.error ? "danger" : a.warning ? "warning" : "muted";
101
+ return { color, children: text };
102
+ })();
103
+
104
+ export function TplBoard() {
105
+ const [docs, setDocs] = useState(SEED);
106
+ const [query, setQuery] = useState("");
107
+ const [owners, setOwners] = useState<string[]>([]);
108
+ const [open, setOpen] = useState<string | null>(null);
109
+
110
+ const shown = useMemo(() => {
111
+ const q = query.trim().toLowerCase();
112
+ return docs.filter(
113
+ (d) =>
114
+ (!q || d.title.toLowerCase().includes(q) || d.ref.toLowerCase().includes(q)) &&
115
+ (owners.length === 0 || owners.includes(d.owner)),
116
+ );
117
+ }, [docs, query, owners]);
118
+
119
+ const move = (id: string, to: string) =>
120
+ setDocs((all) => all.map((d) => (d.id === id ? { ...d, stage: to as Stage } : d)));
121
+
122
+ const record = docs.find((d) => d.id === open) ?? null;
123
+ const overdue = shown.filter((d) => d.due != null && d.due < "2026-08-30" && d.stage !== "issued").length;
124
+
125
+ return (
126
+ <>
127
+ <ScrollView
128
+ style={{ flex: 1, backgroundColor: colors.white }}
129
+ contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 24, paddingBottom: 96 }}
130
+ >
131
+ <View style={{ maxWidth: 1240, width: "100%", alignSelf: "center", gap: 16 }}>
132
+ <Text size="xxl" weight="semibold">
133
+ Document approvals
134
+ </Text>
135
+
136
+ {/* Search leftmost, then the facets — the same toolbar order a register
137
+ uses, because the board is a VIEW of the same population and the
138
+ reader should not relearn where the search box lives. */}
139
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
140
+ <View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
141
+ <SearchInput value={query} onChangeText={setQuery} placeholder="Search documents…" accessibilityLabel="Search documents" />
142
+ </View>
143
+ <FilterChip
144
+ label="Owner"
145
+ summary={owners.length > 0 ? <View style={{ flexDirection: "row", gap: 2 }}>{owners.slice(0, 4).map((o) => <Avatar key={o} name={o} size="sm" />)}</View> : undefined}
146
+ onClear={() => setOwners([])}
147
+ clearLabel="Clear owner filter"
148
+ >
149
+ <OptionList
150
+ multi
151
+ search={{ mode: "none" }}
152
+ options={OWNERS}
153
+ value={owners}
154
+ onValueChange={setOwners}
155
+ renderOptionContent={(o) => <MemberChip name={o.label ?? o.value} size="sm" />}
156
+ />
157
+ </FilterChip>
158
+ </View>
159
+
160
+ {/* What is AT STAKE, not how many rows there are: the columns already
161
+ state their own sizes, so repeating the count here would be one
162
+ fact printed twice a few pixels apart. */}
163
+ <SummaryLine
164
+ items={[
165
+ { label: "awaiting a decision", value: shown.filter((d) => d.stage === "intake" || d.stage === "review").length },
166
+ ...(overdue > 0 ? [{ label: "past their date", value: overdue, tone: "warning" as const }] : []),
167
+ ]}
168
+ />
169
+
170
+ <Board>
171
+ {STAGES.map((s) => {
172
+ const inStage = shown.filter((d) => d.stage === s.key);
173
+ return (
174
+ <BoardColumn
175
+ key={s.key}
176
+ columnKey={s.key}
177
+ /* The heading is the component the value's data role owns — a
178
+ stage is a status, so it wears the same OptionBadge the
179
+ record surface uses for it. Two surfaces, one vocabulary. */
180
+ heading={<OptionBadge variant="dot" value={{ key: s.key, label: s.label, color: s.color }} />}
181
+ count={inStage.length}
182
+ emptyMessage="Nothing at this stage"
183
+ >
184
+ {inStage.map((d) => (
185
+ <BoardCard
186
+ key={d.id}
187
+ id={d.id}
188
+ title={d.title}
189
+ onPress={() => setOpen(d.id)}
190
+ /* Every stage but this one. Declaring them on the card is
191
+ what makes the keyboard menu and the drop target agree —
192
+ the drag resolves against this same list. */
193
+ moves={STAGES.filter((x) => x.key !== s.key).map((x) => ({ key: x.key, label: x.label }))}
194
+ onMove={(to) => move(d.id, to)}
195
+ >
196
+ <MemberChip name={d.owner} size="sm" />
197
+ {/* The date and its countdown in ONE run, in the kit's
198
+ deadline tone — a card is read at a glance, and a
199
+ separate badge for "late" would say twice what the
200
+ annotation already says once. */}
201
+ {d.due ? (
202
+ <Text size="xs" {...dueProps(d.due, d.stage === "issued")} />
203
+ ) : (
204
+ <Text size="xs" color="muted">
205
+ No date
206
+ </Text>
207
+ )}
208
+ </BoardCard>
209
+ ))}
210
+ </BoardColumn>
211
+ );
212
+ })}
213
+ </Board>
214
+ </View>
215
+ </ScrollView>
216
+
217
+ {/* A card is a door into the SAME record surface a register would open —
218
+ never a second detail view built for the board. */}
219
+ <Drawer open={record != null} onOpenChange={(v) => !v && setOpen(null)} title={record?.title}>
220
+ {record ? (
221
+ <DrawerScrollArea>
222
+ <DetailTable labelWidth={140}>
223
+ <DetailRow label="Reference">
224
+ <Text size="sm" tabular>{record.ref}</Text>
225
+ </DetailRow>
226
+ <DetailRow label="Stage">
227
+ <OptionBadge variant="dot" value={{ key: record.stage, label: STAGES.find((s) => s.key === record.stage)?.label ?? record.stage, color: STAGES.find((s) => s.key === record.stage)?.color ?? "zinc" }} />
228
+ </DetailRow>
229
+ <DetailRow label="Owner">
230
+ <MemberChip name={record.owner} />
231
+ </DetailRow>
232
+ <DetailRow label="Due">
233
+ {record.due ? <Text size="sm" {...dueProps(record.due, record.stage === "issued")} /> : <Text size="sm" color="muted">No date</Text>}
234
+ </DetailRow>
235
+ </DetailTable>
236
+ {/* The same act the card offers, in reach of the person who opened
237
+ it — a reader who drilled in should not have to go back out to
238
+ advance the thing they just read. */}
239
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, paddingTop: 16 }}>
240
+ {STAGES.filter((s) => s.key !== record.stage).map((s) => (
241
+ <Button
242
+ key={s.key}
243
+ color="secondary"
244
+ title={`Move to ${s.label}`}
245
+ onPress={() => {
246
+ move(record.id, s.key);
247
+ setOpen(null);
248
+ }}
249
+ />
250
+ ))}
251
+ </View>
252
+ </DrawerScrollArea>
253
+ ) : null}
254
+ </Drawer>
255
+ </>
256
+ );
257
+ }