@lotics/ui 7.19.3 → 9.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 (75) hide show
  1. package/AGENTS.md +298 -159
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +2 -2
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_dieline.tsx +3 -3
  7. package/examples/tpl_documents.tsx +790 -0
  8. package/examples/tpl_item_list.tsx +1015 -124
  9. package/examples/tpl_lookup.tsx +37 -10
  10. package/examples/tpl_pick.tsx +3 -3
  11. package/examples/tpl_pivot.tsx +1 -1
  12. package/examples/tpl_record.tsx +1354 -0
  13. package/examples/tpl_report.tsx +7 -7
  14. package/examples/tpl_rollup.tsx +6 -6
  15. package/examples/tpl_shifts.tsx +2 -2
  16. package/examples/tpl_statements.tsx +221 -0
  17. package/examples/tpl_stock.tsx +7 -7
  18. package/examples/tpl_task_board.tsx +48 -32
  19. package/examples/tpl_tasks.tsx +47 -47
  20. package/examples/tpl_tower.tsx +2 -2
  21. package/package.json +9 -9
  22. package/src/agent_run.tsx +1 -1
  23. package/src/capture_row.tsx +59 -0
  24. package/src/change_review.tsx +732 -236
  25. package/src/checklist.tsx +104 -0
  26. package/src/chip.tsx +12 -3
  27. package/src/composer.tsx +22 -1
  28. package/src/confidence.tsx +1 -1
  29. package/src/control_surface.ts +0 -14
  30. package/src/detail_row.tsx +137 -10
  31. package/src/finding.tsx +112 -80
  32. package/src/floating_action_bar.tsx +1 -1
  33. package/src/inline_date_picker.tsx +8 -3
  34. package/src/inline_edit.tsx +40 -10
  35. package/src/inline_member_select.tsx +3 -0
  36. package/src/inline_number_input.tsx +5 -2
  37. package/src/inline_select.tsx +8 -3
  38. package/src/inline_tag_select.tsx +140 -0
  39. package/src/inline_text_input.tsx +5 -2
  40. package/src/inline_time_picker.tsx +5 -2
  41. package/src/ledger.tsx +220 -0
  42. package/src/locale.tsx +43 -16
  43. package/src/progress_bar.tsx +32 -1
  44. package/src/record_summary.tsx +101 -0
  45. package/src/section_heading.tsx +16 -8
  46. package/src/sources.tsx +8 -5
  47. package/src/suggestion_chip.tsx +47 -0
  48. package/src/use_section_nav.test.ts +69 -0
  49. package/src/use_section_nav.ts +59 -0
  50. package/examples/tpl_assistant.tsx +0 -174
  51. package/examples/tpl_billing.tsx +0 -344
  52. package/examples/tpl_briefing.tsx +0 -121
  53. package/examples/tpl_compare.tsx +0 -133
  54. package/examples/tpl_crosscheck.tsx +0 -120
  55. package/examples/tpl_detail.tsx +0 -232
  56. package/examples/tpl_directory.tsx +0 -260
  57. package/examples/tpl_draft.tsx +0 -163
  58. package/examples/tpl_extract.tsx +0 -193
  59. package/examples/tpl_intake.tsx +0 -206
  60. package/examples/tpl_match.tsx +0 -134
  61. package/examples/tpl_order.tsx +0 -482
  62. package/examples/tpl_quick.tsx +0 -211
  63. package/examples/tpl_ratedesk.tsx +0 -386
  64. package/examples/tpl_record_plain.tsx +0 -259
  65. package/examples/tpl_settings.tsx +0 -178
  66. package/examples/tpl_timeline.tsx +0 -244
  67. package/examples/tpl_triage.tsx +0 -112
  68. package/examples/tpl_wizard.tsx +0 -223
  69. package/src/discrepancy.tsx +0 -114
  70. package/src/match_sides.tsx +0 -79
  71. package/src/record_fields.tsx +0 -68
  72. package/src/review_card.tsx +0 -172
  73. package/src/scored_option.tsx +0 -138
  74. package/src/spec_list.tsx +0 -81
  75. package/src/triage_row.tsx +0 -99
