@lotics/ui 28.3.1 → 29.2.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 +93 -24
- 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 +6 -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_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/result_header.tsx +1 -1
- package/src/use_change_set.ts +115 -0
- package/src/change_review.tsx +0 -800
package/docs/templates.md
CHANGED
|
@@ -255,17 +255,28 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
|
|
|
255
255
|
(`AgentRun` in a `FollowScroll`) reads them → `ClarifyWizard` asks the ambiguities the
|
|
256
256
|
analysis surfaced (described options, one custom-answer slot; its Cancel/Back/Next/Submit
|
|
257
257
|
live in the `DialogFooter` — `ClarifyWizardScope` wraps the Dialog, `ClarifyWizardActions`
|
|
258
|
-
rides the footer) → the IMPORT stream
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
258
|
+
rides the footer) → the IMPORT stream DRAFTS the records → the PREVIEW lays every one of
|
|
259
|
+
them out in full, editable in place → ONE commit creates them → the dialog ends on the
|
|
260
|
+
RESULT LIST — one hand-rolled result row per record (outcome mark, what landed, key
|
|
261
|
+
figures — the anatomy in ai_patterns §the one law; secondary "Done" to dismiss).
|
|
262
|
+
Correction past the receipt uses the record's ordinary verbs (no drawer on a bulk landing
|
|
263
|
+
either). BELOW the
|
|
264
264
|
dropzone, "or enter manually" → the record VARIANTS as `CardSelectItem`s, each opening the
|
|
265
265
|
same THREE-field create-then-refine form (a single manual create still opens its drawer).
|
|
266
266
|
The template mocks the clarify step as two app-orchestrated phases; a real app can do it in
|
|
267
267
|
ONE run — every app agent carries `ask_user_choice`, so the run parks on the agent's own
|
|
268
268
|
question and `useAgentRun().pendingChoice`/`answerChoice` drive the same `ClarifyWizard`.
|
|
269
|
+
- **The PREVIEW is the gate, and the fork is who supplied the values.** The import stream
|
|
270
|
+
DRAFTS; it does not write. Every drafted record then renders in full — all four fields as
|
|
271
|
+
they will be stored, each editable in place, a `DiffMark` per card, Keep/Drop via
|
|
272
|
+
`useChangeSet` (`initial: "accepted"` — the operator drops the exceptions rather than
|
|
273
|
+
approving six identical records) — and ONE commit named for its outcome creates them. The
|
|
274
|
+
manual form beside it skips all of that and saves direct: the operator typed those three
|
|
275
|
+
values and read them on the way in. Only the DOCUMENT path needs the gate, because a
|
|
276
|
+
receipt saying "Created 3 records" is the first moment a misread order reference could
|
|
277
|
+
have been caught, and by then it is in the data. The receipt still follows the write — it
|
|
278
|
+
states the outcome, ROUTES, and carries the deterministic checks (a checksum, a count
|
|
279
|
+
reconciliation) that a person cannot eyeball anyway.
|
|
269
280
|
|
|
270
281
|
### `tpl_pick` — the guided queue
|
|
271
282
|
|
|
@@ -676,12 +687,20 @@ menu holds only Rename/Download). Inside its dialog the run FORKS into three tas
|
|
|
676
687
|
phase runs inside a `FollowScroll` (swapped back to `DialogScrollArea` on the phase flip,
|
|
677
688
|
so review opens at the top):
|
|
678
689
|
|
|
679
|
-
- **Extract** (read the documents, fill the record) —
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
before
|
|
683
|
-
|
|
684
|
-
|
|
690
|
+
- **Extract** (read the documents, fill the record) — a `DetailTable` of proposed values, each
|
|
691
|
+
row a label plus a `DiffValue` and its own Keep/Drop, with `useChangeSet` holding the
|
|
692
|
+
verdicts. **Editing IS the review**; the four shapes are ONE component — an add omits
|
|
693
|
+
`before`, a removal omits `after` (the struck value IS the change), an update passes both,
|
|
694
|
+
and a conflict is a `DiffValue` on its placeholder over `CardSelectItem` candidates plus a
|
|
695
|
+
type-another-value option, Keep gated until one is picked. Proposed line items arrive as a
|
|
696
|
+
`Table` with `DiffMark` leading each row — a set of lines reads like the ledger it is about.
|
|
697
|
+
The line table also works the ATTACHMENT case: a dropped invoice's proposal is WHICH LINE it
|
|
698
|
+
belongs to, so the Document column is a `DiffValue` whose sides are file chips, and a file
|
|
699
|
+
replacing one already filed shows both with the superseded one struck. Its untouched lines
|
|
700
|
+
are deliberate — a fixture where every row changed would prove nothing about the screen that
|
|
701
|
+
matters, and they are why an untouched row draws NO mark: the eye finds the filled discs
|
|
702
|
+
among the blanks without reading the column.
|
|
703
|
+
Keep-all + one outcome-named Apply commit.
|
|
685
704
|
- **Cross-check** (compare the documents against the record and each other) — ranked,
|
|
686
705
|
display-only `Finding`s the human acts on, closing in a recorded verdict.
|
|
687
706
|
- **Edit with AI** — the `askAi` handoff: dialogue-shaped file iteration happens in the
|
|
@@ -53,6 +53,8 @@ import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
|
53
53
|
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
54
54
|
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
55
55
|
import { useSelection } from "@lotics/ui/use_selection";
|
|
56
|
+
import { DiffMark } from "@lotics/ui/diff_mark";
|
|
57
|
+
import { useChangeSet } from "@lotics/ui/use_change_set";
|
|
56
58
|
|
|
57
59
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
60
|
// Template, Register — THE canonical register, and the ONE work-execution list
|
|
@@ -70,9 +72,17 @@ import { useSelection } from "@lotics/ui/use_selection";
|
|
|
70
72
|
|
|
71
73
|
// The customer book the new-case form searches — find-or-create (pick an existing
|
|
72
74
|
// customer or coin a new one) instead of typing a raw name that risks a duplicate.
|
|
75
|
+
// The customer book. The import's preview asks it whether each drafted record's
|
|
76
|
+
// customer already exists, so the two named in `PROPOSED_BY_CUSTOMER` that the
|
|
77
|
+
// wizard's own question calls known ("Blue Harbor Foods isn't in the customer
|
|
78
|
+
// book yet" — implying the other two are) have to actually BE here. They were
|
|
79
|
+
// not, and the old receipt hid it by hardcoding the one new name instead of
|
|
80
|
+
// asking the book; every drafted record now reads as a new customer the moment
|
|
81
|
+
// anything checks. The fixture is the source, so the source has to agree.
|
|
73
82
|
const CUSTOMER_OPTIONS: PickerOption[] = [
|
|
74
83
|
"Northwind Packaging", "Crestline Foods", "Atlas Components", "Summit Logistics",
|
|
75
84
|
"Michael Torres", "Anna Whitfield", "Robert Hayes", "Karen Mitchell",
|
|
85
|
+
"Meridian Trading Co.", "Northgate Textiles",
|
|
76
86
|
].map((name) => ({ value: name, label: name }));
|
|
77
87
|
|
|
78
88
|
// The assignable roster — an app feeds `useMembers()` here.
|
|
@@ -385,13 +395,24 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
385
395
|
// ─── Enter data — the INTAKE fork ────────────────────────────────────────────
|
|
386
396
|
// AI FIRST, form as fallback. The one "Enter data" CTA opens a phased dialog:
|
|
387
397
|
// drop files (the hero) → a short ANALYZE stream reads them → the run asks the
|
|
388
|
-
// genuine ambiguities the analysis surfaced → the IMPORT stream
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
398
|
+
// genuine ambiguities the analysis surfaced → the IMPORT stream DRAFTS the
|
|
399
|
+
// records → the PREVIEW lays every drafted record out in full, every field
|
|
400
|
+
// editable in place → one commit creates them → the RECEIPT states the outcome
|
|
401
|
+
// and routes.
|
|
402
|
+
//
|
|
403
|
+
// The preview is the one law's DOCUMENT-create branch, and the fork is WHO
|
|
404
|
+
// supplied the values, not whether a before-state exists. The manual form below
|
|
405
|
+
// skips it and saves direct — the operator typed those three values and read
|
|
406
|
+
// them on the way in. These came off a spreadsheet nobody has read, and a
|
|
407
|
+
// receipt saying "Created 3 records" is the first moment a misread order
|
|
408
|
+
// reference could have been caught, by which point it is in the data. So the
|
|
409
|
+
// values are shown BEFORE the write, as they will be stored, editable where
|
|
410
|
+
// they are wrong.
|
|
411
|
+
//
|
|
412
|
+
// It is deliberately not a diff. Nothing exists to compare against, so the
|
|
413
|
+
// whole record is the review; `DiffMark` earns its place only for the one fact
|
|
414
|
+
// that IS relational — whether each record's customer matched the book or is
|
|
415
|
+
// being created alongside it.
|
|
395
416
|
// Below the dropzone, the MANUAL variants (each flavors the same 3-field
|
|
396
417
|
// create-then-refine gate). The mock plays the clarify step as two phases; a
|
|
397
418
|
// real app can run it as ONE agent run — every app agent carries
|
|
@@ -400,11 +421,16 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
400
421
|
// shape: the mock below satisfies the same `AgentRunLike` the hook does.
|
|
401
422
|
|
|
402
423
|
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
403
|
-
type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "done" | "form";
|
|
424
|
+
type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "preview" | "done" | "form";
|
|
404
425
|
type ManualVariant = "export" | "import";
|
|
405
426
|
|
|
406
427
|
type Proposal = { id: string; khach: string; dienThoai: string; phi: number; orders: string };
|
|
407
428
|
|
|
429
|
+
/** The preview's editable copy of one drafted record — the run's output after
|
|
430
|
+
* the operator has had it. Local state, seeded ONCE when the draft stream ends;
|
|
431
|
+
* a real app seeds it from the run's structured output the same way. */
|
|
432
|
+
type Draft = Proposal & { newCustomer: boolean };
|
|
433
|
+
|
|
408
434
|
// What the (mock) import drafts, per the wizard's grouping answer. A real app
|
|
409
435
|
// takes these from the import run's structured output.
|
|
410
436
|
const PROPOSED_BY_CUSTOMER: Proposal[] = [
|
|
@@ -452,8 +478,10 @@ const importScript = (grouping: "customer" | "order"): Part[] => [
|
|
|
452
478
|
{ type: "reasoning", text: grouping === "customer" ? "Group the six orders under their three customers, one record each, fees summed per customer." : "One record per order — six records, fees split from the order totals." },
|
|
453
479
|
doneTool("i1", "Extract order lines"),
|
|
454
480
|
doneTool("i2", "Match customers"),
|
|
455
|
-
doneTool("i3", grouping === "customer" ? "
|
|
456
|
-
|
|
481
|
+
doneTool("i3", grouping === "customer" ? "Draft 3 records" : "Draft 6 records"),
|
|
482
|
+
// DRAFTED, not created — the stream's closing line must not claim a write
|
|
483
|
+
// that has not happened. The operator sees them next, in full, and presses.
|
|
484
|
+
{ type: "text", text: `Drafted **${grouping === "customer" ? 3 : 6} records** — check them below before they are created.` },
|
|
457
485
|
];
|
|
458
486
|
|
|
459
487
|
/** The manual gate stays the THREE-field create-then-refine form — past the
|
|
@@ -481,12 +509,23 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
481
509
|
// on the created records (create + link, leave unlinked, or the user's own
|
|
482
510
|
// instruction via the custom slot).
|
|
483
511
|
const [customerPlan, setCustomerPlan] = useState("New customer created and linked");
|
|
512
|
+
// What the import DRAFTED — the preview's working copy, seeded once when the
|
|
513
|
+
// draft stream ends and edited in place from then on. It is not server data
|
|
514
|
+
// (nothing is stored yet), so it is the one thing here that legitimately
|
|
515
|
+
// lives in `useState`.
|
|
516
|
+
const [drafts, setDrafts] = useState<Draft[]>([]);
|
|
484
517
|
// the RECEIPT — what the import CREATED. A real app READS BACK what the
|
|
485
518
|
// save stored (values + record ids) and runs its DETERMINISTIC checks over
|
|
486
519
|
// the stored values (a reference checksum, a count reconciliation). ONE
|
|
487
520
|
// record renders its receipt directly; several render the attention-first
|
|
488
521
|
// register whose rows press straight through to the records.
|
|
489
522
|
const [landed, setLanded] = useState<{ id: string; title: string; detail: string; ref: string; refFlagged: boolean; fee: string }[]>([]);
|
|
523
|
+
// Keep/Drop over the drafted records. Everything is IN by default: the
|
|
524
|
+
// operator's job here is to catch the wrong one, not to approve six right
|
|
525
|
+
// ones — `initial: "pending"` would turn a review into a second round of
|
|
526
|
+
// data entry. Nothing is gated on `settled` for the same reason.
|
|
527
|
+
const kept = useChangeSet(drafts.map((d) => d.id));
|
|
528
|
+
|
|
490
529
|
// form state (the manual gate)
|
|
491
530
|
const [khach, setKhach] = useState("");
|
|
492
531
|
const [dienThoai, setDienThoai] = useState("");
|
|
@@ -547,26 +586,19 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
547
586
|
// Auto-advance: analysis flows into the wizard.
|
|
548
587
|
setTimeout(() => setPhase("clarify"), 500);
|
|
549
588
|
} else {
|
|
550
|
-
// The import
|
|
551
|
-
//
|
|
589
|
+
// The import DRAFTS — nothing is written here. The drafts go to the
|
|
590
|
+
// preview, which is the gate; the write happens on one press there.
|
|
552
591
|
setTimeout(() => {
|
|
553
592
|
const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// The mock flags one record's reference (a failed checksum); a
|
|
564
|
-
// real app computes this over the STORED value.
|
|
565
|
-
refFlagged: p.khach === "Blue Harbor Foods",
|
|
566
|
-
fee: String(p.phi),
|
|
567
|
-
}));
|
|
568
|
-
setLanded(rows);
|
|
569
|
-
setPhase("done");
|
|
593
|
+
setDrafts(
|
|
594
|
+
proposals.map((p) => ({
|
|
595
|
+
...p,
|
|
596
|
+
// The one relational fact in a screen full of new values, and
|
|
597
|
+
// the only thing here a `DiffMark` can honestly say.
|
|
598
|
+
newCustomer: !CUSTOMER_OPTIONS.some((o) => o.label === p.khach),
|
|
599
|
+
})),
|
|
600
|
+
);
|
|
601
|
+
setPhase("preview");
|
|
570
602
|
}, 500);
|
|
571
603
|
}
|
|
572
604
|
}
|
|
@@ -584,6 +616,8 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
584
616
|
const reset = () => {
|
|
585
617
|
setPhase("intake");
|
|
586
618
|
setDocs([]);
|
|
619
|
+
setDrafts([]);
|
|
620
|
+
kept.reset();
|
|
587
621
|
setLanded([]);
|
|
588
622
|
setCustomerPlan("New customer created and linked");
|
|
589
623
|
setKhach("");
|
|
@@ -592,6 +626,41 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
592
626
|
};
|
|
593
627
|
const close = () => { onOpenChange(false); reset(); };
|
|
594
628
|
|
|
629
|
+
/**
|
|
630
|
+
* THE COMMIT. It writes what is on screen — the edited drafts, minus the
|
|
631
|
+
* dropped ones — and only then computes the receipt, over the values that
|
|
632
|
+
* were actually stored.
|
|
633
|
+
*
|
|
634
|
+
* The deterministic checks belong on THIS side of the write for the reason
|
|
635
|
+
* the one law gives: a person cannot eyeball-verify a checksum, so the
|
|
636
|
+
* preview is where human judgment applies (is this the right customer, is
|
|
637
|
+
* that fee plausible) and the receipt is where arithmetic does.
|
|
638
|
+
*/
|
|
639
|
+
const commitDrafts = () => {
|
|
640
|
+
const writing = drafts.filter((d) => kept.status(d.id) !== "rejected");
|
|
641
|
+
if (writing.length === 0) return;
|
|
642
|
+
onCreateMany(writing.map((d) => ({ khach: d.khach, dienThoai: d.dienThoai, phi: d.phi })));
|
|
643
|
+
setLanded(
|
|
644
|
+
writing.map((d) => ({
|
|
645
|
+
id: d.id,
|
|
646
|
+
title: grouping === "customer" ? d.khach : `${d.orders} — ${d.khach}`,
|
|
647
|
+
// The wizard's answers are load-bearing: the unknown customer's row
|
|
648
|
+
// states how it landed (created + linked / left unassigned / the
|
|
649
|
+
// user's own instruction).
|
|
650
|
+
detail: [d.dienThoai, formatMoney(d.phi), d.newCustomer ? customerPlan : ""].filter(Boolean).join(", "),
|
|
651
|
+
ref: d.orders,
|
|
652
|
+
// The mock flags one record's reference (a failed checksum); a real app
|
|
653
|
+
// computes this over the STORED value.
|
|
654
|
+
refFlagged: d.orders.includes("PO-7313"),
|
|
655
|
+
fee: String(d.phi),
|
|
656
|
+
})),
|
|
657
|
+
);
|
|
658
|
+
setPhase("done");
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const editDraft = (id: string, patch: Partial<Draft>) =>
|
|
662
|
+
setDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)));
|
|
663
|
+
|
|
595
664
|
const createManual = () => {
|
|
596
665
|
if (khach.trim() === "") return;
|
|
597
666
|
onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
|
|
@@ -601,6 +670,9 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
601
670
|
const title =
|
|
602
671
|
phase === "form" ? (variant === "export" ? "New export case" : "New import case")
|
|
603
672
|
: phase === "intake" ? "Enter data"
|
|
673
|
+
// Named for what the operator is about to DO, not for the machinery: they
|
|
674
|
+
// are checking records, and the count is the size of the job.
|
|
675
|
+
: phase === "preview" ? `Check ${drafts.length} drafted ${drafts.length === 1 ? "record" : "records"}`
|
|
604
676
|
: phase === "done" ? "Saved"
|
|
605
677
|
: "Import from files";
|
|
606
678
|
|
|
@@ -693,6 +765,89 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
693
765
|
<AgentRunPane run={run} onCancel={() => setPhase("intake")} />
|
|
694
766
|
) : null}
|
|
695
767
|
|
|
768
|
+
{phase === "preview" ? (
|
|
769
|
+
// THE GATE. Every drafted record, laid out as it will be STORED — all
|
|
770
|
+
// four fields, not a three-line summary — and every one of them
|
|
771
|
+
// editable where it sits. The law's reason for editing over
|
|
772
|
+
// accept/reject: a wrong proposal you can fix costs a keystroke, one
|
|
773
|
+
// you can only reject costs the whole re-entry.
|
|
774
|
+
<DialogScrollArea>
|
|
775
|
+
<View style={{ gap: 12 }}>
|
|
776
|
+
<Text size="xs" color="muted">
|
|
777
|
+
Nothing is saved yet. Correct anything that is wrong, drop what should not be created, then create the rest.
|
|
778
|
+
</Text>
|
|
779
|
+
{drafts.map((d) => {
|
|
780
|
+
const dropped = kept.status(d.id) === "rejected";
|
|
781
|
+
const title = grouping === "customer" ? d.khach : d.orders;
|
|
782
|
+
return (
|
|
783
|
+
<View key={d.id} style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 12, overflow: "hidden" }}>
|
|
784
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 8, backgroundColor: colors.zinc[50] }}>
|
|
785
|
+
{/* The mark says what will happen to this card. Emphasised
|
|
786
|
+
because the preview is scanned, not read: a filled
|
|
787
|
+
shape per card is found without reading the column. */}
|
|
788
|
+
<DiffMark kind={dropped ? "removed" : "added"} />
|
|
789
|
+
<Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>{title}</Text>
|
|
790
|
+
{dropped ? (
|
|
791
|
+
<Link size="sm" onPress={() => kept.undo(d.id)} accessibilityLabel={`Keep ${title}`}>Keep</Link>
|
|
792
|
+
) : (
|
|
793
|
+
<Link size="sm" onPress={() => kept.reject(d.id)} accessibilityLabel={`Do not create ${title}`}>Don’t create</Link>
|
|
794
|
+
)}
|
|
795
|
+
</View>
|
|
796
|
+
{/* A dropped card COLLAPSES rather than dimming in place:
|
|
797
|
+
its fields are not going to be written, so leaving them
|
|
798
|
+
legible-but-grey invites edits to a record that will not
|
|
799
|
+
exist. The header keeps its identity and the Keep verb. */}
|
|
800
|
+
{dropped ? null : (
|
|
801
|
+
<View style={{ paddingHorizontal: 12 }}>
|
|
802
|
+
<DetailTable>
|
|
803
|
+
<DetailRow
|
|
804
|
+
label="Customer"
|
|
805
|
+
description={d.newCustomer ? customerPlan : undefined}
|
|
806
|
+
>
|
|
807
|
+
<InlineTextInput
|
|
808
|
+
value={d.khach}
|
|
809
|
+
onSave={(v) => editDraft(d.id, { khach: v })}
|
|
810
|
+
accessibilityLabel={`Customer for ${title}`}
|
|
811
|
+
/>
|
|
812
|
+
</DetailRow>
|
|
813
|
+
<DetailRow label="Orders">
|
|
814
|
+
<InlineTextInput
|
|
815
|
+
value={d.orders}
|
|
816
|
+
onSave={(v) => editDraft(d.id, { orders: v })}
|
|
817
|
+
accessibilityLabel={`Orders for ${title}`}
|
|
818
|
+
/>
|
|
819
|
+
</DetailRow>
|
|
820
|
+
<DetailRow label="Phone">
|
|
821
|
+
{/* The document carried no phone for one of these.
|
|
822
|
+
An empty box would read as "blank" — a value
|
|
823
|
+
somebody chose — so the placeholder NAMES the
|
|
824
|
+
absence: unknown is not empty. */}
|
|
825
|
+
<InlineTextInput
|
|
826
|
+
value={d.dienThoai}
|
|
827
|
+
placeholder="Not on the document"
|
|
828
|
+
onSave={(v) => editDraft(d.id, { dienThoai: v })}
|
|
829
|
+
accessibilityLabel={`Phone for ${title}`}
|
|
830
|
+
/>
|
|
831
|
+
</DetailRow>
|
|
832
|
+
<DetailRow label="Service fee">
|
|
833
|
+
<InlineNumberInput
|
|
834
|
+
value={d.phi}
|
|
835
|
+
min={0}
|
|
836
|
+
format={(v) => (v == null ? "" : formatMoney(v))}
|
|
837
|
+
onSave={(v) => editDraft(d.id, { phi: v ?? 0 })}
|
|
838
|
+
accessibilityLabel={`Service fee for ${title}`}
|
|
839
|
+
/>
|
|
840
|
+
</DetailRow>
|
|
841
|
+
</DetailTable>
|
|
842
|
+
</View>
|
|
843
|
+
)}
|
|
844
|
+
</View>
|
|
845
|
+
);
|
|
846
|
+
})}
|
|
847
|
+
</View>
|
|
848
|
+
</DialogScrollArea>
|
|
849
|
+
) : null}
|
|
850
|
+
|
|
696
851
|
{phase === "done" ? (
|
|
697
852
|
// The RECEIPT (ai_patterns §the one law): states the outcome and
|
|
698
853
|
// routes — NOTHING here edits; the RECORD is the edit surface,
|
|
@@ -804,6 +959,24 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
804
959
|
<DialogFooter>
|
|
805
960
|
<AgentRunActions run={run} />
|
|
806
961
|
</DialogFooter>
|
|
962
|
+
) : phase === "preview" ? (
|
|
963
|
+
<DialogFooter>
|
|
964
|
+
<Button
|
|
965
|
+
title="Back"
|
|
966
|
+
color="secondary"
|
|
967
|
+
onPress={() => { setDrafts([]); kept.reset(); setPhase("intake"); }}
|
|
968
|
+
/>
|
|
969
|
+
{/* Named for its OUTCOME and carrying the count that will actually
|
|
970
|
+
be written — never "Apply", never the proposal count. Disabled
|
|
971
|
+
at zero kept, which is the only state where the press would do
|
|
972
|
+
nothing. */}
|
|
973
|
+
<Button
|
|
974
|
+
title={`Create ${kept.keptCount} ${kept.keptCount === 1 ? "record" : "records"}`}
|
|
975
|
+
color="primary"
|
|
976
|
+
disabled={kept.keptCount === 0}
|
|
977
|
+
onPress={commitDrafts}
|
|
978
|
+
/>
|
|
979
|
+
</DialogFooter>
|
|
807
980
|
) : phase === "done" ? (
|
|
808
981
|
<DialogFooter>
|
|
809
982
|
{/* Dismissal, not an action — the records already landed. */}
|