@lotics/ui 14.1.0 → 14.3.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 +5 -0
- package/docs/ai_patterns.md +24 -9
- package/docs/catalog.md +36 -9
- package/docs/composition.md +8 -0
- package/docs/templates.md +32 -20
- package/examples/tpl_item_list.tsx +303 -58
- package/examples/tpl_record.tsx +24 -15
- package/package.json +2 -1
- package/src/file_badge.tsx +6 -3
- package/src/file_badge_fit.test.ts +49 -0
- package/src/file_badge_fit.ts +34 -0
- package/src/file_thumbnail.tsx +31 -8
- package/src/follow_scroll.tsx +32 -37
- package/src/linked_record_box.tsx +5 -23
- package/src/press_door.tsx +68 -0
- package/src/pressable_highlight.tsx +7 -0
- package/src/table.tsx +7 -28
- package/examples/tpl_documents.tsx +0 -808
|
@@ -1,808 +0,0 @@
|
|
|
1
|
-
import { Fragment, useEffect, useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
-
import { Text } from "@lotics/ui/text";
|
|
5
|
-
import { Icon } from "@lotics/ui/icon";
|
|
6
|
-
import { Button } from "@lotics/ui/button";
|
|
7
|
-
import { Divider } from "@lotics/ui/divider";
|
|
8
|
-
import { Section, SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
9
|
-
import { FileRow } from "@lotics/ui/file_row";
|
|
10
|
-
import { pickFiles } from "@lotics/ui/file_picker";
|
|
11
|
-
import { Alert } from "@lotics/ui/alert";
|
|
12
|
-
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
13
|
-
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
14
|
-
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
15
|
-
import { Finding, FindingComparison } from "@lotics/ui/finding";
|
|
16
|
-
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
17
|
-
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
18
|
-
import { FormTextInput } from "@lotics/ui/form_text_input";
|
|
19
|
-
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
20
|
-
import { SearchInput } from "@lotics/ui/search_input";
|
|
21
|
-
import { useSelection } from "@lotics/ui/use_selection";
|
|
22
|
-
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
23
|
-
import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
|
|
24
|
-
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
25
|
-
import { AgentRun } from "@lotics/ui/agent_run";
|
|
26
|
-
import { type SourceRef } from "@lotics/ui/sources";
|
|
27
|
-
import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
|
|
28
|
-
|
|
29
|
-
import { CompletionState } from "@lotics/ui/completion_state";
|
|
30
|
-
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
31
|
-
|
|
32
|
-
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
33
|
-
|
|
34
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
-
// Template · Document desk — THE worked example for everything document-driven
|
|
36
|
-
// on a record. A dossier's files block feeds ONE "Use AI" entry that FORKS into
|
|
37
|
-
// the two document tasks: EXTRACT (read the documents and fill the record —
|
|
38
|
-
// ONE `Change` section per RECORD whose body is `ChangeFields` stacking a
|
|
39
|
-
// `ChangeField` per proposed value: label · the − band when replacing · the
|
|
40
|
-
// editable + value. Editing IS the review; an add has no `before`, a removal
|
|
41
|
-
// is the − band alone, and a conflict shows the read-only outcome band over
|
|
42
|
-
// candidate rows + the type-another-value third option) and CROSS-CHECK
|
|
43
|
-
// (compare the documents against the record and each other — ranked
|
|
44
|
-
// display-only findings the human acts on), and EDIT WITH AI (the askAi handoff —
|
|
45
|
-
// dialogue-shaped file iteration happens in the Lotics chat; the fork is the ONE
|
|
46
|
-
// AI entry point, never per-row AI buttons). Plus CREATE DOCUMENTS: a
|
|
47
|
-
// readiness checklist → generate → the files land back on the record. All
|
|
48
|
-
// mock, useState + the timer that streams the AgentRun. The record template
|
|
49
|
-
// (tpl_record) carries this desk as its Documents section — change it in
|
|
50
|
-
// BOTH. One sanctioned divergence: GENERATION. This page has no output
|
|
51
|
-
// pipeline, so Create documents runs as the toolbar dialog here; tpl_record
|
|
52
|
-
// moves it into its Document set OUTPUT section (intake desk / output section
|
|
53
|
-
// split) and drops the toolbar button.
|
|
54
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
type ScriptStep = { id: string; label: string; detail?: string; kind?: "tool" };
|
|
57
|
-
type Task = "extract" | "check";
|
|
58
|
-
type Phase = "fork" | "running" | "review" | "done";
|
|
59
|
-
|
|
60
|
-
interface Doc { id: string; name: string; mimeType: string; kind: string; sizeKB: number; added: string; addedAt: number; url?: string }
|
|
61
|
-
|
|
62
|
-
// Display derives from the canonical numeric — strings would sort "8.4 MB" < "96 KB".
|
|
63
|
-
function fmtSize(kb: number): string {
|
|
64
|
-
if (kb <= 0) return "—";
|
|
65
|
-
return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
|
66
|
-
}
|
|
67
|
-
const MOCK_PHOTO_URL =
|
|
68
|
-
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNmNTllMGInLz48cmVjdCB4PScyNjInIHk9JzI0JyB3aWR0aD0nMTAnIGhlaWdodD0nMTg2JyBmaWxsPScjNTI1MjUyJy8+PHJlY3QgeD0nMTUwJyB5PScyNCcgd2lkdGg9JzEyMicgaGVpZ2h0PScxMCcgZmlsbD0nIzUyNTI1MicvPjwvc3ZnPg==";
|
|
69
|
-
const DOCS: Doc[] = [
|
|
70
|
-
{ id: "f1", name: "invoice.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 214, added: "26 Jun", addedAt: 626 },
|
|
71
|
-
{ id: "f2", name: "packing-list.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 96, added: "26 Jun", addedAt: 626 },
|
|
72
|
-
{ id: "f3", name: "booking-confirmation.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 182, added: "28 Jun", addedAt: 628 },
|
|
73
|
-
{ id: "f4", name: "photos.zip", mimeType: "application/zip", kind: "ZIP", sizeKB: 8602, added: "30 Jun", addedAt: 630 },
|
|
74
|
-
// an IMAGE file — the register renders it as a real square thumbnail (the
|
|
75
|
-
// SVG data URI stands in for the stored photo URL a live app serves)
|
|
76
|
-
{ id: "f5", name: "delivery-photo.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1424, added: "30 Jun", addedAt: 630, url: MOCK_PHOTO_URL },
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
const EXTRACT_STEPS: ScriptStep[] = [
|
|
80
|
-
{ id: "e1", label: "Reading the selected documents", detail: "Page by page, tables included" },
|
|
81
|
-
{ id: "e2", label: "get_record", kind: "tool" },
|
|
82
|
-
{ id: "e3", label: "Extracting field values", detail: "9 fields · 2 order lines" },
|
|
83
|
-
{ id: "e4", label: "Comparing against the record", detail: "3 match · 1 new · 1 change · 1 conflict" },
|
|
84
|
-
];
|
|
85
|
-
const CHECK_STEPS: ScriptStep[] = [
|
|
86
|
-
{ id: "c1", label: "Reading the selected documents", detail: "Quantities, parties, dates and terms" },
|
|
87
|
-
{ id: "c2", label: "get_record", kind: "tool" },
|
|
88
|
-
{ id: "c3", label: "Cross-checking documents and record", detail: "18 fields compared across the sources" },
|
|
89
|
-
];
|
|
90
|
-
|
|
91
|
-
// The shapes of an extract decision — an ADD, an UPDATE, a REMOVAL, a source
|
|
92
|
-
// CONFLICT — are the SAME `ChangeField` row: an add has no `before`, an update
|
|
93
|
-
// bands it, a removal is the − band alone, and a conflict's outcome is the
|
|
94
|
-
// read-only + band over the candidate rows.
|
|
95
|
-
const CARRIER_REF_PROPOSED = "MAEU129394855";
|
|
96
|
-
const VESSEL_CURRENT = "MSC AURA";
|
|
97
|
-
const VESSEL_PROPOSED = "MAERSK SALINA";
|
|
98
|
-
const CONSIGNEE_CURRENT = "Nordic Furniture AB";
|
|
99
|
-
const NOTIFY_CURRENT = "Euro Textile Trading GmbH, Frankfurt";
|
|
100
|
-
const EXTRACT_REASONING = "The booking confirmation names the substitute vessel for this shipping week; the invoice and the packing list disagree on the consignee.";
|
|
101
|
-
const CONSIGNEE_OPTIONS = [
|
|
102
|
-
{ value: "Nordic Furniture AB, Jönköping DC", source: "invoice.pdf", recommended: true },
|
|
103
|
-
{ value: "NF Distribution ApS, Kolding", source: "packing-list.pdf" },
|
|
104
|
-
];
|
|
105
|
-
const EXTRACT_FIELD_IDS = ["carrier_ref", "vessel", "consignee", "gross", "notify"] as const;
|
|
106
|
-
|
|
107
|
-
// Cross-check findings — severity reads through ONE colored dot badge (red /
|
|
108
|
-
// amber / zinc), the rest stays calm text.
|
|
109
|
-
type Severity = "critical" | "warning" | "info";
|
|
110
|
-
interface Check { id: string; severity: Severity; title: string; detail?: string; comparison?: { values: { label: string; value: string }[]; delta?: string }; sources?: string[] }
|
|
111
|
-
const FINDINGS: Check[] = [
|
|
112
|
-
{
|
|
113
|
-
id: "q1", severity: "critical", title: "Quantity disagrees between the invoice and the packing list",
|
|
114
|
-
detail: "Short-shipping against the invoice risks a customs query and a client claim.",
|
|
115
|
-
comparison: { values: [{ label: "invoice.pdf", value: "480 pcs" }, { label: "packing-list.pdf", value: "440 pcs" }], delta: "−40 pcs" },
|
|
116
|
-
sources: ["invoice.pdf", "packing-list.pdf"],
|
|
117
|
-
},
|
|
118
|
-
{
|
|
119
|
-
id: "q2", severity: "warning", title: "Consignee differs between the invoice and the booking",
|
|
120
|
-
detail: "The invoice names the buyer's own warehouse; the booking routes delivery through a distribution partner. One of them files wrong.",
|
|
121
|
-
sources: ["invoice.pdf", "booking-confirmation.pdf"],
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
id: "q4", severity: "warning", title: "No certificate of origin among the documents",
|
|
125
|
-
detail: "Destination customs requires a certificate of origin for these goods — request it before the shipping cut-off.",
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
id: "q3", severity: "info", title: "Booking cut-off is earlier than the invoiced ship week",
|
|
129
|
-
detail: "Documents close 14 Jul on the booking; the invoice quotes shipment in the week of 18 Jul. No conflict if the cargo is ready.",
|
|
130
|
-
sources: ["booking-confirmation.pdf"],
|
|
131
|
-
},
|
|
132
|
-
];
|
|
133
|
-
|
|
134
|
-
// A tiny REAL pdf (data URI) so the gallery preview genuinely renders.
|
|
135
|
-
const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
|
|
136
|
-
const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: d.url ?? MOCK_PDF_URL });
|
|
137
|
-
|
|
138
|
-
// The documents register — columns ONCE (the Table renders the header band
|
|
139
|
-
// and every cell width from these; facts get dedicated scannable columns
|
|
140
|
-
// instead of a crammed meta string). Same grammar as the case register
|
|
141
|
-
// (tpl_item_list): leading checkbox + select-all, trailing ⋯ gutter.
|
|
142
|
-
const DOC_COLUMNS: TableColumn[] = [
|
|
143
|
-
{ key: "doc", label: "Document", flex: 1, sortable: true },
|
|
144
|
-
{ key: "size", label: "Size", width: 90, align: "right", sortable: true, priority: 3 },
|
|
145
|
-
{ key: "added", label: "Added", width: 96, sortable: true, priority: 2 },
|
|
146
|
-
];
|
|
147
|
-
|
|
148
|
-
interface Producible { id: string; label: string; file: string; sizeKB: number; needs?: { key: string; label: string }[] }
|
|
149
|
-
const PRODUCIBLE: Producible[] = [
|
|
150
|
-
{ id: "g1", label: "Shipping instruction", file: "shipping-instruction.pdf", sizeKB: 84 },
|
|
151
|
-
{ id: "g2", label: "Certificate of origin", file: "certificate-of-origin.pdf", sizeKB: 61 },
|
|
152
|
-
{ id: "g3", label: "Delivery note", file: "delivery-note.pdf", sizeKB: 72, needs: [{ key: "delivery_address", label: "Delivery address" }, { key: "commodity_code", label: "Commodity code" }] },
|
|
153
|
-
{ id: "g4", label: "Customs declaration draft", file: "customs-declaration-draft.pdf", sizeKB: 118 },
|
|
154
|
-
];
|
|
155
|
-
|
|
156
|
-
export function TplDocuments() {
|
|
157
|
-
const [files, setFiles] = useState<Doc[]>(DOCS);
|
|
158
|
-
const sel = useSelection();
|
|
159
|
-
|
|
160
|
-
// Use AI — one entry off the selection, forking into the two document tasks.
|
|
161
|
-
const [aiOpen, setAiOpen] = useState(false);
|
|
162
|
-
const [picked, setPicked] = useState<Doc[]>([]);
|
|
163
|
-
const [brief, setBrief] = useState("");
|
|
164
|
-
// The fork SELECTS a task; the footer CTA runs it (select → confirm, never
|
|
165
|
-
// act-on-press for an analysis that costs a run).
|
|
166
|
-
const [taskChoice, setTaskChoice] = useState<Task | "edit" | null>(null);
|
|
167
|
-
// True when the dialog opened from a fresh upload — the fork then offers
|
|
168
|
-
// keeping the files without running anything.
|
|
169
|
-
const [uploadFlow, setUploadFlow] = useState(false);
|
|
170
|
-
// Full-page preview over whichever list the user was looking at.
|
|
171
|
-
const [preview, setPreview] = useState<{ files: DisplayFile[]; index: number } | null>(null);
|
|
172
|
-
const openPreview = (docs: Doc[], index: number) => setPreview({ files: docs.map(toDisplay), index });
|
|
173
|
-
const [task, setTask] = useState<Task | null>(null);
|
|
174
|
-
const [phase, setPhase] = useState<Phase>("fork");
|
|
175
|
-
const [revealed, setRevealed] = useState(0);
|
|
176
|
-
// The HOST owns the review: one editable proposal per field (editing IS the
|
|
177
|
-
// review), a tri-state decision per field (dropped rows collapse struck,
|
|
178
|
-
// out of the apply), and the conflict's pick (empty until the user chooses).
|
|
179
|
-
const [carrierRef, setCarrierRef] = useState(CARRIER_REF_PROPOSED);
|
|
180
|
-
const [vessel, setVessel] = useState(VESSEL_PROPOSED);
|
|
181
|
-
const [consignee, setConsignee] = useState<string | null>(null);
|
|
182
|
-
const [grossWeight, setGrossWeight] = useState("1,540");
|
|
183
|
-
// Order lines proposed from the packing list — RECORD ops (ChangeRecord).
|
|
184
|
-
const [lineAdd, setLineAdd] = useState<ChangeStatus>("pending");
|
|
185
|
-
const [lineEdit, setLineEdit] = useState<Record<string, "kept" | "dropped">>({});
|
|
186
|
-
const [newItem, setNewItem] = useState("Corner protectors, foam");
|
|
187
|
-
const [newQty, setNewQty] = useState("400");
|
|
188
|
-
const [editedQty, setEditedQty] = useState("1,450");
|
|
189
|
-
const lineEditRow = (id: string) => ({
|
|
190
|
-
status: lineEdit[id] ?? ("pending" as const),
|
|
191
|
-
onKeep: () => setLineEdit((m) => ({ ...m, [id]: "kept" as const })),
|
|
192
|
-
onDrop: () => setLineEdit((m) => ({ ...m, [id]: "dropped" as const })),
|
|
193
|
-
onUndo: () => setLineEdit((m) => { const n = { ...m }; delete n[id]; return n; }),
|
|
194
|
-
});
|
|
195
|
-
const [consigneePick, setConsigneePick] = useState<number | "custom" | null>(null);
|
|
196
|
-
const [customConsignee, setCustomConsignee] = useState("");
|
|
197
|
-
const [fieldDecisions, setFieldDecisions] = useState<Record<string, "kept" | "dropped">>({});
|
|
198
|
-
|
|
199
|
-
const openAi = () => { setPicked(files.filter((f) => sel.has(f.id))); setUploadFlow(false); setAiOpen(true); };
|
|
200
|
-
// The upload path's save: pending picked files land on the record only here.
|
|
201
|
-
const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
|
|
202
|
-
const closeAi = () => {
|
|
203
|
-
setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
|
|
204
|
-
setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); setLineAdd("pending"); setLineEdit({}); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); setFieldDecisions({});
|
|
205
|
-
};
|
|
206
|
-
const startTask = (t: Task) => { setTask(t); setRevealed(0); setPhase("running"); };
|
|
207
|
-
// ⋯ menu per document row. "Edit with AI" is the app→chat handoff for
|
|
208
|
-
// dialogue-shaped work on THIS document — a real app calls the SDK:
|
|
209
|
-
// void askAi({ file_ids: [f.id], record_ids: [orderRecordId], prompt: `Update ${f.name} — ` });
|
|
210
|
-
// and the Lotics messenger opens a fresh chat with the file attached AND
|
|
211
|
-
// previewed beside it; the prompt is prefilled, editable, never auto-sent.
|
|
212
|
-
// Structured judgment that commits back into fields stays on the Use-AI
|
|
213
|
-
// review flow. Rename works for real; Download is host-served in an app
|
|
214
|
-
// (openExternal(file.url)).
|
|
215
|
-
// One sort at a time; the third toggle on a column clears it (register law).
|
|
216
|
-
const [docSort, setDocSort] = useState<SortState | null>(null);
|
|
217
|
-
const [docSearch, setDocSearch] = useState("");
|
|
218
|
-
const docQuery = docSearch.trim().toLowerCase();
|
|
219
|
-
const visibleFiles = sortBy(
|
|
220
|
-
docQuery ? files.filter((f) => f.name.toLowerCase().includes(docQuery)) : files,
|
|
221
|
-
docSort,
|
|
222
|
-
(f, key) => (key === "doc" ? f.name.toLowerCase() : key === "size" ? f.sizeKB : f.addedAt),
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
const [renameTarget, setRenameTarget] = useState<Doc | null>(null);
|
|
226
|
-
const [renameDraft, setRenameDraft] = useState("");
|
|
227
|
-
const openRename = (f: Doc) => { setRenameTarget(f); setRenameDraft(f.name); };
|
|
228
|
-
const saveRename = () => {
|
|
229
|
-
const name = renameDraft.trim();
|
|
230
|
-
if (renameTarget && name) setFiles((fs) => fs.map((x) => (x.id === renameTarget.id ? { ...x, name } : x)));
|
|
231
|
-
setRenameTarget(null);
|
|
232
|
-
};
|
|
233
|
-
const editWithAi = (docs: Doc[]) => {
|
|
234
|
-
const names = docs.map((d) => d.name).join(", ");
|
|
235
|
-
Alert.alert(
|
|
236
|
-
"Edit with AI",
|
|
237
|
-
`Opens the Lotics chat on a fresh thread with ${names} attached${docs.length === 1 ? " and previewed side by side" : ""} — describe the change and the agent edits as new versions.`,
|
|
238
|
-
[{ text: "OK" }],
|
|
239
|
-
);
|
|
240
|
-
};
|
|
241
|
-
const fileMenuFor = (f: Doc): ActionMenuItem[] => [
|
|
242
|
-
{ key: "rename", label: "Rename", icon: "pencil", onPress: () => openRename(f) },
|
|
243
|
-
{ key: "download", label: "Download", icon: "download", onPress: () => { /* a real app: openExternal(f.url) */ } },
|
|
244
|
-
];
|
|
245
|
-
|
|
246
|
-
const removeSelected = () => {
|
|
247
|
-
Alert.alert(
|
|
248
|
-
`Remove ${sel.count} ${sel.count === 1 ? "file" : "files"}?`,
|
|
249
|
-
"They come off the order's documents — the originals stay wherever they came from.",
|
|
250
|
-
[
|
|
251
|
-
{ text: "Keep files", style: "cancel" },
|
|
252
|
-
{ text: "Remove", style: "destructive", onPress: () => { setFiles((fs) => fs.filter((f) => !sel.has(f.id))); sel.clear(); } },
|
|
253
|
-
],
|
|
254
|
-
);
|
|
255
|
-
};
|
|
256
|
-
const decideField = (id: string, d: "kept" | "dropped" | undefined) =>
|
|
257
|
-
setFieldDecisions((m) => {
|
|
258
|
-
const n = { ...m };
|
|
259
|
-
if (d) n[id] = d;
|
|
260
|
-
else delete n[id];
|
|
261
|
-
return n;
|
|
262
|
-
});
|
|
263
|
-
const fieldRow = (id: string) => ({
|
|
264
|
-
status: fieldDecisions[id] ?? ("pending" as const),
|
|
265
|
-
onKeep: () => decideField(id, "kept"),
|
|
266
|
-
onDrop: () => decideField(id, "dropped"),
|
|
267
|
-
onUndo: () => decideField(id, undefined),
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// The streaming timer idiom — reveal one script step at a time, then settle.
|
|
271
|
-
const script = task === "check" ? CHECK_STEPS : EXTRACT_STEPS;
|
|
272
|
-
useEffect(() => {
|
|
273
|
-
if (phase !== "running") return;
|
|
274
|
-
if (revealed >= script.length) {
|
|
275
|
-
const t = setTimeout(() => setPhase("review"), 850);
|
|
276
|
-
return () => clearTimeout(t);
|
|
277
|
-
}
|
|
278
|
-
const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 280 : 680);
|
|
279
|
-
return () => clearTimeout(t);
|
|
280
|
-
}, [phase, revealed, script.length]);
|
|
281
|
-
|
|
282
|
-
const runItems: Part[] = [
|
|
283
|
-
{ type: "text", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
|
|
284
|
-
...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): Part =>
|
|
285
|
-
i < revealed
|
|
286
|
-
? { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "output-available", input: undefined, output: undefined }
|
|
287
|
-
: { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "input-available", input: undefined },
|
|
288
|
-
),
|
|
289
|
-
];
|
|
290
|
-
if (revealed >= script.length) {
|
|
291
|
-
runItems.push({ type: "text", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Apply counts what actually commits: the kept fields.
|
|
295
|
-
const applyCount = Object.values(fieldDecisions).filter((d) => d === "kept").length + Object.values(lineEdit).filter((d) => d === "kept").length + (lineAdd === "accepted" ? 1 : 0);
|
|
296
|
-
// Keep-all lives with the HOST (field decisions the registry can't see):
|
|
297
|
-
// keep every still-pending field; the unresolved conflict stays pending.
|
|
298
|
-
const keepAll = () => {
|
|
299
|
-
setLineAdd((st) => (st === "pending" ? "accepted" : st));
|
|
300
|
-
setLineEdit((m) => ({ qty: m.qty ?? "kept", ...m }));
|
|
301
|
-
setFieldDecisions((m) => {
|
|
302
|
-
const n = { ...m };
|
|
303
|
-
for (const f of EXTRACT_FIELD_IDS) {
|
|
304
|
-
if (f === "consignee" && consignee == null) continue;
|
|
305
|
-
if (n[f] == null) n[f] = "kept";
|
|
306
|
-
}
|
|
307
|
-
return n;
|
|
308
|
-
});
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
// Create documents — a readiness checklist; generated files land on the record.
|
|
312
|
-
const gen = useSelection();
|
|
313
|
-
const [genOpen, setGenOpen] = useState(false);
|
|
314
|
-
// Required inputs some documents still miss — filled in the dialog's own
|
|
315
|
-
// fill screen, saved onto the record, readiness recomputes.
|
|
316
|
-
const [docFieldValues, setDocFieldValues] = useState<Record<string, string>>({});
|
|
317
|
-
const [fillTarget, setFillTarget] = useState<Producible | null>(null);
|
|
318
|
-
const [fillDraft, setFillDraft] = useState<Record<string, string>>({});
|
|
319
|
-
const missingOf = (p: Producible) => (p.needs ?? []).filter((n) => !docFieldValues[n.key]?.trim());
|
|
320
|
-
const openFill = (p: Producible) => {
|
|
321
|
-
setFillTarget(p);
|
|
322
|
-
setFillDraft(Object.fromEntries((p.needs ?? []).map((n) => [n.key, docFieldValues[n.key] ?? ""])));
|
|
323
|
-
};
|
|
324
|
-
const saveFill = () => {
|
|
325
|
-
setDocFieldValues((v) => ({ ...v, ...fillDraft }));
|
|
326
|
-
setFillTarget(null);
|
|
327
|
-
};
|
|
328
|
-
const [genDone, setGenDone] = useState(false);
|
|
329
|
-
// Generated documents stay PENDING until the user commits them — adding to
|
|
330
|
-
// the record is a decision (the same law as the upload flow), and the files
|
|
331
|
-
// are previewable while deciding.
|
|
332
|
-
const [genPending, setGenPending] = useState<Doc[]>([]);
|
|
333
|
-
// Generation is real work in a live app (template fill + render per
|
|
334
|
-
// document) — it runs as a visible scripted AgentRun, never a teleport.
|
|
335
|
-
const [genRunning, setGenRunning] = useState(false);
|
|
336
|
-
const [genRevealed, setGenRevealed] = useState(0);
|
|
337
|
-
const [genSteps, setGenSteps] = useState<{ id: string; label: string }[]>([]);
|
|
338
|
-
const generate = () => {
|
|
339
|
-
const chosen = PRODUCIBLE.filter((p) => gen.has(p.id));
|
|
340
|
-
setGenSteps([
|
|
341
|
-
{ id: "read", label: "Reading the order's data" },
|
|
342
|
-
...chosen.map((p) => ({ id: p.id, label: `Filling ${p.label}` })),
|
|
343
|
-
]);
|
|
344
|
-
setGenPending(chosen.map((p) => ({ id: `gen-${p.id}`, name: p.file, mimeType: "application/pdf", kind: "PDF", sizeKB: p.sizeKB, added: "", addedAt: 0 })));
|
|
345
|
-
setGenRevealed(0);
|
|
346
|
-
setGenRunning(true);
|
|
347
|
-
};
|
|
348
|
-
useEffect(() => {
|
|
349
|
-
if (!genRunning) return;
|
|
350
|
-
if (genRevealed >= genSteps.length) {
|
|
351
|
-
const t = setTimeout(() => { setGenRunning(false); setGenDone(true); }, 600);
|
|
352
|
-
return () => clearTimeout(t);
|
|
353
|
-
}
|
|
354
|
-
const t = setTimeout(() => setGenRevealed((r) => r + 1), genRevealed === 0 ? 260 : 620);
|
|
355
|
-
return () => clearTimeout(t);
|
|
356
|
-
}, [genRunning, genRevealed, genSteps.length]);
|
|
357
|
-
const addGeneratedToRecord = () => {
|
|
358
|
-
setFiles((fs) => [...fs, ...genPending.map((g) => ({ ...g, added: "just now", addedAt: 999 }))]);
|
|
359
|
-
closeGen();
|
|
360
|
-
};
|
|
361
|
-
const closeGen = () => { setGenOpen(false); setGenDone(false); setGenRunning(false); setGenRevealed(0); setFillTarget(null); setGenPending([]); gen.clear(); };
|
|
362
|
-
|
|
363
|
-
return (
|
|
364
|
-
<>
|
|
365
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
|
|
366
|
-
<View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 24 }}>
|
|
367
|
-
<View style={{ gap: 2 }}>
|
|
368
|
-
<Text size="xxl" weight="semibold">PO-2481 — Nordic Furniture AB</Text>
|
|
369
|
-
<Text size="sm" color="muted">Sea freight · Gothenburg → Ho Chi Minh City · FOB · ETD 18 Jul 2026</Text>
|
|
370
|
-
</View>
|
|
371
|
-
|
|
372
|
-
<Section>
|
|
373
|
-
<SectionHeading>
|
|
374
|
-
<SectionHeadingTitle>Documents</SectionHeadingTitle>
|
|
375
|
-
</SectionHeading>
|
|
376
|
-
{/* toolbar — search LEFT, the CTAs RIGHT, one row (the register band). */}
|
|
377
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
378
|
-
<View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
|
|
379
|
-
<SearchInput
|
|
380
|
-
placeholder="Search documents…"
|
|
381
|
-
value={docSearch}
|
|
382
|
-
onChangeText={setDocSearch}
|
|
383
|
-
accessibilityLabel="Search documents"
|
|
384
|
-
/>
|
|
385
|
-
</View>
|
|
386
|
-
<View style={{ flex: 1 }} />
|
|
387
|
-
<Button title="Create documents" color="muted" onPress={() => setGenOpen(true)} />
|
|
388
|
-
<Button
|
|
389
|
-
title="Add files"
|
|
390
|
-
color="secondary"
|
|
391
|
-
onPress={() => {
|
|
392
|
-
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then((chosen) => {
|
|
393
|
-
if (chosen.length === 0) return;
|
|
394
|
-
const added = chosen.map((f, i) => ({ id: `added-${f.name}-${i}`, name: f.name, mimeType: f.type || "application/pdf", kind: (f.type || "application/pdf").split("/")[1]?.toUpperCase().slice(0, 4) ?? "FILE", sizeKB: Math.round(f.size / 1024), added: "just now", addedAt: 999 }));
|
|
395
|
-
// PENDING until the user chooses: saving is a decision the
|
|
396
|
-
// dialog asks for (save only / run a task), never a side
|
|
397
|
-
// effect of picking — closing the dialog discards.
|
|
398
|
-
setPicked(added);
|
|
399
|
-
setUploadFlow(true);
|
|
400
|
-
setAiOpen(true);
|
|
401
|
-
});
|
|
402
|
-
}}
|
|
403
|
-
/>
|
|
404
|
-
</View>
|
|
405
|
-
<Table
|
|
406
|
-
columns={DOC_COLUMNS}
|
|
407
|
-
leading={24}
|
|
408
|
-
trailing={44}
|
|
409
|
-
sort={docSort}
|
|
410
|
-
onSort={(key) => setDocSort(cycleSort(docSort, key))}
|
|
411
|
-
selectAll={
|
|
412
|
-
<CheckboxInput
|
|
413
|
-
accessibilityLabel="Select all documents"
|
|
414
|
-
checked={sel.allSelected(visibleFiles.map((f) => f.id))}
|
|
415
|
-
indeterminate={sel.indeterminate(visibleFiles.map((f) => f.id))}
|
|
416
|
-
onChange={(on) => sel.setAll(visibleFiles.map((f) => f.id), on)}
|
|
417
|
-
/>
|
|
418
|
-
}
|
|
419
|
-
>
|
|
420
|
-
{visibleFiles.map((f, fi) => (
|
|
421
|
-
<TableRow
|
|
422
|
-
key={f.id}
|
|
423
|
-
onPress={() => openPreview(visibleFiles, fi)}
|
|
424
|
-
marked={sel.has(f.id)}
|
|
425
|
-
accessibilityLabel={`Open ${f.name}`}
|
|
426
|
-
leading={<CheckboxInput accessibilityLabel={`Select ${f.name}`} checked={sel.has(f.id)} onChange={(on) => sel.toggle(f.id, on)} />}
|
|
427
|
-
trailing={<ActionMenu items={fileMenuFor(f)} accessibilityLabel={`Actions for ${f.name}`} />}
|
|
428
|
-
>
|
|
429
|
-
<TableCell>
|
|
430
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
431
|
-
{/* ONE square 32px slot for every type: an image fills it as a
|
|
432
|
-
real thumbnail, a document centers its badge in it */}
|
|
433
|
-
<FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
|
|
434
|
-
<Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
|
|
435
|
-
</View>
|
|
436
|
-
</TableCell>
|
|
437
|
-
<TableCell>
|
|
438
|
-
<Text size="sm" tabular>{fmtSize(f.sizeKB)}</Text>
|
|
439
|
-
</TableCell>
|
|
440
|
-
<TableCell>
|
|
441
|
-
<Text size="sm" tabular>{f.added}</Text>
|
|
442
|
-
</TableCell>
|
|
443
|
-
</TableRow>
|
|
444
|
-
))}
|
|
445
|
-
</Table>
|
|
446
|
-
</Section>
|
|
447
|
-
</View>
|
|
448
|
-
</ScrollView>
|
|
449
|
-
|
|
450
|
-
<FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
|
|
451
|
-
<Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
|
|
452
|
-
<Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
|
|
453
|
-
<Button title="Use AI" color="primary" onPress={openAi} />
|
|
454
|
-
</FloatingActionBar>
|
|
455
|
-
|
|
456
|
-
{/* Use AI — fork → running → review → done, all inside one dialog. The review
|
|
457
|
-
provider wraps the WHOLE dialog so the `Change`s (scroll area) and the
|
|
458
|
-
commit bar (`DialogFooter`) share one review context. */}
|
|
459
|
-
<ChangeReview>
|
|
460
|
-
<Dialog open={aiOpen} onOpenChange={(o) => { if (!o) closeAi(); }} maxWidth={620}>
|
|
461
|
-
<DialogHeader>
|
|
462
|
-
<DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI · ${picked.length} ${picked.length === 1 ? "file" : "files"}`}</DialogHeaderTitle>
|
|
463
|
-
</DialogHeader>
|
|
464
|
-
<DialogScrollArea>
|
|
465
|
-
{phase === "fork" ? (
|
|
466
|
-
<View style={{ gap: 16 }}>
|
|
467
|
-
<View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
|
|
468
|
-
<View style={{ gap: 8 }}>
|
|
469
|
-
<CardSelectItem accessibilityLabel="Extract data — read the documents and fill the record" onPress={() => setTaskChoice("extract")} selected={taskChoice === "extract"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
470
|
-
<Icon name="scan" size={18} color={colors.zinc[700]} />
|
|
471
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
472
|
-
<Text size="sm" weight="semibold">Extract data</Text>
|
|
473
|
-
<Text size="xs" color="muted">Read the documents and fill the record — what's new, what changes, what conflicts.</Text>
|
|
474
|
-
</View>
|
|
475
|
-
</CardSelectItem>
|
|
476
|
-
<CardSelectItem accessibilityLabel="Cross-check — compare the documents against the record" onPress={() => setTaskChoice("check")} selected={taskChoice === "check"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
477
|
-
<Icon name="list-checks" size={18} color={colors.zinc[700]} />
|
|
478
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
479
|
-
<Text size="sm" weight="semibold">Cross-check</Text>
|
|
480
|
-
<Text size="xs" color="muted">Compare the documents against the record and each other — what disagrees and why.</Text>
|
|
481
|
-
</View>
|
|
482
|
-
</CardSelectItem>
|
|
483
|
-
<CardSelectItem accessibilityLabel="Edit with AI — open the chat agent with these documents" onPress={() => setTaskChoice("edit")} selected={taskChoice === "edit"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
484
|
-
<Icon name="square-pen" size={18} color={colors.zinc[700]} />
|
|
485
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
486
|
-
<Text size="sm" weight="semibold">Edit with AI</Text>
|
|
487
|
-
<Text size="xs" color="muted">Open the chat with the documents attached — describe the change, the agent edits them as new versions.</Text>
|
|
488
|
-
</View>
|
|
489
|
-
</CardSelectItem>
|
|
490
|
-
</View>
|
|
491
|
-
{/* Optional steering for the CHECK — findings serve ANY file-based
|
|
492
|
-
request the user briefs, not just the stock cross-check. */}
|
|
493
|
-
{taskChoice === "check" ? (
|
|
494
|
-
<FormTextInput
|
|
495
|
-
label="Instructions (optional)"
|
|
496
|
-
placeholder="Anything specific to check — e.g. verify the commodity codes against the order"
|
|
497
|
-
value={brief}
|
|
498
|
-
onChangeText={setBrief}
|
|
499
|
-
multiline
|
|
500
|
-
accessibilityLabel="Instructions for the agent"
|
|
501
|
-
/>
|
|
502
|
-
) : null}
|
|
503
|
-
</View>
|
|
504
|
-
) : null}
|
|
505
|
-
|
|
506
|
-
{phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
|
|
507
|
-
|
|
508
|
-
{phase === "review" && task === "extract" ? (
|
|
509
|
-
<View style={{ gap: 16 }}>
|
|
510
|
-
<View style={{ gap: 8 }}>
|
|
511
|
-
<Text size="md" weight="semibold">Files read</Text>
|
|
512
|
-
<View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
|
|
513
|
-
</View>
|
|
514
|
-
<View style={{ gap: 8 }}>
|
|
515
|
-
<ChangeReviewHeader />
|
|
516
|
-
{/* ONE section for the RECORD — ChangeFields stacks a ChangeField
|
|
517
|
-
per proposed value: label · the − band when replacing · the
|
|
518
|
-
editable + value. Editing IS the review; each field decides
|
|
519
|
-
for ITSELF (Keep/Drop), and Apply commits the kept rows. */}
|
|
520
|
-
<Change id="order">
|
|
521
|
-
<ChangeReasoning>{EXTRACT_REASONING}</ChangeReasoning>
|
|
522
|
-
<ChangeFields>
|
|
523
|
-
{/* An ADD — the record holds nothing yet, so no `before`. */}
|
|
524
|
-
<ChangeField label="Carrier reference" value={carrierRef} summary={carrierRef} {...fieldRow("carrier_ref")}>
|
|
525
|
-
<ChangeValueInput value={carrierRef} onChangeText={setCarrierRef} accessibilityLabel="Carrier reference" />
|
|
526
|
-
</ChangeField>
|
|
527
|
-
{/* An UPDATE — the current value banded above the editor. */}
|
|
528
|
-
<ChangeField label="Vessel" value={vessel} summary={vessel} before={VESSEL_CURRENT} {...fieldRow("vessel")}>
|
|
529
|
-
<ChangeValueInput value={vessel} onChangeText={setVessel} accessibilityLabel="Vessel" />
|
|
530
|
-
</ChangeField>
|
|
531
|
-
{/* A CONFLICT — the sources disagree: the read-only outcome
|
|
532
|
-
band stays on its placeholder until the user picks a
|
|
533
|
-
candidate below or types a third value — never a
|
|
534
|
-
pre-selection; Keep is gated until resolved. */}
|
|
535
|
-
<ChangeField
|
|
536
|
-
label="Consignee"
|
|
537
|
-
value={consignee ?? ""}
|
|
538
|
-
summary={consignee ?? undefined}
|
|
539
|
-
before={CONSIGNEE_CURRENT}
|
|
540
|
-
valueReadOnly
|
|
541
|
-
placeholder="Pick a candidate below"
|
|
542
|
-
reasoning="The invoice and the packing list disagree — pick a candidate or type your own."
|
|
543
|
-
candidates={CONSIGNEE_OPTIONS.map((c, i2) => ({ value: c.value, source: c.source, selected: consigneePick === i2 }))}
|
|
544
|
-
onPickCandidate={(c) => { const i2 = CONSIGNEE_OPTIONS.findIndex((x) => x.value === c.value); setConsigneePick(i2); setConsignee(c.value); }}
|
|
545
|
-
customValue={customConsignee}
|
|
546
|
-
customSelected={consigneePick === "custom"}
|
|
547
|
-
onCustomSelect={() => { setConsigneePick("custom"); setConsignee(customConsignee || null); }}
|
|
548
|
-
onCustomValue={(v) => { setCustomConsignee(v); setConsignee(v || null); }}
|
|
549
|
-
keepDisabled={consignee == null}
|
|
550
|
-
{...fieldRow("consignee")}
|
|
551
|
-
/>
|
|
552
|
-
{/* An ADD-only field — new information the documents carry:
|
|
553
|
-
the + band alone, same grammar as every diff. */}
|
|
554
|
-
<ChangeField label="Gross weight" value={grossWeight} summary={grossWeight} {...fieldRow("gross")}>
|
|
555
|
-
<ChangeValueInput value={grossWeight} onChangeText={setGrossWeight} unit="kg" accessibilityLabel="Gross weight" />
|
|
556
|
-
</ChangeField>
|
|
557
|
-
{/* A REMOVAL — the − band alone (empty value, no editor): the
|
|
558
|
-
documents show this value no longer applies. */}
|
|
559
|
-
<ChangeField
|
|
560
|
-
label="Notify party"
|
|
561
|
-
before={NOTIFY_CURRENT}
|
|
562
|
-
reasoning="No notify party appears on any document — the consignee is notified directly."
|
|
563
|
-
{...fieldRow("notify")}
|
|
564
|
-
/>
|
|
565
|
-
</ChangeFields>
|
|
566
|
-
</Change>
|
|
567
|
-
{/* RECORD ops — the packing list also proposes ORDER LINES:
|
|
568
|
-
a ChangeRecord card per item (add = one card decision;
|
|
569
|
-
edit = its changed fields decide themselves). A divider +
|
|
570
|
-
breathing room set the sub-section off from the fields. */}
|
|
571
|
-
<View style={{ paddingTop: 10 }}>
|
|
572
|
-
<Divider />
|
|
573
|
-
</View>
|
|
574
|
-
<ChangeReviewHeader title="Order lines" />
|
|
575
|
-
<ChangeRecord
|
|
576
|
-
id="line-add"
|
|
577
|
-
tone="add"
|
|
578
|
-
title="New line"
|
|
579
|
-
status={lineAdd}
|
|
580
|
-
onAccept={() => setLineAdd("accepted")}
|
|
581
|
-
onReject={() => setLineAdd("rejected")}
|
|
582
|
-
onUndo={() => setLineAdd("pending")}
|
|
583
|
-
summary={`${newItem} (${newQty} pcs)`}
|
|
584
|
-
>
|
|
585
|
-
<ChangeField label="Item" value={newItem} summary={newItem}>
|
|
586
|
-
<ChangeValueInput value={newItem} onChangeText={setNewItem} accessibilityLabel="Item" />
|
|
587
|
-
</ChangeField>
|
|
588
|
-
<ChangeField label="Quantity" value={newQty} summary={`${newQty} pcs`}>
|
|
589
|
-
<ChangeValueInput value={newQty} onChangeText={setNewQty} unit="pcs" accessibilityLabel="Quantity" />
|
|
590
|
-
</ChangeField>
|
|
591
|
-
</ChangeRecord>
|
|
592
|
-
<ChangeRecord id="line-edit" tone="edit" title="Flat-pack cartons — existing line" summary={`Flat-pack cartons — ${editedQty} pcs`}>
|
|
593
|
-
<ChangeField label="Quantity" before="1,200 pcs" value={editedQty} summary={`${editedQty} pcs`} {...lineEditRow("qty")}>
|
|
594
|
-
<ChangeValueInput value={editedQty} onChangeText={setEditedQty} unit="pcs" accessibilityLabel="Line quantity" />
|
|
595
|
-
</ChangeField>
|
|
596
|
-
</ChangeRecord>
|
|
597
|
-
</View>
|
|
598
|
-
</View>
|
|
599
|
-
) : null}
|
|
600
|
-
|
|
601
|
-
{phase === "review" && task === "check" ? (
|
|
602
|
-
<View style={{ gap: 16 }}>
|
|
603
|
-
<View style={{ gap: 8 }}>
|
|
604
|
-
<Text size="md" weight="semibold">Documents checked</Text>
|
|
605
|
-
<View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
|
|
606
|
-
</View>
|
|
607
|
-
<View style={{ gap: 14 }}>
|
|
608
|
-
<ChangeReviewHeader title="Findings" />
|
|
609
|
-
{/* Display-only: findings inform the verdict the footer records.
|
|
610
|
-
The kit `Finding` owns severity word · title · detail · the
|
|
611
|
-
PROMINENT metric · Sources; hairlines separate them. */}
|
|
612
|
-
{FINDINGS.map((c, i) => (
|
|
613
|
-
<Fragment key={c.id}>
|
|
614
|
-
{i > 0 ? <Divider /> : null}
|
|
615
|
-
<Finding
|
|
616
|
-
severity={c.severity}
|
|
617
|
-
title={c.title}
|
|
618
|
-
detail={c.detail}
|
|
619
|
-
sources={(c.sources ?? []).map((name): SourceRef => ({ id: name, label: name, kind: "document" }))}
|
|
620
|
-
onOpenSource={() => { /* preview stub */ }}
|
|
621
|
-
>
|
|
622
|
-
{c.comparison ? <FindingComparison values={c.comparison.values} delta={c.comparison.delta} /> : null}
|
|
623
|
-
</Finding>
|
|
624
|
-
</Fragment>
|
|
625
|
-
))}
|
|
626
|
-
</View>
|
|
627
|
-
</View>
|
|
628
|
-
) : null}
|
|
629
|
-
|
|
630
|
-
{phase === "done" ? (
|
|
631
|
-
<CompletionState title={`${applyCount} ${applyCount === 1 ? "change" : "changes"} applied to PO-2481`} summary="The files stay on the record — each updated field keeps its source document." />
|
|
632
|
-
) : null}
|
|
633
|
-
</DialogScrollArea>
|
|
634
|
-
{phase === "fork" ? (
|
|
635
|
-
<DialogFooter>
|
|
636
|
-
{uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
|
|
637
|
-
<Button
|
|
638
|
-
title={taskChoice === "extract" ? "Extract data" : taskChoice === "check" ? "Run cross-check" : taskChoice === "edit" ? "Open chat" : "Run"}
|
|
639
|
-
color="primary"
|
|
640
|
-
disabled={taskChoice == null}
|
|
641
|
-
onPress={() => {
|
|
642
|
-
if (!taskChoice) return;
|
|
643
|
-
if (uploadFlow) commitUpload();
|
|
644
|
-
if (taskChoice === "edit") {
|
|
645
|
-
// The app→chat handoff — a real app calls the SDK and closes:
|
|
646
|
-
// void askAi({
|
|
647
|
-
// file_ids: picked.map((f) => f.id),
|
|
648
|
-
// record_ids: [orderRecordId],
|
|
649
|
-
// prompt: "Update these documents — ",
|
|
650
|
-
// });
|
|
651
|
-
// The messenger opens a fresh chat with the files attached (a
|
|
652
|
-
// single file also opens previewed beside it); the prompt is
|
|
653
|
-
// prefilled, editable, never auto-sent.
|
|
654
|
-
editWithAi(picked);
|
|
655
|
-
closeAi();
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
startTask(taskChoice);
|
|
659
|
-
}}
|
|
660
|
-
/>
|
|
661
|
-
</DialogFooter>
|
|
662
|
-
) : phase === "review" && task === "extract" ? (
|
|
663
|
-
<DialogFooter>
|
|
664
|
-
{/* N = the kept fields; the minKept swap disables Apply exactly at
|
|
665
|
-
N = 0 (the registry can't see field-level decisions, so the host
|
|
666
|
-
gates — and hands Keep-all its own handler). */}
|
|
667
|
-
<ChangeReviewActions onAcceptAll={keepAll} onDiscard={closeAi} discardLabel="Cancel" onApply={() => setPhase("done")} applyLabel={`Update record (${applyCount})`} minKept={0} applyDisabled={applyCount === 0} />
|
|
668
|
-
</DialogFooter>
|
|
669
|
-
) : phase === "review" && task === "check" ? (
|
|
670
|
-
<DialogFooter>
|
|
671
|
-
{/* The findings ARE the outcome — the human reads them and goes to
|
|
672
|
-
act (Extract, a manual fix). No phantom "record verdict" write:
|
|
673
|
-
a persisted check-status would go stale on the next edit. */}
|
|
674
|
-
<Button title="Done" color="primary" onPress={() => { sel.clear(); closeAi(); }} />
|
|
675
|
-
</DialogFooter>
|
|
676
|
-
) : phase === "done" ? (
|
|
677
|
-
<DialogFooter>
|
|
678
|
-
<Button title="Done" color="primary" onPress={() => { sel.clear(); closeAi(); }} />
|
|
679
|
-
</DialogFooter>
|
|
680
|
-
) : null}
|
|
681
|
-
</Dialog>
|
|
682
|
-
</ChangeReview>
|
|
683
|
-
|
|
684
|
-
{/* Create documents — pick from the readiness checklist, generate, land back on the record. */}
|
|
685
|
-
<Dialog open={genOpen} onOpenChange={(o) => { if (!o) closeGen(); }} maxWidth={560}>
|
|
686
|
-
<DialogHeader><DialogHeaderTitle>{fillTarget ? `${fillTarget.label} — missing fields` : "Create documents"}</DialogHeaderTitle></DialogHeader>
|
|
687
|
-
<DialogScrollArea>
|
|
688
|
-
{fillTarget ? (
|
|
689
|
-
<View style={{ gap: 14 }}>
|
|
690
|
-
<Text size="sm" color="muted">These values save onto the order and unlock the document.</Text>
|
|
691
|
-
{(fillTarget.needs ?? []).map((n) => (
|
|
692
|
-
<FormTextInput
|
|
693
|
-
key={n.key}
|
|
694
|
-
label={n.label}
|
|
695
|
-
value={fillDraft[n.key] ?? ""}
|
|
696
|
-
onChangeText={(t) => setFillDraft((d) => ({ ...d, [n.key]: t }))}
|
|
697
|
-
accessibilityLabel={n.label}
|
|
698
|
-
/>
|
|
699
|
-
))}
|
|
700
|
-
</View>
|
|
701
|
-
) : genRunning ? (
|
|
702
|
-
<AgentRun
|
|
703
|
-
parts={genSteps.slice(0, Math.min(genRevealed + 1, genSteps.length)).map((st, i): Part =>
|
|
704
|
-
i < genRevealed
|
|
705
|
-
? { type: "dynamic-tool", toolName: st.label, toolCallId: st.id, state: "output-available", input: undefined, output: undefined }
|
|
706
|
-
: { type: "dynamic-tool", toolName: st.label, toolCallId: st.id, state: "input-available", input: undefined },
|
|
707
|
-
)}
|
|
708
|
-
state={genRevealed >= genSteps.length ? "done" : "streaming"}
|
|
709
|
-
/>
|
|
710
|
-
) : !genDone ? (
|
|
711
|
-
<View>
|
|
712
|
-
{PRODUCIBLE.map((p, i) => (
|
|
713
|
-
<Fragment key={p.id}>
|
|
714
|
-
{i > 0 ? <Divider /> : null}
|
|
715
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10, minHeight: 44 }}>
|
|
716
|
-
<CheckboxInput accessibilityLabel={`Create ${p.label}`} checked={gen.has(p.id)} onChange={(on) => gen.toggle(p.id, on)} />
|
|
717
|
-
<View style={{ flex: 1, gap: 1 }}>
|
|
718
|
-
<Text size="sm" weight="medium">{p.label}</Text>
|
|
719
|
-
{missingOf(p).length > 0 ? (
|
|
720
|
-
<Text size="xs" color="warning">{`Missing: ${missingOf(p).map((n) => n.label).join(", ")}`}</Text>
|
|
721
|
-
) : (
|
|
722
|
-
<Text size="xs" color="muted">All fields available</Text>
|
|
723
|
-
)}
|
|
724
|
-
</View>
|
|
725
|
-
{missingOf(p).length > 0 ? <Button title="Add missing fields" color="muted" onPress={() => openFill(p)} /> : null}
|
|
726
|
-
</View>
|
|
727
|
-
</Fragment>
|
|
728
|
-
))}
|
|
729
|
-
</View>
|
|
730
|
-
) : (
|
|
731
|
-
/* Review-before-commit, not a celebration: the files ARE the
|
|
732
|
-
message (no count headline), the footer carries the mechanics
|
|
733
|
-
(no instructional filler), and the one fact the rows can't show
|
|
734
|
-
is provenance — the single line this screen keeps. */
|
|
735
|
-
<View style={{ gap: 12 }}>
|
|
736
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
737
|
-
<Icon name="circle-check" size={16} color={colors.emerald[600]} />
|
|
738
|
-
<Text size="sm" weight="medium">Created from the order's data</Text>
|
|
739
|
-
</View>
|
|
740
|
-
<View style={{ gap: 4 }}>
|
|
741
|
-
{genPending.map((g, gi) => (
|
|
742
|
-
<FileRow
|
|
743
|
-
key={g.id}
|
|
744
|
-
name={g.name}
|
|
745
|
-
meta={`${g.kind} · ${fmtSize(g.sizeKB)}`}
|
|
746
|
-
mimeType={g.mimeType}
|
|
747
|
-
onPress={() => openPreview(genPending, gi)}
|
|
748
|
-
trailing={
|
|
749
|
-
<Button
|
|
750
|
-
title="Download"
|
|
751
|
-
color="secondary"
|
|
752
|
-
icon="download"
|
|
753
|
-
accessibilityLabel={`Download ${g.name}`}
|
|
754
|
-
onPress={() => { /* a real app: openExternal(g.url) */ }}
|
|
755
|
-
/>
|
|
756
|
-
}
|
|
757
|
-
/>
|
|
758
|
-
))}
|
|
759
|
-
</View>
|
|
760
|
-
</View>
|
|
761
|
-
)}
|
|
762
|
-
</DialogScrollArea>
|
|
763
|
-
<DialogFooter>
|
|
764
|
-
{fillTarget ? (
|
|
765
|
-
<>
|
|
766
|
-
<Button title="Back" color="muted" onPress={() => setFillTarget(null)} />
|
|
767
|
-
<Button
|
|
768
|
-
title="Save fields"
|
|
769
|
-
color="primary"
|
|
770
|
-
disabled={(fillTarget.needs ?? []).some((n) => !fillDraft[n.key]?.trim())}
|
|
771
|
-
onPress={saveFill}
|
|
772
|
-
/>
|
|
773
|
-
</>
|
|
774
|
-
) : genRunning ? null : !genDone ? (
|
|
775
|
-
<>
|
|
776
|
-
<Button title="Cancel" color="muted" onPress={closeGen} />
|
|
777
|
-
<Button title={`Generate ${gen.count} ${gen.count === 1 ? "document" : "documents"}`} color="primary" disabled={gen.count === 0} onPress={generate} />
|
|
778
|
-
</>
|
|
779
|
-
) : (
|
|
780
|
-
<>
|
|
781
|
-
<Button title="Discard" color="muted" onPress={closeGen} />
|
|
782
|
-
<Button title={`Add to record (${genPending.length})`} color="primary" onPress={addGeneratedToRecord} />
|
|
783
|
-
</>
|
|
784
|
-
)}
|
|
785
|
-
</DialogFooter>
|
|
786
|
-
</Dialog>
|
|
787
|
-
{/* Rename — small focused dialog; Save disabled while empty. */}
|
|
788
|
-
<Dialog open={renameTarget != null} onOpenChange={(o) => { if (!o) setRenameTarget(null); }} maxWidth={420}>
|
|
789
|
-
<DialogHeader><DialogHeaderTitle>Rename file</DialogHeaderTitle></DialogHeader>
|
|
790
|
-
<DialogScrollArea>
|
|
791
|
-
<FormTextInput label="Filename" value={renameDraft} onChangeText={setRenameDraft} accessibilityLabel="Filename" />
|
|
792
|
-
</DialogScrollArea>
|
|
793
|
-
<DialogFooter>
|
|
794
|
-
<Button title="Cancel" color="muted" onPress={() => setRenameTarget(null)} />
|
|
795
|
-
<Button title="Save" color="primary" disabled={renameDraft.trim() === ""} onPress={saveRename} />
|
|
796
|
-
</DialogFooter>
|
|
797
|
-
</Dialog>
|
|
798
|
-
{/* Mounted only while open — its per-file hooks want a stable list. */}
|
|
799
|
-
{preview ? (
|
|
800
|
-
<FileGalleryModal
|
|
801
|
-
files={preview.files}
|
|
802
|
-
activeIndex={preview.index}
|
|
803
|
-
onIndexChange={(i) => setPreview((p2) => (i == null || !p2 ? null : { ...p2, index: i }))}
|
|
804
|
-
/>
|
|
805
|
-
) : null}
|
|
806
|
-
</>
|
|
807
|
-
);
|
|
808
|
-
}
|