@lotics/ui 41.0.1 → 41.1.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 +1 -1
- package/MIGRATION.md +23 -0
- package/docs/catalog.md +20 -1
- package/docs/data_entry.md +44 -1
- package/examples/tpl_item_list.tsx +21 -0
- package/examples/tpl_record.tsx +243 -36
- package/package.json +2 -1
- package/src/file_rows.tsx +13 -3
- package/src/inline_files.tsx +219 -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` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). 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, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
18
|
+
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
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). |
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,29 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 41.1.0 — `FileRows` no longer captions every row with the file type
|
|
8
|
+
|
|
9
|
+
`meta` defaulted to the file-type label, so a row read `contract.pdf` with `PDF` under it. The
|
|
10
|
+
type was already stated three other ways — the `FileBadge`'s colour, the label printed inside
|
|
11
|
+
that glyph, and the filename's own extension — so the caption was a fourth statement of one
|
|
12
|
+
fact, costing a line of row height per file to say nothing.
|
|
13
|
+
|
|
14
|
+
**Every `FileRows` in the repo took this default and passed no `meta`**, so this changes what
|
|
15
|
+
your file lists render: each row loses its second line and gets shorter. No API is removed and
|
|
16
|
+
nothing breaks a type.
|
|
17
|
+
|
|
18
|
+
To keep the old caption, ask for it:
|
|
19
|
+
|
|
20
|
+
```diff
|
|
21
|
+
- <FileRows files={files} />
|
|
22
|
+
+ <FileRows files={files} meta={(f) => resolveMime(f.mimeType).label} />
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`resolveMime` is exported from `@lotics/ui/file_badge`.
|
|
26
|
+
|
|
27
|
+
Better: pass something the NAME does not already carry — what an invoice covers, who uploaded
|
|
28
|
+
it, when it landed. That is what a second line is for.
|
|
29
|
+
|
|
7
30
|
## 41.0.0 — `RingGauge` is `ProgressRing`, and circular progress is one component
|
|
8
31
|
|
|
9
32
|
`RingGauge` is **deleted**. `ProgressRing` replaces it and covers both what it did and the
|
package/docs/catalog.md
CHANGED
|
@@ -926,6 +926,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
926
926
|
a data-grid cell is `<InlineSelect variant="bare" …/>`, not a `SelectCell` (naming a component for a
|
|
927
927
|
USE, and duplicating the picker stack to flip two style properties, are both things the kit forbids).
|
|
928
928
|
Hand-roll a pressable cell to match `bare`, never a background wash.
|
|
929
|
+
- **`inline_files`** — `InlineFiles`: the FILES member of the Inline\* family — a record's
|
|
930
|
+
attachments as a `DetailRow` value. A `FileRows` LIST (press a row to preview full-screen, ⋯
|
|
931
|
+
to remove) with ONE CTA under it; no selection mode, no bulk bar, no upload queue — reach for
|
|
932
|
+
`FilesEditor` when a surface genuinely needs those, which a record row does not. **`multiple`
|
|
933
|
+
is the FIELD's cardinality and it changes the verb**: many → the CTA reads `addMoreLabel` and
|
|
934
|
+
the host APPENDS in `onAdd`; one → it reads `replaceLabel` and the host SETS, because "add
|
|
935
|
+
another" is not an act a single-file field can perform and offering it is how a record ends up
|
|
936
|
+
holding two of something the schema says there is one of (the component owns the affordance,
|
|
937
|
+
it cannot own the persistence). `blockedReason` renders a sentence instead of the CTA for a
|
|
938
|
+
field with nothing to attach TO yet — never a disabled button, which says only that you may
|
|
939
|
+
not. A LIST and not switchable: documents are identified by NAME, images by CONTENT, so a
|
|
940
|
+
photo field wants `FileGrid`/`FilesEditor` instead — that is a judgment about the data, so it
|
|
941
|
+
picks the component rather than setting a prop. Sits on the inline grid's VERTICAL beat with
|
|
942
|
+
NO horizontal inset (unlike `InlineSlot`, which insets 9px to align a raw STRING's baseline):
|
|
943
|
+
its children are boxes, and a box aligns with the editors' box, not with their text.
|
|
929
944
|
- **`inline_static`** — `InlineStatic`: a READ-ONLY value matching the Inline\* box metrics
|
|
930
945
|
EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
|
|
931
946
|
total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
|
|
@@ -1546,7 +1561,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1546
1561
|
`TableRow` 52, this component's own `md` 56), which is what "squeezed" literally was.
|
|
1547
1562
|
- **`file_rows`** — `FileRows`: batteries-included file list — row press → built-in gallery
|
|
1548
1563
|
+ a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
|
|
1549
|
-
`FileGalleryModal`; localized via the `gallery` locale slice.
|
|
1564
|
+
`FileGalleryModal`; localized via the `gallery` locale slice. **`meta` has NO default**: the
|
|
1565
|
+
badge states the type as colour and as a label, and the filename ends in the extension, so a
|
|
1566
|
+
type caption was a fourth statement of one fact costing a line of row height. Pass a second
|
|
1567
|
+
line only when it carries something the NAME does not — what an invoice covers, who uploaded
|
|
1568
|
+
it, when it landed.
|
|
1550
1569
|
- **`file_badge`** — `FileBadge`: the two-tone file-type mark (PDF / XLSX / DOCX / video /
|
|
1551
1570
|
audio …) driven by MIME. Its `size` is the badge's WIDTH and everything scales off it —
|
|
1552
1571
|
the mark is TALLER than it is wide (26 × 32 by default), so a square slot takes the
|
package/docs/data_entry.md
CHANGED
|
@@ -25,7 +25,8 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
|
|
|
25
25
|
| 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 |
|
|
26
26
|
| a YES/NO on a record | **`CheckboxInput`** as the `DetailRow` value | a boolean needs no editor mode |
|
|
27
27
|
| a STATUS with terminal outcomes | [**disposition**](#disposition--lifecycle-status-is-asymmetric-by-phase) (open → resolve → revise) | guides the decision |
|
|
28
|
-
| FILES | [
|
|
28
|
+
| FILES on a record ROW | [**`InlineFiles`**](#inlinefiles--the-files-member-of-the-inline-family) — a list + one CTA, on the inline-control grid | documents are identified by NAME, and the value column stays a column |
|
|
29
|
+
| FILES as a whole SECTION | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery; intake = CTA + drop-anywhere + paste) | add / preview / delete, with select + bulk |
|
|
29
30
|
| a state TRANSITION mid-flow | [**stage gate**](#stage-gates--tiered-by-weight) (popover / dialog by weight) | right-sized friction |
|
|
30
31
|
|
|
31
32
|
**The right-input-per-field law.** A field gets the control its SHAPE wants, never a default
|
|
@@ -553,6 +554,48 @@ blue open) with a quiet, REVERSIBLE **Change**: a popover STATE SWITCHER (each s
|
|
|
553
554
|
dot + label, current marked + disabled), not a generic text menu. A composition (Badge + Button +
|
|
554
555
|
Popover/MenuButton).
|
|
555
556
|
|
|
557
|
+
## `InlineFiles` — the FILES member of the `Inline*` family
|
|
558
|
+
|
|
559
|
+
A record field whose value is documents. `<DetailRow label="Scan"><InlineFiles … /></DetailRow>`
|
|
560
|
+
— a `FileRows` list (press a row to preview it full-screen, ⋯ to remove) with one CTA under it,
|
|
561
|
+
sitting on the inline-control grid via `InlineSlot`.
|
|
562
|
+
|
|
563
|
+
It exists because every other field type shipped BOTH halves — `Select`/`InlineSelect`,
|
|
564
|
+
`TextInputField`/`InlineTextInput` — and files shipped only the section-scale one. So a record
|
|
565
|
+
field holding documents got hand-rolled per app as `FileRows` + `pickFiles` + a `Button`, and
|
|
566
|
+
each copy re-derived the same decisions. The one they missed is `InlineSlot`: raw children in a
|
|
567
|
+
value column start at the cell edge and 20px tall, beside editors that are 40px with a 9px text
|
|
568
|
+
inset, so the column acquires two left edges and two baselines and stops reading as a column.
|
|
569
|
+
|
|
570
|
+
**Cardinality is a property of the FIELD, and it changes the verb.**
|
|
571
|
+
|
|
572
|
+
| `multiple` | picker | CTA once something is attached | the host's `onAdd` must |
|
|
573
|
+
|---|---|---|---|
|
|
574
|
+
| `true` (default) | many at once | `addMoreLabel` — "Add page" | APPEND |
|
|
575
|
+
| `false` | exactly one | `replaceLabel` — "Replace contract" | **SET** |
|
|
576
|
+
|
|
577
|
+
"Add another" is not an act a single-file field can perform, and offering it produces a field
|
|
578
|
+
holding two of something the schema says there is one of. The component gets the affordance
|
|
579
|
+
right; it cannot enforce the persistence, because it does not own the data — so the difference
|
|
580
|
+
lives at the call site, `onAdd={([f]) => setFile(f)}` against
|
|
581
|
+
`onAdd={(fs) => setFiles([...files, ...fs])}`, where it is decided.
|
|
582
|
+
|
|
583
|
+
**Nothing to attach TO yet is a real state.** Pass `blockedReason` and the CTA is replaced by a
|
|
584
|
+
sentence naming the act that unblocks it ("attach the invoice number first"). Never a disabled
|
|
585
|
+
button: disabled says you may not and leaves the reader to work out why and what would change
|
|
586
|
+
it — same pixels, one of them useful.
|
|
587
|
+
|
|
588
|
+
**A LIST, and not switchable.** Documents are identified by NAME; a PDF/Word/Excel thumbnail is
|
|
589
|
+
a grey page identical to every other grey page, so a grid of them is a wall of one tile under a
|
|
590
|
+
truncated caption. Images are identified by their CONTENT and want `FileGrid`/`FilesEditor`.
|
|
591
|
+
Which one a field holds is a judgment about the DATA, so it picks the component rather than
|
|
592
|
+
setting a prop on this one.
|
|
593
|
+
|
|
594
|
+
Lean by design — no selection mode, no bulk bar, no upload queue. The kit never uploads: the
|
|
595
|
+
host takes `File[]`, stores them, and passes the result back as `files`. Return a promise from
|
|
596
|
+
`onAdd` and the CTA stays busy until it settles, which is the whole loading state — a control
|
|
597
|
+
that re-arms immediately invites the second press that files it twice.
|
|
598
|
+
|
|
556
599
|
## Attachments — a full add / preview / DELETE field
|
|
557
600
|
|
|
558
601
|
### Intake — files land THREE ways, on every surface that holds files
|
|
@@ -49,6 +49,7 @@ import { FileDropTarget } from "@lotics/ui/file_drop_target";
|
|
|
49
49
|
import { Link } from "@lotics/ui/link";
|
|
50
50
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
51
51
|
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
52
|
+
import { InlineFiles } from "@lotics/ui/inline_files";
|
|
52
53
|
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
53
54
|
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
54
55
|
import { useSelection } from "@lotics/ui/use_selection";
|
|
@@ -361,6 +362,7 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
361
362
|
const [ngayNhan, setNgayNhan] = useState(() => (r ? isoReceived(r.ngayNhan) : ""));
|
|
362
363
|
const [collected, setCollected] = useState(r?.daThu ? "yes" : "no");
|
|
363
364
|
const [fee, setFee] = useState<number | null>(r?.phi ?? null);
|
|
365
|
+
const [docs, setDocs] = useState<DisplayFile[]>([]);
|
|
364
366
|
if (!r) return null;
|
|
365
367
|
return (
|
|
366
368
|
<>
|
|
@@ -380,6 +382,25 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
380
382
|
<DetailRow label="Fee">
|
|
381
383
|
<InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
|
|
382
384
|
</DetailRow>
|
|
385
|
+
{/* FILES AS A FIELD — the `Inline*` member that used to be missing,
|
|
386
|
+
so a record's documents got hand-rolled per app as `FileRows` +
|
|
387
|
+
`pickFiles` + a `Button`, each copy re-deriving the same
|
|
388
|
+
decisions and usually missing `InlineSlot` (which is what keeps
|
|
389
|
+
this value column a column rather than two left edges).
|
|
390
|
+
|
|
391
|
+
The MANY case: `multiple` defaults true, the CTA becomes
|
|
392
|
+
"Add document" once something is attached, and the host APPENDS.
|
|
393
|
+
`tpl_record` shows the single-file case, where the verb is
|
|
394
|
+
replace — see the table in data_entry.md §InlineFiles. */}
|
|
395
|
+
<DetailRow label="Documents">
|
|
396
|
+
<InlineFiles
|
|
397
|
+
files={docs}
|
|
398
|
+
onAdd={(picked) => setDocs((p) => [...p, ...toDisplayFiles(picked)])}
|
|
399
|
+
onRemove={(f) => setDocs((p) => p.filter((x) => x.id !== f.id))}
|
|
400
|
+
addLabel="Attach document"
|
|
401
|
+
addMoreLabel="Add document"
|
|
402
|
+
/>
|
|
403
|
+
</DetailRow>
|
|
383
404
|
<DetailRow label="Collected">
|
|
384
405
|
<InlineSelect
|
|
385
406
|
value={collected}
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
2
|
+
import { Pressable, ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
5
5
|
import { DRAWER_GUTTER } from "@lotics/ui/drawer";
|
|
@@ -8,6 +8,7 @@ import { BackButton } from "@lotics/ui/back_button";
|
|
|
8
8
|
import { Divider } from "@lotics/ui/divider";
|
|
9
9
|
import { Link } from "@lotics/ui/link";
|
|
10
10
|
import { Icon } from "@lotics/ui/icon";
|
|
11
|
+
import { TextLink } from "@lotics/ui/text_link";
|
|
11
12
|
import { Alert } from "@lotics/ui/alert";
|
|
12
13
|
import type { PickerOption } from "@lotics/ui/picker";
|
|
13
14
|
import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
|
|
@@ -31,7 +32,9 @@ import { Skeleton } from "@lotics/ui/skeleton";
|
|
|
31
32
|
import { CommentList, type CommentEditFormProps, type ThreadComment, type ThreadFile } from "@lotics/ui/comments_thread";
|
|
32
33
|
import { Composer } from "@lotics/ui/composer";
|
|
33
34
|
import { IconButton } from "@lotics/ui/icon_button";
|
|
35
|
+
import { FileBadge } from "@lotics/ui/file_badge";
|
|
34
36
|
import { FileRows } from "@lotics/ui/file_rows";
|
|
37
|
+
import { InlineFiles } from "@lotics/ui/inline_files";
|
|
35
38
|
import { FileGrid } from "@lotics/ui/file_grid";
|
|
36
39
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
37
40
|
import { InlineStatic } from "@lotics/ui/inline_static";
|
|
@@ -399,6 +402,19 @@ const PANEL_GUTTER = PANEL_W + RAIL_GAP;
|
|
|
399
402
|
* `Drawer`'s 600 it sat between two columns of its own order and stopped reading
|
|
400
403
|
* as the thing the page is about. */
|
|
401
404
|
const CONTENT_MAX = 680;
|
|
405
|
+
/** The page's own horizontal padding. Named because `groupW` subtracts it and
|
|
406
|
+
* the panel's fold threshold must add it back — they were written apart, and
|
|
407
|
+
* the threshold omitted it, so the reading column could sit a full 56px UNDER
|
|
408
|
+
* the floor the same code declared. A floor that is not enforced is not a
|
|
409
|
+
* floor; it is a comment. */
|
|
410
|
+
const PAGE_PAD = 28;
|
|
411
|
+
/** What the reading column needs to stay readable beside both gutters — a label
|
|
412
|
+
* column plus its values, not what it would like. Below this the discussion
|
|
413
|
+
* panel FOLDS to an inline section, which is the template's stated rule
|
|
414
|
+
* ("when the two compete the panel is what gives way") finally implemented:
|
|
415
|
+
* the panel's width is a constant, so without a floor on this side the reading
|
|
416
|
+
* column absorbed every squeeze and the rule ran backwards. */
|
|
417
|
+
const CONTENT_FLOOR = 420;
|
|
402
418
|
|
|
403
419
|
// ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
|
|
404
420
|
// record's documents surface: files feed ONE "Use AI" entry that forks into
|
|
@@ -415,6 +431,7 @@ function fmtSize(kb: number): string {
|
|
|
415
431
|
if (kb <= 0) return "—";
|
|
416
432
|
return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
|
417
433
|
}
|
|
434
|
+
const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
|
|
418
435
|
const MOCK_PHOTO_URL =
|
|
419
436
|
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNmNTllMGInLz48cmVjdCB4PScyNjInIHk9JzI0JyB3aWR0aD0nMTAnIGhlaWdodD0nMTg2JyBmaWxsPScjNTI1MjUyJy8+PHJlY3QgeD0nMTUwJyB5PScyNCcgd2lkdGg9JzEyMicgaGVpZ2h0PScxMCcgZmlsbD0nIzUyNTI1MicvPjwvc3ZnPg==";
|
|
420
437
|
const DOCS: Doc[] = [
|
|
@@ -436,6 +453,11 @@ const DOCS: Doc[] = [
|
|
|
436
453
|
{ id: "p4", name: "cont-rong-mat-truoc.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1231, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2U5ZDVmZicvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhMWExYWEnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYmUxMjNjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTI5M2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNiZTEyM2MnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
437
454
|
{ id: "p5", name: "cont-rong-ben-trong.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1368, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlY2FjYScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM0NDQwM2MnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjMGY3NjZlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMzMzQxNTUnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwZjc2NmUnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
438
455
|
{ id: "p6", name: "bien-so-xe.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1505, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlZjA4YScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM1MjUyNTInLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjN2UyMmNlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTNhOGEnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyM3ZTIyY2UnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
456
|
+
// NOT an image, deliberately: the signed note is evidence of the same hand-over
|
|
457
|
+
// as the frames around it, and it is the tile a fixture of eight JPGs never
|
|
458
|
+
// rendered. `FileThumbnail` shows a doc tile (badge + name) — no code needed,
|
|
459
|
+
// only a fixture honest enough to reach it.
|
|
460
|
+
{ id: "p8", name: "bien-ban-giao-nhan-da-ky.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 412, added: "02 Jul", addedAt: 702, url: MOCK_PDF_URL },
|
|
439
461
|
{ id: "p7", name: "bang-ke-giao-nhan.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1642, added: "02 Jul", addedAt: 702, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2E1ZjNmYycvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM3MzczNzMnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYzI0MTBjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwNjRlM2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNjMjQxMGMnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
440
462
|
];
|
|
441
463
|
|
|
@@ -607,14 +629,30 @@ const PDF = "application/pdf";
|
|
|
607
629
|
|
|
608
630
|
/**
|
|
609
631
|
* A file rendered as a table VALUE — the filename at the size every other cell
|
|
610
|
-
* uses.
|
|
632
|
+
* uses. The ONE-FACT cell: the name IS the value, so it is rendered like the
|
|
633
|
+
* quantity beside it and nothing else is added.
|
|
611
634
|
*
|
|
612
635
|
* It carried a `FileBadge` at first, which in a cell that size shrank its own
|
|
613
636
|
* label to five pixels — off every rung of the type scale. Making it legible
|
|
614
637
|
* means a 34px box setting the height of a 52px row, for a fact the extension
|
|
615
|
-
* already carries.
|
|
616
|
-
*
|
|
617
|
-
*
|
|
638
|
+
* already carries.
|
|
639
|
+
*
|
|
640
|
+
* The discriminator is HOW MANY FACTS the cell owes the reader. This cell owes
|
|
641
|
+
* ONE — which document — and the name is it.
|
|
642
|
+
*
|
|
643
|
+
* A cell owing TWO is a different shape and wants a mark this one must not
|
|
644
|
+
* borrow: a reference the desk quotes (an invoice number) PLUS whether the
|
|
645
|
+
* document behind it is on file, where nothing in the value answers the second
|
|
646
|
+
* question. `tpl_item_list`'s register is the worked example — it is a
|
|
647
|
+
* full-width surface with room for the column, which this page is not: measured
|
|
648
|
+
* at 1280 the fee table already shows 3 of its 5 columns, because a record page
|
|
649
|
+
* spends its width on a nav rail and a comments column. A column that always
|
|
650
|
+
* drops teaches nothing, so the rule lives here and the example lives there.
|
|
651
|
+
*
|
|
652
|
+
* The mark is a monochrome clip, NOT a `FileBadge`: a badge's payload beyond
|
|
653
|
+
* presence is the file's TYPE, its label is unreadable at row scale (5px
|
|
654
|
+
* measured), and on a ledger where every document is a PDF it spends a hue on
|
|
655
|
+
* something that never varies.
|
|
618
656
|
*/
|
|
619
657
|
function DocName({ doc }: { doc: LineDoc }) {
|
|
620
658
|
return <Text size="sm" numberOfLines={1}>{doc.name}</Text>;
|
|
@@ -665,7 +703,6 @@ const FINDINGS: Check[] = [
|
|
|
665
703
|
];
|
|
666
704
|
|
|
667
705
|
// A tiny REAL pdf (data URI) so the gallery preview genuinely renders.
|
|
668
|
-
const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
|
|
669
706
|
const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: d.url ?? MOCK_PDF_URL });
|
|
670
707
|
/** A comment's ThreadFile (snake_case, the API shape) → the kit's DisplayFile. */
|
|
671
708
|
const commentFileToDisplay = (f: ThreadFile): DisplayFile => ({ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.url ?? "", thumbnailUrl: f.thumbnail_url });
|
|
@@ -707,6 +744,16 @@ const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
|
|
707
744
|
// display VAT / Invoice no / Note — it drops them silently. And `note` is prose:
|
|
708
745
|
// it has no column width at any measure.
|
|
709
746
|
type FeeDirection = "charge" | "cost";
|
|
747
|
+
/** A picked `File` as the kit's `DisplayFile`. A real host uploads and returns
|
|
748
|
+
* the stored object; the object URL stands in for that here. */
|
|
749
|
+
let feeDocSeq = 0;
|
|
750
|
+
const asDisplayFile = (f: File): DisplayFile => ({
|
|
751
|
+
id: `fee-doc-u${(feeDocSeq += 1)}`,
|
|
752
|
+
filename: f.name,
|
|
753
|
+
mimeType: f.type || "application/octet-stream",
|
|
754
|
+
url: URL.createObjectURL(f),
|
|
755
|
+
});
|
|
756
|
+
|
|
710
757
|
interface Fee {
|
|
711
758
|
id: string;
|
|
712
759
|
name: string;
|
|
@@ -717,25 +764,41 @@ interface Fee {
|
|
|
717
764
|
vat: number | null;
|
|
718
765
|
due: string;
|
|
719
766
|
invoiceNo: string;
|
|
767
|
+
/** The supplier's original, filed against THIS fee. A number is what the field
|
|
768
|
+
* claimed; these are what actually arrived, and the gap between them is the
|
|
769
|
+
* work a payables desk does. */
|
|
770
|
+
docs: DisplayFile[];
|
|
720
771
|
paid: boolean;
|
|
721
772
|
note: string;
|
|
722
773
|
}
|
|
723
774
|
let feeSeq = 4;
|
|
724
775
|
const FEE_SEED: Fee[] = [
|
|
725
|
-
{ id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", paid: true, note: "" },
|
|
726
|
-
{ id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", paid: false, note: "Last-mile to the port" },
|
|
727
|
-
|
|
728
|
-
|
|
776
|
+
{ id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", docs: [], paid: true, note: "" },
|
|
777
|
+
{ id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", docs: [{ id: "fee-doc-1", filename: "NH-2044-invoice.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], paid: false, note: "Last-mile to the port" },
|
|
778
|
+
// UNPAID with a number claimed and nothing filed — the state the status column
|
|
779
|
+
// exists to surface. It was `paid: true`, which meant "Awaiting invoice" could
|
|
780
|
+
// never render and the design could not be seen at all.
|
|
781
|
+
{ id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", docs: [], paid: false, note: "" },
|
|
782
|
+
// A PHOTO of an invoice, not a PDF — the case the badge exists for. A supplier
|
|
783
|
+
// who sends a phone snap has not sent a tax document, and the colour says so
|
|
784
|
+
// before anyone opens it. A fixture of nothing but PDFs makes the badge look
|
|
785
|
+
// like decoration, because the one thing it carries never varies.
|
|
786
|
+
{ id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "HF-8821", docs: [{ id: "fee-doc-2", filename: "storage-invoice-photo.jpg", mimeType: "image/jpeg", url: MOCK_PHOTO_URL }], paid: false, note: "3 extra days at Central hub" },
|
|
729
787
|
];
|
|
730
788
|
// `priority` = the mobile contract: Party then Type drop first on a narrow
|
|
731
789
|
// container, Amount + Status survive with the identity; at the floor the rows
|
|
732
790
|
// STACK label-over-value — and the expansion always carries the full fee.
|
|
791
|
+
// THREE columns, not five — the register's grammar. A column per FACT spends
|
|
792
|
+
// width on separation the reader never asked for: the party belongs to the fee's
|
|
793
|
+
// identity and the payment state belongs to its amount, so each rides line 2 of
|
|
794
|
+
// the cell it qualifies. Grouping bought 246px (Party 150 + Status 96), which is
|
|
795
|
+
// more than the document column costs — the table gained information and lost
|
|
796
|
+
// two columns. `tpl_item_list` stacks both of these already; this table was the
|
|
797
|
+
// outlier.
|
|
733
798
|
const FEE_COLUMNS: TableColumn[] = [
|
|
734
799
|
{ key: "fee", label: "Fee", flex: 1 },
|
|
735
|
-
{ key: "type", label: "Type", width: 72, priority: 3 },
|
|
736
|
-
{ key: "party", label: "Party", width: 150, priority: 4 },
|
|
737
800
|
{ key: "amount", label: "Amount", width: 104, align: "right", priority: 1 },
|
|
738
|
-
{ key: "
|
|
801
|
+
{ key: "invoice", label: "Invoice", width: 116, priority: 2 },
|
|
739
802
|
];
|
|
740
803
|
const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
|
|
741
804
|
|
|
@@ -1162,7 +1225,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1162
1225
|
const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
|
|
1163
1226
|
const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
|
|
1164
1227
|
const addFee = () => {
|
|
1165
|
-
const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", paid: false, note: "" };
|
|
1228
|
+
const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", docs: [], paid: false, note: "" };
|
|
1166
1229
|
setFees((prev) => [...prev, f]);
|
|
1167
1230
|
setFeeView({ kind: "edit", id: f.id });
|
|
1168
1231
|
};
|
|
@@ -1173,6 +1236,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1173
1236
|
]);
|
|
1174
1237
|
};
|
|
1175
1238
|
// The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
|
|
1239
|
+
/**
|
|
1240
|
+
* MONEY state only — it rides under the amount it is about.
|
|
1241
|
+
*
|
|
1242
|
+
* "Awaiting docs" lived here while the document had no column of its own, and
|
|
1243
|
+
* it was a conflation: whether a supplier's original has arrived is not a fact
|
|
1244
|
+
* about the money, and putting it in the money's state meant a fee could not
|
|
1245
|
+
* report both at once. Now the Invoice column carries the document and this
|
|
1246
|
+
* carries the payment, and each says one thing.
|
|
1247
|
+
*/
|
|
1176
1248
|
const feeStatus = (f: Fee): { text: string; danger: boolean } =>
|
|
1177
1249
|
f.paid ? { text: "Paid", danger: false }
|
|
1178
1250
|
: feeOverdue(f) ? { text: "Overdue", danger: true }
|
|
@@ -1278,6 +1350,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1278
1350
|
const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
|
|
1279
1351
|
const seq = useRef(414);
|
|
1280
1352
|
const taxId = customer?.taxId ?? "";
|
|
1353
|
+
/** The ONE signed contract — a single-file field, not a document set. */
|
|
1354
|
+
const [contract, setContract] = useState<DisplayFile | null>(null);
|
|
1281
1355
|
const taxIdValid = TAX_ID_RE.test(taxId);
|
|
1282
1356
|
|
|
1283
1357
|
// ── the document desk (see the module banner above; the record's files ARE
|
|
@@ -1286,7 +1360,20 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1286
1360
|
// The photo subset of the SAME collection — one list, two surfaces. Derived
|
|
1287
1361
|
// rather than a second state, or the two sections would drift the moment
|
|
1288
1362
|
// anything is added to one of them.
|
|
1289
|
-
|
|
1363
|
+
/**
|
|
1364
|
+
* EVERYTHING on the record, not the image subset.
|
|
1365
|
+
*
|
|
1366
|
+
* It filtered `mimeType.startsWith("image/")`, which read well until you asked
|
|
1367
|
+
* what happens to the signed delivery note: it is evidence of the same
|
|
1368
|
+
* hand-over as the photos beside it, and a MIME test filed it somewhere else.
|
|
1369
|
+
* A reader looking for "what came back from the yard" then has to know which
|
|
1370
|
+
* of two lenses holds each artifact — a question about the FILE FORMAT, which
|
|
1371
|
+
* is never the question they actually have.
|
|
1372
|
+
*
|
|
1373
|
+
* `FileThumbnail` already renders a non-image as a doc tile (badge + name), so
|
|
1374
|
+
* nothing here had to change for it to work — only the filter that hid it.
|
|
1375
|
+
*/
|
|
1376
|
+
const photoDocs = files;
|
|
1290
1377
|
/**
|
|
1291
1378
|
* SELECT MODE — for the grid only, and only because a grid has nowhere free
|
|
1292
1379
|
* to put a checkbox.
|
|
@@ -1934,7 +2021,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1934
2021
|
// two gutters. The floor is what the column needs to stay readable (a label
|
|
1935
2022
|
// column plus its values), not what it would like — hold out for the ceiling and
|
|
1936
2023
|
// the thread folds away on every screen under ~1220.
|
|
1937
|
-
|
|
2024
|
+
//
|
|
2025
|
+
// Every term `groupW` spends must appear here, PAGE_PAD included. It did not,
|
|
2026
|
+
// and the omission ran in the dangerous direction: the check passed while the
|
|
2027
|
+
// column was under its floor, so the panel kept its constant 300 and the record
|
|
2028
|
+
// — the thing the page is about — took the loss. The two lines are one
|
|
2029
|
+
// calculation and have to be read as one.
|
|
2030
|
+
const wide = pageWidth != null && pageWidth >= PAGE_PAD * 2 + GUTTER + PANEL_GUTTER + CONTENT_FLOOR;
|
|
1938
2031
|
// Narrow: the discussion is one tap off the same bar the sections are on —
|
|
1939
2032
|
// on the RIGHT, opposite the section picker, since it is about the record as a
|
|
1940
2033
|
// whole rather than a place in it.
|
|
@@ -1947,8 +2040,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1947
2040
|
// record scrolls. (Capping the ScrollView at the column's width instead makes
|
|
1948
2041
|
// the gutters dead zones.) Mirrors the flex maths the row does below: the
|
|
1949
2042
|
// group is centred inside the 28px content padding.
|
|
1950
|
-
const groupW = pageWidth == null ? 0 : Math.min(pageWidth -
|
|
1951
|
-
const railLeft = pageWidth == null ? 0 :
|
|
2043
|
+
const groupW = pageWidth == null ? 0 : Math.min(pageWidth - PAGE_PAD * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
|
|
2044
|
+
const railLeft = pageWidth == null ? 0 : PAGE_PAD + Math.max(0, (pageWidth - PAGE_PAD * 2 - groupW) / 2);
|
|
1952
2045
|
const panelRight = railLeft;
|
|
1953
2046
|
|
|
1954
2047
|
/**
|
|
@@ -2379,6 +2472,32 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2379
2472
|
<DetailRow label="Opened">
|
|
2380
2473
|
<InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
|
|
2381
2474
|
</DetailRow>
|
|
2475
|
+
{/* FILES AS A FIELD, the SINGLE case — one signed contract, not
|
|
2476
|
+
a document set.
|
|
2477
|
+
`multiple={false}` changes the verb, and the verb is the
|
|
2478
|
+
point: "add another" is not something a one-file field can
|
|
2479
|
+
do, and offering it produces a record holding two of a thing
|
|
2480
|
+
the schema says there is one of. So the CTA reads REPLACE,
|
|
2481
|
+
the picker takes one, and the host SETS instead of appending
|
|
2482
|
+
— the component gets the affordance right but cannot enforce
|
|
2483
|
+
the persistence, which is why the difference is visible right
|
|
2484
|
+
here at the call site.
|
|
2485
|
+
|
|
2486
|
+
`blockedReason` is the third state: a field with nothing to
|
|
2487
|
+
attach TO yet says which act unblocks it, rather than showing
|
|
2488
|
+
a disabled button that says only that you may not. */}
|
|
2489
|
+
<DetailRow label="Signed contract">
|
|
2490
|
+
<InlineFiles
|
|
2491
|
+
files={contract === null ? [] : [contract]}
|
|
2492
|
+
multiple={false}
|
|
2493
|
+
onAdd={([picked]) => setContract({ id: "doc-contract", filename: picked.name, mimeType: picked.type || PDF, url: URL.createObjectURL(picked) })}
|
|
2494
|
+
onRemove={() => setContract(null)}
|
|
2495
|
+
addLabel="Attach contract"
|
|
2496
|
+
replaceLabel="Replace contract"
|
|
2497
|
+
accept="application/pdf"
|
|
2498
|
+
blockedReason={taxId.trim() === "" ? "Add the tax ID before filing a contract." : undefined}
|
|
2499
|
+
/>
|
|
2500
|
+
</DetailRow>
|
|
2382
2501
|
{/* The countdown ANNOTATES the date it is about — the worked
|
|
2383
2502
|
example for @lotics/ui/deadline. It replaced a hand-rolled
|
|
2384
2503
|
`error={overdue ? … }`, which was binary: a date due
|
|
@@ -2689,20 +2808,39 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2689
2808
|
its NAME: a PDF thumbnail is a grey page identical to every other grey
|
|
2690
2809
|
page, so a grid of them is a wall of one tile. A photo is identified
|
|
2691
2810
|
by its CONTENT — `IMG_4471.jpg` tells nobody anything — so the tile IS
|
|
2692
|
-
the identity and a grid puts twelve on screen at once.
|
|
2693
|
-
same verbs, different surface, decided by what the reader recognises.
|
|
2811
|
+
the identity and a grid puts twelve on screen at once.
|
|
2694
2812
|
(AGENTS.md: documents get a LIST, images get a GRID.)
|
|
2695
2813
|
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2814
|
+
BOTH SURFACES SHOW THE WHOLE COLLECTION — they are two LENSES over
|
|
2815
|
+
one set, not two sets. Files answers "what is on this record" in a
|
|
2816
|
+
scannable column; Photos answers "what did it LOOK like" at a glance.
|
|
2817
|
+
A reader who guesses wrong still finds the artifact instead of
|
|
2818
|
+
concluding it was never filed, and nobody has to know a file's FORMAT
|
|
2819
|
+
before they know where to look for it.
|
|
2820
|
+
|
|
2821
|
+
The grid used to filter to `image/*`. That made the split a fact about
|
|
2822
|
+
MIME rather than about reading, and it put the signed delivery note in
|
|
2823
|
+
a different section from the photos of the same hand-over — while this
|
|
2824
|
+
very comment promised that the odd `.docx` among forty phone photos
|
|
2825
|
+
would stay in the grid. It could not: the filter forbade what the
|
|
2826
|
+
sentence described. `FileThumbnail` already renders a non-image as a
|
|
2827
|
+
doc tile (badge + filename), so nothing had to be built — only the
|
|
2828
|
+
filter that hid it had to go.
|
|
2829
|
+
|
|
2830
|
+
THE COST IS REAL AND DELIBERATE: every file now appears twice. The
|
|
2831
|
+
trade is that duplication is cheap to skim past, whereas an artifact
|
|
2832
|
+
filed under the wrong lens looks like an artifact that was never
|
|
2833
|
+
filed. A record whose attachments are overwhelmingly paperwork should
|
|
2834
|
+
drop the grid entirely rather than carry a wall of identical tiles —
|
|
2835
|
+
the choice is per-record-TYPE, made once, not per file.
|
|
2836
|
+
|
|
2837
|
+
Ordering follows the collection, so a record heavy in documents leads
|
|
2838
|
+
this grid with grey tiles. If that reads badly for your record type,
|
|
2839
|
+
sort images first rather than reinstating the filter.
|
|
2840
|
+
|
|
2841
|
+
The tiles are mixed by construction now, so the FIXTURE has to be
|
|
2842
|
+
mixed too — a grid of eight JPGs never once renders the doc tile it
|
|
2843
|
+
has always been able to render, which is how the gap survived.
|
|
2706
2844
|
|
|
2707
2845
|
The ADD rides the heading row, right edge, exactly as it does on Files
|
|
2708
2846
|
and Fees — a section's add sits in the same spot whether the section
|
|
@@ -2777,7 +2915,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2777
2915
|
title="Add photos"
|
|
2778
2916
|
color="primary"
|
|
2779
2917
|
onPress={() => {
|
|
2780
|
-
void pickFiles({ accept: "image/*", multiple: true }).then(intakeFiles);
|
|
2918
|
+
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
2781
2919
|
}}
|
|
2782
2920
|
/>
|
|
2783
2921
|
)}
|
|
@@ -3058,6 +3196,35 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3058
3196
|
<DetailRow label="Invoice no" description="The party's invoice or debit note">
|
|
3059
3197
|
<InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
|
|
3060
3198
|
</DetailRow>
|
|
3199
|
+
{/* THE FILE, ON THE ROW THAT OWES IT — the shape a
|
|
3200
|
+
payables desk actually works in: a table of
|
|
3201
|
+
charges, each row expanding to the document that
|
|
3202
|
+
justifies it. The register cell above can only
|
|
3203
|
+
MARK whether a document is held (and on this page
|
|
3204
|
+
not even that — the fee table measures 400px and
|
|
3205
|
+
its declared columns already total 422, so two
|
|
3206
|
+
drop before the flex column gets a pixel). The
|
|
3207
|
+
managing happens here.
|
|
3208
|
+
|
|
3209
|
+
`multiple`, because a supplier invoice arrives as
|
|
3210
|
+
one PDF or as three scanned pages, and the second
|
|
3211
|
+
page must not replace the first.
|
|
3212
|
+
|
|
3213
|
+
`blockedReason` when no number has been claimed:
|
|
3214
|
+
there is nothing to file the scan AGAINST yet, and
|
|
3215
|
+
a sentence naming that act beats a disabled button
|
|
3216
|
+
that only says no. */}
|
|
3217
|
+
<DetailRow label="Supplier original">
|
|
3218
|
+
<InlineFiles
|
|
3219
|
+
files={f.docs}
|
|
3220
|
+
onAdd={(picked) => patchFee(f.id, { docs: [...f.docs, ...picked.map(asDisplayFile)] })}
|
|
3221
|
+
onRemove={(doc) => patchFee(f.id, { docs: f.docs.filter((d) => d.id !== doc.id) })}
|
|
3222
|
+
addLabel="Attach original"
|
|
3223
|
+
addMoreLabel="Add page"
|
|
3224
|
+
accept="application/pdf,image/*"
|
|
3225
|
+
blockedReason={f.invoiceNo.trim() === "" ? "Enter the invoice number first — the scan is filed against it." : undefined}
|
|
3226
|
+
/>
|
|
3227
|
+
</DetailRow>
|
|
3061
3228
|
<DetailRow label="Status">
|
|
3062
3229
|
<InlineSelect
|
|
3063
3230
|
value={f.paid ? "paid" : "unpaid"}
|
|
@@ -3096,11 +3263,51 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3096
3263
|
</View>
|
|
3097
3264
|
}
|
|
3098
3265
|
>
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
<TableCell
|
|
3103
|
-
|
|
3266
|
+
{/* IDENTITY over its PARTY — `tpl_item_list`'s customer cell
|
|
3267
|
+
exactly: the thing you scan for on line 1, what qualifies
|
|
3268
|
+
it on line 2, in one cell rather than two columns. */}
|
|
3269
|
+
<TableCell>
|
|
3270
|
+
<View style={{ gap: 2 }}>
|
|
3271
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text>
|
|
3272
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
3273
|
+
{f.party || (f.direction === "charge" ? "Charge" : "Cost")}
|
|
3274
|
+
</Text>
|
|
3275
|
+
</View>
|
|
3276
|
+
</TableCell>
|
|
3277
|
+
{/* AMOUNT over its PAYMENT state — the register's fee cell,
|
|
3278
|
+
same stack, same rungs. The state qualifies the number it
|
|
3279
|
+
sits under, so it needs no column and no repeated label. */}
|
|
3280
|
+
<TableCell>
|
|
3281
|
+
<View style={{ gap: 2, alignItems: "flex-end" }}>
|
|
3282
|
+
<Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text>
|
|
3283
|
+
<Text size="xs" tabular numberOfLines={1} color={st.danger ? "danger" : "muted"}>{st.text}</Text>
|
|
3284
|
+
</View>
|
|
3285
|
+
</TableCell>
|
|
3286
|
+
{/* THE DOCUMENT, with a column of its own at last.
|
|
3287
|
+
Held: a clip + the number, pressable to preview. Claimed
|
|
3288
|
+
but not arrived: the number in WARNING tone — the fee
|
|
3289
|
+
cannot be paid, and that now reads in the column the fact
|
|
3290
|
+
belongs to instead of borrowing the money's state. Never
|
|
3291
|
+
claimed (cash at the gate): an em dash, because nothing is
|
|
3292
|
+
owed and a permanent warning on every such row trains the
|
|
3293
|
+
eye straight past the column. */}
|
|
3294
|
+
<TableCell>
|
|
3295
|
+
{f.invoiceNo.trim() === "" ? (
|
|
3296
|
+
<Text size="sm" color="muted">—</Text>
|
|
3297
|
+
) : f.docs.length === 0 ? (
|
|
3298
|
+
<Text size="sm" color="muted" numberOfLines={1}>{f.invoiceNo}</Text>
|
|
3299
|
+
) : (
|
|
3300
|
+
<Pressable
|
|
3301
|
+
onPress={() => openPreview(DOCS, 0)}
|
|
3302
|
+
accessibilityRole="button"
|
|
3303
|
+
accessibilityLabel={`Open invoice ${f.invoiceNo}`}
|
|
3304
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
|
|
3305
|
+
>
|
|
3306
|
+
<FileBadge mimeType={f.docs[0].mimeType} size={22} />
|
|
3307
|
+
<TextLink size="sm">{f.invoiceNo}</TextLink>
|
|
3308
|
+
</Pressable>
|
|
3309
|
+
)}
|
|
3310
|
+
</TableCell>
|
|
3104
3311
|
</TableRow>
|
|
3105
3312
|
);
|
|
3106
3313
|
})}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "41.0
|
|
3
|
+
"version": "41.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -153,6 +153,7 @@
|
|
|
153
153
|
"./inline_edit": "./src/inline_edit.tsx",
|
|
154
154
|
"./inline_slot": "./src/inline_slot.tsx",
|
|
155
155
|
"./inline_static": "./src/inline_static.tsx",
|
|
156
|
+
"./inline_files": "./src/inline_files.tsx",
|
|
156
157
|
"./inline_text_input": "./src/inline_text_input.tsx",
|
|
157
158
|
"./inline_number_input": "./src/inline_number_input.tsx",
|
|
158
159
|
"./inline_select": "./src/inline_select.tsx",
|
package/src/file_rows.tsx
CHANGED
|
@@ -10,7 +10,6 @@ import { Alert } from "./alert";
|
|
|
10
10
|
import { FileRow } from "./file_row";
|
|
11
11
|
import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
12
12
|
import { FileGalleryModal } from "./file_gallery_modal";
|
|
13
|
-
import { resolveMime } from "./file_badge";
|
|
14
13
|
import { downloadFileFromUrl } from "./download";
|
|
15
14
|
import { type GalleryLabels } from "./file_preview_types";
|
|
16
15
|
import { useLoticsLocale } from "./locale";
|
|
@@ -18,7 +17,18 @@ import type { DisplayFile } from "./file_thumbnail";
|
|
|
18
17
|
|
|
19
18
|
export interface FileRowsProps {
|
|
20
19
|
files: DisplayFile[];
|
|
21
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Secondary line per file. Default: NONE.
|
|
22
|
+
*
|
|
23
|
+
* It defaulted to the file-type label, which every row already states twice —
|
|
24
|
+
* the `FileBadge` encodes the type as its colour AND prints it inside the
|
|
25
|
+
* glyph, and the filename ends in the extension. A fourth statement of one
|
|
26
|
+
* fact is not a caption, it is noise, and it cost every consumer a line of
|
|
27
|
+
* row height to say nothing.
|
|
28
|
+
*
|
|
29
|
+
* Pass something the NAME does not already carry — what an invoice covers, who
|
|
30
|
+
* uploaded it, when it landed.
|
|
31
|
+
*/
|
|
22
32
|
meta?: (file: DisplayFile) => string | undefined;
|
|
23
33
|
/** Adds a (confirmed) "Remove" to each ⋯ menu and the gallery toolbar. The host
|
|
24
34
|
* drops the file from its own state. */
|
|
@@ -84,7 +94,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
|
|
|
84
94
|
<FileRow
|
|
85
95
|
key={file.id}
|
|
86
96
|
name={file.filename}
|
|
87
|
-
meta={meta ? meta(file) :
|
|
97
|
+
meta={meta ? meta(file) : undefined}
|
|
88
98
|
mimeType={file.mimeType}
|
|
89
99
|
onPress={() => setActiveIndex(index)}
|
|
90
100
|
trailing={<ActionMenu accessibilityLabel={`${l.actions}: ${file.filename}`} items={items} />}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { FileRows } from "./file_rows";
|
|
6
|
+
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
7
|
+
import { pickFiles } from "./file_picker";
|
|
8
|
+
import { type DisplayFile } from "./file_thumbnail";
|
|
9
|
+
import { type GalleryLabels } from "./file_preview_types";
|
|
10
|
+
import { useLoticsLocale } from "./locale";
|
|
11
|
+
|
|
12
|
+
export interface InlineFilesProps {
|
|
13
|
+
/** What is attached now. */
|
|
14
|
+
files: DisplayFile[];
|
|
15
|
+
/**
|
|
16
|
+
* The user picked these. The HOST uploads them and persists the result — this
|
|
17
|
+
* kit never talks to a server, so `files` only changes when the host says so.
|
|
18
|
+
*
|
|
19
|
+
* **`multiple` decides what the host must DO with them**: appending on a
|
|
20
|
+
* single-file field is how a field that holds one contract quietly ends up
|
|
21
|
+
* holding three. See that prop.
|
|
22
|
+
*
|
|
23
|
+
* Return a promise and the CTA stays busy until it settles, which is the whole
|
|
24
|
+
* of this component's loading state: a picked file is in flight for as long as
|
|
25
|
+
* the host takes, and a control that re-arms immediately invites the second
|
|
26
|
+
* press that files it twice.
|
|
27
|
+
*
|
|
28
|
+
* Omit for a read-only field — the same list, no CTA.
|
|
29
|
+
*/
|
|
30
|
+
onAdd?: (picked: File[]) => void | Promise<void>;
|
|
31
|
+
/** Adds a (confirmed) Remove to each row's ⋯ menu. Omit for add-only. */
|
|
32
|
+
onRemove?: (file: DisplayFile) => void;
|
|
33
|
+
/** Open outside the app — a new tab on the frontend, `openExternal` in a
|
|
34
|
+
* sandboxed app. Omit and the row still previews in place. */
|
|
35
|
+
onOpenExternal?: (file: DisplayFile) => void;
|
|
36
|
+
/** Override the download (the host frontend sends `credentials`). */
|
|
37
|
+
onDownload?: (file: DisplayFile) => void;
|
|
38
|
+
/**
|
|
39
|
+
* **The FIELD's cardinality, not the picker's convenience.** Default `true`.
|
|
40
|
+
*
|
|
41
|
+
* `true` — a set. The picker takes several at once, and the CTA reads
|
|
42
|
+
* `addMoreLabel` once something is attached, because adding a second page to a
|
|
43
|
+
* document set is an ordinary act.
|
|
44
|
+
*
|
|
45
|
+
* `false` — exactly one. The picker takes one, and the CTA reads
|
|
46
|
+
* `replaceLabel`, because "add another" is not a thing a single-file field can
|
|
47
|
+
* do: the honest verb is replace, and offering "add" there produces a field
|
|
48
|
+
* holding two of something the schema says there is one of. **The host must
|
|
49
|
+
* then SET rather than append in `onAdd`** — the component cannot enforce it
|
|
50
|
+
* (it does not own the data), and the failure is silent, so the call site
|
|
51
|
+
* reads `onAdd={([f]) => setFile(f)}` against `onAdd={(fs) => setFiles([...files, ...fs])}`
|
|
52
|
+
* and the difference is visible where it is decided.
|
|
53
|
+
*
|
|
54
|
+
* This is a fact about the field, which is why it is a prop and the LIST shape
|
|
55
|
+
* is not: one file or many, the reader still identifies a document by its name.
|
|
56
|
+
*/
|
|
57
|
+
multiple?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* CTA words for the three states — nothing attached, adding to a set,
|
|
60
|
+
* replacing the one.
|
|
61
|
+
*
|
|
62
|
+
* Three, because they are three different acts and a generic "Add files"
|
|
63
|
+
* misleads in two of them. Name them for the domain ("Attach original" /
|
|
64
|
+
* "Add page" / "Replace contract") and the field stops needing a caption to
|
|
65
|
+
* explain itself. Each falls back to the one before it, so a single-file field
|
|
66
|
+
* that never says `replaceLabel` still reads sensibly.
|
|
67
|
+
*/
|
|
68
|
+
addLabel?: string;
|
|
69
|
+
addMoreLabel?: string;
|
|
70
|
+
replaceLabel?: string;
|
|
71
|
+
/** Replaces the CTA's words while `onAdd` is in flight. Omit and the CTA keeps
|
|
72
|
+
* its words and simply disables — no invented English in a localized app. */
|
|
73
|
+
busyLabel?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Renders this sentence INSTEAD of the CTA — for a field with nothing to
|
|
76
|
+
* attach TO yet (a scan whose invoice does not exist; a line before its order
|
|
77
|
+
* is saved).
|
|
78
|
+
*
|
|
79
|
+
* Deliberately not a disabled button. A disabled control states that you may
|
|
80
|
+
* not, and leaves the reader to work out why and what would change it; a
|
|
81
|
+
* sentence states the act that unblocks it. Same pixels, one of them useful.
|
|
82
|
+
*/
|
|
83
|
+
blockedReason?: string;
|
|
84
|
+
/** Shown when nothing is attached AND there is no CTA. Default "—". */
|
|
85
|
+
emptyLabel?: string;
|
|
86
|
+
/** Native accept filter, e.g. `"application/pdf,image/*"`. */
|
|
87
|
+
accept?: string;
|
|
88
|
+
/** Translated gallery/menu chrome, forwarded to `FileRows`. */
|
|
89
|
+
labels?: Partial<GalleryLabels>;
|
|
90
|
+
/** Credentials mode for the preview fetches — `"include"` for auth-gated
|
|
91
|
+
* proxy URLs, omitted for an app's presigned URLs. */
|
|
92
|
+
credentials?: RequestCredentials;
|
|
93
|
+
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The FILES member of the `Inline*` family — a record's attachments, editable in
|
|
98
|
+
* the value column of a `DetailRow`.
|
|
99
|
+
*
|
|
100
|
+
* Every other field type had both halves already (`Select`/`InlineSelect`,
|
|
101
|
+
* `TextInputField`/`InlineTextInput`); files had only the section-scale one
|
|
102
|
+
* (`FilesEditor`), so a record FIELD holding documents was hand-rolled per app as
|
|
103
|
+
* `FileRows` + a picker + a button. Each copy re-derived the same decisions —
|
|
104
|
+
* including the alignment one, which has a counter-intuitive answer: this field
|
|
105
|
+
* sits on the inline grid's VERTICAL beat but takes NO horizontal inset, because
|
|
106
|
+
* its children are boxes rather than text (see the comment on the wrapper).
|
|
107
|
+
*
|
|
108
|
+
* A LIST, not a grid, and not switchable: documents are identified by NAME — a
|
|
109
|
+
* PDF/Word/Excel thumbnail is a grey page identical to every other grey page — so
|
|
110
|
+
* a grid of them is a wall of one tile under a truncated caption. Images are
|
|
111
|
+
* identified by their CONTENT and want `FileGrid` / `FilesEditor` instead. Which
|
|
112
|
+
* one a field holds is a judgment about the DATA, so it picks the component, not
|
|
113
|
+
* a prop on this one.
|
|
114
|
+
*
|
|
115
|
+
* Lean on purpose: no selection mode, no bulk bar, no upload queue. It shows,
|
|
116
|
+
* previews, adds and removes. Reach for `FilesEditor` when a surface genuinely
|
|
117
|
+
* needs multi-select and bulk download — a record row does not.
|
|
118
|
+
*/
|
|
119
|
+
export function InlineFiles(props: InlineFilesProps) {
|
|
120
|
+
const {
|
|
121
|
+
files,
|
|
122
|
+
onAdd,
|
|
123
|
+
onRemove,
|
|
124
|
+
onOpenExternal,
|
|
125
|
+
onDownload,
|
|
126
|
+
multiple = true,
|
|
127
|
+
addLabel,
|
|
128
|
+
addMoreLabel,
|
|
129
|
+
replaceLabel,
|
|
130
|
+
busyLabel,
|
|
131
|
+
blockedReason,
|
|
132
|
+
emptyLabel = "—",
|
|
133
|
+
accept,
|
|
134
|
+
labels,
|
|
135
|
+
credentials,
|
|
136
|
+
onError,
|
|
137
|
+
} = props;
|
|
138
|
+
|
|
139
|
+
const loc = useLoticsLocale().filesEditor;
|
|
140
|
+
const [busy, setBusy] = useState(false);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Single-flight, released in `finally`.
|
|
144
|
+
*
|
|
145
|
+
* The picker is INSIDE the guard, not around it: a second press while the
|
|
146
|
+
* dialog is open would open a second dialog, and both would resolve into
|
|
147
|
+
* `onAdd`. Cancelling still settles (`pickFiles` resolves `[]`), so the guard
|
|
148
|
+
* releases without the host being called.
|
|
149
|
+
*/
|
|
150
|
+
const add = useCallback(async () => {
|
|
151
|
+
if (busy || onAdd === undefined) return;
|
|
152
|
+
setBusy(true);
|
|
153
|
+
try {
|
|
154
|
+
const picked = await pickFiles({ accept, multiple });
|
|
155
|
+
if (picked.length === 0) return;
|
|
156
|
+
await onAdd(picked);
|
|
157
|
+
} finally {
|
|
158
|
+
setBusy(false);
|
|
159
|
+
}
|
|
160
|
+
}, [busy, onAdd, accept, multiple]);
|
|
161
|
+
|
|
162
|
+
const base = addLabel ?? loc.upload;
|
|
163
|
+
const cta =
|
|
164
|
+
files.length === 0
|
|
165
|
+
? base
|
|
166
|
+
: multiple
|
|
167
|
+
? (addMoreLabel ?? base)
|
|
168
|
+
: (replaceLabel ?? base);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
/* The inline grid's VERTICAL contract only — deliberately not `InlineSlot`.
|
|
172
|
+
*
|
|
173
|
+
* That component pads 8px + a 1px border so a raw STRING's baseline lands
|
|
174
|
+
* where an editor's text does, which is right for text and wrong here: these
|
|
175
|
+
* children are BOXES (file rows, a button), and a box aligns with the
|
|
176
|
+
* editors' box, not with their text. Measured, `InlineSlot` pushed this
|
|
177
|
+
* field 9px right of every input above it — one value column, two left
|
|
178
|
+
* edges, which is the exact defect the slot exists to prevent, arriving
|
|
179
|
+
* through the slot itself.
|
|
180
|
+
*
|
|
181
|
+
* The LIST fills the column and the CTA does not, because they are different
|
|
182
|
+
* kinds of thing: a file row is this field's VALUE and every other value in
|
|
183
|
+
* the column (a text input, a select) spans it, so a short row would read as
|
|
184
|
+
* a different column; the button is an ACTION, and a full-bleed one reads as
|
|
185
|
+
* the section's rather than this field's. Hence stretch by default, with the
|
|
186
|
+
* CTA opting out via `alignSelf` rather than the container shrinking both.
|
|
187
|
+
*/
|
|
188
|
+
<View style={{ minHeight: INLINE_CONTROL_HEIGHT, justifyContent: "center" }}>
|
|
189
|
+
<View style={{ gap: 8, width: "100%" }}>
|
|
190
|
+
{files.length > 0 ? (
|
|
191
|
+
<FileRows
|
|
192
|
+
files={files}
|
|
193
|
+
onRemove={onRemove}
|
|
194
|
+
onOpenExternal={onOpenExternal}
|
|
195
|
+
onDownload={onDownload}
|
|
196
|
+
onError={onError}
|
|
197
|
+
labels={labels}
|
|
198
|
+
credentials={credentials}
|
|
199
|
+
/>
|
|
200
|
+
) : null}
|
|
201
|
+
{blockedReason !== undefined ? (
|
|
202
|
+
<Text size="sm" color="muted">{blockedReason}</Text>
|
|
203
|
+
) : onAdd !== undefined ? (
|
|
204
|
+
<View style={{ alignSelf: "flex-start" }}>
|
|
205
|
+
<Button
|
|
206
|
+
title={busy ? (busyLabel ?? cta) : cta}
|
|
207
|
+
icon="paperclip"
|
|
208
|
+
color="secondary"
|
|
209
|
+
disabled={busy}
|
|
210
|
+
onPress={() => void add()}
|
|
211
|
+
/>
|
|
212
|
+
</View>
|
|
213
|
+
) : files.length === 0 ? (
|
|
214
|
+
<Text size="sm" color="muted">{emptyLabel}</Text>
|
|
215
|
+
) : null}
|
|
216
|
+
</View>
|
|
217
|
+
</View>
|
|
218
|
+
);
|
|
219
|
+
}
|