@lotics/ui 7.19.2 → 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.
- package/AGENTS.md +128 -92
- package/examples/tpl_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_lookup.tsx +37 -10
- package/examples/tpl_task_board.tsx +32 -19
- package/examples/tpl_tasks.tsx +32 -19
- package/package.json +2 -9
- package/src/agent_run.tsx +1 -1
- package/src/change_review.tsx +732 -236
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- package/src/locale.tsx +22 -16
- package/src/sources.tsx +8 -5
- package/src/use_option_list.test.ts +39 -0
- package/src/use_option_list.ts +5 -2
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_ratedesk.tsx +0 -386
- package/examples/tpl_triage.tsx +0 -112
- 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
|
@@ -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
|
-
];
|
package/examples/tpl_triage.tsx
DELETED
|
@@ -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
|
-
}
|
package/src/discrepancy.tsx
DELETED
|
@@ -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
|
-
});
|