@lotics/ui 7.19.3 → 8.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.
@@ -1,386 +0,0 @@
1
- import { useMemo, useState, useEffect, useCallback } from "react";
2
- import { View, ScrollView } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { Card } from "@lotics/ui/card";
5
- import { Button } from "@lotics/ui/button";
6
- import { Badge } from "@lotics/ui/badge";
7
- import { Icon } from "@lotics/ui/icon";
8
- import { EmptyState } from "@lotics/ui/empty_state";
9
- import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
10
- import { type PickerOption } from "@lotics/ui/picker";
11
- import { ColumnFilter, type FilterableColumn, type ColumnFilterValue } from "@lotics/ui/column_filter";
12
- import { FileDropzone } from "@lotics/ui/file_dropzone";
13
- import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
14
- import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
15
- import { type DisplayFile } from "@lotics/ui/file_thumbnail";
16
- import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
17
- import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
18
- import { InlineNumberInput } from "@lotics/ui/inline_number_input";
19
- import { CompletionState } from "@lotics/ui/completion_state";
20
- import { ChangeReview, ChangeReviewActions, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
21
- import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
22
- import { formatDate } from "@lotics/ui/format_date";
23
- import { colors, solid, type ColorName } from "@lotics/ui/colors";
24
-
25
- // ─────────────────────────────────────────────────────────────────────────────
26
- // Template · Rate desk — the "AI bulk-ingest + filterable register" shape. The
27
- // page IS the queryable register (visit → search/filter → find any row); the
28
- // register is the hero, lookups are the frequent job. Updating prices is the
29
- // OCCASIONAL job, so it lives behind ONE header CTA that opens a Dialog wizard:
30
- // drop several source documents at once → an agent reads them all (`AgentRun`) →
31
- // it proposes a batch of NEW / UPDATED rows grouped by source, each editable →
32
- // review → commit → the dialog closes back to the register. The honest answer to
33
- // "live data": you don't fetch it, you re-ingest the source sheets in seconds.
34
- // Net-new rows get a "New" badge, updates show the old→new delta. Pairs
35
- // `tpl_extract`'s ingest (as a modal wizard) with `tpl_directory`'s filterable
36
- // register. All mock.
37
- // ─────────────────────────────────────────────────────────────────────────────
38
-
39
- const usd = (n: number | null) => (n == null ? "—" : n.toLocaleString("en-US"));
40
- const DAY = 86400000;
41
-
42
- // The live register — the rate card you filter. A couple of rows are near/past
43
- // validity so the staleness colour (amber ≤14 days · red expired) is visible.
44
- interface Rate { id: string; lane: string; carrier: string; unit: string; buy: number; sell: number; thc: number; lss: number; validUntil: Date }
45
- const d = (s: string) => new Date(s);
46
- const RATES: Rate[] = [
47
- { id: "r1", lane: "HCM → Busan", carrier: "ONE", unit: "40'", buy: 480, sell: 680, thc: 100, lss: 45, validUntil: d("2026-12-31") },
48
- { id: "r2", lane: "HCM → Hamburg", carrier: "Evergreen", unit: "40'HC", buy: 2600, sell: 2900, thc: 130, lss: 90, validUntil: d("2026-06-22") },
49
- { id: "r3", lane: "HCM → Hamburg", carrier: "Evergreen", unit: "40'", buy: 2500, sell: 2800, thc: 130, lss: 90, validUntil: d("2026-06-22") },
50
- { id: "r4", lane: "HCM → Hamburg", carrier: "Evergreen", unit: "20'", buy: 1500, sell: 1750, thc: 110, lss: 70, validUntil: d("2026-06-22") },
51
- { id: "r5", lane: "HCM → Hamburg", carrier: "MSC", unit: "40'HC", buy: 2700, sell: 3000, thc: 135, lss: 95, validUntil: d("2026-06-08") },
52
- { id: "r6", lane: "HCM → Los Angeles", carrier: "Maersk", unit: "40'HC", buy: 1950, sell: 2250, thc: 130, lss: 85, validUntil: d("2026-08-31") },
53
- { id: "r7", lane: "HCM → Los Angeles", carrier: "ONE", unit: "20'", buy: 1050, sell: 1300, thc: 110, lss: 70, validUntil: d("2026-08-31") },
54
- { id: "r8", lane: "HCM → Los Angeles", carrier: "ONE", unit: "40'HC", buy: 1850, sell: 2150, thc: 125, lss: 80, validUntil: d("2026-08-31") },
55
- { id: "r9", lane: "Hai Phong → Singapore", carrier: "COSCO", unit: "20'", buy: 320, sell: 480, thc: 95, lss: 40, validUntil: d("2026-12-31") },
56
- { id: "r10", lane: "Hai Phong → Singapore", carrier: "Evergreen", unit: "40'", buy: 520, sell: 720, thc: 100, lss: 45, validUntil: d("2026-12-31") },
57
- ];
58
-
59
- type Status = "new" | "update";
60
- interface Line { id: string; carrier: string; lane: string; unit: string; buy: number; sell: number; thc: number; lss: number; validUntil: string; status: Status; prevBuy?: number; uncertain?: boolean }
61
- // New and Updated are two states of ONE dimension (the proposal vs the live
62
- // register), so they render through ONE Badge driven by this map — never a badge
63
- // for one and bare text for the other. New stands out (blue); Updated is the
64
- // quiet neutral default (a tonal Badge with no color).
65
- const STATUS_BADGE: Record<Status, { label: string; color?: ColorName }> = {
66
- new: { label: "New", color: "blue" },
67
- update: { label: "Updated" },
68
- };
69
- const EXTRACTED: Line[] = [
70
- { id: "e1", carrier: "Evergreen", lane: "HCM → Hamburg", unit: "40'HC", buy: 2750, sell: 3050, thc: 135, lss: 95, validUntil: "31 Jul 2026", status: "update", prevBuy: 2600 },
71
- { id: "e2", carrier: "Evergreen", lane: "HCM → Hamburg", unit: "40'", buy: 2650, sell: 2950, thc: 135, lss: 95, validUntil: "31 Jul 2026", status: "update", prevBuy: 2500 },
72
- { id: "e3", carrier: "Evergreen", lane: "HCM → Rotterdam", unit: "40'HC", buy: 2800, sell: 3100, thc: 135, lss: 95, validUntil: "31 Jul 2026", status: "new", uncertain: true },
73
- { id: "m1", carrier: "MSC", lane: "HCM → Hamburg", unit: "40'HC", buy: 2820, sell: 3120, thc: 138, lss: 98, validUntil: "30 Jun 2026", status: "update", prevBuy: 2700 },
74
- { id: "m2", carrier: "MSC", lane: "HCM → Antwerp", unit: "40'HC", buy: 2880, sell: 3180, thc: 140, lss: 100, validUntil: "30 Jun 2026", status: "new" },
75
- { id: "o1", carrier: "ONE", lane: "HCM → Los Angeles", unit: "40'HC", buy: 1900, sell: 2200, thc: 120, lss: 80, validUntil: "09 Jul 2026", status: "update", prevBuy: 1850 },
76
- { id: "o2", carrier: "ONE", lane: "HCM → Oakland", unit: "40'HC", buy: 2000, sell: 2300, thc: 120, lss: 80, validUntil: "09 Jul 2026", status: "new" },
77
- { id: "o3", carrier: "ONE", lane: "HCM → Busan", unit: "40'", buy: 500, sell: 700, thc: 120, lss: 80, validUntil: "09 Jul 2026", status: "update", prevBuy: 480 },
78
- ];
79
- const READ_STEPS: { label: string; kind?: "step" | "tool" }[] = [
80
- { label: "Read EVERGREEN sheet (FAK spot)", kind: "tool" },
81
- { label: "Read MSC sheet (export tariff)", kind: "tool" },
82
- { label: "Read ONE sheet (spot confirmation)", kind: "tool" },
83
- { label: "Extract rate lines & match against the live card" },
84
- { label: "Classify New / Updated by lane + carrier" },
85
- ];
86
-
87
- type Phase = "idle" | "reading" | "review" | "done";
88
-
89
- export function TplRatedesk() {
90
- // The register is LIVE state (seeded from RATES) so accepted proposals land in
91
- // it on Apply; the filters + their options all derive from it.
92
- const [rates, setRates] = useState<Rate[]>(RATES);
93
-
94
- // ── filter the register: a From → To route picker (flight-search style — two
95
- // comboboxes), then ColumnFilter pills for the secondary dimensions ──────────
96
- const orig = (lane: string) => lane.split(" → ")[0] ?? lane;
97
- const dest = (lane: string) => lane.split(" → ")[1] ?? lane;
98
- const [fromV, setFromV] = useState<PickerOption | null>(null);
99
- const [toV, setToV] = useState<PickerOption | null>(null);
100
- const [carrierF, setCarrierF] = useState<ColumnFilterValue | undefined>();
101
- const [unitF, setUnitF] = useState<ColumnFilterValue | undefined>();
102
- const originOpts: PickerOption[] = [...new Set(rates.map((r) => orig(r.lane)))].sort().map((x) => ({ value: x, label: x }));
103
- const destOpts: PickerOption[] = [...new Set(rates.map((r) => dest(r.lane)))].sort().map((x) => ({ value: x, label: x }));
104
- const carrierCol: FilterableColumn = { key: "carrier", label: "Carrier", type: "select", options: [...new Set(rates.map((r) => r.carrier))].sort().map((x) => ({ value: x, label: x })) };
105
- const unitCol: FilterableColumn = { key: "unit", label: "Container", type: "select", options: [...new Set(rates.map((r) => r.unit))].map((x) => ({ value: x, label: x })) };
106
- const picked = (v: ColumnFilterValue | undefined) => (v?.kind === "select" ? v.selected : []);
107
- const filtered = useMemo(() => rates.filter((r) => {
108
- if (fromV && orig(r.lane) !== fromV.value) return false;
109
- if (toV && dest(r.lane) !== toV.value) return false;
110
- const cs = picked(carrierF), us = picked(unitF);
111
- if (cs.length && !cs.includes(r.carrier)) return false;
112
- if (us.length && !us.includes(r.unit)) return false;
113
- return true;
114
- }), [rates, fromV, toV, carrierF, unitF]);
115
-
116
- // ── ingest flow — lives in a dialog opened from the header CTA ──────────────
117
- const [importOpen, setImportOpen] = useState(false);
118
- const [phase, setPhase] = useState<Phase>("idle");
119
- const [files, setFiles] = useState<DisplayFile[]>([]);
120
- const [activeFile, setActiveFile] = useState<number | null>(null);
121
- const [lines, setLines] = useState<Line[]>([]);
122
- const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
123
- const [savedCount, setSavedCount] = useState(0);
124
-
125
- const onFiles = useCallback((fs: File[]) => setFiles((prev) => [
126
- ...prev,
127
- ...fs.map((f, i) => ({ id: `${f.name}-${prev.length + i}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) })),
128
- ]), []);
129
- const removeFile = useCallback((id: string) => setFiles((prev) => prev.filter((f) => f.id !== id)), []);
130
-
131
- const [revealed, setRevealed] = useState(0);
132
- useEffect(() => {
133
- if (phase !== "reading") return;
134
- if (revealed >= READ_STEPS.length) {
135
- const t = setTimeout(() => { setLines(EXTRACTED); setPhase("review"); }, 600);
136
- return () => clearTimeout(t);
137
- }
138
- const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 120 : 640);
139
- return () => clearTimeout(t);
140
- }, [phase, revealed]);
141
-
142
- const reset = useCallback(() => { setFiles([]); setLines([]); setDecisions({}); setRevealed(0); setPhase("idle"); }, []);
143
- const closeImport = useCallback(() => { setImportOpen(false); reset(); }, [reset]);
144
- const editLine = useCallback((id: string, k: "buy" | "sell", v: number | null) => {
145
- if (v == null) return;
146
- setLines((ls) => ls.map((l) => (l.id === id ? { ...l, [k]: v } : l)));
147
- }, []);
148
- const decide = useCallback((id: string, status: ChangeReviewItemStatus | null) => {
149
- setDecisions((d) => { const n = { ...d }; if (status) n[id] = status; else delete n[id]; return n; });
150
- }, []);
151
-
152
- // Apply the KEPT proposals into the live register: an existing lane+carrier+unit
153
- // is updated in place, a net-new one is appended. Nothing wrote until now.
154
- const applyAccepted = useCallback(() => {
155
- const accepted = lines.filter((l) => decisions[l.id] === "accepted");
156
- setRates((prev) => {
157
- const next = [...prev];
158
- for (const l of accepted) {
159
- const rate: Rate = { id: l.id, lane: l.lane, carrier: l.carrier, unit: l.unit, buy: l.buy, sell: l.sell, thc: l.thc, lss: l.lss, validUntil: new Date(l.validUntil) };
160
- const idx = next.findIndex((r) => r.lane === l.lane && r.carrier === l.carrier && r.unit === l.unit);
161
- if (idx >= 0) next[idx] = { ...rate, id: next[idx].id };
162
- else next.push(rate);
163
- }
164
- return next;
165
- });
166
- setSavedCount(accepted.length);
167
- setPhase("done");
168
- }, [lines, decisions]);
169
-
170
- const runItems: AgentRunItem[] = READ_STEPS.slice(0, Math.min(revealed + 1, READ_STEPS.length)).map((s, i) => ({
171
- type: "step", id: `r${i}`, label: s.label, kind: s.kind, status: i < revealed ? "done" : "running",
172
- }));
173
- const newCount = lines.filter((l) => l.status === "new").length;
174
-
175
- return (
176
- <>
177
- <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 20, paddingBottom: 72, gap: 18 }}>
178
- {/* HEADER BAND — the register is the page; updating prices is one CTA */}
179
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
180
- <View style={{ gap: 2, flex: 1 }}>
181
- <Text size="xl" weight="semibold">Rate desk</Text>
182
- <Text size="sm" color="muted">Look up any route, carrier or container. Prices come from carrier sheets — re-ingest to refresh.</Text>
183
- </View>
184
- <Button title="Import prices" color="primary" onPress={() => setImportOpen(true)} />
185
- </View>
186
-
187
- {/* REGISTER — the live rate card, filter to anything */}
188
- <View style={{ gap: 12 }}>
189
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
190
- <Text size="sm" weight="semibold" style={{ flex: 1 }}>Rate card</Text>
191
- <Text size="xs" color="muted" tabular>{filtered.length === rates.length ? `${rates.length} rates` : `${filtered.length}/${rates.length} rates`}</Text>
192
- </View>
193
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
194
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexGrow: 1, flexBasis: 360, minWidth: 280 }}>
195
- <View style={{ flex: 1, minWidth: 120 }}>
196
- <Combobox options={originOpts} recentOptions={originOpts} value={fromV} onValueChange={setFromV}>
197
- <ComboboxInput icon="map-pin" clearable onClear={() => setFromV(null)} placeholder="From" accessibilityLabel="Origin port" />
198
- <ComboboxContent recentsLabel="Origin" />
199
- </Combobox>
200
- </View>
201
- <Icon name="arrow-right" size={14} color={colors.zinc[400]} />
202
- <View style={{ flex: 1, minWidth: 120 }}>
203
- <Combobox options={destOpts} recentOptions={destOpts} value={toV} onValueChange={setToV}>
204
- <ComboboxInput icon="map-pin" clearable onClear={() => setToV(null)} placeholder="To" accessibilityLabel="Destination port" />
205
- <ComboboxContent recentsLabel="Destination" />
206
- </Combobox>
207
- </View>
208
- </View>
209
- <ColumnFilter column={carrierCol} value={carrierF} onChange={setCarrierF} clearLabel="Clear carrier" />
210
- <ColumnFilter column={unitCol} value={unitF} onChange={setUnitF} clearLabel="Clear container" />
211
- </View>
212
- <Card style={{ padding: 0 }}>
213
- {filtered.length === 0 ? (
214
- <EmptyState icon="tag" message="No rates match" hint="Try another route or clear a filter." />
215
- ) : (
216
- <Table columns={CARD_COLS}>
217
- {filtered.map((r) => (
218
- <TableRow key={r.id} minHeight={44}>
219
- <TableCell><Text size="sm" weight="medium" numberOfLines={1}>{r.lane}</Text></TableCell>
220
- <TableCell><Text size="sm" numberOfLines={1}>{r.carrier}</Text></TableCell>
221
- <TableCell><Text size="sm" color="muted">{r.unit}</Text></TableCell>
222
- <TableCell><Text size="sm" tabular>{usd(r.buy)}</Text></TableCell>
223
- <TableCell><Text size="sm" weight="medium" tabular>{usd(r.sell)}</Text></TableCell>
224
- <TableCell><Text size="sm" color="muted" tabular>{r.thc}</Text></TableCell>
225
- <TableCell><Text size="sm" color="muted" tabular>{r.lss}</Text></TableCell>
226
- <TableCell><ValidityCell d={r.validUntil} /></TableCell>
227
- </TableRow>
228
- ))}
229
- </Table>
230
- )}
231
- </Card>
232
- </View>
233
- </ScrollView>
234
-
235
- {/* IMPORT WIZARD — the AI bulk-ingest flow, drop → read → review → commit */}
236
- <Dialog open={importOpen} onOpenChange={(o) => { if (!o) closeImport(); }} maxWidth={940}>
237
- <DialogHeader><DialogHeaderTitle>Import prices</DialogHeaderTitle></DialogHeader>
238
- <DialogScrollArea>
239
- {phase === "idle" ? (
240
- <View style={{ gap: 12 }}>
241
- {files.length > 0 ? <FileThumbnailGrid files={files} itemSize={96} onRemove={removeFile} onFilePress={(f) => setActiveFile(files.findIndex((x) => x.id === f.id))} /> : null}
242
- <FileDropzone onFiles={onFiles} accept="image/*,application/pdf" multiple height={files.length > 0 ? 96 : 168} label={files.length > 0 ? "Add more sheets" : "Drop carrier rate sheets here"} hint="several at once · one sheet per carrier · JPG, PNG, PDF" />
243
- {files.length > 0 ? <Text size="sm" color="muted" tabular>{files.length} sheets · click to view, ✕ to drop</Text> : null}
244
- </View>
245
- ) : null}
246
-
247
- {phase === "reading" ? <AgentRun items={runItems} state="streaming" /> : null}
248
-
249
- {phase === "review" ? (
250
- <ChangeReview
251
- title="Suggested rates"
252
- summary={`${newCount} new · ${lines.length - newCount} updated — keep or drop each, edit any price, then apply. A ⚠ line is worth double-checking.`}
253
- items={lines}
254
- getKey={(l) => l.id}
255
- statusOf={(l) => decisions[l.id] ?? "pending"}
256
- renderItem={(l) => <RateProposal line={l} onEdit={editLine} />}
257
- renderSummary={(l, kept) => (
258
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
259
- <Text size="sm" weight="medium" numberOfLines={1}>{l.lane}</Text>
260
- <Text size="xs" color="muted" numberOfLines={1} tabular style={[{ flex: 1 }, kept ? undefined : { textDecorationLine: "line-through" }]}>{l.carrier} · {l.unit} · sell {usd(l.sell)}</Text>
261
- </View>
262
- )}
263
- onAcceptItem={(i) => decide(lines[i].id, "accepted")}
264
- onRejectItem={(i) => decide(lines[i].id, "rejected")}
265
- onUndoItem={(i) => decide(lines[i].id, null)}
266
- />
267
- ) : null}
268
-
269
- {phase === "done" ? (
270
- <CompletionState title={`${savedCount} rates updated`} summary="The rate card is ready for quoting — new lanes and price changes are in." />
271
- ) : null}
272
- </DialogScrollArea>
273
-
274
- {/* idle / review / done carry footer actions; reading just streams. The
275
- review commit bar pins HERE via ChangeReviewActions — it never scrolls
276
- away inside the list. */}
277
- {phase === "idle" ? (
278
- <DialogFooter>
279
- {files.length > 0 ? <Button title="Clear" color="muted" onPress={() => setFiles([])} /> : null}
280
- <Button title={files.length ? `Read ${files.length} sheets` : "Read sheets"} color="primary" disabled={!files.length} onPress={() => { setRevealed(0); setPhase("reading"); }} />
281
- </DialogFooter>
282
- ) : phase === "review" ? (
283
- <DialogFooter>
284
- <ChangeReviewActions
285
- items={lines}
286
- statusOf={(l) => decisions[l.id] ?? "pending"}
287
- onAcceptItem={(i) => decide(lines[i].id, "accepted")}
288
- onApply={applyAccepted}
289
- onDiscard={reset}
290
- applyLabel={`Apply ${lines.filter((l) => decisions[l.id] === "accepted").length} kept`}
291
- />
292
- </DialogFooter>
293
- ) : phase === "done" ? (
294
- <DialogFooter>
295
- <Button title="Import more" color="secondary" onPress={reset} />
296
- <Button title="Done" color="primary" onPress={closeImport} />
297
- </DialogFooter>
298
- ) : null}
299
- </Dialog>
300
-
301
- <FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} />
302
- </>
303
- );
304
- }
305
-
306
- function ValidityCell({ d }: { d: Date | null }) {
307
- if (!d) return <Text size="sm" color="muted">—</Text>;
308
- const days = Math.floor((d.getTime() - Date.now()) / DAY);
309
- if (days < 0) return <Text size="sm" weight="medium" tabular style={{ color: solid("red") }}>Expired</Text>;
310
- if (days <= 14) return <Text size="sm" weight="medium" tabular style={{ color: solid("amber") }}>{formatDate(d)}</Text>;
311
- return <Text size="sm" color="muted" tabular>{formatDate(d)}</Text>;
312
- }
313
-
314
- // The compositional body for a proposed rate inside ChangeReview. The ROUTE is
315
- // the identity (origin → dest, the arrow a quiet connector); the price BAND is
316
- // the decision — Buy and Sell editable, Margin (sell − buy) derived as the number
317
- // a forwarder actually decides on. An update shows what the buy WAS underneath.
318
- const PRICE_H = 40; // matches InlineNumberInput's resting height, so the band aligns
319
-
320
- function RateProposal({ line, onEdit }: { line: Line; onEdit: (id: string, k: "buy" | "sell", v: number | null) => void }) {
321
- const [o, d] = line.lane.split(" → ");
322
- return (
323
- <View style={{ gap: 16 }}>
324
- {/* identity — the route + who/what, status to the right */}
325
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
326
- <View style={{ flex: 1, gap: 3 }}>
327
- <View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
328
- {line.uncertain ? <View style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: solid("amber") }} /> : null}
329
- <Text size="sm" weight="semibold" numberOfLines={1}>{o}</Text>
330
- <Icon name="arrow-right" size={13} color={colors.zinc[400]} />
331
- <Text size="sm" weight="semibold" numberOfLines={1}>{d ?? line.lane}</Text>
332
- </View>
333
- <Text size="xs" color="muted">{line.carrier} · {line.unit}</Text>
334
- </View>
335
- <Badge color={STATUS_BADGE[line.status].color} label={STATUS_BADGE[line.status].label} />
336
- </View>
337
-
338
- {/* the decision band — Buy / Sell / Margin, tabular, editable */}
339
- <View style={{ flexDirection: "row", alignItems: "flex-start" }}>
340
- <PriceField label="Buy" prev={line.prevBuy} value={line.buy} laneLabel={line.lane} onSave={(v) => onEdit(line.id, "buy", v)} />
341
- <PriceField label="Sell" value={line.sell} laneLabel={line.lane} onSave={(v) => onEdit(line.id, "sell", v)} />
342
- <Metric label="Margin" value={`+${usd(line.sell - line.buy)}`} />
343
- </View>
344
-
345
- <Text size="xs" color="muted" tabular>Valid to {line.validUntil} · THC {line.thc} · LSS {line.lss}</Text>
346
- </View>
347
- );
348
- }
349
-
350
- function PriceField({ label, prev, value, laneLabel, onSave }: { label: string; prev?: number; value: number; laneLabel: string; onSave: (v: number | null) => void }) {
351
- const up = prev != null && value > prev;
352
- return (
353
- <View style={{ flex: 1, gap: 2 }}>
354
- <Text size="xs" color="muted" weight="medium" style={{ paddingHorizontal: 8 }}>{label}</Text>
355
- <InlineNumberInput value={value} accessibilityLabel={`${label} ${laneLabel}`} format={usd} onSave={onSave} />
356
- {prev != null && prev !== value ? (
357
- <View style={{ flexDirection: "row", alignItems: "center", gap: 3, paddingHorizontal: 8 }}>
358
- <Icon name={up ? "arrow-up" : "arrow-down"} size={11} color={up ? solid("amber") : solid("emerald")} />
359
- <Text size="xs" tabular style={{ color: up ? solid("amber") : solid("emerald") }}>was {usd(prev)}</Text>
360
- </View>
361
- ) : null}
362
- </View>
363
- );
364
- }
365
-
366
- function Metric({ label, value }: { label: string; value: string }) {
367
- return (
368
- <View style={{ flex: 1, gap: 2 }}>
369
- <Text size="xs" color="muted" weight="medium" style={{ paddingHorizontal: 8 }}>{label}</Text>
370
- <View style={{ height: PRICE_H, justifyContent: "center", paddingHorizontal: 8 }}>
371
- <Text size="sm" weight="medium" tabular>{value}</Text>
372
- </View>
373
- </View>
374
- );
375
- }
376
-
377
- const CARD_COLS: TableColumn[] = [
378
- { key: "lane", label: "Lane", flex: 1.5 },
379
- { key: "carrier", label: "Carrier", flex: 1.2 },
380
- { key: "unit", label: "Container", width: 96 },
381
- { key: "buy", label: "Buy", width: 80, align: "right" },
382
- { key: "sell", label: "Sell", width: 80, align: "right" },
383
- { key: "thc", label: "THC", width: 60, align: "right" },
384
- { key: "lss", label: "LSS", width: 60, align: "right" },
385
- { key: "valid", label: "Valid to", width: 108, align: "right" },
386
- ];
@@ -1,112 +0,0 @@
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 { KPIStrip } from "@lotics/ui/kpi_strip";
7
- import { SegmentedControl } from "@lotics/ui/segmented_control";
8
- import { EmptyState } from "@lotics/ui/empty_state";
9
- import { TriageRow } from "@lotics/ui/triage_row";
10
- import type { ConfidenceLevel } from "@lotics/ui/confidence";
11
-
12
- // ─────────────────────────────────────────────────────────────────────────────
13
- // Template · Triage — an inbox the agent has CLASSIFIED and routed. Each item
14
- // carries the agent's category and a suggested action with a confidence; the
15
- // human accepts the call, overrides it, or dismisses the item. High-confidence
16
- // items batch-accept so attention goes to the ambiguous ones. Leads, support
17
- // tickets, inbound documents, emails. The agent does the sorting; the human
18
- // keeps the decision.
19
- // ─────────────────────────────────────────────────────────────────────────────
20
-
21
- interface T {
22
- id: string;
23
- title: string;
24
- preview: string;
25
- meta: string;
26
- category: { label: string };
27
- suggestedAction: string;
28
- confidence: ConfidenceLevel;
29
- status: "open" | "accepted" | "dismissed";
30
- }
31
-
32
- const SEED: T[] = [
33
- { id: "t1", title: "RFQ — 2×40HC Cát Lái → Hamburg, reefer", preview: "Hi, we need a quote for two reefer containers of frozen shrimp departing next week…", meta: "08:12", category: { label: "Sales" }, suggestedAction: "Assign to Nguyen", confidence: "high", status: "open" },
34
- { id: "t2", title: "Where is my shipment HBL-4471?", preview: "Customer asking for an ETA update on the Hamburg booking, getting anxious about demurrage…", meta: "08:39", category: { label: "Support" }, suggestedAction: "Send ETA, open ticket", confidence: "high", status: "open" },
35
- { id: "t3", title: "Invoice INV-2026-0318 — payment confirmation", preview: "Attached the TT receipt for 41,300,000 ₫, please confirm and release the documents…", meta: "09:02", category: { label: "Billing" }, suggestedAction: "Apply the payment", confidence: "high", status: "open" },
36
- { id: "t4", title: "Re: partnership opportunity (sponsored)", preview: "Boost your logistics with our revolutionary platform, limited-time offer just for you…", meta: "09:15", category: { label: "Spam" }, suggestedAction: "Move to spam", confidence: "high", status: "open" },
37
- { id: "t5", title: "Complaint — damaged cartons on delivery", preview: "Three cartons arrived crushed, we need a claim opened and a replacement schedule…", meta: "09:41", category: { label: "Support" }, suggestedAction: "Open a damage claim", confidence: "medium", status: "open" },
38
- { id: "t6", title: "Quote request — air freight, urgent samples", preview: "Need 12 kg of samples to Rotterdam by Friday, what's the fastest option and cost…", meta: "10:03", category: { label: "Sales" }, suggestedAction: "Assign to the air desk", confidence: "medium", status: "open" },
39
- { id: "t7", title: "Updated bank details for remittance", preview: "Please update our account for future payments to the following beneficiary…", meta: "10:20", category: { label: "Billing" }, suggestedAction: "Hold — verify sender first", confidence: "low", status: "open" },
40
- { id: "t8", title: "Newsletter: port congestion outlook Q3", preview: "Our latest market report on Asia–Europe capacity and rate trends is now available…", meta: "Yesterday", category: { label: "Spam" }, suggestedAction: "Move to spam", confidence: "medium", status: "accepted" },
41
- ];
42
-
43
- export function TplTriage() {
44
- const [rows, setRows] = useState<T[]>(SEED);
45
- const [tab, setTab] = useState<"open" | "resolved">("open");
46
-
47
- const set = (id: string, status: T["status"]) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
48
- const acceptAllHigh = () => setRows((prev) => prev.map((r) => (r.status === "open" && r.confidence === "high" ? { ...r, status: "accepted" } : r)));
49
-
50
- const open = rows.filter((r) => r.status === "open");
51
- const resolved = rows.filter((r) => r.status !== "open");
52
- const visible = tab === "open" ? open : resolved;
53
- const highOpen = open.filter((r) => r.confidence === "high").length;
54
- const review = open.filter((r) => r.confidence !== "high").length;
55
-
56
- return (
57
- <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
58
- <View style={{ width: "100%", maxWidth: 820, alignSelf: "center", gap: 16 }}>
59
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
60
- <View style={{ flex: 1, gap: 2 }}>
61
- <Text size="xl" weight="semibold">Inbox triage</Text>
62
- <Text size="sm" color="muted">The agent classifies and routes each item — accept the call, override, or dismiss</Text>
63
- </View>
64
- {tab === "open" && highOpen > 0 ? (
65
- <Button title={`Accept ${highOpen} high-confidence`} color="secondary" onPress={acceptAllHigh} />
66
- ) : null}
67
- </View>
68
-
69
- <KPIStrip
70
- items={[
71
- { label: "New", value: open.length, format: "number", caption: "awaiting triage" },
72
- { label: "Ready to auto-route", value: highOpen, format: "number", info: "High-confidence items the agent is sure about — safe to accept in bulk." },
73
- { label: "Needs review", value: review, format: "number", tone: review > 0 ? "warning" : "default", info: "Medium / low confidence — the agent flagged these for a human to confirm the routing." },
74
- { label: "Handled", value: rows.filter((r) => r.status === "accepted").length, format: "number" },
75
- ]}
76
- />
77
-
78
- <SegmentedControl
79
- accessibilityLabel="Queue"
80
- options={[
81
- { label: `New · ${open.length}`, value: "open" },
82
- { label: `Handled · ${resolved.length}`, value: "resolved" },
83
- ]}
84
- value={tab}
85
- onValueChange={setTab}
86
- />
87
-
88
- {visible.length === 0 ? (
89
- <EmptyState icon="circle-check" message={tab === "open" ? "Inbox zero" : "Nothing handled yet"} hint={tab === "open" ? "Every item has been routed or dismissed" : undefined} />
90
- ) : (
91
- <View style={{ gap: 10 }}>
92
- {visible.map((r) => (
93
- <TriageRow
94
- key={r.id}
95
- title={r.title}
96
- preview={r.preview}
97
- meta={r.meta}
98
- category={r.category}
99
- suggestedAction={r.suggestedAction}
100
- confidence={r.confidence}
101
- status={r.status}
102
- onAccept={() => set(r.id, "accepted")}
103
- onOverride={() => {}}
104
- onDismiss={() => set(r.id, "dismissed")}
105
- />
106
- ))}
107
- </View>
108
- )}
109
- </View>
110
- </ScrollView>
111
- );
112
- }
@@ -1,114 +0,0 @@
1
- import { StyleSheet, View } from "react-native";
2
- import { colors, solid } from "./colors";
3
- import { Text } from "./text";
4
- import { Icon } from "./icon";
5
- import { Badge } from "./badge";
6
- import { Button } from "./button";
7
- import { CardSelectItem } from "./card_select_item";
8
-
9
- export interface DiscrepancyValue {
10
- /** Where this value came from — the document / record / system of record. */
11
- source: string;
12
- value: string;
13
- /** The agent's recommended truth among the conflicting values. */
14
- recommended?: boolean;
15
- }
16
-
17
- export interface DiscrepancyProps {
18
- /** The field that disagrees across sources. */
19
- field: string;
20
- /** The agent's explanation of the conflict — shown above the options so the
21
- * human reads WHY before picking. */
22
- note?: string;
23
- values: DiscrepancyValue[];
24
- /** Resolve the conflict by picking a value (index into `values`) — pressing a
25
- * card commits it; there is no separate confirm button. */
26
- onResolve?: (index: number) => void;
27
- /** Send for manual handling instead of picking. */
28
- onFlag?: () => void;
29
- /** Resolved → the chosen index settles the card. */
30
- resolvedIndex?: number;
31
- flagged?: boolean;
32
- }
33
-
34
- /**
35
- * A field whose value DISAGREES across sources — the unit of an AI cross-check
36
- * / audit. The agent's explanation sits up top; below it the conflicting values
37
- * are identical selectable cards (the global press/hover ring), the one the
38
- * agent believes carrying a neutral "Agent's pick" tag — NOT a pre-selected
39
- * highlight. Pressing a card resolves the conflict to it — no confirm step.
40
- * Symmetric, unlike `ChangeReview`'s before→after.
41
- */
42
- export function Discrepancy(props: DiscrepancyProps) {
43
- const { field, values, note, onResolve, onFlag, resolvedIndex, flagged } = props;
44
- const resolved = resolvedIndex != null || flagged === true;
45
-
46
- return (
47
- <View style={[styles.card, resolved ? styles.resolved : null]}>
48
- <Text size="sm" weight="semibold" numberOfLines={1}>
49
- {field}
50
- </Text>
51
-
52
- {resolved ? (
53
- <View style={styles.outcome}>
54
- <Icon name={flagged ? "triangle-alert" : "circle-check"} size={15} color={flagged ? solid("amber") : solid("emerald")} />
55
- <Text size="sm" color="muted">
56
- {flagged
57
- ? "Flagged for manual review"
58
- : `Resolved to ${values[resolvedIndex as number].source} — ${values[resolvedIndex as number].value}`}
59
- </Text>
60
- </View>
61
- ) : (
62
- <>
63
- {note ? (
64
- <Text size="sm" color="muted">
65
- {note}
66
- </Text>
67
- ) : null}
68
-
69
- <View style={styles.values}>
70
- {values.map((v, i) => (
71
- <CardSelectItem
72
- key={`${v.source}-${i}`}
73
- accessibilityLabel={`Resolve ${field} to ${v.source}: ${v.value}`}
74
- onPress={() => onResolve?.(i)}
75
- style={styles.valueBox}
76
- >
77
- <Text size="sm" color="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
78
- {v.source}
79
- </Text>
80
- <View style={{ flex: 1 }} />
81
- {v.recommended ? <Badge label="Agent's pick" /> : null}
82
- <Text size="md" weight="semibold" tabular>
83
- {v.value}
84
- </Text>
85
- </CardSelectItem>
86
- ))}
87
- </View>
88
-
89
- {onFlag ? (
90
- <View style={styles.footer}>
91
- <Button title="Flag for review" color="muted" onPress={onFlag} />
92
- </View>
93
- ) : null}
94
- </>
95
- )}
96
- </View>
97
- );
98
- }
99
-
100
- const styles = StyleSheet.create({
101
- card: {
102
- borderWidth: 1,
103
- borderColor: colors.border,
104
- backgroundColor: colors.white,
105
- borderRadius: 12,
106
- padding: 16,
107
- gap: 12,
108
- },
109
- resolved: { backgroundColor: colors.zinc[50] },
110
- outcome: { flexDirection: "row", alignItems: "center", gap: 8 },
111
- values: { gap: 8 },
112
- valueBox: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 12, paddingHorizontal: 14 },
113
- footer: { flexDirection: "row", justifyContent: "flex-end" },
114
- });