@lotics/ui 28.3.1 → 29.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 +12 -1
- package/MIGRATION.md +26 -0
- package/docs/ai_patterns.md +158 -111
- package/docs/catalog.md +100 -25
- package/docs/data_entry.md +13 -0
- package/docs/templates.md +31 -12
- package/examples/tpl_item_list.tsx +201 -28
- package/examples/tpl_record.tsx +390 -123
- package/examples/tpl_task_board.tsx +45 -29
- package/package.json +7 -3
- package/src/agent_run.tsx +1 -1
- package/src/clarify_wizard.tsx +1 -1
- package/src/diff_mark.tsx +110 -0
- package/src/diff_value.tsx +232 -0
- package/src/file_badge.tsx +2 -0
- package/src/file_intake.ts +17 -2
- package/src/file_row.tsx +58 -6
- package/src/file_thumbnail.tsx +113 -30
- package/src/file_thumbnail_grid.tsx +13 -1
- package/src/finding.tsx +2 -3
- package/src/inline_slot.tsx +58 -0
- package/src/inline_static.tsx +5 -23
- package/src/locale.tsx +18 -46
- package/src/mime.ts +76 -0
- package/src/result_header.tsx +1 -1
- package/src/use_change_set.ts +115 -0
- package/src/change_review.tsx +0 -800
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
@@ -48,6 +48,7 @@ import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
|
48
48
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
49
49
|
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
50
50
|
import { FileRow } from "@lotics/ui/file_row";
|
|
51
|
+
import { InlineSlot } from "@lotics/ui/inline_slot";
|
|
51
52
|
import { pickFiles } from "@lotics/ui/file_picker";
|
|
52
53
|
import { FileDropTarget } from "@lotics/ui/file_drop_target";
|
|
53
54
|
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
@@ -66,7 +67,9 @@ import { AgentRun } from "@lotics/ui/agent_run";
|
|
|
66
67
|
import { FollowScroll } from "@lotics/ui/follow_scroll";
|
|
67
68
|
import { useContainerSize } from "@lotics/ui/size_boundary";
|
|
68
69
|
import { type SourceRef } from "@lotics/ui/sources";
|
|
69
|
-
import {
|
|
70
|
+
import { DiffValue } from "@lotics/ui/diff_value";
|
|
71
|
+
import { DiffMark } from "@lotics/ui/diff_mark";
|
|
72
|
+
import { useChangeSet, type ChangeSet } from "@lotics/ui/use_change_set";
|
|
70
73
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
71
74
|
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
72
75
|
|
|
@@ -435,9 +438,9 @@ const CHECK_STEPS: ScriptStep[] = [
|
|
|
435
438
|
];
|
|
436
439
|
|
|
437
440
|
// The shapes of an extract decision — an ADD, an UPDATE, a REMOVAL, a source
|
|
438
|
-
// CONFLICT — are the SAME `
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
+
// CONFLICT — are the SAME `DiffValue`: an add omits `before`, an update passes
|
|
442
|
+
// both, a removal omits `after` (the struck value IS the change), and a
|
|
443
|
+
// conflict sits on its placeholder over the candidate rows until one is picked.
|
|
441
444
|
const CARRIER_REF_PROPOSED = "MAEU129394855";
|
|
442
445
|
const VESSEL_CURRENT = "MSC AURA";
|
|
443
446
|
const VESSEL_PROPOSED = "MAERSK SALINA";
|
|
@@ -448,7 +451,177 @@ const CONSIGNEE_OPTIONS = [
|
|
|
448
451
|
{ value: "Nordic Furniture AB, Jönköping DC", source: "invoice.pdf", recommended: true },
|
|
449
452
|
{ value: "NF Distribution ApS, Kolding", source: "packing-list.pdf" },
|
|
450
453
|
];
|
|
454
|
+
/**
|
|
455
|
+
* A field carrying its own verdict. Deliberately LOCAL to this template rather
|
|
456
|
+
* than a kit component: the row is layout, and the kit's own rule is that an
|
|
457
|
+
* extraction must encode a contract or a behaviour, never layout convenience.
|
|
458
|
+
* `dev/pages/ai.tsx` has its own twenty-line version that reads differently, and
|
|
459
|
+
* that is the system working — one atom, two shapes.
|
|
460
|
+
*/
|
|
461
|
+
function ReviewField<Id extends string>({ id, label, review, kind, why, keepDisabled, children }: {
|
|
462
|
+
id: Id;
|
|
463
|
+
label: string;
|
|
464
|
+
review: ChangeSet<Id>;
|
|
465
|
+
/**
|
|
466
|
+
* WHAT this row does to the record, when it does anything.
|
|
467
|
+
*
|
|
468
|
+
* The mark rides the LABEL, not the value, and that is an alignment decision
|
|
469
|
+
* before it is a semantic one. Beside the value it indents every row it marks
|
|
470
|
+
* by its own width plus a gap, so an unmarked row needs a spacer of exactly
|
|
471
|
+
* the glyph's width to keep up and the two drift. The label column is a FIXED
|
|
472
|
+
* width with one left edge, so a mark placed there aligns down the page for
|
|
473
|
+
* free and the value column keeps the single grid `DetailRow` maintains.
|
|
474
|
+
*/
|
|
475
|
+
kind?: "added" | "changed" | "removed";
|
|
476
|
+
why?: string;
|
|
477
|
+
keepDisabled?: boolean;
|
|
478
|
+
children: ReactNode;
|
|
479
|
+
}) {
|
|
480
|
+
const status = review.status(id);
|
|
481
|
+
const dropped = status === "rejected";
|
|
482
|
+
|
|
483
|
+
// ONE slot, fixed width AND height, mark or no mark. The height matters as
|
|
484
|
+
// much as the width: this is an inline-flex box inside `DetailRow`'s label
|
|
485
|
+
// Text, so an empty one baselines differently from one holding a glyph, and
|
|
486
|
+
// the unmarked rows sit several pixels off their own labels for no reason a
|
|
487
|
+
// reader could ever guess at.
|
|
488
|
+
const labelNode = (
|
|
489
|
+
<View style={{ // TOP-aligned, and the slot is sized to the label's own LINE — not
|
|
490
|
+
// centred on the label BLOCK. Centring is identical while the label
|
|
491
|
+
// fits on one line and wrong the moment it wraps: a two-line label is
|
|
492
|
+
// 40px, so the mark drifted to the middle of the pair (+9px, against −1
|
|
493
|
+
// on its single-line neighbours) and the column of marks stopped being a
|
|
494
|
+
// column. `DetailRow` solves this for its own label the same way; moving
|
|
495
|
+
// the mark up here without carrying the rule over is what reopened it.
|
|
496
|
+
flexDirection: "row", alignItems: "flex-start", gap: 8, opacity: dropped ? 0.5 : 1 }}>
|
|
497
|
+
<View style={{ width: 22, height: 20, alignItems: "center", justifyContent: "center" }}>
|
|
498
|
+
{kind === undefined ? null : <DiffMark kind={kind} />}
|
|
499
|
+
</View>
|
|
500
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
|
|
501
|
+
</View>
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
if (status !== "pending") {
|
|
505
|
+
const kept = status === "accepted";
|
|
506
|
+
return (
|
|
507
|
+
<DetailRow label={labelNode}>
|
|
508
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
509
|
+
<View style={{ flex: 1, opacity: kept ? 1 : 0.5 }}>{children}</View>
|
|
510
|
+
<Button title="Undo" color="muted" onPress={() => review.undo(id)} />
|
|
511
|
+
</View>
|
|
512
|
+
</DetailRow>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
return (
|
|
516
|
+
// The reason renders INSIDE the value block, above the verdict — not via
|
|
517
|
+
// `DetailRow`'s `description`, which lands under the whole cell and
|
|
518
|
+
// therefore under the buttons. A justification printed after the two
|
|
519
|
+
// controls that act on it is a justification nobody reads: the reader has
|
|
520
|
+
// already decided by the time they reach it.
|
|
521
|
+
<DetailRow label={labelNode}>
|
|
522
|
+
<View style={{ gap: 6 }}>
|
|
523
|
+
{children}
|
|
524
|
+
{why == null ? null : <Text size="xs" color="muted">{why}</Text>}
|
|
525
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 8 }}>
|
|
526
|
+
<Button title="Drop" color="muted" onPress={() => review.reject(id)} />
|
|
527
|
+
<Button title="Keep" color="secondary" disabled={keepDisabled} onPress={() => review.accept(id)} />
|
|
528
|
+
</View>
|
|
529
|
+
</View>
|
|
530
|
+
</DetailRow>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Keep / Drop for a whole proposed line, collapsing to an Undo once decided. */
|
|
535
|
+
function LineVerdict<Id extends string>({ id, review, label }: { id: Id; review: ChangeSet<Id>; label: string }) {
|
|
536
|
+
if (review.status(id) !== "pending") {
|
|
537
|
+
return <Button title="Undo" color="muted" onPress={() => review.undo(id)} />;
|
|
538
|
+
}
|
|
539
|
+
return (
|
|
540
|
+
<View style={{ flexDirection: "row", gap: 4 }}>
|
|
541
|
+
<IconButton icon="x" accessibilityLabel={`Drop ${label}`} onPress={() => review.reject(id)} />
|
|
542
|
+
<IconButton icon="check" accessibilityLabel={`Keep ${label}`} onPress={() => review.accept(id)} />
|
|
543
|
+
</View>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
451
547
|
const EXTRACT_FIELD_IDS = ["carrier_ref", "vessel", "consignee", "gross", "notify"] as const;
|
|
548
|
+
/**
|
|
549
|
+
* The proposed order lines, each its own verdict.
|
|
550
|
+
*
|
|
551
|
+
* Two of them change a VALUE and two change an ATTACHMENT, which is the case a
|
|
552
|
+
* review surface usually forgets: dropping a supplier invoice on a record does
|
|
553
|
+
* not edit a number, it decides WHICH LINE the document belongs to, and that
|
|
554
|
+
* decision is exactly as wrong-able as a misread figure. It gets the same
|
|
555
|
+
* treatment — shown before it applies, on the line it lands on, with whatever
|
|
556
|
+
* it displaces still visible.
|
|
557
|
+
*/
|
|
558
|
+
const LINE_IDS = ["line-add", "line-edit-qty", "line-doc", "line-doc-swap"] as const;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* What the run proposes for the record's FILES section — the third diff shape,
|
|
562
|
+
* after the field form and the line table.
|
|
563
|
+
*
|
|
564
|
+
* A files section is not a field: it is a SET, so what changes is MEMBERSHIP
|
|
565
|
+
* plus per-document properties. `added` and `removed` are membership and the
|
|
566
|
+
* row's own mark carries them. `changed` splits again — a document can be
|
|
567
|
+
* SUPERSEDED (new bytes in the same slot) or RECLASSIFIED (the same bytes filed
|
|
568
|
+
* differently), and only the second has a value to diff. Rendering a mark with
|
|
569
|
+
* no visible diff on a reclassify would say the file itself changed.
|
|
570
|
+
*/
|
|
571
|
+
const DOC_IDS = ["doc-inv", "doc-bol", "doc-coo", "doc-dup"] as const;
|
|
572
|
+
|
|
573
|
+
type ProposedDoc = {
|
|
574
|
+
id: (typeof DOC_IDS)[number];
|
|
575
|
+
kind: "added" | "changed" | "removed";
|
|
576
|
+
name: string;
|
|
577
|
+
mimeType: string;
|
|
578
|
+
note?: string;
|
|
579
|
+
supersedes?: string;
|
|
580
|
+
filedAs?: { before: string; after: string };
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const PROPOSED_DOCS: ProposedDoc[] = [
|
|
584
|
+
{ id: "doc-inv", kind: "added", name: "invoice-8841.pdf", mimeType: "application/pdf", note: "Split from pages 1-2 of the scanned bundle" },
|
|
585
|
+
{ id: "doc-bol", kind: "changed", name: "bill-of-lading.pdf", mimeType: "application/pdf", supersedes: "scan-0042.jpg" },
|
|
586
|
+
{ id: "doc-coo", kind: "changed", name: "cert-origin.pdf", mimeType: "application/pdf", filedAs: { before: "Other", after: "Certificate of origin" } },
|
|
587
|
+
{ id: "doc-dup", kind: "removed", name: "photos.zip", mimeType: "application/zip", note: "Already on the record under the same name" },
|
|
588
|
+
];
|
|
589
|
+
|
|
590
|
+
/** The document already filed against a line, and the one being proposed. */
|
|
591
|
+
type LineDoc = { name: string; mimeType: string };
|
|
592
|
+
const PDF = "application/pdf";
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A file rendered as a table VALUE — the filename at the size every other cell
|
|
596
|
+
* uses.
|
|
597
|
+
*
|
|
598
|
+
* It carried a `FileBadge` at first, which in a cell that size shrank its own
|
|
599
|
+
* label to five pixels — off every rung of the type scale. Making it legible
|
|
600
|
+
* means a 34px box setting the height of a 52px row, for a fact the extension
|
|
601
|
+
* already carries. The discriminator is what the file IS on the surface: the
|
|
602
|
+
* row's SUBJECT (a `FileRow`, a tile) earns a badge; one VALUE among columns is
|
|
603
|
+
* a value, rendered like the quantity beside it.
|
|
604
|
+
*/
|
|
605
|
+
function DocName({ doc }: { doc: LineDoc }) {
|
|
606
|
+
return <Text size="sm" numberOfLines={1}>{doc.name}</Text>;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The UNTOUCHED lines — the reason this table exists in this shape.
|
|
611
|
+
*
|
|
612
|
+
* A fixture of two proposed lines and nothing else flatters the design: every
|
|
613
|
+
* row carries a change, so any mark at all is findable. A real record has a
|
|
614
|
+
* dozen lines and a document touches one or two of them, and the question the
|
|
615
|
+
* operator actually has — WHICH line did this invoice land on — is a search
|
|
616
|
+
* problem. These rows are here to make the review honest, and they are why an
|
|
617
|
+
* untouched row renders NO mark: the eye finds the filled discs among the
|
|
618
|
+
* blanks without reading the column.
|
|
619
|
+
*/
|
|
620
|
+
const UNTOUCHED_LINES: { id: string; item: string; qty: string; doc?: LineDoc }[] = [
|
|
621
|
+
{ id: "l-oak", item: "Kiln-dried oak panels, 18mm", qty: "640 pcs", doc: { name: "packing-list.pdf", mimeType: PDF } },
|
|
622
|
+
{ id: "l-foam", item: "Foam corner guards", qty: "2,400 pcs", doc: { name: "packing-list.pdf", mimeType: PDF } },
|
|
623
|
+
{ id: "l-desiccant", item: "Desiccant sachets, 50g", qty: "900 pcs" },
|
|
624
|
+
];
|
|
452
625
|
|
|
453
626
|
// Cross-check findings — severity reads through ONE colored dot badge (red /
|
|
454
627
|
// amber / zinc), the rest stays calm text.
|
|
@@ -1121,21 +1294,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1121
1294
|
const [vessel, setVessel] = useState(VESSEL_PROPOSED);
|
|
1122
1295
|
const [consignee, setConsignee] = useState<string | null>(null);
|
|
1123
1296
|
const [grossWeight, setGrossWeight] = useState("1,540");
|
|
1124
|
-
// Order lines proposed from the packing list —
|
|
1125
|
-
const [lineAdd, setLineAdd] = useState<ChangeStatus>("pending");
|
|
1126
|
-
const [lineEdit, setLineEdit] = useState<Record<string, "kept" | "dropped">>({});
|
|
1297
|
+
// Order lines proposed from the packing list — one verdict per line.
|
|
1127
1298
|
const [newItem, setNewItem] = useState("Corner protectors, foam");
|
|
1128
1299
|
const [newQty, setNewQty] = useState("400");
|
|
1129
1300
|
const [editedQty, setEditedQty] = useState("1,450");
|
|
1130
|
-
const
|
|
1131
|
-
status: lineEdit[id] ?? ("pending" as const),
|
|
1132
|
-
onKeep: () => setLineEdit((m) => ({ ...m, [id]: "kept" as const })),
|
|
1133
|
-
onDrop: () => setLineEdit((m) => ({ ...m, [id]: "dropped" as const })),
|
|
1134
|
-
onUndo: () => setLineEdit((m) => { const n = { ...m }; delete n[id]; return n; }),
|
|
1135
|
-
});
|
|
1301
|
+
const lines = useChangeSet(LINE_IDS, { initial: "pending" });
|
|
1136
1302
|
const [consigneePick, setConsigneePick] = useState<number | "custom" | null>(null);
|
|
1137
1303
|
const [customConsignee, setCustomConsignee] = useState("");
|
|
1138
|
-
const
|
|
1304
|
+
const fields = useChangeSet(EXTRACT_FIELD_IDS, { initial: "pending" });
|
|
1305
|
+
const docs = useChangeSet(DOC_IDS, { initial: "pending" });
|
|
1139
1306
|
|
|
1140
1307
|
const openAi = () => { setPicked(files.filter((f) => sel.has(f.id))); setUploadFlow(false); setAiOpen(true); };
|
|
1141
1308
|
// THE standard files intake — one handler, three ways in: the Documents
|
|
@@ -1156,7 +1323,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1156
1323
|
const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
|
|
1157
1324
|
const closeAi = () => {
|
|
1158
1325
|
setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
|
|
1159
|
-
setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540");
|
|
1326
|
+
setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); lines.reset(); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); fields.reset();
|
|
1160
1327
|
};
|
|
1161
1328
|
const startTask = (t: Task) => { setTask(t); setRevealed(0); setPhase("running"); };
|
|
1162
1329
|
// ⋯ menu per document row. "Edit with AI" is the app→chat handoff for
|
|
@@ -1206,19 +1373,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1206
1373
|
],
|
|
1207
1374
|
);
|
|
1208
1375
|
};
|
|
1209
|
-
const decideField = (id: string, d: "kept" | "dropped" | undefined) =>
|
|
1210
|
-
setFieldDecisions((m) => {
|
|
1211
|
-
const n = { ...m };
|
|
1212
|
-
if (d) n[id] = d;
|
|
1213
|
-
else delete n[id];
|
|
1214
|
-
return n;
|
|
1215
|
-
});
|
|
1216
|
-
const fieldRow = (id: string) => ({
|
|
1217
|
-
status: fieldDecisions[id] ?? ("pending" as const),
|
|
1218
|
-
onKeep: () => decideField(id, "kept"),
|
|
1219
|
-
onDrop: () => decideField(id, "dropped"),
|
|
1220
|
-
onUndo: () => decideField(id, undefined),
|
|
1221
|
-
});
|
|
1222
1376
|
|
|
1223
1377
|
// The streaming timer idiom — reveal one script step at a time, then settle.
|
|
1224
1378
|
const script = task === "check" ? CHECK_STEPS : EXTRACT_STEPS;
|
|
@@ -1244,21 +1398,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1244
1398
|
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." });
|
|
1245
1399
|
}
|
|
1246
1400
|
|
|
1247
|
-
// Apply counts what actually commits:
|
|
1248
|
-
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1401
|
+
// Apply counts what actually commits: kept fields plus kept lines.
|
|
1402
|
+
// Every set the review renders, or the button claims a smaller write than the
|
|
1403
|
+
// screen shows. The documents block was added after this line existed, which
|
|
1404
|
+
// is exactly how a commit count drifts from its surface.
|
|
1405
|
+
const applyCount = fields.keptCount + lines.keptCount + docs.keptCount;
|
|
1406
|
+
// Keep-all stays the HOST's — only this screen knows that an unresolved
|
|
1407
|
+
// conflict (`consignee == null`) must stay pending rather than be swept in.
|
|
1251
1408
|
const keepAll = () => {
|
|
1252
|
-
setLineAdd((st) => (st === "pending" ? "accepted" : st));
|
|
1253
|
-
setLineEdit((m) => ({ qty: m.qty ?? "kept", ...m }));
|
|
1254
|
-
setFieldDecisions((m) => {
|
|
1255
|
-
const n = { ...m };
|
|
1256
1409
|
for (const f of EXTRACT_FIELD_IDS) {
|
|
1257
1410
|
if (f === "consignee" && consignee == null) continue;
|
|
1258
|
-
if (
|
|
1411
|
+
if (fields.status(f) === "pending") fields.accept(f);
|
|
1259
1412
|
}
|
|
1260
|
-
|
|
1261
|
-
});
|
|
1413
|
+
lines.acceptAll();
|
|
1262
1414
|
};
|
|
1263
1415
|
|
|
1264
1416
|
// ── the classification fields — each one carries the input its shape wants
|
|
@@ -3365,7 +3517,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3365
3517
|
{/* Use AI — fork → running → review → done, all inside one dialog. The review
|
|
3366
3518
|
provider wraps the WHOLE dialog so the `Change`s (scroll area) and the
|
|
3367
3519
|
commit bar (`DialogFooter`) share one review context. */}
|
|
3368
|
-
<ChangeReview>
|
|
3369
3520
|
<Dialog open={aiOpen} onOpenChange={(o) => { if (!o) closeAi(); }} maxWidth={620}>
|
|
3370
3521
|
<DialogHeader>
|
|
3371
3522
|
<DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI (${picked.length} ${picked.length === 1 ? "file" : "files"})`}</DialogHeaderTitle>
|
|
@@ -3429,88 +3580,202 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3429
3580
|
<View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
|
|
3430
3581
|
</View>
|
|
3431
3582
|
<View style={{ gap: 8 }}>
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
<
|
|
3438
|
-
<
|
|
3439
|
-
<
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
<
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
{
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3583
|
+
{/* The heading, its counter and every row below are ordinary
|
|
3584
|
+
components reading ONE `useChangeSet`. `DiffValue` carries each
|
|
3585
|
+
field's mini-diff; the row around it belongs to this template,
|
|
3586
|
+
so a screen that needs a different shape writes a different
|
|
3587
|
+
row instead of bending a container. */}
|
|
3588
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
3589
|
+
<Text size="md" weight="semibold" style={{ flex: 1 }}>Suggested edits</Text>
|
|
3590
|
+
<Text size="xs" color="muted">{`${fields.keptCount} of ${fields.total} kept`}</Text>
|
|
3591
|
+
</View>
|
|
3592
|
+
<Text size="sm" color="muted">{EXTRACT_REASONING}</Text>
|
|
3593
|
+
<DetailTable labelWidth={140}>
|
|
3594
|
+
{/* An ADD — the record holds nothing yet, so no `before`. */}
|
|
3595
|
+
<ReviewField id="carrier_ref" label="Carrier reference" review={fields} kind="added">
|
|
3596
|
+
<InlineTextInput value={carrierRef} onSave={setCarrierRef} accessibilityLabel="Carrier reference" />
|
|
3597
|
+
</ReviewField>
|
|
3598
|
+
{/* An UPDATE — both values stay visible. */}
|
|
3599
|
+
<ReviewField id="vessel" label="Vessel" review={fields} kind="changed">
|
|
3600
|
+
<InlineSlot><DiffValue layout="inline" before={VESSEL_CURRENT} after={vessel} /></InlineSlot>
|
|
3601
|
+
</ReviewField>
|
|
3602
|
+
{/* A CONFLICT — the sources disagree, so the value is the
|
|
3603
|
+
read-only outcome of a pick and Keep is gated until one is
|
|
3604
|
+
made. Never a pre-selection. */}
|
|
3605
|
+
<ReviewField id="consignee" label="Consignee" review={fields} kind="changed" keepDisabled={consignee == null}
|
|
3606
|
+
why="The invoice and the packing list disagree — pick a candidate or type your own.">
|
|
3607
|
+
<View style={{ gap: 6 }}>
|
|
3608
|
+
<InlineSlot><DiffValue layout="inline" before={CONSIGNEE_CURRENT} after={consignee ?? undefined} placeholder="Pick a candidate below" /></InlineSlot>
|
|
3609
|
+
{CONSIGNEE_OPTIONS.map((c, ci) => (
|
|
3610
|
+
<CardSelectItem key={c.value} selected={consigneePick === ci} accessibilityLabel={c.value}
|
|
3611
|
+
onPress={() => { setConsigneePick(ci); setConsignee(c.value); }}>
|
|
3612
|
+
<View style={{ gap: 2 }}>
|
|
3613
|
+
<Text size="sm">{c.value}</Text>
|
|
3614
|
+
<Text size="xs" color="muted">{c.source}</Text>
|
|
3615
|
+
</View>
|
|
3616
|
+
</CardSelectItem>
|
|
3617
|
+
))}
|
|
3618
|
+
<CardSelectItem selected={consigneePick === "custom"} accessibilityLabel="Type another value"
|
|
3619
|
+
onPress={() => { setConsigneePick("custom"); setConsignee(customConsignee || null); }}>
|
|
3620
|
+
<Text size="sm">{customConsignee === "" ? "Type another value" : customConsignee}</Text>
|
|
3621
|
+
</CardSelectItem>
|
|
3622
|
+
{consigneePick === "custom" ? (
|
|
3623
|
+
<InlineTextInput value={customConsignee} onSave={(v) => { setCustomConsignee(v); setConsignee(v || null); }} placeholder="Consignee…" accessibilityLabel="Custom consignee" />
|
|
3624
|
+
) : null}
|
|
3625
|
+
</View>
|
|
3626
|
+
</ReviewField>
|
|
3627
|
+
<ReviewField id="gross" label="Gross weight" review={fields}>
|
|
3628
|
+
<InlineTextInput value={grossWeight} onSave={setGrossWeight} accessibilityLabel="Gross weight" />
|
|
3629
|
+
</ReviewField>
|
|
3630
|
+
{/* A REMOVAL — `after` omitted, so the struck value IS the change. */}
|
|
3631
|
+
<ReviewField id="notify" label="Notify party" review={fields} kind="removed"
|
|
3632
|
+
why="No notify party appears on any document — the consignee is notified directly.">
|
|
3633
|
+
<InlineSlot><DiffValue layout="inline" before={NOTIFY_CURRENT} placeholder="removed" /></InlineSlot>
|
|
3634
|
+
</ReviewField>
|
|
3635
|
+
</DetailTable>
|
|
3636
|
+
|
|
3637
|
+
{/* RECORD ops — the packing list also proposes ORDER LINES. They
|
|
3638
|
+
read as a TABLE because that is what they are; the old family
|
|
3639
|
+
could only render them as a stack of cards. */}
|
|
3488
3640
|
<View style={{ paddingTop: 10 }}>
|
|
3489
3641
|
<Divider />
|
|
3490
3642
|
</View>
|
|
3491
|
-
<
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
<
|
|
3512
|
-
|
|
3513
|
-
|
|
3643
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
3644
|
+
<Text size="md" weight="semibold" style={{ flex: 1 }}>Order lines</Text>
|
|
3645
|
+
<Text size="xs" color="muted">{`${lines.keptCount} of ${lines.total} kept`}</Text>
|
|
3646
|
+
</View>
|
|
3647
|
+
{/* The WHOLE line set, changed and untouched together. Showing
|
|
3648
|
+
only the two proposals would answer "what did it find" while
|
|
3649
|
+
leaving the operator's real question — where does this sit in
|
|
3650
|
+
the order — to a second screen. The `Document` column is a
|
|
3651
|
+
first-class diff: a dropped invoice's proposal IS which line
|
|
3652
|
+
it attaches to. */}
|
|
3653
|
+
<Table columns={[
|
|
3654
|
+
{ key: "mark", label: "", width: 30, priority: 4 },
|
|
3655
|
+
{ key: "item", label: "Item", flex: 1 },
|
|
3656
|
+
{ key: "qty", label: "Quantity", width: 116, align: "right", priority: 1 },
|
|
3657
|
+
{ key: "doc", label: "Document", width: 168, priority: 2 },
|
|
3658
|
+
{ key: "act", label: "", width: 92, priority: 3 },
|
|
3659
|
+
]}>
|
|
3660
|
+
<TableRow>
|
|
3661
|
+
<TableCell><DiffMark kind="unchanged" /></TableCell>
|
|
3662
|
+
<TableCell><Text size="sm" numberOfLines={2}>{UNTOUCHED_LINES[0].item}</Text></TableCell>
|
|
3663
|
+
<TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>{UNTOUCHED_LINES[0].qty}</Text></TableCell>
|
|
3664
|
+
<TableCell><DocName doc={UNTOUCHED_LINES[0].doc!} /></TableCell>
|
|
3665
|
+
<TableCell><View /></TableCell>
|
|
3666
|
+
</TableRow>
|
|
3667
|
+
<TableRow>
|
|
3668
|
+
<TableCell><DiffMark kind={lines.status("line-edit-qty") === "rejected" ? "unchanged" : "changed"} /></TableCell>
|
|
3669
|
+
<TableCell><Text size="sm" numberOfLines={2}>Flat-pack cartons</Text></TableCell>
|
|
3670
|
+
<TableCell><DiffValue align="right" tabular before="1,200 pcs" after={`${editedQty} pcs`} delta="+250 pcs" /></TableCell>
|
|
3671
|
+
<TableCell><DocName doc={{ name: "packing-list.pdf", mimeType: PDF }} /></TableCell>
|
|
3672
|
+
<TableCell><LineVerdict id="line-edit-qty" review={lines} label="Flat-pack cartons" /></TableCell>
|
|
3673
|
+
</TableRow>
|
|
3674
|
+
<TableRow>
|
|
3675
|
+
{/* THE ATTACHMENT. The line's values do not move at all — the
|
|
3676
|
+
whole proposal is that this invoice belongs HERE, so the
|
|
3677
|
+
document cell is the only thing that reads as a change. */}
|
|
3678
|
+
<TableCell><DiffMark kind={lines.status("line-doc") === "rejected" ? "unchanged" : "changed"} /></TableCell>
|
|
3679
|
+
<TableCell><Text size="sm" numberOfLines={2}>Steel banding coils</Text></TableCell>
|
|
3680
|
+
<TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>80 pcs</Text></TableCell>
|
|
3681
|
+
<TableCell>
|
|
3682
|
+
{lines.status("line-doc") === "rejected"
|
|
3683
|
+
? <Text size="xs" color="muted">None</Text>
|
|
3684
|
+
: <DiffValue after={<DocName doc={{ name: "invoice-8841.pdf", mimeType: PDF }} />} />}
|
|
3685
|
+
</TableCell>
|
|
3686
|
+
<TableCell><LineVerdict id="line-doc" review={lines} label="the invoice on Steel banding coils" /></TableCell>
|
|
3687
|
+
</TableRow>
|
|
3688
|
+
<TableRow>
|
|
3689
|
+
<TableCell><DiffMark kind="unchanged" /></TableCell>
|
|
3690
|
+
<TableCell><Text size="sm" numberOfLines={2}>{UNTOUCHED_LINES[1].item}</Text></TableCell>
|
|
3691
|
+
<TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>{UNTOUCHED_LINES[1].qty}</Text></TableCell>
|
|
3692
|
+
<TableCell><DocName doc={UNTOUCHED_LINES[1].doc!} /></TableCell>
|
|
3693
|
+
<TableCell><View /></TableCell>
|
|
3694
|
+
</TableRow>
|
|
3695
|
+
<TableRow>
|
|
3696
|
+
{/* A document REPLACING one already filed. `DiffValue` strikes
|
|
3697
|
+
a node with a drawn rule, because `line-through` on a Text
|
|
3698
|
+
does not cross a View child — without it the superseded
|
|
3699
|
+
scan would sit at full strength beside its replacement. */}
|
|
3700
|
+
<TableCell><DiffMark kind={lines.status("line-doc-swap") === "rejected" ? "unchanged" : "changed"} /></TableCell>
|
|
3701
|
+
<TableCell><Text size="sm" numberOfLines={2}>Pallet collars</Text></TableCell>
|
|
3702
|
+
<TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>320 pcs</Text></TableCell>
|
|
3703
|
+
<TableCell>
|
|
3704
|
+
{lines.status("line-doc-swap") === "rejected"
|
|
3705
|
+
? <DocName doc={{ name: "scan-0042.jpg", mimeType: "image/jpeg" }} />
|
|
3706
|
+
: (
|
|
3707
|
+
<DiffValue
|
|
3708
|
+
before={<DocName doc={{ name: "scan-0042.jpg", mimeType: "image/jpeg" }} />}
|
|
3709
|
+
after={<DocName doc={{ name: "invoice-8841-p2.pdf", mimeType: PDF }} />}
|
|
3710
|
+
/>
|
|
3711
|
+
)}
|
|
3712
|
+
</TableCell>
|
|
3713
|
+
<TableCell><LineVerdict id="line-doc-swap" review={lines} label="the replacement scan on Pallet collars" /></TableCell>
|
|
3714
|
+
</TableRow>
|
|
3715
|
+
<TableRow>
|
|
3716
|
+
<TableCell><DiffMark kind={lines.status("line-add") === "rejected" ? "unchanged" : "added"} /></TableCell>
|
|
3717
|
+
<TableCell><InlineTextInput value={newItem} onSave={setNewItem} accessibilityLabel="Item" /></TableCell>
|
|
3718
|
+
<TableCell><InlineTextInput value={newQty} onSave={setNewQty} accessibilityLabel="Quantity" /></TableCell>
|
|
3719
|
+
<TableCell><Text size="xs" color="muted">None</Text></TableCell>
|
|
3720
|
+
<TableCell><LineVerdict id="line-add" review={lines} label={newItem} /></TableCell>
|
|
3721
|
+
</TableRow>
|
|
3722
|
+
<TableRow>
|
|
3723
|
+
<TableCell><DiffMark kind="unchanged" /></TableCell>
|
|
3724
|
+
<TableCell><Text size="sm" numberOfLines={2}>{UNTOUCHED_LINES[2].item}</Text></TableCell>
|
|
3725
|
+
<TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>{UNTOUCHED_LINES[2].qty}</Text></TableCell>
|
|
3726
|
+
<TableCell><Text size="xs" color="muted">None</Text></TableCell>
|
|
3727
|
+
<TableCell><View /></TableCell>
|
|
3728
|
+
</TableRow>
|
|
3729
|
+
</Table>
|
|
3730
|
+
|
|
3731
|
+
{/* THE FILES SECTION — the record's document set, reviewed in the
|
|
3732
|
+
shape the record renders it in. `FileRow` takes the mark in
|
|
3733
|
+
`leading` so the names still form a column, and a node `meta`
|
|
3734
|
+
so a reclassify can carry its diff on the PROPERTY that moved
|
|
3735
|
+
rather than implying the bytes changed. */}
|
|
3736
|
+
<View style={{ paddingTop: 10 }}>
|
|
3737
|
+
<Divider />
|
|
3738
|
+
</View>
|
|
3739
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
3740
|
+
<Text size="md" weight="semibold" style={{ flex: 1 }}>Documents</Text>
|
|
3741
|
+
<Text size="xs" color="muted">{`${docs.keptCount} of ${docs.total} kept`}</Text>
|
|
3742
|
+
</View>
|
|
3743
|
+
<View style={{ gap: 2 }}>
|
|
3744
|
+
{PROPOSED_DOCS.map((d) => {
|
|
3745
|
+
const status = docs.status(d.id);
|
|
3746
|
+
const dropped = status === "rejected";
|
|
3747
|
+
const meta =
|
|
3748
|
+
dropped ? d.note
|
|
3749
|
+
: d.supersedes ? `Replaces ${d.supersedes}`
|
|
3750
|
+
: d.filedAs ? (
|
|
3751
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
3752
|
+
<Text size="xs" color="muted">Filed as</Text>
|
|
3753
|
+
<DiffValue layout="inline" size="xs" before={d.filedAs.before} after={d.filedAs.after} />
|
|
3754
|
+
</View>
|
|
3755
|
+
) : d.note;
|
|
3756
|
+
return (
|
|
3757
|
+
<View key={d.id} style={{ opacity: dropped || d.kind === "removed" ? 0.55 : 1 }}>
|
|
3758
|
+
<FileRow
|
|
3759
|
+
size="md"
|
|
3760
|
+
name={d.name}
|
|
3761
|
+
mimeType={d.mimeType}
|
|
3762
|
+
meta={meta}
|
|
3763
|
+
leading={<DiffMark kind={dropped ? "unchanged" : d.kind} />}
|
|
3764
|
+
trailing={
|
|
3765
|
+
status === "pending" ? (
|
|
3766
|
+
<View style={{ flexDirection: "row", gap: 4 }}>
|
|
3767
|
+
<IconButton icon="x" accessibilityLabel={`Drop ${d.name}`} onPress={() => docs.reject(d.id)} />
|
|
3768
|
+
<IconButton icon="check" accessibilityLabel={`Keep ${d.name}`} onPress={() => docs.accept(d.id)} />
|
|
3769
|
+
</View>
|
|
3770
|
+
) : (
|
|
3771
|
+
<Button title="Undo" color="muted" onPress={() => docs.undo(d.id)} />
|
|
3772
|
+
)
|
|
3773
|
+
}
|
|
3774
|
+
/>
|
|
3775
|
+
</View>
|
|
3776
|
+
);
|
|
3777
|
+
})}
|
|
3778
|
+
</View>
|
|
3514
3779
|
</View>
|
|
3515
3780
|
</View>
|
|
3516
3781
|
) : null}
|
|
@@ -3522,7 +3787,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3522
3787
|
<View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
|
|
3523
3788
|
</View>
|
|
3524
3789
|
<View style={{ gap: 14 }}>
|
|
3525
|
-
<
|
|
3790
|
+
<Text size="md" weight="semibold">Findings</Text>
|
|
3526
3791
|
{/* Display-only: findings inform the verdict the footer records.
|
|
3527
3792
|
The kit `Finding` owns severity word, title, detail, the
|
|
3528
3793
|
PROMINENT metric, Sources; hairlines separate them. */}
|
|
@@ -3582,7 +3847,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3582
3847
|
{/* N = the kept fields; the minKept swap disables Apply exactly at
|
|
3583
3848
|
N = 0 (the registry can't see field-level decisions, so the host
|
|
3584
3849
|
gates — and hands Keep-all its own handler). */}
|
|
3585
|
-
<
|
|
3850
|
+
<Button title="Keep all" color="muted" onPress={keepAll} />
|
|
3851
|
+
<View style={{ flex: 1 }} />
|
|
3852
|
+
<Button title="Cancel" color="secondary" onPress={closeAi} />
|
|
3853
|
+
<Button title={`Update record (${applyCount})`} color="primary" disabled={applyCount === 0} onPress={() => setPhase("done")} />
|
|
3586
3854
|
</DialogFooter>
|
|
3587
3855
|
) : phase === "review" && task === "check" ? (
|
|
3588
3856
|
<DialogFooter>
|
|
@@ -3597,7 +3865,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3597
3865
|
</DialogFooter>
|
|
3598
3866
|
) : null}
|
|
3599
3867
|
</Dialog>
|
|
3600
|
-
</ChangeReview>
|
|
3601
3868
|
|
|
3602
3869
|
{/* Rename — small focused dialog; Save disabled while empty. */}
|
|
3603
3870
|
<Dialog open={renameTarget != null} onOpenChange={(o) => { if (!o) setRenameTarget(null); }} maxWidth={420}>
|