@lotics/ui 15.0.0 → 15.1.1
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 +15 -1
- package/MIGRATION.md +24 -2
- package/docs/catalog.md +34 -4
- package/docs/data_entry.md +53 -2
- package/docs/templates.md +21 -3
- package/examples/tpl_item_list.tsx +146 -48
- package/examples/tpl_record.tsx +37 -12
- package/package.json +10 -1
- package/src/dom_region.ts +14 -0
- package/src/dom_region.web.ts +13 -0
- package/src/file_drop_target.tsx +16 -0
- package/src/file_drop_target.web.tsx +118 -0
- package/src/file_dropzone.tsx +51 -96
- package/src/file_intake.test.ts +124 -0
- package/src/file_intake.ts +148 -0
- package/src/locale.tsx +2 -2
- package/src/use_paste_files.ts +14 -0
- package/src/use_paste_files.web.ts +101 -0
package/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
|
|
18
|
-
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments, stage gates. |
|
|
18
|
+
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates. |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI proposes, the human decides — composer, live run feed (`AgentRun`), review-before-apply, findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
|
|
21
21
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
|
|
@@ -27,6 +27,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
27
27
|
license to hand-roll.
|
|
28
28
|
- **One canonical component per data role** (member → `MemberChip`, select → `OptionBadge`,
|
|
29
29
|
files → `FilePreview` family, …) — the catalog's Reach-by-role outranks neighboring code.
|
|
30
|
+
- **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
|
|
31
|
+
attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
|
|
32
|
+
never a second chip. A chip beside a name reads as its PEER (a colored one reads louder),
|
|
33
|
+
inverting the hierarchy the row is scanned by. →
|
|
34
|
+
[composition.md §`Badge` is for STATUS only](./docs/composition.md).
|
|
30
35
|
- **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
|
|
31
36
|
hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
|
|
32
37
|
- **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
|
|
@@ -36,6 +41,15 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
36
41
|
(the tab stop, name, focus ring), never `PressableHighlight`, which wraps its children in the
|
|
37
42
|
button. `Table`/`TableRow` and `LinkedRecordBox` already do this. →
|
|
38
43
|
[composition.md](./docs/composition.md).
|
|
44
|
+
- **Files land THREE ways on any surface that holds them** — the section's Add CTA
|
|
45
|
+
(`pickFiles`), and a drag OR paste, both from ONE **`<FileDropTarget … paste>`** wrapping the
|
|
46
|
+
region — a Files section, OR a WHOLE surface (a record's section stack, a register page), so a
|
|
47
|
+
drop/paste anywhere on it routes to that surface's intake (its `paste` scopes Ctrl/Cmd+V to the
|
|
48
|
+
region's focus; `usePasteFiles` is the bare hook for a region-less modal), all on ONE handler.
|
|
49
|
+
Paste routes to the top-most FOCUSED region, else the top of the stack — two peer file sections
|
|
50
|
+
each win when focus is in them. Nobody should hunt for a dropzone; a `FileDropzone` well is the
|
|
51
|
+
empty state, not the only way in. Since a wrapped region shows no zone, name the paths with a
|
|
52
|
+
muted heading description ("Drag, paste, or click to add files"). → [data_entry.md](./docs/data_entry.md).
|
|
39
53
|
- **Every state designed** — skeleton (mirroring layout), empty, error; no layout shift.
|
|
40
54
|
- **Copy states content, not mechanics** — a `description` names the section's content or one
|
|
41
55
|
domain rule; widget behavior ("edits in place, saves on its own") and selling points never
|
package/MIGRATION.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @lotics/ui — migration notes
|
|
2
2
|
|
|
3
|
-
Breaking changes per major,
|
|
4
|
-
|
|
3
|
+
Breaking changes, newest first — normally per major, plus the rare minor that broke a type
|
|
4
|
+
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
|
+
this file exists only to move an app from one release to the next.
|
|
5
6
|
|
|
6
7
|
## v15 from 14.x
|
|
7
8
|
|
|
@@ -12,6 +13,27 @@ just the name, so labels can vary by input and by the new **`awaiting`** step st
|
|
|
12
13
|
an expanded step's Output panel), `onRetry` (a retry button under the terminal error row), and
|
|
13
14
|
the new `ApprovalPrompt` + `MessageActions` components with their locale slices.
|
|
14
15
|
|
|
16
|
+
## v14 from 13.x
|
|
17
|
+
|
|
18
|
+
The ai-sdk `UIMessage.parts` shape IS the `AgentRun` contract — the bespoke transcript type is
|
|
19
|
+
gone. `AgentRun`/`AgentProgress`'s **`items: AgentRunItem[]`** was replaced by **`parts:
|
|
20
|
+
readonly AgentUIPart[]`**, and the **`AgentRunItem`** / **`AgentRunStep`** / **`AgentStepStatus`**
|
|
21
|
+
exports were deleted. An app feeds it straight from the SDK: `useAgentRun().items` →
|
|
22
|
+
**`.parts`** (`@lotics/app-sdk` ≥ 0.54.0, whose SSE reducer now emits ai `dynamic-tool` / `text`
|
|
23
|
+
/ `reasoning` parts); chat passes `message.parts`. Hand-built demo transcripts are rewritten as
|
|
24
|
+
parts. Additively: a run-level **`error`** prop for the BREAKING failure that lives OUTSIDE
|
|
25
|
+
`parts` (a killed stream / API error) — a per-tool failure stays in its own `output-error` part
|
|
26
|
+
— and tool I/O now expands IN PLACE on press instead of opening a `Peek` popover.
|
|
27
|
+
|
|
28
|
+
## 13.9.0 — a BREAKING type change in a MINOR
|
|
29
|
+
|
|
30
|
+
**`ClarifyOption.description` went optional → REQUIRED** (every answer explains itself). It
|
|
31
|
+
shipped in a minor, which is why it surprises an app on `^13`: a `<Clarify options={…}>` whose
|
|
32
|
+
options carry only `label` stops typechecking on any 13.9.0+ install. Fix by writing the
|
|
33
|
+
one-line description each option always needed. Same release: `Clarify`'s hardcoded "Question"
|
|
34
|
+
eyebrow was dropped (the question text stays), and `allowCustom` ("Other…" free text) +
|
|
35
|
+
`ClarifyWizard` were added.
|
|
36
|
+
|
|
15
37
|
## v13 from 12.x
|
|
16
38
|
|
|
17
39
|
`RadioPicker`'s **`direction`** prop was removed — the picker is always the stacked column of
|
package/docs/catalog.md
CHANGED
|
@@ -225,7 +225,11 @@ tile a pressable door — e.g. tap-to-preview — carrying an accessible button
|
|
|
225
225
|
filename as its name, overridable per tile with `accessibilityLabel` when what the press
|
|
226
226
|
DOES reads better than a raw filename; `selectedIds` for a selection overlay),
|
|
227
227
|
`UploadingThumbnail` (the single in-flight tile FileGrid renders — reach for it only when
|
|
228
|
-
hand-rolling a non-grid upload layout), `
|
|
228
|
+
hand-rolling a non-grid upload layout), `FileDropTarget` (wrap ANY region — a record's Files
|
|
229
|
+
section, a card, a panel — so a file dragged onto it lands; `paste` also takes Ctrl/Cmd+V
|
|
230
|
+
scoped to that region's focus; the region-wrapper next to `FileDropzone`'s dedicated well) +
|
|
231
|
+
`usePasteFiles` (the bare clipboard hook underneath, for a region-less surface) — the halves of
|
|
232
|
+
"files land without hunting for a dropzone", `FileRow` (a horizontal file/document LINE —
|
|
229
233
|
badge-or-placeholder + name + meta + a composable `trailing` slot for a status badge /
|
|
230
234
|
action / remove; `onPress` makes the whole row a pressable door, `trailing` stays an
|
|
231
235
|
independently-pressable sibling; for checklists & readable lists), `FileBadge` (the two-tone
|
|
@@ -905,9 +909,35 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
905
909
|
- **`file_picker`** — `pickFiles({ accept?, multiple? }) → Promise<File[]>`: opens the
|
|
906
910
|
browser file dialog imperatively — the trigger half behind every Add-file CTA; cancel
|
|
907
911
|
resolves `[]` on modern engines.
|
|
908
|
-
- **`file_dropzone`** — `FileDropzone`: the drag-drop capture well (`onFiles`,
|
|
909
|
-
`label`/`hint`/`dropLabel`, `height`); click falls back to a picker
|
|
910
|
-
|
|
912
|
+
- **`file_dropzone`** — `FileDropzone`: the dedicated drag-drop capture well (`onFiles`,
|
|
913
|
+
`accept`, `label`/`hint`/`dropLabel`, `height`); click falls back to a picker, and while
|
|
914
|
+
mounted it ALSO takes a paste (Ctrl/Cmd+V) via its inner `FileDropTarget` `paste`, scoped to
|
|
915
|
+
the well's region. `label`/`dropLabel`/`hint` default from the locale's `fileDropzone` slice
|
|
916
|
+
(the hint now names the paste) — pass them only to override.
|
|
917
|
+
- **`file_drop_target`** — `FileDropTarget`: makes ANY region accept a file drag-drop —
|
|
918
|
+
`{ onFiles, accept?, multiple?, paste?, disabled?, children, style? }` wrapped around a
|
|
919
|
+
section, a card, a whole panel, so nobody hunts for a dropzone. While a drag hovers, the
|
|
920
|
+
region wears the kit's accent ring + wash (a box-shadow — nothing reflows); pass a FUNCTION
|
|
921
|
+
child (`(dragging) => …`) to draw the drag state yourself and the target paints nothing (how
|
|
922
|
+
`FileDropzone` keeps its well styling). **`paste`** ALSO takes Ctrl/Cmd+V, wiring
|
|
923
|
+
`usePasteFiles` scoped to THIS region's focus — so two peer file sections each win when
|
|
924
|
+
focus is in them, instead of the last-mounted one grabbing every paste. Native renders the
|
|
925
|
+
children unchanged.
|
|
926
|
+
- **`use_paste_files`** — `usePasteFiles({ onFiles, accept?, multiple?, enabled?, region? })`:
|
|
927
|
+
the clipboard half of intake — Ctrl/Cmd+V adds a copied file or a screenshot to the open
|
|
928
|
+
surface (most reach it via `FileDropTarget`'s `paste`; use the bare hook for a region-less
|
|
929
|
+
surface like a modal dialog). Scope it with `enabled` ("while this dialog/record is open"); a
|
|
930
|
+
paste carrying no acceptable file is left alone, so typing Ctrl+V in a text field still pastes
|
|
931
|
+
text. **Routing (`region`):** among all enabled sinks, the paste goes to the TOP-MOST whose
|
|
932
|
+
`region` contains `document.activeElement`; with none focused it falls back to the top of the
|
|
933
|
+
stack (the last-mounted surface — right for a dialog over a screen). A sink with no `region`
|
|
934
|
+
is a pure stack participant, so a single existing consumer is unchanged. Native is a no-op.
|
|
935
|
+
- **`file_intake`** — the shared, RN-free intake core + the contracts above:
|
|
936
|
+
`matchesAccept(file, accept)`, `filesFromTransfer(dataTransfer, { accept?, multiple? })`
|
|
937
|
+
(one extractor for BOTH `drop` and `paste`, with the clipboard-`items` fallback a pasted
|
|
938
|
+
screenshot needs), `selectPasteSink(entries)` (the pure focus-then-stack routing rule the
|
|
939
|
+
`.web` sink applies), and the types `FileIntakeFilter` / `FileTransferLike` / `RegionRef` /
|
|
940
|
+
`PasteSinkEntry` / `UsePasteFilesOptions` / `FileDropTargetProps`.
|
|
911
941
|
- **`files_editor`** — `FilesEditor` — THE all-in-one attachment field: `FileGrid` + a
|
|
912
942
|
toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode
|
|
913
943
|
(Select all · a Menu of Download/Share/Delete · Done; the per-tile ✕ is select-mode-only,
|
package/docs/data_entry.md
CHANGED
|
@@ -24,7 +24,7 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
|
|
|
24
24
|
| ONE pick from a BIG registry (ports, projects…) | on a RECORD surface: **`InlineSelect searchable`** (search lives in the popover); on a form/attach surface: **`Combobox`** with `value` | an inline row stays an inline editor — a persistent search input breaks the surface's grammar |
|
|
25
25
|
| a YES/NO on a record | **`CheckboxInput`** as the `DetailRow` value | a boolean needs no editor mode |
|
|
26
26
|
| a STATUS with terminal outcomes | [**disposition**](#disposition--lifecycle-status-is-asymmetric-by-phase) (open → resolve → revise) | guides the decision |
|
|
27
|
-
| FILES | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery) | add / preview / delete |
|
|
27
|
+
| FILES | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery; intake = CTA + drop-anywhere + paste) | add / preview / delete |
|
|
28
28
|
| a state TRANSITION mid-flow | [**stage gate**](#stage-gates--tiered-by-weight) (popover / dialog by weight) | right-sized friction |
|
|
29
29
|
|
|
30
30
|
**The right-input-per-field law.** A field gets the control its SHAPE wants, never a default
|
|
@@ -340,6 +340,56 @@ Popover/MenuButton).
|
|
|
340
340
|
|
|
341
341
|
## Attachments — a full add / preview / DELETE field
|
|
342
342
|
|
|
343
|
+
### Intake — files land THREE ways, on every surface that holds files
|
|
344
|
+
|
|
345
|
+
A user should never hunt for a dropzone. Any surface that holds files carries all three
|
|
346
|
+
intake paths, all wired to ONE handler:
|
|
347
|
+
|
|
348
|
+
1. **The section's own CTA** — `pickFiles` behind an Add-file button (the explicit path).
|
|
349
|
+
2. **Drop anywhere on the region + paste** — wrap the region in
|
|
350
|
+
**`<FileDropTarget onFiles accept multiple paste disabled style>`**. It makes ARBITRARY
|
|
351
|
+
children a drop region: drag a file over any part of it and the region wears the accent ring
|
|
352
|
+
+ wash (a box-shadow, so nothing reflows), drop and `onFiles` fires. Add **`paste`** and the
|
|
353
|
+
SAME region also takes Ctrl/Cmd+V (a screenshot or a copied file) — so the region carries
|
|
354
|
+
both non-explicit paths from one wrapper. This is the region-wrapper; `FileDropzone` stays
|
|
355
|
+
the dedicated empty-state WELL (and is itself a `FileDropTarget` with `paste` — a function
|
|
356
|
+
child, `(dragging) => …`, lets a child own the drag visual and the target paint nothing).
|
|
357
|
+
**The region can be a WHOLE SURFACE, not just a Files section.** Wrap a record's entire
|
|
358
|
+
section stack (or a register's whole page) in ONE `FileDropTarget` and a drop/paste ANYWHERE
|
|
359
|
+
on it routes to that surface's intake — the user never has to find the Files section. Give
|
|
360
|
+
`flex:1` (+ `minWidth:0` inside a flex row) so the wrapper is layout-transparent, and
|
|
361
|
+
`disabled` while an intake dialog is open so a paste can't start a second one. When the whole
|
|
362
|
+
surface is the target, the inner section drops its OWN `FileDropTarget` (redundant — one drop
|
|
363
|
+
would otherwise double it); its affordance line stays.
|
|
364
|
+
3. **The bare paste hook** — **`usePasteFiles({ onFiles, accept, multiple, enabled, region })`**
|
|
365
|
+
is what `paste` wires internally; reach for it directly only for a region-LESS surface (a
|
|
366
|
+
modal dialog with no wrapper). Scope it with `enabled` ("while this record/dialog is open");
|
|
367
|
+
a paste with no acceptable file is left alone, so Ctrl+V in a text field still pastes text.
|
|
368
|
+
|
|
369
|
+
**Paste routing (focus, then stack).** When two file targets are enabled on the same layer,
|
|
370
|
+
the paste goes to the TOP-MOST one whose `region` contains focus (`document.activeElement`) —
|
|
371
|
+
so two peer file sections each win while the user is working in them, never "last-mounted
|
|
372
|
+
silently grabs everything." With none focused it falls back to the top of the stack (the
|
|
373
|
+
last-mounted surface — the right default for a dialog stacked over a screen). A `usePasteFiles`
|
|
374
|
+
with no `region` is a pure stack participant, so a single existing consumer is byte-for-byte
|
|
375
|
+
unchanged. `FileDropTarget paste` passes its own region, so you get focus routing for free.
|
|
376
|
+
|
|
377
|
+
Both drop and paste are web behaviors with native no-op siblings — wrap/call them
|
|
378
|
+
unconditionally on a shared screen. The two templates are the worked examples of the
|
|
379
|
+
WHOLE-SURFACE shape: `examples/tpl_record.tsx` wraps its ENTIRE section stack in one
|
|
380
|
+
`<FileDropTarget … paste disabled={aiOpen}>` → a drop/paste anywhere on the record opens the
|
|
381
|
+
`intakeFiles` fork dialog; `examples/tpl_item_list.tsx` wraps its whole register page → a
|
|
382
|
+
drop/paste anywhere opens the "Enter data" intake PRE-SEEDED with the dropped files (the
|
|
383
|
+
register also keeps a section-scoped `FileDropTarget` on the record drawer's Files section, so
|
|
384
|
+
both scopes are shown). Each is one `intakeFiles`/`addFiles`/seed handler behind the wrapper
|
|
385
|
+
plus the Add/Enter CTA.
|
|
386
|
+
|
|
387
|
+
**Make the two invisible paths discoverable.** A `FileDropTarget`-wrapped section shows no
|
|
388
|
+
dropzone, so drag + paste are invisible unless you SAY so. Every files section carries a muted
|
|
389
|
+
affordance line under its heading — **`<SectionHeadingTitle description="Drag, paste, or click
|
|
390
|
+
to add files">`** (both templates do this). A `FileDropzone`'s own hint already names the paste
|
|
391
|
+
(its default `fileDropzone.hint` is "or click, or paste (⌘V)").
|
|
392
|
+
|
|
343
393
|
### Default: `FilesEditor`
|
|
344
394
|
|
|
345
395
|
**`<FilesEditor files onAdd onRemove>`** bundles the upload-aware grid + a toolbar below it
|
|
@@ -368,7 +418,8 @@ Reach for the lower-level pieces below only when you need custom chrome.
|
|
|
368
418
|
### The pieces — `FileDropzone`, `FileGrid`, `FileGalleryModal`, `FileRow`
|
|
369
419
|
|
|
370
420
|
Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
|
|
371
|
-
accent; click falls back to a picker
|
|
421
|
+
accent; click falls back to a picker; a mounted zone is also the paste sink, so Ctrl/Cmd+V
|
|
422
|
+
works inside an open upload dialog); display what landed with **`<FileGrid files uploads>`**
|
|
372
423
|
ABOVE the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add
|
|
373
424
|
more" affordance — only the empty state leads with it).
|
|
374
425
|
|
package/docs/templates.md
CHANGED
|
@@ -178,7 +178,12 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
|
|
|
178
178
|
**disabled** checkbox (the same gating as any blocked line). A row's status is
|
|
179
179
|
`Badge variant="dot"` — never the tonal `Badge` (that's the drawer/header twin; → [composition
|
|
180
180
|
grammar §"Status indicators have a WEIGHT"](./composition.md)) — and the SAME dot vocabulary
|
|
181
|
-
drives the status facet. A
|
|
181
|
+
drives the status facet. A cell that carries supporting detail is a HIERARCHY, not two peers:
|
|
182
|
+
the identity on top (`size="sm"`, `weight="medium"` when it IS the row's name), its supporting
|
|
183
|
+
value beneath as `size="xs" color="muted"`, `gap: 2` — and a category / type / attribute goes
|
|
184
|
+
on THAT line, never a second chip beside the name (which reads as its peer, or louder when
|
|
185
|
+
colored, inverting what the row is scanned by; → [composition grammar §`Badge` is for STATUS
|
|
186
|
+
only](./composition.md)). A per-row action `Button` (here Print) is the row's primary action in
|
|
182
187
|
the trailing column, `⋯` its overflow. A select-all band, footer totals + `Pagination`, and a
|
|
183
188
|
`FloatingActionBar` carrying the bulk action while rows are ticked.
|
|
184
189
|
- **A row press opens the PRODUCTION workspace `Drawer`**, which demonstrates the full
|
|
@@ -193,7 +198,9 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
|
|
|
193
198
|
drawer header swaps to a back button + the pushed id and the ◀ ▶ sequencer hides; back
|
|
194
199
|
pops with scroll preserved.
|
|
195
200
|
- **Files** with its own Add CTA — `FileRows` CRUD via `pickFiles`, plus an EXPECTED
|
|
196
|
-
document as a ghost `FileRow placeholder` with a Request action.
|
|
201
|
+
document as a ghost `FileRow placeholder` with a Request action. The section is wrapped in
|
|
202
|
+
a `<FileDropTarget … paste>`, so a drag anywhere onto it or a Ctrl/Cmd+V lands on the same
|
|
203
|
+
`addFiles` handler; a `SectionHeadingTitle description` names the three ways in.
|
|
197
204
|
- **Payment** — the `Ledger` (charge/received groups with sums, peekable fee rows,
|
|
198
205
|
`LedgerTotal`) + a Record-payment popover that appends a receipt.
|
|
199
206
|
- **Activity** — the CRM touch-log shape: optional outcome pills (`ChipGroup`,
|
|
@@ -205,7 +212,11 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
|
|
|
205
212
|
whose press opens the shared `FileGalleryModal`.
|
|
206
213
|
- A closing `DangerZone`.
|
|
207
214
|
- **The "Enter data" INTAKE FORK** (the New CTA) — AI FIRST, the form as fallback, one phased
|
|
208
|
-
dialog
|
|
215
|
+
dialog. The whole register page is ALSO a `<FileDropTarget … paste>` (the whole-surface intake
|
|
216
|
+
standard): a file dropped or Ctrl/Cmd+V pasted ANYWHERE on the register opens this same dialog
|
|
217
|
+
PRE-SEEDED with the dropped files (list-first — the paid "Read with AI" stays a deliberate
|
|
218
|
+
button). The dialog is CONTROLLED (`open`/`onOpenChange` + `seedNames`) so both the button and
|
|
219
|
+
the top-level drop drive it. The dialog itself: a `FileDropzone` hero (drop the customer's files) → a short ANALYZE stream
|
|
209
220
|
(`AgentRun` in a `FollowScroll`) reads them → `ClarifyWizard` asks the ambiguities the
|
|
210
221
|
analysis surfaced (described options, one custom-answer slot) → the IMPORT stream drafts the
|
|
211
222
|
records → `ChangeRecord` cards to Keep/Drop (fee editable in place) + one outcome-named
|
|
@@ -280,6 +291,13 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
280
291
|
- **Documents** — the Agents "Document desk" pattern (this template is its worked example —
|
|
281
292
|
see the Agents chapter below): the register `Table` (search · Add files) whose selection
|
|
282
293
|
feeds the `FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
|
|
294
|
+
The template is the worked example of the THREE-way, WHOLE-SURFACE file intake: the Add-files
|
|
295
|
+
CTA plus a `<FileDropTarget … paste disabled={aiOpen}>` wrapping the ENTIRE section stack (not
|
|
296
|
+
just Documents) — a drag or Ctrl/Cmd+V ANYWHERE on the record (`disabled` while the intake
|
|
297
|
+
dialog runs so a paste can't start a second one) — all landing in ONE `intakeFiles` fork
|
|
298
|
+
dialog. The Documents section therefore carries NO drop target of its own (the whole-record
|
|
299
|
+
one covers it), only its Add-files CTA and a muted `SectionHeadingTitle description` ("Drag,
|
|
300
|
+
paste, or click to add files") that keeps the otherwise-invisible paths discoverable.
|
|
283
301
|
Every row's leading visual is a `FileThumbnail` in ONE square 32px slot — an image file
|
|
284
302
|
fills it as a real thumbnail, a document centers its badge in it — never a bare `FileBadge`
|
|
285
303
|
(mixed footprints misalign the identity column). The desk holds what ARRIVES; generation
|
|
@@ -57,6 +57,7 @@ import { FollowScroll } from "@lotics/ui/follow_scroll";
|
|
|
57
57
|
import { ClarifyWizard, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
|
|
58
58
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
59
59
|
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
60
|
+
import { FileDropTarget } from "@lotics/ui/file_drop_target";
|
|
60
61
|
import { ChangeField, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeValueInput, type ChangeStatus } from "@lotics/ui/change_review";
|
|
61
62
|
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
62
63
|
import { MemberSelect } from "@lotics/ui/member_select";
|
|
@@ -461,6 +462,11 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
|
|
|
461
462
|
const [declared, setDeclared] = useState<number | null>(isSeed ? 25_000_000 : null);
|
|
462
463
|
const [tags, setTags] = useState<string[]>(isSeed ? (hs.trangThai === "hoantat" ? ["corporate"] : ["corporate", "courier"]) : []);
|
|
463
464
|
const [files, setFiles] = useState<DisplayFile[]>(isSeed ? [sampleDoc("id-scan", "#3b82f6"), sampleDoc("application-form", "#10b981")] : []);
|
|
465
|
+
// THE standard files intake — one handler, three ways in: the section's Add
|
|
466
|
+
// CTA, a drag anywhere onto the Files section, and Ctrl/Cmd+V — the last two
|
|
467
|
+
// both ride the section's `<FileDropTarget paste>` below. Every record surface
|
|
468
|
+
// carries all three; the user should never have to hunt for a dropzone.
|
|
469
|
+
const addFiles = (picked: File[]) => setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
|
|
464
470
|
// ── the case's money: CHARGES owed vs RECEIPTS received — outstanding is
|
|
465
471
|
// the difference. Recording a payment appends a receipt; everything
|
|
466
472
|
// (bar, group sums, the closing number) recomputes from the two lists.
|
|
@@ -688,17 +694,23 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
|
|
|
688
694
|
<Divider />
|
|
689
695
|
{/* FILES — the document CRUD: FileRows (press → gallery preview, ⋯ →
|
|
690
696
|
remove) + the section's OWN CTA; an EXPECTED document renders as a
|
|
691
|
-
ghost placeholder row with its own chase action.
|
|
697
|
+
ghost placeholder row with its own chase action.
|
|
698
|
+
The whole section is a FileDropTarget with `paste` — drag a file
|
|
699
|
+
anywhere onto it, or Ctrl/Cmd+V while focus is in it — the standard
|
|
700
|
+
files-intake behavior every record surface should carry. */}
|
|
701
|
+
<FileDropTarget onFiles={addFiles} paste>
|
|
692
702
|
<Section>
|
|
693
703
|
<SectionHeading>
|
|
694
|
-
|
|
704
|
+
{/* The standard files-section affordance line — drag / paste / click
|
|
705
|
+
are otherwise invisible on a FileDropTarget-wrapped section. */}
|
|
706
|
+
<SectionHeadingTitle description="Drag, paste, or click to add files">Files</SectionHeadingTitle>
|
|
695
707
|
<SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta>
|
|
696
708
|
<Button
|
|
697
709
|
title="Add file"
|
|
698
710
|
color="secondary"
|
|
699
711
|
onPress={() => {
|
|
700
712
|
void pickFiles({ multiple: true }).then((picked) => {
|
|
701
|
-
if (picked.length > 0)
|
|
713
|
+
if (picked.length > 0) addFiles(picked);
|
|
702
714
|
});
|
|
703
715
|
}}
|
|
704
716
|
/>
|
|
@@ -711,6 +723,7 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
|
|
|
711
723
|
trailing={<Button title="Request" color="muted" onPress={() => {}} />}
|
|
712
724
|
/>
|
|
713
725
|
</Section>
|
|
726
|
+
</FileDropTarget>
|
|
714
727
|
<Divider />
|
|
715
728
|
{/* the PAYMENT summary — a plain financial STATEMENT: the two sides
|
|
716
729
|
of the ledger (charges owed · receipts received), every amount on
|
|
@@ -1101,14 +1114,22 @@ const importScript = (grouping: "customer" | "order"): Part[] => [
|
|
|
1101
1114
|
/** The manual gate stays the THREE-field create-then-refine form — past the
|
|
1102
1115
|
* popover threshold (1–2 fields), so a Dialog pane; NOT a wizard: the record's
|
|
1103
1116
|
* own workspace (the drawer it opens into) is where everything else is refined. */
|
|
1104
|
-
function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
1117
|
+
function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany }: {
|
|
1118
|
+
/** Controlled open — the intake is driven by BOTH the toolbar "Enter data"
|
|
1119
|
+
* button (opens empty) AND the register's whole-surface FileDropTarget (opens
|
|
1120
|
+
* PRE-SEEDED via `seedDocs`), so the trigger lives with the parent. */
|
|
1121
|
+
open: boolean;
|
|
1122
|
+
onOpenChange: (open: boolean) => void;
|
|
1123
|
+
/** Files to pre-seed the intake WELL when the dialog opens from a top-level
|
|
1124
|
+
* drop/paste — a dropped pile lands as thumbnail cards (list-first, NOT
|
|
1125
|
+
* auto-analyze), ready for the deliberate "Read with AI". Empty from the button. */
|
|
1126
|
+
seedDocs: DisplayFile[];
|
|
1105
1127
|
onCreate: (khach: string, dienThoai: string, phi: number) => void;
|
|
1106
1128
|
onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
|
|
1107
1129
|
}) {
|
|
1108
1130
|
const { small } = useScreenSize();
|
|
1109
|
-
const [open, setOpen] = useState(false);
|
|
1110
1131
|
const [phase, setPhase] = useState<IntakePhase>("intake");
|
|
1111
|
-
const [
|
|
1132
|
+
const [docs, setDocs] = useState<DisplayFile[]>([]);
|
|
1112
1133
|
const [variant, setVariant] = useState<ManualVariant>("export");
|
|
1113
1134
|
const [grouping, setGrouping] = useState<"customer" | "order">("customer");
|
|
1114
1135
|
// Q2's answer is load-bearing too: it decides how the unknown customer lands
|
|
@@ -1123,13 +1144,25 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1123
1144
|
const [dienThoai, setDienThoai] = useState("");
|
|
1124
1145
|
const [phi, setPhi] = useState<number | null>(null);
|
|
1125
1146
|
|
|
1126
|
-
//
|
|
1147
|
+
// Opening PRE-SEEDS the intake well: a top-level drop/paste passes the dropped
|
|
1148
|
+
// files via `seedDocs`, the button passes []. Keyed on `open` alone — the seed
|
|
1149
|
+
// is settled before the parent flips open, and re-seeding on every render would
|
|
1150
|
+
// fight the user's own add/remove among the thumbnails.
|
|
1151
|
+
useEffect(() => {
|
|
1152
|
+
if (open) {
|
|
1153
|
+
setPhase("intake");
|
|
1154
|
+
setDocs(seedDocs);
|
|
1155
|
+
}
|
|
1156
|
+
}, [open]);
|
|
1157
|
+
|
|
1158
|
+
// The analyze stream names the files it reads; the reveal serves analyze + import.
|
|
1159
|
+
const docNames = docs.map((d) => d.filename);
|
|
1127
1160
|
const [revealed, setRevealed] = useState(0);
|
|
1128
|
-
const script = phase === "analyze" ? analyzeScript(
|
|
1161
|
+
const script = phase === "analyze" ? analyzeScript(docNames) : phase === "running" ? importScript(grouping) : [];
|
|
1129
1162
|
useEffect(() => {
|
|
1130
1163
|
if (phase !== "analyze" && phase !== "running") return;
|
|
1131
1164
|
setRevealed(0);
|
|
1132
|
-
const total = phase === "analyze" ? analyzeScript(
|
|
1165
|
+
const total = phase === "analyze" ? analyzeScript(docNames).length : importScript(grouping).length;
|
|
1133
1166
|
let n = 0;
|
|
1134
1167
|
const t = setInterval(() => {
|
|
1135
1168
|
n += 1;
|
|
@@ -1145,6 +1178,11 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1145
1178
|
// settled before their stream phase begins.
|
|
1146
1179
|
}, [phase]);
|
|
1147
1180
|
|
|
1181
|
+
// ONE intake sink — every way IN adds to the pile: the whole-dialog
|
|
1182
|
+
// FileDropTarget (drag/paste anywhere on the open dialog), the big empty-state
|
|
1183
|
+
// dropzone, and the compact add-more dropzone all call this.
|
|
1184
|
+
const addDocs = (fs: File[]) => setDocs((prev) => [...prev, ...toDisplayFiles(fs)]);
|
|
1185
|
+
|
|
1148
1186
|
const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
|
|
1149
1187
|
// Kept = explicitly ACCEPTED (the kit's review contract): Apply stays disabled
|
|
1150
1188
|
// at 0 kept, and the bar's own Keep-all presses every pending card's Keep.
|
|
@@ -1152,7 +1190,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1152
1190
|
|
|
1153
1191
|
const reset = () => {
|
|
1154
1192
|
setPhase("intake");
|
|
1155
|
-
|
|
1193
|
+
setDocs([]);
|
|
1156
1194
|
setCardStatus({});
|
|
1157
1195
|
setFees({});
|
|
1158
1196
|
setCustomerPlan("New customer created and linked");
|
|
@@ -1160,7 +1198,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1160
1198
|
setDienThoai("");
|
|
1161
1199
|
setPhi(null);
|
|
1162
1200
|
};
|
|
1163
|
-
const close = () => {
|
|
1201
|
+
const close = () => { onOpenChange(false); reset(); };
|
|
1164
1202
|
|
|
1165
1203
|
const applyProposals = () => {
|
|
1166
1204
|
const kept = proposals.filter((p) => cardStatus[p.id] === "accepted");
|
|
@@ -1180,47 +1218,81 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1180
1218
|
: "Import from files";
|
|
1181
1219
|
|
|
1182
1220
|
return (
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1221
|
+
<ChangeReview>
|
|
1222
|
+
{/* the file/stream phases (intake/analyze/clarify/running/review) run
|
|
1223
|
+
WIDE — room for the thumbnail hero + the AgentRun/ClarifyWizard/review
|
|
1224
|
+
streams; only the manual `form` stays a narrow single-column pane.
|
|
1225
|
+
760 sits just under the kit Dialog's default 786 maxWidth cap. */}
|
|
1226
|
+
<Dialog width={phase === "form" ? 480 : 760} open={open} onOpenChange={(o) => { if (!o) close(); }}>
|
|
1187
1227
|
<DialogHeader>
|
|
1188
1228
|
<DialogHeaderTitle>{title}</DialogHeaderTitle>
|
|
1189
1229
|
</DialogHeader>
|
|
1190
1230
|
|
|
1191
1231
|
{phase === "intake" ? (
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
<
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1232
|
+
// The WHOLE open dialog is a FileDropTarget: drag OR paste anywhere on
|
|
1233
|
+
// it adds files, in BOTH modes (the ring/wash frames the dialog on drag
|
|
1234
|
+
// — "drop anywhere here"). Nested with the dropzones below, which each
|
|
1235
|
+
// light their own well; stopPropagation keeps one drop from double-adding.
|
|
1236
|
+
<FileDropTarget onFiles={addDocs} paste style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
|
|
1237
|
+
{/* ONE dialog, TWO MODES (not two Dialogs): FILES-PRESENT shows the
|
|
1238
|
+
pile + a compact add-more well + the deliberate Read; EMPTY shows
|
|
1239
|
+
the big dropzone hero + the manual chooser. Dropping in either ADDS
|
|
1240
|
+
files (via addDocs) — the paid AI read stays a DELIBERATE button,
|
|
1241
|
+
never a side effect of a drop. A pile means the user came to
|
|
1242
|
+
process files, so the manual cards fall away. */}
|
|
1243
|
+
{docs.length > 0 ? (
|
|
1244
|
+
<View style={{ gap: 16 }}>
|
|
1245
|
+
{/* the pile, as document cards — removable, the hero at top */}
|
|
1246
|
+
<FileThumbnailGrid
|
|
1247
|
+
files={docs}
|
|
1248
|
+
minItemWidth={120}
|
|
1249
|
+
onRemove={(id) => setDocs((prev) => prev.filter((d) => d.id !== id))}
|
|
1250
|
+
/>
|
|
1251
|
+
{/* add-more is a real WELL (draggable + clickable), not a button —
|
|
1252
|
+
compact so the pile stays the hero */}
|
|
1253
|
+
<FileDropzone
|
|
1254
|
+
height={96}
|
|
1255
|
+
label="Drop more files"
|
|
1256
|
+
hint="or click to add"
|
|
1257
|
+
onFiles={addDocs}
|
|
1258
|
+
/>
|
|
1259
|
+
<Button
|
|
1260
|
+
title={`Read ${docs.length} ${docs.length === 1 ? "file" : "files"} with AI`}
|
|
1261
|
+
color="primary"
|
|
1262
|
+
onPress={() => setPhase("analyze")}
|
|
1263
|
+
/>
|
|
1264
|
+
</View>
|
|
1265
|
+
) : (
|
|
1266
|
+
<>
|
|
1267
|
+
<FileDropzone
|
|
1268
|
+
label="Drop the customer's files"
|
|
1269
|
+
hint="Orders, invoices, spreadsheets — read them with AI"
|
|
1270
|
+
onFiles={addDocs}
|
|
1271
|
+
/>
|
|
1272
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1273
|
+
<View style={{ flex: 1 }}><Divider /></View>
|
|
1274
|
+
<Text size="xs" color="muted">or enter manually</Text>
|
|
1275
|
+
<View style={{ flex: 1 }}><Divider /></View>
|
|
1213
1276
|
</View>
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1277
|
+
<View style={{ gap: 8 }}>
|
|
1278
|
+
<CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1279
|
+
<Icon name="arrow-up" size={18} color={colors.zinc[700]} />
|
|
1280
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
1281
|
+
<Text size="sm" weight="semibold">Export case</Text>
|
|
1282
|
+
<Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
|
|
1283
|
+
</View>
|
|
1284
|
+
</CardSelectItem>
|
|
1285
|
+
<CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1286
|
+
<Icon name="arrow-down" size={18} color={colors.zinc[700]} />
|
|
1287
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
1288
|
+
<Text size="sm" weight="semibold">Import case</Text>
|
|
1289
|
+
<Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
|
|
1290
|
+
</View>
|
|
1291
|
+
</CardSelectItem>
|
|
1220
1292
|
</View>
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
</
|
|
1293
|
+
</>
|
|
1294
|
+
)}
|
|
1295
|
+
</FileDropTarget>
|
|
1224
1296
|
) : null}
|
|
1225
1297
|
|
|
1226
1298
|
{phase === "analyze" || phase === "running" ? (
|
|
@@ -1336,8 +1408,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1336
1408
|
</DialogFooter>
|
|
1337
1409
|
) : null}
|
|
1338
1410
|
</Dialog>
|
|
1339
|
-
|
|
1340
|
-
</>
|
|
1411
|
+
</ChangeReview>
|
|
1341
1412
|
);
|
|
1342
1413
|
}
|
|
1343
1414
|
|
|
@@ -1405,6 +1476,15 @@ export function TplItemList() {
|
|
|
1405
1476
|
records.forEach((r) => addRow(r.khach, r.dienThoai, r.phi));
|
|
1406
1477
|
revealNew();
|
|
1407
1478
|
};
|
|
1479
|
+
// The intake dialog is CONTROLLED here so BOTH the toolbar button and the
|
|
1480
|
+
// whole-register FileDropTarget drive it: the button opens it empty, a
|
|
1481
|
+
// top-level drop/paste opens it PRE-SEEDED with the dropped files (mapped to
|
|
1482
|
+
// DisplayFiles so the pile shows as thumbnail cards — the paid "Read with AI"
|
|
1483
|
+
// stays a deliberate button).
|
|
1484
|
+
const [intakeOpen, setIntakeOpen] = useState(false);
|
|
1485
|
+
const [intakeSeed, setIntakeSeed] = useState<DisplayFile[]>([]);
|
|
1486
|
+
const openIntake = () => { setIntakeSeed([]); setIntakeOpen(true); };
|
|
1487
|
+
const openIntakeSeeded = (dropped: File[]) => { setIntakeSeed(toDisplayFiles(dropped)); setIntakeOpen(true); };
|
|
1408
1488
|
const [sort, setSort] = useState<SortState | null>(null);
|
|
1409
1489
|
const [assignee, setAssignee] = useState<string[]>([]);
|
|
1410
1490
|
const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
|
|
@@ -1475,6 +1555,17 @@ export function TplItemList() {
|
|
|
1475
1555
|
|
|
1476
1556
|
return (
|
|
1477
1557
|
<View style={{ flex: 1 }}>
|
|
1558
|
+
{/* WHOLE-SURFACE INTAKE — the standard this template DEMONSTRATES: the ENTIRE
|
|
1559
|
+
register page is one `FileDropTarget`, so a file dropped or a Ctrl/Cmd+V
|
|
1560
|
+
pasted ANYWHERE on the register opens the intake dialog PRE-SEEDED with
|
|
1561
|
+
those files (list-first — the paid "Read with AI" stays a button). No
|
|
1562
|
+
`disabled`: the intake's own inner `FileDropzone` and the record drawer's
|
|
1563
|
+
Files section are later-mounted paste sinks, so while either is open the
|
|
1564
|
+
paste routes THERE (top of stack); a nested drop is stopPropagation-safe.
|
|
1565
|
+
`flex:1` keeps the ScrollView filling the page (the bar + drawer overlay
|
|
1566
|
+
on top); the accent ring frames the page on drag. A real app opts in
|
|
1567
|
+
per-app — this page is the reference shape. */}
|
|
1568
|
+
<FileDropTarget onFiles={openIntakeSeeded} paste style={{ flex: 1 }}>
|
|
1478
1569
|
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
1479
1570
|
<View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
|
|
1480
1571
|
{/* header — title only; the New case action lives on the toolbar row */}
|
|
@@ -1546,7 +1637,9 @@ export function TplItemList() {
|
|
|
1546
1637
|
onChange={(v) => { setFeeAmount(v); setPage(0); }}
|
|
1547
1638
|
/>
|
|
1548
1639
|
</View>
|
|
1549
|
-
|
|
1640
|
+
{/* the intake TRIGGER — the register's whole-surface drop/paste opens
|
|
1641
|
+
the same dialog (see the FileDropTarget wrapper below) */}
|
|
1642
|
+
<Button title="Enter data" color="primary" onPress={openIntake} />
|
|
1550
1643
|
</View>
|
|
1551
1644
|
|
|
1552
1645
|
{/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
|
|
@@ -1603,6 +1696,11 @@ export function TplItemList() {
|
|
|
1603
1696
|
</View>
|
|
1604
1697
|
</View>
|
|
1605
1698
|
</ScrollView>
|
|
1699
|
+
</FileDropTarget>
|
|
1700
|
+
|
|
1701
|
+
{/* the intake dialog — CONTROLLED, driven by the toolbar button AND the
|
|
1702
|
+
whole-register drop/paste above (opened seeded with the dropped files) */}
|
|
1703
|
+
<EnterDataDialog open={intakeOpen} onOpenChange={setIntakeOpen} seedDocs={intakeSeed} onCreate={createRecord} onCreateMany={createRecordsBulk} />
|
|
1606
1704
|
|
|
1607
1705
|
{/* bulk action over the ticked cases — the shared floating bar */}
|
|
1608
1706
|
<FloatingActionBar
|