@lotics/ui 14.2.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/docs/ai_patterns.md +23 -8
- package/docs/catalog.md +7 -4
- package/docs/templates.md +32 -20
- package/examples/tpl_item_list.tsx +303 -58
- package/examples/tpl_record.tsx +24 -15
- package/package.json +1 -1
- package/src/follow_scroll.tsx +32 -37
- package/examples/tpl_documents.tsx +0 -808
package/docs/ai_patterns.md
CHANGED
|
@@ -39,7 +39,7 @@ Three shapes:
|
|
|
39
39
|
|
|
40
40
|
The Document desk's Use-AI fork IS this table as UI: Extract data (1) · Cross-check (2) ·
|
|
41
41
|
Edit with AI (3) — one entry point, three outcome shapes
|
|
42
|
-
([`
|
|
42
|
+
([`tpl_record`](../examples/tpl_record.tsx)'s Documents section).
|
|
43
43
|
|
|
44
44
|
## Command / compose — `Composer`
|
|
45
45
|
|
|
@@ -138,10 +138,14 @@ pass it as the `error` prop and it renders as a terminal danger row under the tr
|
|
|
138
138
|
|
|
139
139
|
**In a bounded container, wrap it in `FollowScroll`.** An `AgentRun` streaming inside a dialog,
|
|
140
140
|
drawer, or fixed-height panel grows BELOW the fold — a plain scroll container doesn't follow. Wrap
|
|
141
|
-
the feed: `<FollowScroll style={{ maxHeight: … }}><AgentRun … /></FollowScroll>` —
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
the feed: `<FollowScroll style={{ maxHeight: … }}><AgentRun … /></FollowScroll>` — the same
|
|
142
|
+
inverted-list mechanism as a chat message list, packaged as a wrapper: the follow happens at
|
|
143
|
+
LAYOUT level (each growth paints already pinned — no scroll-after-paint flash), scrolling up
|
|
144
|
+
releases it, and the feed stays at the newest content when the run settles (`AgentProgress`'s
|
|
145
|
+
expanded panel does this). When settling REPLACES the feed with different content (a results
|
|
146
|
+
pane), swap `FollowScroll` for a plain scroller on the phase flip — the remount opens the new
|
|
147
|
+
content at the top; a transcript that persists just stays in `FollowScroll`. A full-page chat
|
|
148
|
+
list is already inverted and doesn't need the wrapper.
|
|
145
149
|
|
|
146
150
|
Fed natively by `@lotics/app-sdk`'s `useAgentRun().parts` (reasoning + per-tool I/O + state come
|
|
147
151
|
for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={run.error} />` — see
|
|
@@ -171,7 +175,7 @@ when done.
|
|
|
171
175
|
|
|
172
176
|
Every "the agent proposes → the human accepts / edits / dismisses → nothing auto-applies" surface
|
|
173
177
|
composes from twelve pieces in three groups (all in `@lotics/ui/change_review`; worked example:
|
|
174
|
-
[`
|
|
178
|
+
[`tpl_record`](../examples/tpl_record.tsx)'s Use-AI extract flow).
|
|
175
179
|
|
|
176
180
|
**Frame**
|
|
177
181
|
|
|
@@ -278,7 +282,9 @@ Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebro
|
|
|
278
282
|
question — no progress bar, and no eyebrow at all for a single question. Each step is a `Clarify`; advance only once the
|
|
279
283
|
current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
|
|
280
284
|
`custom` true when the answer came from "Other…"), `onCancel` dismisses. All navigation chrome is
|
|
281
|
-
locale-wired (the `clarify` slice). The multi-question form of the ask-back.
|
|
285
|
+
locale-wired (the `clarify` slice). The multi-question form of the ask-back. Worked example:
|
|
286
|
+
[`tpl_item_list`](../examples/tpl_item_list.tsx)'s intake fork (the wizard between the analyze
|
|
287
|
+
and import runs).
|
|
282
288
|
|
|
283
289
|
## Provenance — `Sources`
|
|
284
290
|
|
|
@@ -362,7 +368,7 @@ ranked set, don't cram it into chat prose:
|
|
|
362
368
|
so the alternatives are first-class and picking a different one changes the outcome; refining
|
|
363
369
|
the description re-ranks. For look-up-and-explain: tariff/classification codes, fee lookup,
|
|
364
370
|
policy Q&A, a spec/compliance desk. (NOT a chat with the answer in a bubble.)
|
|
365
|
-
- **Document desk** ([`
|
|
371
|
+
- **Document desk** (in [`tpl_record`](../examples/tpl_record.tsx) — THE go-to for
|
|
366
372
|
document-driven records) — the record's files block feeds ONE "Use AI" entry that FORKS into
|
|
367
373
|
the two document tasks, each a specialized run with a task-pure result: **Extract** (files
|
|
368
374
|
read → fields already matching fold into one quiet line → every add / update / conflict a
|
|
@@ -376,6 +382,15 @@ ranked set, don't cram it into chat prose:
|
|
|
376
382
|
full-page `FileGalleryModal`. Plus **Create documents**: a readiness checklist (unchecked by
|
|
377
383
|
default, missing inputs called out) → generate → the files land back on the record. Never merge
|
|
378
384
|
the two AI tasks into one mixed output — the fork is the design.
|
|
385
|
+
- **Intake desk** (in [`tpl_item_list`](../examples/tpl_item_list.tsx)'s "Enter data" fork) — the
|
|
386
|
+
register's New CTA leads with AI: a `FileDropzone` hero → a short ANALYZE run reads the files →
|
|
387
|
+
`ClarifyWizard` asks the ambiguities the analysis surfaced → the IMPORT run drafts records →
|
|
388
|
+
`ChangeRecord` cards + one Apply land the rows; the manual VARIANTS sit below the dropzone as
|
|
389
|
+
`CardSelectItem`s, each opening the classic create-then-refine form. The template mocks the
|
|
390
|
+
clarify step as TWO app-orchestrated phases (analyze-run → wizard → import-run); a real app
|
|
391
|
+
can do it in ONE run — every app agent carries `ask_user_choice`, so the run PARKS on the
|
|
392
|
+
agent's own question and `useAgentRun().pendingChoice`/`answerChoice` drive the same
|
|
393
|
+
`ClarifyWizard` (see the SDK's [ai doc](../../app-sdk/docs/ai.md)).
|
|
379
394
|
- **Triage** — an inbox the agent classified + routed is a `ChangeReview` of `Change`s (body: the
|
|
380
395
|
item + the agent's call; the standard Keep/Drop verbs; Keep-all covers the high-confidence
|
|
381
396
|
sweep). Leads, tickets, documents, emails.
|
package/docs/catalog.md
CHANGED
|
@@ -1001,10 +1001,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1001
1001
|
- **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
|
|
1002
1002
|
`parts` prop) — a composer's working state.
|
|
1003
1003
|
- **`follow_scroll`** — `FollowScroll`: a scroll container that FOLLOWS its growing content —
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1004
|
+
the chat message list's inverted mechanism packaged as a wrapper (single-cell inverted list),
|
|
1005
|
+
so the follow happens at LAYOUT level: each growth paints already pinned (no flash), scrolling
|
|
1006
|
+
up releases it, and short content still reads top-down. Wrap a streaming `AgentRun` with it in
|
|
1007
|
+
any bounded container (a dialog, a drawer, a panel — put the height bound on `style`);
|
|
1008
|
+
`AgentProgress`'s expanded panel uses it. Inherits the inverted pattern's quirks (scrollbar
|
|
1009
|
+
thumb runs opposite; keyboard paging inverted) — same as chat. A full-page chat list is
|
|
1010
|
+
already inverted and doesn't need it.
|
|
1008
1011
|
- **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: calibrated
|
|
1009
1012
|
high/med/low; localized via the provider.
|
|
1010
1013
|
- **`change_review`** — the COMPOUND review family — frame: `ChangeReview` provider/stack ·
|
package/docs/templates.md
CHANGED
|
@@ -50,7 +50,7 @@ the package index is [../AGENTS.md](../AGENTS.md).
|
|
|
50
50
|
| Shift signup + staffing | `tpl_shifts` |
|
|
51
51
|
| AI produces a visual artifact on a canvas | `tpl_dieline` |
|
|
52
52
|
| AI ranks answers — look-up-and-explain | `tpl_lookup` |
|
|
53
|
-
| AI over a record's documents (extract / cross-check / generate) | `
|
|
53
|
+
| AI over a record's documents (extract / cross-check / generate) | `tpl_record` — its Documents + Document set sections |
|
|
54
54
|
|
|
55
55
|
## Composition rules — how a record surface is assembled
|
|
56
56
|
|
|
@@ -204,6 +204,17 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
|
|
|
204
204
|
newest first — an entry's attachments ride its expandable details as a thumbnail grid
|
|
205
205
|
whose press opens the shared `FileGalleryModal`.
|
|
206
206
|
- A closing `DangerZone`.
|
|
207
|
+
- **The "Enter data" INTAKE FORK** (the New CTA) — AI FIRST, the form as fallback, one phased
|
|
208
|
+
dialog: a `FileDropzone` hero (drop the customer's files) → a short ANALYZE stream
|
|
209
|
+
(`AgentRun` in a `FollowScroll`) reads them → `ClarifyWizard` asks the ambiguities the
|
|
210
|
+
analysis surfaced (described options, one custom-answer slot) → the IMPORT stream drafts the
|
|
211
|
+
records → `ChangeRecord` cards to Keep/Drop (fee editable in place) + one outcome-named
|
|
212
|
+
Apply that really lands the rows in the register (no drawer on a bulk landing). BELOW the
|
|
213
|
+
dropzone, "or enter manually" → the record VARIANTS as `CardSelectItem`s, each opening the
|
|
214
|
+
same THREE-field create-then-refine form (a single manual create still opens its drawer).
|
|
215
|
+
The template mocks the clarify step as two app-orchestrated phases; a real app can do it in
|
|
216
|
+
ONE run — every app agent carries `ask_user_choice`, so the run parks on the agent's own
|
|
217
|
+
question and `useAgentRun().pendingChoice`/`answerChoice` drive the same `ClarifyWizard`.
|
|
207
218
|
|
|
208
219
|
### `tpl_pick` — the guided queue
|
|
209
220
|
|
|
@@ -266,15 +277,13 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
266
277
|
(dashed add-ghost when unset), sized to the list's `trailingWidth` so the title never overlaps.
|
|
267
278
|
(Contrast the General section's dates/assignee, the same editors in the default `variant="form"`
|
|
268
279
|
— a zinc-50 chip + hover-border + the full member chip.)
|
|
269
|
-
- **Documents** — the Agents "Document desk" pattern (
|
|
270
|
-
|
|
271
|
-
`FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
|
|
272
|
-
row's leading visual is a `FileThumbnail` in ONE square 32px slot — an image file
|
|
273
|
-
it as a real thumbnail, a document centers its badge in it — never a bare `FileBadge`
|
|
274
|
-
(mixed footprints misalign the identity column).
|
|
275
|
-
|
|
276
|
-
Create moves to the Document set OUTPUT section below; `tpl_documents` (no output
|
|
277
|
-
pipeline on its page) keeps the toolbar "Create documents" dialog.
|
|
280
|
+
- **Documents** — the Agents "Document desk" pattern (this template is its worked example —
|
|
281
|
+
see the Agents chapter below): the register `Table` (search · Add files) whose selection
|
|
282
|
+
feeds the `FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
|
|
283
|
+
Every row's leading visual is a `FileThumbnail` in ONE square 32px slot — an image file
|
|
284
|
+
fills it as a real thumbnail, a document centers its badge in it — never a bare `FileBadge`
|
|
285
|
+
(mixed footprints misalign the identity column). The desk holds what ARRIVES; generation
|
|
286
|
+
lives in the Document set OUTPUT section below.
|
|
278
287
|
- **Customer — the LINKED-RECORD reference** via **`LinkedRecordBox`**: another record's data
|
|
279
288
|
NEVER renders as field rows on this one — a BORDERED, fully pressable box (glyph anchor ·
|
|
280
289
|
identity · facts stacked VERTICALLY) whose press opens the detail drawer; the verbs ride the
|
|
@@ -460,8 +469,9 @@ double-shift days flagged). Accepting and assigning move the board live.
|
|
|
460
469
|
## Agents
|
|
461
470
|
|
|
462
471
|
Screens where AI does the work and the human reviews. `tpl_dieline` *produces* an artifact;
|
|
463
|
-
`tpl_lookup`
|
|
464
|
-
contracts and the propose→review→apply
|
|
472
|
+
`tpl_lookup` *structures* information for a decision; the document desk (in `tpl_record`)
|
|
473
|
+
does both over a record's files. The AI component contracts and the propose→review→apply
|
|
474
|
+
loop live in [ai_patterns.md](./ai_patterns.md).
|
|
465
475
|
|
|
466
476
|
### `tpl_dieline` — the design canvas
|
|
467
477
|
|
|
@@ -484,12 +494,15 @@ rows, policy `Callout`s, `Confidence`, and `Sources`. The flow is input → matc
|
|
|
484
494
|
a single confident verdict: classification is ambiguous, so the alternatives are first-class.
|
|
485
495
|
Refine the description in a follow-up and the ranking updates.
|
|
486
496
|
|
|
487
|
-
###
|
|
497
|
+
### The document desk — in `tpl_record`
|
|
488
498
|
|
|
489
|
-
THE worked example for everything document-driven on a record
|
|
490
|
-
|
|
499
|
+
THE worked example for everything document-driven on a record lives on the record template
|
|
500
|
+
(its Documents section — no standalone template). A record's files block feeds ONE
|
|
501
|
+
**"Use AI"** entry — the one AI entry point, never per-row AI buttons (the per-row `⋯`
|
|
491
502
|
menu holds only Rename/Download). Inside its dialog the run FORKS into three tasks as
|
|
492
|
-
`CardSelectItem`s (select → a confirming footer CTA, never one-click)
|
|
503
|
+
`CardSelectItem`s (select → a confirming footer CTA, never one-click), and the streaming
|
|
504
|
+
phase runs inside a `FollowScroll` (swapped back to `DialogScrollArea` on the phase flip,
|
|
505
|
+
so review opens at the top):
|
|
493
506
|
|
|
494
507
|
- **Extract** (read the documents, fill the record) — the open `ChangeFields` review: ONE
|
|
495
508
|
`Change` section per record whose body stacks a `ChangeField` per proposed value (label ·
|
|
@@ -502,10 +515,9 @@ menu holds only Rename/Download). Inside its dialog the run FORKS into three tas
|
|
|
502
515
|
- **Edit with AI** — the `askAi` handoff: dialogue-shaped file iteration happens in the
|
|
503
516
|
platform chat (the documents attached to a fresh thread), not in the app.
|
|
504
517
|
|
|
505
|
-
|
|
506
|
-
(
|
|
507
|
-
|
|
508
|
-
PENDING (previewable) until the user commits them onto the record.
|
|
518
|
+
GENERATION is separate and deterministic — the record's Document set OUTPUT section
|
|
519
|
+
(readiness checklist → template fill with a `loading` trigger, never `AgentRun` theater;
|
|
520
|
+
the set appears at once).
|
|
509
521
|
|
|
510
522
|
All mock — `useState` plus a timer streams the `AgentRun`; a real app drives the same
|
|
511
523
|
components from its live agent run.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState, useRef } from "react";
|
|
1
|
+
import { useEffect, useState, useRef } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
3
4
|
import { Text } from "@lotics/ui/text";
|
|
4
5
|
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
6
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
@@ -50,7 +51,14 @@ import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
|
50
51
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
51
52
|
import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
|
|
52
53
|
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
53
|
-
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
54
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
|
|
55
|
+
import { AgentRun } from "@lotics/ui/agent_run";
|
|
56
|
+
import { FollowScroll } from "@lotics/ui/follow_scroll";
|
|
57
|
+
import { ClarifyWizard, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
|
|
58
|
+
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
59
|
+
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
60
|
+
import { ChangeField, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeValueInput, type ChangeStatus } from "@lotics/ui/change_review";
|
|
61
|
+
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
54
62
|
import { MemberSelect } from "@lotics/ui/member_select";
|
|
55
63
|
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
56
64
|
import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
|
|
@@ -1025,71 +1033,294 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
1025
1033
|
);
|
|
1026
1034
|
}
|
|
1027
1035
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1036
|
+
// ─── Enter data — the INTAKE fork ────────────────────────────────────────────
|
|
1037
|
+
// AI FIRST, form as fallback. The one "Enter data" CTA opens a phased dialog:
|
|
1038
|
+
// drop files (the hero) → a short ANALYZE stream reads them → `ClarifyWizard`
|
|
1039
|
+
// asks the genuine ambiguities the analysis surfaced → the IMPORT stream drafts
|
|
1040
|
+
// the records → `ChangeRecord` cards to review + apply (rows really land).
|
|
1041
|
+
// Below the dropzone, the MANUAL variants (each flavors the same 3-field
|
|
1042
|
+
// create-then-refine gate). The mock plays the clarify step as two phases; a
|
|
1043
|
+
// real app can run it as ONE agent run — every app agent carries
|
|
1044
|
+
// `ask_user_choice`, so the run parks on the agent's own question and
|
|
1045
|
+
// `useAgentRun().pendingChoice`/`answerChoice` drive this same wizard.
|
|
1046
|
+
|
|
1047
|
+
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
1048
|
+
type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "review" | "form";
|
|
1049
|
+
type ManualVariant = "export" | "import";
|
|
1050
|
+
|
|
1051
|
+
type Proposal = { id: string; khach: string; dienThoai: string; phi: number; orders: string };
|
|
1052
|
+
|
|
1053
|
+
// What the (mock) import drafts, per the wizard's grouping answer. A real app
|
|
1054
|
+
// takes these from the import run's structured output.
|
|
1055
|
+
const PROPOSED_BY_CUSTOMER: Proposal[] = [
|
|
1056
|
+
{ id: "pc1", khach: "Meridian Trading Co.", dienThoai: "(555) 014-2288", phi: 240, orders: "PO-7311 · PO-7314" },
|
|
1057
|
+
{ id: "pc2", khach: "Northgate Textiles", dienThoai: "(555) 019-4471", phi: 180, orders: "PO-7312 · PO-7316" },
|
|
1058
|
+
{ id: "pc3", khach: "Blue Harbor Foods", dienThoai: "", phi: 210, orders: "PO-7313 · PO-7315" },
|
|
1059
|
+
];
|
|
1060
|
+
const PROPOSED_BY_ORDER: Proposal[] = PROPOSED_BY_CUSTOMER.flatMap((c) =>
|
|
1061
|
+
c.orders.split(" · ").map((po, i) => ({ id: `${c.id}-${i}`, khach: c.khach, dienThoai: c.dienThoai, phi: Math.round(c.phi / 2), orders: po })),
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
// The wizard's questions come FROM the analysis (a real app renders them off the
|
|
1065
|
+
// analyze run's structured output) — informed, described, one custom-answer slot.
|
|
1066
|
+
const INTAKE_QUESTIONS: ClarifyWizardQuestion[] = [
|
|
1067
|
+
{
|
|
1068
|
+
question: "6 orders across 3 customers. How should they become records?",
|
|
1069
|
+
answers: [
|
|
1070
|
+
{ value: "customer", label: "One record per customer", description: "3 records — each customer's orders grouped into one workspace and checklist." },
|
|
1071
|
+
{ value: "order", label: "One record per order", description: "6 records — every order tracked on its own; more rows, finer-grained status." },
|
|
1072
|
+
],
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
question: "Blue Harbor Foods isn't in the customer book yet. What should happen?",
|
|
1076
|
+
allowCustom: true,
|
|
1077
|
+
answers: [
|
|
1078
|
+
{ value: "create", label: "Create the customer", description: "A new customer record is added and linked as the records land." },
|
|
1079
|
+
{ value: "skip", label: "Leave unassigned", description: "The records are created without a customer — link one later from each record." },
|
|
1080
|
+
],
|
|
1081
|
+
},
|
|
1082
|
+
];
|
|
1083
|
+
|
|
1084
|
+
const doneTool = (id: string, name: string): Part => ({ type: "dynamic-tool", toolName: name, toolCallId: id, state: "output-available", input: undefined, output: undefined });
|
|
1085
|
+
|
|
1086
|
+
const analyzeScript = (fileNames: string[]): Part[] => [
|
|
1087
|
+
{ type: "reasoning", text: "Before drafting anything I need to know what these files contain and whether the parties already exist in the book." },
|
|
1088
|
+
...fileNames.map((n, i) => doneTool(`a${i}`, `Read ${n}`)),
|
|
1089
|
+
doneTool("a-match", "Match customers"),
|
|
1090
|
+
{ type: "text", text: "Found **6 order confirmations** across **3 customers** — one customer isn't in the book yet. Two quick questions before I draft the records." },
|
|
1091
|
+
];
|
|
1092
|
+
|
|
1093
|
+
const importScript = (grouping: "customer" | "order"): Part[] => [
|
|
1094
|
+
{ 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." },
|
|
1095
|
+
doneTool("i1", "Extract order lines"),
|
|
1096
|
+
doneTool("i2", "Match customers"),
|
|
1097
|
+
doneTool("i3", grouping === "customer" ? "Draft 3 records" : "Draft 6 records"),
|
|
1098
|
+
{ type: "text", text: `Drafted **${grouping === "customer" ? 3 : 6} records** — review below, edit any value, then apply.` },
|
|
1099
|
+
];
|
|
1100
|
+
|
|
1101
|
+
/** The manual gate stays the THREE-field create-then-refine form — past the
|
|
1102
|
+
* popover threshold (1–2 fields), so a Dialog pane; NOT a wizard: the record's
|
|
1103
|
+
* own workspace (the drawer it opens into) is where everything else is refined. */
|
|
1104
|
+
function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
1105
|
+
onCreate: (khach: string, dienThoai: string, phi: number) => void;
|
|
1106
|
+
onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
|
|
1107
|
+
}) {
|
|
1108
|
+
const { small } = useScreenSize();
|
|
1033
1109
|
const [open, setOpen] = useState(false);
|
|
1110
|
+
const [phase, setPhase] = useState<IntakePhase>("intake");
|
|
1111
|
+
const [fileNames, setFileNames] = useState<string[]>([]);
|
|
1112
|
+
const [variant, setVariant] = useState<ManualVariant>("export");
|
|
1113
|
+
const [grouping, setGrouping] = useState<"customer" | "order">("customer");
|
|
1114
|
+
// review state — per-card verdicts + the one editable value (the fee)
|
|
1115
|
+
const [cardStatus, setCardStatus] = useState<Record<string, ChangeStatus>>({});
|
|
1116
|
+
const [fees, setFees] = useState<Record<string, string>>({});
|
|
1117
|
+
// form state (the manual gate)
|
|
1034
1118
|
const [khach, setKhach] = useState("");
|
|
1035
1119
|
const [dienThoai, setDienThoai] = useState("");
|
|
1036
1120
|
const [phi, setPhi] = useState<number | null>(null);
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1121
|
+
|
|
1122
|
+
// The stream reveal — one revealer serves both the analyze and import phases.
|
|
1123
|
+
const [revealed, setRevealed] = useState(0);
|
|
1124
|
+
const script = phase === "analyze" ? analyzeScript(fileNames) : phase === "running" ? importScript(grouping) : [];
|
|
1125
|
+
useEffect(() => {
|
|
1126
|
+
if (phase !== "analyze" && phase !== "running") return;
|
|
1127
|
+
setRevealed(0);
|
|
1128
|
+
const total = phase === "analyze" ? analyzeScript(fileNames).length : importScript(grouping).length;
|
|
1129
|
+
let n = 0;
|
|
1130
|
+
const t = setInterval(() => {
|
|
1131
|
+
n += 1;
|
|
1132
|
+
setRevealed(n);
|
|
1133
|
+
if (n >= total) {
|
|
1134
|
+
clearInterval(t);
|
|
1135
|
+
// Auto-advance: analysis flows into the wizard; the import into review.
|
|
1136
|
+
setTimeout(() => setPhase(phase === "analyze" ? "clarify" : "review"), 500);
|
|
1137
|
+
}
|
|
1138
|
+
}, 650);
|
|
1139
|
+
return () => clearInterval(t);
|
|
1140
|
+
// Deliberately keyed on `phase` alone — the file list and grouping are
|
|
1141
|
+
// settled before their stream phase begins.
|
|
1142
|
+
}, [phase]);
|
|
1143
|
+
|
|
1144
|
+
const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
|
|
1145
|
+
// Kept = explicitly ACCEPTED (the kit's review contract): Apply stays disabled
|
|
1146
|
+
// at 0 kept, and the bar's own Keep-all presses every pending card's Keep.
|
|
1147
|
+
const keptCount = proposals.filter((p) => cardStatus[p.id] === "accepted").length;
|
|
1148
|
+
|
|
1149
|
+
const reset = () => {
|
|
1150
|
+
setPhase("intake");
|
|
1151
|
+
setFileNames([]);
|
|
1152
|
+
setCardStatus({});
|
|
1153
|
+
setFees({});
|
|
1041
1154
|
setKhach("");
|
|
1042
1155
|
setDienThoai("");
|
|
1043
1156
|
setPhi(null);
|
|
1044
1157
|
};
|
|
1158
|
+
const close = () => { setOpen(false); reset(); };
|
|
1159
|
+
|
|
1160
|
+
const applyProposals = () => {
|
|
1161
|
+
const kept = proposals.filter((p) => cardStatus[p.id] === "accepted");
|
|
1162
|
+
onCreateMany(kept.map((p) => ({ khach: p.khach, dienThoai: p.dienThoai, phi: Number(fees[p.id] ?? p.phi) || 0 })));
|
|
1163
|
+
close();
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
const createManual = () => {
|
|
1167
|
+
if (khach.trim() === "") return;
|
|
1168
|
+
onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
|
|
1169
|
+
close();
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const title =
|
|
1173
|
+
phase === "form" ? (variant === "export" ? "New export case" : "New import case")
|
|
1174
|
+
: phase === "intake" ? "Enter data"
|
|
1175
|
+
: "Import from files";
|
|
1176
|
+
|
|
1045
1177
|
return (
|
|
1046
1178
|
<>
|
|
1047
|
-
<Button title="
|
|
1048
|
-
<
|
|
1179
|
+
<Button title="Enter data" color="primary" onPress={() => setOpen(true)} />
|
|
1180
|
+
<ChangeReview>
|
|
1181
|
+
<Dialog width={phase === "review" ? 560 : 480} open={open} onOpenChange={(o) => { if (!o) close(); }}>
|
|
1049
1182
|
<DialogHeader>
|
|
1050
|
-
<DialogHeaderTitle>
|
|
1183
|
+
<DialogHeaderTitle>{title}</DialogHeaderTitle>
|
|
1051
1184
|
</DialogHeader>
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
<
|
|
1055
|
-
{
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
>
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
<
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1185
|
+
|
|
1186
|
+
{phase === "intake" ? (
|
|
1187
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
|
|
1188
|
+
{/* the hero: AI reads the files — no manual step */}
|
|
1189
|
+
<FileDropzone
|
|
1190
|
+
label="Drop the customer's files"
|
|
1191
|
+
hint="Orders, invoices, spreadsheets — AI reads them and drafts the records"
|
|
1192
|
+
onFiles={(fs) => {
|
|
1193
|
+
setFileNames(fs.length > 0 ? fs.map((f) => f.name) : ["orders-june.pdf", "manifest.xlsx"]);
|
|
1194
|
+
setPhase("analyze");
|
|
1195
|
+
}}
|
|
1196
|
+
/>
|
|
1197
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1198
|
+
<View style={{ flex: 1 }}><Divider /></View>
|
|
1199
|
+
<Text size="xs" color="muted">or enter manually</Text>
|
|
1200
|
+
<View style={{ flex: 1 }}><Divider /></View>
|
|
1201
|
+
</View>
|
|
1202
|
+
<View style={{ gap: 8 }}>
|
|
1203
|
+
<CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1204
|
+
<Icon name="arrow-up" size={18} color={colors.zinc[700]} />
|
|
1205
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
1206
|
+
<Text size="sm" weight="semibold">Export case</Text>
|
|
1207
|
+
<Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
|
|
1208
|
+
</View>
|
|
1209
|
+
</CardSelectItem>
|
|
1210
|
+
<CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1211
|
+
<Icon name="arrow-down" size={18} color={colors.zinc[700]} />
|
|
1212
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
1213
|
+
<Text size="sm" weight="semibold">Import case</Text>
|
|
1214
|
+
<Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
|
|
1215
|
+
</View>
|
|
1216
|
+
</CardSelectItem>
|
|
1217
|
+
</View>
|
|
1218
|
+
</View>
|
|
1219
|
+
) : null}
|
|
1220
|
+
|
|
1221
|
+
{phase === "analyze" || phase === "running" ? (
|
|
1222
|
+
// Streams follow in a FollowScroll (padding mirrors DialogScrollArea);
|
|
1223
|
+
// the phase flip swaps the scroller out, so the next pane opens at the top.
|
|
1224
|
+
<FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
|
|
1225
|
+
<AgentRun parts={script.slice(0, revealed)} state={revealed >= script.length ? "done" : "streaming"} />
|
|
1226
|
+
</FollowScroll>
|
|
1227
|
+
) : null}
|
|
1228
|
+
|
|
1229
|
+
{phase === "clarify" ? (
|
|
1230
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 20 }}>
|
|
1231
|
+
<ClarifyWizard
|
|
1232
|
+
questions={INTAKE_QUESTIONS}
|
|
1233
|
+
onCancel={() => setPhase("intake")}
|
|
1234
|
+
onSubmit={(answers: ClarifyWizardAnswer[]) => {
|
|
1235
|
+
setGrouping(answers[0]?.value === "order" ? "order" : "customer");
|
|
1236
|
+
setPhase("running");
|
|
1237
|
+
}}
|
|
1238
|
+
/>
|
|
1239
|
+
</View>
|
|
1240
|
+
) : null}
|
|
1241
|
+
|
|
1242
|
+
{phase === "review" ? (
|
|
1243
|
+
<DialogScrollArea>
|
|
1244
|
+
<View style={{ gap: 8 }}>
|
|
1245
|
+
<ChangeReviewHeader title={`${proposals.length} records drafted`} />
|
|
1246
|
+
{proposals.map((p) => (
|
|
1247
|
+
<ChangeRecord
|
|
1248
|
+
key={p.id}
|
|
1249
|
+
id={p.id}
|
|
1250
|
+
tone="add"
|
|
1251
|
+
title={grouping === "customer" ? p.khach : `${p.orders} — ${p.khach}`}
|
|
1252
|
+
summary={`${p.orders}${p.dienThoai ? ` · ${p.dienThoai}` : ""}`}
|
|
1253
|
+
status={cardStatus[p.id] ?? "pending"}
|
|
1254
|
+
onAccept={() => setCardStatus((s) => ({ ...s, [p.id]: "accepted" }))}
|
|
1255
|
+
onReject={() => setCardStatus((s) => ({ ...s, [p.id]: "rejected" }))}
|
|
1256
|
+
onUndo={() => setCardStatus((s) => ({ ...s, [p.id]: "pending" }))}
|
|
1257
|
+
>
|
|
1258
|
+
<ChangeField label="Customer" value={p.khach} summary={p.khach} />
|
|
1259
|
+
{p.dienThoai ? <ChangeField label="Phone" value={p.dienThoai} summary={p.dienThoai} /> : null}
|
|
1260
|
+
<ChangeField label="Service fee" value={fees[p.id] ?? String(p.phi)} summary={formatMoney(Number(fees[p.id] ?? p.phi) || 0)}>
|
|
1261
|
+
<ChangeValueInput value={fees[p.id] ?? String(p.phi)} onChangeText={(v) => setFees((s) => ({ ...s, [p.id]: v }))} accessibilityLabel={`Service fee for ${p.khach}`} />
|
|
1262
|
+
</ChangeField>
|
|
1263
|
+
</ChangeRecord>
|
|
1264
|
+
))}
|
|
1265
|
+
</View>
|
|
1266
|
+
</DialogScrollArea>
|
|
1267
|
+
) : null}
|
|
1268
|
+
|
|
1269
|
+
{phase === "form" ? (
|
|
1270
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
|
|
1271
|
+
<Text size="xs" color="muted">
|
|
1272
|
+
{variant === "export"
|
|
1273
|
+
? "The export case starts in Processing — details, tasks, and payment live on the record itself."
|
|
1274
|
+
: "The import case starts in Processing — details, tasks, and payment live on the record itself."}
|
|
1275
|
+
</Text>
|
|
1276
|
+
<FormField label="Customer">
|
|
1277
|
+
{khach === "" ? (
|
|
1278
|
+
/* find-or-create: the input stays a pure search (reflectSelection
|
|
1279
|
+
off — a custom "create" pick has no option to reflect anyway);
|
|
1280
|
+
the pick renders BELOW as the attached line with Change */
|
|
1281
|
+
<Combobox
|
|
1282
|
+
options={CUSTOMER_OPTIONS}
|
|
1283
|
+
onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
|
|
1284
|
+
reflectSelection={false}
|
|
1285
|
+
allowCustom
|
|
1286
|
+
customOptionPlacement="top"
|
|
1287
|
+
customOptionLabel={(q) => `Create new customer “${q}”`}
|
|
1288
|
+
>
|
|
1289
|
+
<ComboboxInput icon="search" placeholder="Search customers, or add a new one…" accessibilityLabel="Customer" />
|
|
1290
|
+
<ComboboxContent />
|
|
1291
|
+
</Combobox>
|
|
1292
|
+
) : (
|
|
1293
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 40 }}>
|
|
1294
|
+
<Icon name={CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "building-2" : "plus"} size={16} color={colors.zinc[400]} />
|
|
1295
|
+
<Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>
|
|
1296
|
+
{khach}
|
|
1297
|
+
{CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " · new customer"}
|
|
1298
|
+
</Text>
|
|
1299
|
+
<Link size="sm" onPress={() => setKhach("")} accessibilityLabel="Change customer">Change</Link>
|
|
1300
|
+
</View>
|
|
1301
|
+
)}
|
|
1302
|
+
</FormField>
|
|
1303
|
+
<FormField label="Phone" optional optionalLabel="Optional">
|
|
1304
|
+
<TextInputField value={dienThoai} onChangeText={setDienThoai} placeholder="(555) 000-0000" accessibilityLabel="Phone" />
|
|
1305
|
+
</FormField>
|
|
1306
|
+
<FormField label="Service fee">
|
|
1307
|
+
<NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Service fee" />
|
|
1308
|
+
</FormField>
|
|
1309
|
+
</View>
|
|
1310
|
+
) : null}
|
|
1311
|
+
|
|
1312
|
+
{phase === "form" ? (
|
|
1313
|
+
<DialogFooter>
|
|
1314
|
+
<Button title="Back" color="secondary" onPress={() => { setKhach(""); setDienThoai(""); setPhi(null); setPhase("intake"); }} />
|
|
1315
|
+
<Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={createManual} />
|
|
1316
|
+
</DialogFooter>
|
|
1317
|
+
) : phase === "review" ? (
|
|
1318
|
+
<DialogFooter>
|
|
1319
|
+
<ChangeReviewActions onDiscard={close} onApply={applyProposals} applyLabel={`Add ${keptCount} ${keptCount === 1 ? "record" : "records"}`} />
|
|
1320
|
+
</DialogFooter>
|
|
1321
|
+
) : null}
|
|
1092
1322
|
</Dialog>
|
|
1323
|
+
</ChangeReview>
|
|
1093
1324
|
</>
|
|
1094
1325
|
);
|
|
1095
1326
|
}
|
|
@@ -1119,7 +1350,7 @@ export function TplItemList() {
|
|
|
1119
1350
|
// EMPTY; the commons wait as ghost suggestions).
|
|
1120
1351
|
const [rows, setRows] = useState<HoSo[]>(HO_SO);
|
|
1121
1352
|
const nextMa = useRef(64);
|
|
1122
|
-
const
|
|
1353
|
+
const addRow = (khach: string, dienThoai: string, phi: number): string => {
|
|
1123
1354
|
const now = new Date();
|
|
1124
1355
|
const ma = `RC-2026-${String(nextMa.current++).padStart(4, "0")}`;
|
|
1125
1356
|
const row: HoSo = {
|
|
@@ -1134,16 +1365,30 @@ export function TplItemList() {
|
|
|
1134
1365
|
};
|
|
1135
1366
|
setRows((prev) => [row, ...prev]);
|
|
1136
1367
|
setTaskMap((prev) => ({ ...prev, [ma]: [] }));
|
|
1137
|
-
|
|
1138
|
-
|
|
1368
|
+
return ma;
|
|
1369
|
+
};
|
|
1370
|
+
// Reveal what was just created: clear every filter that could hide the new
|
|
1371
|
+
// row(s) and land on page 1.
|
|
1372
|
+
const revealNew = () => {
|
|
1139
1373
|
setTab("all");
|
|
1140
1374
|
setSearch("");
|
|
1141
1375
|
setAssignee([]);
|
|
1142
1376
|
setFeeStatus(null);
|
|
1143
1377
|
setSort(null);
|
|
1144
1378
|
setPage(0);
|
|
1379
|
+
};
|
|
1380
|
+
const createRecord = (khach: string, dienThoai: string, phi: number) => {
|
|
1381
|
+
const ma = addRow(khach, dienThoai, phi);
|
|
1382
|
+
revealNew();
|
|
1383
|
+
// A single manual create opens its drawer for refinement (create-then-refine).
|
|
1145
1384
|
setOpenMa(ma);
|
|
1146
1385
|
};
|
|
1386
|
+
// The AI-import apply — rows land at the top of the register; no drawer (a
|
|
1387
|
+
// bulk landing is reviewed IN the register, refined record by record later).
|
|
1388
|
+
const createRecordsBulk = (records: { khach: string; dienThoai: string; phi: number }[]) => {
|
|
1389
|
+
records.forEach((r) => addRow(r.khach, r.dienThoai, r.phi));
|
|
1390
|
+
revealNew();
|
|
1391
|
+
};
|
|
1147
1392
|
const [sort, setSort] = useState<SortState | null>(null);
|
|
1148
1393
|
const [assignee, setAssignee] = useState<string[]>([]);
|
|
1149
1394
|
const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
|
|
@@ -1285,7 +1530,7 @@ export function TplItemList() {
|
|
|
1285
1530
|
onChange={(v) => { setFeeAmount(v); setPage(0); }}
|
|
1286
1531
|
/>
|
|
1287
1532
|
</View>
|
|
1288
|
-
<
|
|
1533
|
+
<EnterDataDialog onCreate={createRecord} onCreateMany={createRecordsBulk} />
|
|
1289
1534
|
</View>
|
|
1290
1535
|
|
|
1291
1536
|
{/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
|