@@ -1,211 +0,0 @@
1
- import { useRef, useState } from "react";
2
- import { ScrollView, View, type TextInput as RNTextInput } 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 { Alert } from "@lotics/ui/alert";
7
- import { Card, CardBody, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
8
- import { Divider } from "@lotics/ui/divider";
9
- import { Icon, type IconName } from "@lotics/ui/icon";
10
- import { FormField } from "@lotics/ui/form_field";
11
- import { TextInputField } from "@lotics/ui/text_input_field";
12
- import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
13
- import type { PickerOption } from "@lotics/ui/picker";
14
- import { SegmentedControl } from "@lotics/ui/segmented_control";
15
- import { Counter } from "@lotics/ui/counter";
16
- import { EmptyState } from "@lotics/ui/empty_state";
17
-
18
- // ─────────────────────────────────────────────────────────────────────────────
19
- // Template · Quick capture — the SPEED surface, opposite of the heavy form: one
20
- // compact row of inputs, Enter to add, the field keeps focus and the contact
21
- // carries over so the next touchpoint is two keystrokes away. Every entry drops
22
- // into the log below (newest first, undoable). A `SegmentedControl` mode, a
23
- // `Combobox`-as-select, a `Counter` for the bounded duration. No screen-wide
24
- // Save — each entry commits on its own.
25
- // ─────────────────────────────────────────────────────────────────────────────
26
-
27
- type ActType = "call" | "email" | "meeting" | "note";
28
-
29
- const ACT: Record<ActType, { label: string; icon: IconName }> = {
30
- call: { label: "Call", icon: "phone" },
31
- email: { label: "Email", icon: "mail" },
32
- meeting: { label: "Meeting", icon: "calendar" },
33
- note: { label: "Note", icon: "message-square" },
34
- };
35
-
36
- const TYPE_OPTIONS = (Object.keys(ACT) as ActType[]).map((v) => ({ label: ACT[v].label, value: v }));
37
-
38
- interface Contact {
39
- id: string;
40
- name: string;
41
- company: string;
42
- }
43
-
44
- const CONTACTS: Contact[] = [
45
- { id: "c1", name: "Mara Lindqvist", company: "Northwind Traders" },
46
- { id: "c2", name: "Diego Alvarez", company: "Harbor Freight Lines" },
47
- { id: "c3", name: "Priya Nair", company: "Summit Packaging Co." },
48
- { id: "c4", name: "Tom Becker", company: "Atlas Distribution" },
49
- { id: "c5", name: "Lena Fischer", company: "Bluewater Logistics" },
50
- { id: "c6", name: "Sara Cohen", company: "Meridian Retail Group" },
51
- ];
52
-
53
- const CONTACT_OPTIONS: PickerOption<string, Contact>[] = CONTACTS.map((c) => ({
54
- value: c.id,
55
- label: c.name,
56
- data: c,
57
- }));
58
-
59
- interface Entry {
60
- id: string;
61
- type: ActType;
62
- contact: Contact;
63
- note: string;
64
- minutes: number;
65
- time: string;
66
- }
67
-
68
- const SEED: Entry[] = [
69
- { id: "e1", type: "call", contact: CONTACTS[2], note: "Reviewed Q3 volume, wants a revised quote by Friday", minutes: 15, time: "10:05" },
70
- { id: "e2", type: "email", contact: CONTACTS[0], note: "Sent updated pallet spec sheet", minutes: 0, time: "09:40" },
71
- { id: "e3", type: "meeting", contact: CONTACTS[4], note: "Warehouse walkthrough — flagged dock height", minutes: 45, time: "09:12" },
72
- ];
73
-
74
- const fmtDuration = (m: number) => (m === 0 ? "—" : m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}` : `${m}m`);
75
-
76
- export function TplQuick() {
77
- const id = useRef(1);
78
- const noteRef = useRef<RNTextInput>(null);
79
-
80
- const [type, setType] = useState<ActType>("call");
81
- const [contact, setContact] = useState<PickerOption<string, Contact> | null>(null);
82
- const [note, setNote] = useState("");
83
- const [minutes, setMinutes] = useState(15);
84
- const [entries, setEntries] = useState<Entry[]>(SEED);
85
-
86
- const canAdd = contact !== null && note.trim().length > 0;
87
-
88
- const add = () => {
89
- if (!canAdd || !contact?.data) return;
90
- const entry: Entry = {
91
- id: `e_${(id.current += 1)}`,
92
- type,
93
- contact: contact.data,
94
- note: note.trim(),
95
- minutes,
96
- time: "Just now",
97
- };
98
- setEntries((prev) => [entry, ...prev]);
99
- // Keep type + contact for the next touchpoint; clear only the note + duration
100
- // and re-focus the note so logging again is immediate.
101
- setNote("");
102
- setMinutes(type === "email" ? 0 : 15);
103
- noteRef.current?.focus();
104
- };
105
-
106
- return (
107
- <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
108
- <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
109
- <View style={{ gap: 2 }}>
110
- <Text size="xl" weight="semibold">Log activity</Text>
111
- <Text size="sm" color="muted">Capture a touchpoint in seconds — pick the type, the contact, type the note, press Enter</Text>
112
- </View>
113
-
114
- {/* the capture row */}
115
- <Card style={{ padding: 0 }}>
116
- <CardBody>
117
- <SegmentedControl accessibilityLabel="Activity type" options={TYPE_OPTIONS} value={type} onValueChange={setType} style={{ marginBottom: 16 }} />
118
- <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
119
- <FormField label="Contact" style={{ flexGrow: 1, flexBasis: 240 }}>
120
- <Combobox
121
- options={CONTACT_OPTIONS}
122
- value={contact}
123
- onValueChange={setContact}
124
- getOptionDescription={(o) => o.data?.company}
125
- >
126
- <ComboboxInput placeholder="Select a contact" accessibilityLabel="Contact" />
127
- <ComboboxContent />
128
- </Combobox>
129
- </FormField>
130
- <FormField label="Duration" style={{ flexGrow: 0, flexBasis: 180 }}>
131
- <View style={{ minHeight: 40, justifyContent: "center" }}>
132
- <Counter value={minutes} onValueChange={setMinutes} min={0} max={240} step={15} accessibilityLabel="Duration" format={fmtDuration} />
133
- </View>
134
- </FormField>
135
- </View>
136
- <FormField label="Note">
137
- <TextInputField
138
- ref={noteRef}
139
- value={note}
140
- onChangeText={setNote}
141
- placeholder="What happened? (press Enter to log)"
142
- autoFocus
143
- onKeyPress={(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
144
- if (e.nativeEvent.key === "Enter") {
145
- e.preventDefault();
146
- add();
147
- }
148
- }}
149
- />
150
- </FormField>
151
- </CardBody>
152
- <Divider />
153
- <View style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 20, paddingVertical: 12, gap: 8 }}>
154
- <Text size="xs" color="muted" style={{ flex: 1 }}>
155
- {contact?.data ? `${ACT[type].label} · ${contact.data.name}` : "Pick a contact to log against"}
156
- </Text>
157
- <Button title="Log activity" color="primary" icon="plus" disabled={!canAdd} onPress={add} />
158
- </View>
159
- </Card>
160
-
161
- {/* the running log */}
162
- <Card style={{ padding: 0 }}>
163
- <CardHeader>
164
- <CardHeaderTitle>Today</CardHeaderTitle>
165
- <CardHeaderMeta>{entries.length} logged</CardHeaderMeta>
166
- </CardHeader>
167
- {entries.length === 0 ? (
168
- <EmptyState icon="message-square" message="Nothing logged yet" hint="Your activity for the day appears here" />
169
- ) : (
170
- entries.map((e, i) => (
171
- <View key={e.id}>
172
- {i > 0 ? <Divider /> : null}
173
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12 }}>
174
- <View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
175
- <Icon name={ACT[e.type].icon} size={16} color={colors.zinc[600]} />
176
- </View>
177
- <View style={{ flex: 1, gap: 1 }}>
178
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
179
- <Text weight="medium" numberOfLines={1}>{e.contact.name}</Text>
180
- <Text size="xs" color="muted">· {e.contact.company}</Text>
181
- </View>
182
- <Text size="sm" color="muted" numberOfLines={1}>{e.note}</Text>
183
- </View>
184
- <View style={{ alignItems: "flex-end", width: 76 }}>
185
- <Text size="xs" color="muted" tabular>{e.time}</Text>
186
- {e.minutes > 0 ? <Text size="xs" color="muted" tabular>{fmtDuration(e.minutes)}</Text> : null}
187
- </View>
188
- <Button
189
- title="Delete"
190
- color="secondary"
191
- accessibilityLabel={`Delete ${e.contact.name} ${ACT[e.type].label.toLowerCase()}`}
192
- onPress={() =>
193
- Alert.alert(
194
- "Delete activity?",
195
- `Remove the ${ACT[e.type].label.toLowerCase()} logged for ${e.contact.name}. This can't be undone.`,
196
- [
197
- { text: "Cancel", style: "cancel" },
198
- { text: "Delete", style: "destructive", onPress: () => setEntries((prev) => prev.filter((x) => x.id !== e.id)) },
199
- ],
200
- )
201
- }
202
- />
203
- </View>
204
- </View>
205
- ))
206
- )}
207
- </Card>
208
- </View>
209
- </ScrollView>
210
- );
211
- }
@@ -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
- ];