@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.
- package/AGENTS.md +298 -159
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_lookup.tsx +37 -10
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +48 -32
- package/examples/tpl_tasks.tsx +47 -47
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +9 -9
- package/src/agent_run.tsx +1 -1
- package/src/capture_row.tsx +59 -0
- package/src/change_review.tsx +732 -236
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/detail_row.tsx +137 -10
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +43 -16
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/sources.tsx +8 -5
- package/src/suggestion_chip.tsx +47 -0
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_ratedesk.tsx +0 -386
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_triage.tsx +0 -112
- package/examples/tpl_wizard.tsx +0 -223
- package/src/discrepancy.tsx +0 -114
- package/src/match_sides.tsx +0 -79
- package/src/record_fields.tsx +0 -68
- package/src/review_card.tsx +0 -172
- package/src/scored_option.tsx +0 -138
- package/src/spec_list.tsx +0 -81
- package/src/triage_row.tsx +0 -99
package/examples/tpl_quick.tsx
DELETED
|
@@ -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
|
-
];
|