@lotics/ui 41.0.1 → 41.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/MIGRATION.md +106 -0
- package/docs/ai_patterns.md +36 -0
- package/docs/catalog.md +70 -12
- package/docs/composition.md +89 -11
- package/docs/data_entry.md +107 -1
- package/docs/templates.md +120 -9
- package/examples/tpl_item_list.tsx +21 -0
- package/examples/tpl_record.tsx +1006 -45
- package/package.json +4 -1
- package/src/file_rows.tsx +13 -3
- package/src/inline_files.tsx +219 -0
- package/src/inline_text_input.tsx +33 -2
- package/src/markdown.css +43 -0
- package/src/markdown.tsx +7 -1
- package/src/markdown.web.tsx +4 -2
- package/src/markdown_types.ts +21 -0
- package/src/member_chip.tsx +12 -2
- package/src/text_disclosure.tsx +70 -0
- package/src/text_link.tsx +13 -3
- package/src/timeline.tsx +194 -34
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
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
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
+
import { colors, solid } from "@lotics/ui/colors";
|
|
5
5
|
import { DRAWER_GUTTER } from "@lotics/ui/drawer";
|
|
6
6
|
import { Button } from "@lotics/ui/button";
|
|
7
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
|
-
import { Icon } from "@lotics/ui/icon";
|
|
10
|
+
import { Icon, type IconName } from "@lotics/ui/icon";
|
|
11
|
+
import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
|
|
12
|
+
import { MediaPlayer } from "@lotics/ui/media_player";
|
|
13
|
+
import { Markdown } from "@lotics/ui/markdown";
|
|
14
|
+
import { TextDisclosure } from "@lotics/ui/text_disclosure";
|
|
15
|
+
import { TextLink } from "@lotics/ui/text_link";
|
|
11
16
|
import { Alert } from "@lotics/ui/alert";
|
|
12
17
|
import type { PickerOption } from "@lotics/ui/picker";
|
|
13
18
|
import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
|
|
14
19
|
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
15
20
|
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
16
|
-
import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
21
|
+
import { Section, SectionHeading, SectionHeadingTitle, SectionHeadingMeta, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
17
22
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
18
23
|
import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
|
|
19
24
|
import { DateStamp } from "@lotics/ui/date_stamp";
|
|
@@ -31,7 +36,9 @@ import { Skeleton } from "@lotics/ui/skeleton";
|
|
|
31
36
|
import { CommentList, type CommentEditFormProps, type ThreadComment, type ThreadFile } from "@lotics/ui/comments_thread";
|
|
32
37
|
import { Composer } from "@lotics/ui/composer";
|
|
33
38
|
import { IconButton } from "@lotics/ui/icon_button";
|
|
39
|
+
import { FileBadge } from "@lotics/ui/file_badge";
|
|
34
40
|
import { FileRows } from "@lotics/ui/file_rows";
|
|
41
|
+
import { InlineFiles } from "@lotics/ui/inline_files";
|
|
35
42
|
import { FileGrid } from "@lotics/ui/file_grid";
|
|
36
43
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
37
44
|
import { InlineStatic } from "@lotics/ui/inline_static";
|
|
@@ -47,6 +54,10 @@ import { FormField } from "@lotics/ui/form_field";
|
|
|
47
54
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
48
55
|
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
49
56
|
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
57
|
+
import { MemberChip } from "@lotics/ui/member_chip";
|
|
58
|
+
import { WaveAvatar } from "@lotics/ui/wave_avatar";
|
|
59
|
+
import { Inset } from "@lotics/ui/inset";
|
|
60
|
+
import { isImageMimeType } from "@lotics/ui/mime";
|
|
50
61
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
51
62
|
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
52
63
|
import { FileRow } from "@lotics/ui/file_row";
|
|
@@ -295,6 +306,578 @@ const PRIORITY_DOT: Record<string, string> = {
|
|
|
295
306
|
rush: colors.amber[500],
|
|
296
307
|
};
|
|
297
308
|
|
|
309
|
+
// ── ACTIVITY — what has been SAID with the other party, in the order it
|
|
310
|
+
// happened. Every entry answers the same four questions (what came of it, which
|
|
311
|
+
// way, over what, when) and then carries a BODY whose shape depends on what the
|
|
312
|
+
// entry IS: a call has audio, a demo has video, an email has a subject, a note
|
|
313
|
+
// has only its own text.
|
|
314
|
+
//
|
|
315
|
+
// One row anatomy, one varying body — never a row type per medium. The four
|
|
316
|
+
// invariants are what makes the feed scannable; a per-medium row would put the
|
|
317
|
+
// same fact in four places and let them drift.
|
|
318
|
+
type ActivityKind = "call" | "video" | "email" | "message" | "note";
|
|
319
|
+
|
|
320
|
+
interface ActivityEntry {
|
|
321
|
+
key: string;
|
|
322
|
+
kind: ActivityKind;
|
|
323
|
+
/**
|
|
324
|
+
* What came of it, in the reader's words. THIS is the row — see the anatomy
|
|
325
|
+
* note at the section.
|
|
326
|
+
*
|
|
327
|
+
* OPTIONAL, because a feed fills from more than one direction. A person writes
|
|
328
|
+
* an entry; an automation also files one the instant a recording lands, and an
|
|
329
|
+
* extraction files one off a screenshot. Those arrive with no words in them,
|
|
330
|
+
* and the row has to say so rather than borrow a phrase from the enums.
|
|
331
|
+
*/
|
|
332
|
+
gist?: string;
|
|
333
|
+
direction: "in" | "out";
|
|
334
|
+
/** The medium as a person would say it, not an enum: "Zalo", "Google Meet". */
|
|
335
|
+
over: string;
|
|
336
|
+
when: string;
|
|
337
|
+
/**
|
|
338
|
+
* WHO put this here — and this feed has two kinds of author, so the type has
|
|
339
|
+
* two shapes. A person gets a `MemberChip`; an automation, an extraction or an
|
|
340
|
+
* agent gets `WaveAvatar`, the kit's mark for a non-human identity. Modelling
|
|
341
|
+
* both as one string forces the surface to give a recording bot a face and two
|
|
342
|
+
* initials, which is a small fabrication every reader notices and no test
|
|
343
|
+
* catches.
|
|
344
|
+
*/
|
|
345
|
+
by: { kind: "member"; name: string; image?: string } | { kind: "system"; name: string };
|
|
346
|
+
/** An unanswered outreach is a real state and reads differently from a reply. */
|
|
347
|
+
awaiting?: boolean;
|
|
348
|
+
|
|
349
|
+
// ── THE BODY IS A SET OF BLOCKS, NOT A SHAPE PER KIND.
|
|
350
|
+
//
|
|
351
|
+
// Every field below is optional and any combination is legal, because what an
|
|
352
|
+
// entry CARRIES is independent of what it IS: a call may arrive as a recording
|
|
353
|
+
// alone, gain a transcript minutes later and a summary after that; an email
|
|
354
|
+
// carries a subject, prose and attachments; a note carries prose and nothing
|
|
355
|
+
// else. A shape per kind would put the same block in five places and let them
|
|
356
|
+
// drift — and the sixth kind, the one nobody has thought of yet, would need a
|
|
357
|
+
// sixth. `kind` survives only to pick the ROW's glyph and the media element.
|
|
358
|
+
//
|
|
359
|
+
// The blocks, in the order they render:
|
|
360
|
+
|
|
361
|
+
/** The exchange itself, when it was recorded. `kind` picks audio vs video. */
|
|
362
|
+
media?: { src: string; label: string };
|
|
363
|
+
/** VERBATIM and long, folded behind its own toggle: it is the SOURCE a summary
|
|
364
|
+
* was made from — read rarely, and in full when it is read at all. */
|
|
365
|
+
transcript?: string;
|
|
366
|
+
/**
|
|
367
|
+
* Anyone on it the row does not ALREADY name — the other addresses on an email
|
|
368
|
+
* header, and nothing else.
|
|
369
|
+
*
|
|
370
|
+
* Two rules, both learned by getting this wrong. It must be CAPTURABLE: a
|
|
371
|
+
* call's attendees are stored nowhere, so a line naming them can only be
|
|
372
|
+
* invented, and a block with no source teaches app authors to fabricate one.
|
|
373
|
+
* And it must be ADDITIVE: the record IS the counterparty, the supporting line
|
|
374
|
+
* gives the direction and the footer names who logged it, so "From <them>, to
|
|
375
|
+
* <us>" is three facts the row already carries. What is left is the third
|
|
376
|
+
* party — which is the entire value of the block.
|
|
377
|
+
*/
|
|
378
|
+
participants?: string;
|
|
379
|
+
/** An email's subject — the one thing an email has that nothing else does. */
|
|
380
|
+
subject?: string;
|
|
381
|
+
/** Prose a PERSON wrote: an email body, a note. Markdown. */
|
|
382
|
+
body?: string;
|
|
383
|
+
/** Prose a MODEL wrote. Separate from `body` rather than a flag on it, because
|
|
384
|
+
* one entry routinely holds both — a rep's own note and the machine's reading
|
|
385
|
+
* of the same call — and they are different claims that must not merge. */
|
|
386
|
+
bodyByAi?: string;
|
|
387
|
+
files?: DisplayFile[];
|
|
388
|
+
/** Where it happened, when that is somewhere the reader can open — a post, a
|
|
389
|
+
* thread, a ticket. */
|
|
390
|
+
sourceUrl?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// A note somebody typed in FULL rather than summarising — the realistic worst
|
|
394
|
+
// case for a feed, and the one a fixture of tidy one-liners never produces. It
|
|
395
|
+
// is here so the template exercises `Timeline`'s label clamp and the drill-down
|
|
396
|
+
// that pairs with it; unclamped, prose this length drew a 180px row and dragged
|
|
397
|
+
// the disc off the line it names.
|
|
398
|
+
// A self-contained SVG so the capture tiles render with no network.
|
|
399
|
+
const img = (label: string, fill: string) =>
|
|
400
|
+
"data:image/svg+xml," +
|
|
401
|
+
encodeURIComponent(
|
|
402
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="${fill}"/><text x="160" y="128" font-family="sans-serif" font-size="20" fill="white" text-anchor="middle">${label}</text></svg>`,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// VERBATIM, and interleaved the way a real diarised transcript is — short turns,
|
|
406
|
+
// a name per line, no structure to lean on. It is here because the fold that
|
|
407
|
+
// hides it only earns its place against text of this shape: a tidy paragraph
|
|
408
|
+
// would have made an inline render look perfectly reasonable.
|
|
409
|
+
const TRANSCRIPT = [
|
|
410
|
+
"**Sarah:** …so that panel is the reconciliation view. Every paid shipment on the left, every bank line on the right.",
|
|
411
|
+
"**Duc:** And it matches them itself?",
|
|
412
|
+
"**Sarah:** It proposes the match. You confirm it. Nothing posts without a person.",
|
|
413
|
+
"**Duc:** Can you show that again? Marc should see this part.",
|
|
414
|
+
"**Sarah:** Of course. I'll wait.",
|
|
415
|
+
"**Duc:** *(off mic)* …Marc, có rảnh hai phút không?",
|
|
416
|
+
"**Marc:** Sorry — I'm here. What am I looking at?",
|
|
417
|
+
"**Sarah:** Reconciliation. This is the step that takes your team a morning a week.",
|
|
418
|
+
"**Marc:** It's more than a morning. And what does it cost?",
|
|
419
|
+
"**Sarah:** Per document. I'd rather put the figure in writing than say a number now.",
|
|
420
|
+
"**Marc:** Please do. I'm at the board on Thursday and I'm not walking in with a range.",
|
|
421
|
+
"**Duc:** One thing — the person who keeps that spreadsheet isn't on this call.",
|
|
422
|
+
].join("\n\n");
|
|
423
|
+
|
|
424
|
+
// A PHONE call leaves a transcript exactly as a video call does — the medium
|
|
425
|
+
// decides which element plays it, never whether the words exist. Splitting that
|
|
426
|
+
// (video gets a transcript, audio does not) is the sort of gap a fixture creates
|
|
427
|
+
// and a real system never has.
|
|
428
|
+
const CALL_TRANSCRIPT = [
|
|
429
|
+
"**Duc:** …six months in. Changing it now is not a conversation I can win.",
|
|
430
|
+
"**Sarah:** Then let's not have it. What if nothing moves and we sit on top?",
|
|
431
|
+
"**Duc:** On top how?",
|
|
432
|
+
"**Sarah:** Two pieces. A carrier layer, so an order becomes a booking in one press. And reconciliation against what the bank actually paid.",
|
|
433
|
+
"**Duc:** The bookings are the part that hurts. We re-key every one.",
|
|
434
|
+
"**Sarah:** Into the carrier's own site?",
|
|
435
|
+
"**Duc:** Into three of them. Different fields, same shipment.",
|
|
436
|
+
"**Sarah:** That is the layer. Nothing you have today changes.",
|
|
437
|
+
"**Duc:** I'd still need leadership on it. Six months of licence left.",
|
|
438
|
+
].join("\n\n");
|
|
439
|
+
|
|
440
|
+
const LONG_NOTE =
|
|
441
|
+
"Ran the whole process end to end with their coordinator. Costs arrive as a PDF payment " +
|
|
442
|
+
"slip per job, the invoices are then downloaded one at a time against the numbers listed on " +
|
|
443
|
+
"the slip, and everything is keyed twice — once into their software and once into a separate " +
|
|
444
|
+
"master spreadsheet — before it goes to accounts for payment. Their OCR reads the notes field " +
|
|
445
|
+
"and nothing else, so the invoice number and the amount are typed by hand every time. Roughly " +
|
|
446
|
+
"twenty minutes a job, and she does eight to twelve a day.";
|
|
447
|
+
|
|
448
|
+
// The fixture is deliberately UNEVEN: a 34-minute call with a machine summary, a
|
|
449
|
+
// one-line note typed between meetings, an email nobody has answered, a message
|
|
450
|
+
// with an attachment, and one entry of unsummarised prose. A tidy set of similar
|
|
451
|
+
// rows would prove nothing about a feed whose whole problem is that its entries
|
|
452
|
+
// are not alike — and would hide the length case entirely.
|
|
453
|
+
const ACTIVITY: ActivityEntry[] = [
|
|
454
|
+
{
|
|
455
|
+
// ARRIVED, NOT WRITTEN — an automation filed this the moment the recording
|
|
456
|
+
// landed, and nobody has said what came of it yet. The most common shape on
|
|
457
|
+
// a feed that fills from elsewhere, and the one a hand-built fixture never
|
|
458
|
+
// contains, so the row that has to say "no words yet" never gets designed.
|
|
459
|
+
// Note the author: a bot, which is why `by` is not a name string.
|
|
460
|
+
key: "a0",
|
|
461
|
+
kind: "call",
|
|
462
|
+
direction: "in",
|
|
463
|
+
over: "Phone",
|
|
464
|
+
when: "Today, 11:40",
|
|
465
|
+
by: { kind: "system", name: "Recording bot" },
|
|
466
|
+
media: { src: "/sample-audio.mp3", label: "Call recording" },
|
|
467
|
+
transcript: CALL_TRANSCRIPT,
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
// THE FULL CALL: the recording, the verbatim transcript folded behind its
|
|
471
|
+
// own toggle, and a machine reading of it — three blocks on one entry, each
|
|
472
|
+
// a different kind of claim. This is the shape an app should copy.
|
|
473
|
+
key: "a3",
|
|
474
|
+
kind: "video",
|
|
475
|
+
gist: "Demo — the reconciliation step is what sold it; pricing still open",
|
|
476
|
+
direction: "out",
|
|
477
|
+
over: "Google Meet",
|
|
478
|
+
when: "Today, 10:15",
|
|
479
|
+
by: { kind: "member", name: "Sarah Chen" },
|
|
480
|
+
media: { src: "/sample-video.webm", label: "Demo recording" },
|
|
481
|
+
transcript: TRANSCRIPT,
|
|
482
|
+
// NO human note on this entry, deliberately. It carried "the person who
|
|
483
|
+
// maintains it was not in the room" — which the summary's own Risk line says
|
|
484
|
+
// better, and which the participants line was gesturing at too: one fact,
|
|
485
|
+
// three places. When a machine summary is good the rep usually adds nothing,
|
|
486
|
+
// and the note block is already taught by the two note entries below.
|
|
487
|
+
bodyByAi:
|
|
488
|
+
"**Where it landed.** The bank-reconciliation step is what changed the " +
|
|
489
|
+
"room — Duc asked to see it twice and pulled Marc in for it.\n\n**Open.** Pricing. Marc " +
|
|
490
|
+
"wants a per-document figure in writing before the board on Thursday.\n\n**Risk.** The " +
|
|
491
|
+
"spreadsheet owner was absent and is the person whose work this replaces.",
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
key: "a1",
|
|
495
|
+
kind: "note",
|
|
496
|
+
gist: "They want the integration layer built and maintained, not the platform replaced",
|
|
497
|
+
direction: "out",
|
|
498
|
+
over: "Note",
|
|
499
|
+
when: "Today, 09:12",
|
|
500
|
+
by: { kind: "member", name: "Sarah Chen" },
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
key: "a1b",
|
|
504
|
+
kind: "note",
|
|
505
|
+
// The gist and the body are the SAME string on purpose: the row clamps it to
|
|
506
|
+
// two lines and the detail carries it whole. That is a truncation and its
|
|
507
|
+
// source — the drill-down every expandable register row makes — not a second
|
|
508
|
+
// copy of one value.
|
|
509
|
+
gist: LONG_NOTE,
|
|
510
|
+
body: LONG_NOTE,
|
|
511
|
+
direction: "in",
|
|
512
|
+
over: "Site visit",
|
|
513
|
+
when: "Today, 08:05",
|
|
514
|
+
by: { kind: "member", name: "Sarah Chen" },
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
// AN EMAIL carries three things nothing else does: who it was between, what
|
|
518
|
+
// it was called, and a body somebody else composed. The body is `body`, not
|
|
519
|
+
// `bodyByAi` — a counterparty's own words are not a machine's summary, and
|
|
520
|
+
// rendering them alike would be the same mistake in the other direction.
|
|
521
|
+
key: "a2",
|
|
522
|
+
kind: "email",
|
|
523
|
+
subject: "Re: Pricing for the customs documentation module",
|
|
524
|
+
participants: "Copied to accounts@",
|
|
525
|
+
gist: "Asked for the per-document price in writing before the board meeting",
|
|
526
|
+
direction: "in",
|
|
527
|
+
over: "Email",
|
|
528
|
+
when: "Yesterday, 16:40",
|
|
529
|
+
by: { kind: "system", name: "Mailbox sync" },
|
|
530
|
+
body:
|
|
531
|
+
"Thanks for the walkthrough. Before I take this to the board on Thursday I need the " +
|
|
532
|
+
"per-document price **in writing**, and confirmation that the customs forms come out of " +
|
|
533
|
+
"the same record without re-keying.\n\nCould you also confirm the setup is included?" +
|
|
534
|
+
"\n\n> Sent from the board pack thread",
|
|
535
|
+
files: [{ id: "f-pack", filename: "Board pack — draft.pdf", mimeType: "application/pdf", url: "/sample.pdf" }],
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
// A CAPTURE: an extraction filed this off a screenshot of a public post, so
|
|
539
|
+
// the evidence is an image and the entry can point back at where it lives.
|
|
540
|
+
// Images go in a GRID — a photo is identified by what is in it, never by a
|
|
541
|
+
// filename — where a document set would be a row list.
|
|
542
|
+
key: "a6",
|
|
543
|
+
kind: "message",
|
|
544
|
+
gist: "Asked in the forwarders' group who handles Japan customs paperwork",
|
|
545
|
+
direction: "in",
|
|
546
|
+
over: "Facebook Group",
|
|
547
|
+
when: "10 Aug, 21:30",
|
|
548
|
+
by: { kind: "system", name: "Capture" },
|
|
549
|
+
files: [
|
|
550
|
+
{ id: "s1", filename: "post.png", mimeType: "image/png", url: img("Post", "#3f3f46") },
|
|
551
|
+
{ id: "s2", filename: "profile.png", mimeType: "image/png", url: img("Profile", "#52525b") },
|
|
552
|
+
],
|
|
553
|
+
sourceUrl: "https://example.com/groups/forwarders/posts/1849",
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
key: "a4",
|
|
557
|
+
kind: "call",
|
|
558
|
+
gist: "34 minutes — locked into their current platform for six months, wants us to sit on top of it",
|
|
559
|
+
direction: "out",
|
|
560
|
+
over: "Phone",
|
|
561
|
+
when: "3 Aug, 11:20",
|
|
562
|
+
by: { kind: "member", name: "Sarah Chen" },
|
|
563
|
+
media: { src: "/sample-audio.mp3", label: "Call recording" },
|
|
564
|
+
transcript: CALL_TRANSCRIPT,
|
|
565
|
+
bodyByAi:
|
|
566
|
+
"**Context.** International freight forwarder, Japanese ownership, six " +
|
|
567
|
+
"months into their current platform.\n\n**What they need**\n\n1. A carrier API layer — one " +
|
|
568
|
+
"press from an order to a booking.\n2. Bank reconciliation against paid shipments.\n\n" +
|
|
569
|
+
"**Why it has not closed.** Deep commitment to the incumbent; replacing it is a non-starter " +
|
|
570
|
+
"and leadership would have to approve.",
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
key: "a5",
|
|
574
|
+
kind: "message",
|
|
575
|
+
gist: "Sent the one-page summary; no reply yet",
|
|
576
|
+
direction: "out",
|
|
577
|
+
over: "Zalo",
|
|
578
|
+
when: "28 Jul, 18:05",
|
|
579
|
+
by: { kind: "member", name: "Sarah Chen" },
|
|
580
|
+
awaiting: true,
|
|
581
|
+
files: [{ id: "f-sum", filename: "Summary — one page.pdf", mimeType: "application/pdf", url: "/sample.pdf" }],
|
|
582
|
+
},
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* What came WITH the entry, as a clause for the supporting line.
|
|
587
|
+
*
|
|
588
|
+
* A sentence rather than a row of glyphs: an icon run is compact but needs a key
|
|
589
|
+
* the reader has to learn before the first row means anything, and the line it
|
|
590
|
+
* would save already exists.
|
|
591
|
+
*
|
|
592
|
+
* ONLY on an entry with no gist. There, the attachments ARE the content — "not
|
|
593
|
+
* written up yet" is useless without "there is a recording to write it up from".
|
|
594
|
+
* Anywhere else it repeats what the body shows one press away, and it is not
|
|
595
|
+
* free: measured at 375 the supporting line already fills the two lines
|
|
596
|
+
* `Timeline` clamps it at, so an unconditional clause clips the date it was
|
|
597
|
+
* appended to. The clause APPEARING is therefore itself the signal that the row
|
|
598
|
+
* is otherwise empty.
|
|
599
|
+
*/
|
|
600
|
+
function arrivedWith(a: ActivityEntry): string {
|
|
601
|
+
if (a.gist) return "";
|
|
602
|
+
// Name the thing, not its relation to the sentence: "Attachment attached" is
|
|
603
|
+
// the shape a templated clause produces and it reads as a stutter.
|
|
604
|
+
// Lower case throughout, capitalised once at the end — joining already-capital
|
|
605
|
+
// words gave "Recording, Transcript and file attached." the moment a third
|
|
606
|
+
// artifact appeared, which no fixture reached and every real row eventually
|
|
607
|
+
// will. The type guard rather than `filter(Boolean)` is what removes the `!`
|
|
608
|
+
// on the last element: `Boolean` does not narrow.
|
|
609
|
+
const parts = [
|
|
610
|
+
a.media ? "recording" : null,
|
|
611
|
+
a.transcript ? "transcript" : null,
|
|
612
|
+
a.files?.length ? "file" : null,
|
|
613
|
+
].filter((x): x is string => x !== null);
|
|
614
|
+
if (parts.length === 0) return "";
|
|
615
|
+
const list =
|
|
616
|
+
parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
|
|
617
|
+
return ` ${list[0].toUpperCase()}${list.slice(1)} attached.`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* THE ACTIVITY BODY — one component, every block, each rendered only if the
|
|
622
|
+
* entry carries it.
|
|
623
|
+
*
|
|
624
|
+
* This is the half of the anatomy that VARIES, and the reason it is a component
|
|
625
|
+
* rather than five: an app that branches on `kind` writes the media block once
|
|
626
|
+
* per kind and then fixes a bug in four of them. Reading order is fixed and
|
|
627
|
+
* means something — who and what it was, then the artifact, then the verbatim
|
|
628
|
+
* source, then what people made of it, then what came with it, then where it
|
|
629
|
+
* came from and who filed it.
|
|
630
|
+
*/
|
|
631
|
+
function ActivityBody({
|
|
632
|
+
a,
|
|
633
|
+
editing,
|
|
634
|
+
onEdit,
|
|
635
|
+
onToggleEdit,
|
|
636
|
+
onDelete,
|
|
637
|
+
}: {
|
|
638
|
+
a: ActivityEntry;
|
|
639
|
+
/** Set by the footer's Edit verb — see the fields below for why it is a MODE
|
|
640
|
+
* here rather than the resident editors every other section uses. */
|
|
641
|
+
editing: boolean;
|
|
642
|
+
onEdit: (patch: Partial<Pick<ActivityEntry, "gist" | "body">>) => void;
|
|
643
|
+
onToggleEdit: () => void;
|
|
644
|
+
onDelete: () => void;
|
|
645
|
+
}) {
|
|
646
|
+
const [showTranscript, setShowTranscript] = useState(false);
|
|
647
|
+
const images = (a.files ?? []).filter((f) => isImageMimeType(f.mimeType));
|
|
648
|
+
const docs = (a.files ?? []).filter((f) => !isImageMimeType(f.mimeType));
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
<View style={{ gap: 12 }}>
|
|
652
|
+
{/* THE WORDS A PERSON WROTE — the only editable thing on an entry, and the
|
|
653
|
+
one place this template does NOT use a resident editor.
|
|
654
|
+
|
|
655
|
+
Exactly two blocks were authored by a person: the gist and the note.
|
|
656
|
+
Everything else is derived (participants, off a header), verbatim (the
|
|
657
|
+
transcript), an artifact (the media, the files) or a machine's output
|
|
658
|
+
(the summary — you re-run that, you do not hand-edit it). So the verb
|
|
659
|
+
is "Edit", not "Edit entry": an entry is not an editable thing, the
|
|
660
|
+
words typed into it are.
|
|
661
|
+
|
|
662
|
+
WHY A MODE, when every other section on this record is a resident
|
|
663
|
+
`InlineTextInput`. Those sections show each value ONCE. Here the gist is
|
|
664
|
+
already the row's label, so a resident field renders the same sentence
|
|
665
|
+
twice, stacked and identical — which is what it looked like, and no
|
|
666
|
+
amount of quieting the frame fixes a sentence appearing twice.
|
|
667
|
+
|
|
668
|
+
THE EXCEPTION IS AN EMPTY GIST, and it is the important half: an
|
|
669
|
+
automation files an entry the moment a recording lands, the row says
|
|
670
|
+
"Not written up yet", and there is nothing to duplicate. The field shows
|
|
671
|
+
itself and invites the sentence. Without that the feed can RECEIVE an
|
|
672
|
+
entry it gives you no way to finish — a dead end on the most common row
|
|
673
|
+
a multi-writer feed produces. An empty value shows its field; a filled
|
|
674
|
+
one waits to be asked. */}
|
|
675
|
+
{editing || !a.gist ? (
|
|
676
|
+
<InlineTextInput
|
|
677
|
+
value={a.gist ?? ""}
|
|
678
|
+
onSave={(v) => onEdit({ gist: v.trim() || undefined })}
|
|
679
|
+
placeholder="What came of it?"
|
|
680
|
+
accessibilityLabel="What came of it"
|
|
681
|
+
numberOfLines={2}
|
|
682
|
+
autoGrow
|
|
683
|
+
/>
|
|
684
|
+
) : null}
|
|
685
|
+
|
|
686
|
+
{/* HEADER — who it was between, and what it was called. A subject line
|
|
687
|
+
never says who was on it, and on a call there is no subject at all, so
|
|
688
|
+
these are two blocks rather than one formatted string. Both are read
|
|
689
|
+
off the message itself, so neither is editable. */}
|
|
690
|
+
{a.participants ? (
|
|
691
|
+
<Text size="xs" color="muted">{a.participants}</Text>
|
|
692
|
+
) : null}
|
|
693
|
+
{a.subject ? <Text size="sm" weight="medium">{a.subject}</Text> : null}
|
|
694
|
+
|
|
695
|
+
{/* THE ARTIFACT. A recording rendered as a file row makes the reader leave
|
|
696
|
+
the record to hear thirty seconds of a call they are already reading
|
|
697
|
+
about. `kind` is the caller's to state, because one container can hold
|
|
698
|
+
both streams and only this surface knows whether it wants the picture.
|
|
699
|
+
|
|
700
|
+
THE BOX IS THE CALLER'S: `MediaPlayer` fills its parent rather than
|
|
701
|
+
carrying a size, so a player dropped bare into a gap-spaced stack
|
|
702
|
+
collapses to nothing — it renders, it reports no error, and there is
|
|
703
|
+
simply no pixel. Video takes a 16:9 frame so the row does not resize
|
|
704
|
+
when metadata arrives; audio carries its own intrinsic height. */}
|
|
705
|
+
{a.media ? (
|
|
706
|
+
a.kind === "video" ? (
|
|
707
|
+
<View style={{ aspectRatio: 16 / 9, borderRadius: 12, overflow: "hidden" }}>
|
|
708
|
+
<MediaPlayer src={a.media.src} kind="video" accessibilityLabel={a.media.label} />
|
|
709
|
+
</View>
|
|
710
|
+
) : (
|
|
711
|
+
<MediaPlayer src={a.media.src} kind="audio" accessibilityLabel={a.media.label} />
|
|
712
|
+
)
|
|
713
|
+
) : null}
|
|
714
|
+
|
|
715
|
+
{/* THE VERBATIM SOURCE, folded. It belongs next to the summary rather than
|
|
716
|
+
behind a dialog, because the reason anyone opens a transcript is to
|
|
717
|
+
check a claim the summary made — and a modal takes the claim off the
|
|
718
|
+
screen at the moment they want to compare. Revealed in FULL, not into a
|
|
719
|
+
scroll box: a scroller inside a drawer that also scrolls traps the
|
|
720
|
+
wheel, and a reader who pressed "Show transcript" asked for the length. */}
|
|
721
|
+
{a.transcript ? (
|
|
722
|
+
<View style={{ gap: 8, alignItems: "flex-start" }}>
|
|
723
|
+
{/* Underlined text that REVEALS rather than navigates — muted, so the
|
|
724
|
+
ink never promises a trip. Two controls were tried first and both
|
|
725
|
+
are wrong here: `Button color="muted"` measures transparent and
|
|
726
|
+
undecorated at rest (a hover-only affordance), and `Accordion` is a
|
|
727
|
+
list-row disclosure nested inside a list row. See the component's
|
|
728
|
+
own doc and composition.md §"Commit & feedback surfaces". */}
|
|
729
|
+
<TextDisclosure
|
|
730
|
+
expanded={showTranscript}
|
|
731
|
+
onToggle={setShowTranscript}
|
|
732
|
+
label="transcript"
|
|
733
|
+
/>
|
|
734
|
+
{/* PLAIN, not a tinted well. The toggle directly above already says
|
|
735
|
+
what this is and where it came from, and a panel here would put two
|
|
736
|
+
identical recessed boxes on one row meaning two different things —
|
|
737
|
+
a verbatim record and a machine's reading of it. */}
|
|
738
|
+
{showTranscript ? <Markdown variant="embedded">{a.transcript}</Markdown> : null}
|
|
739
|
+
</View>
|
|
740
|
+
) : null}
|
|
741
|
+
|
|
742
|
+
{/* PROSE A PERSON WROTE — a note of their own. It sits on the page's own
|
|
743
|
+
ground, which is what makes the recessed block below legible as "not
|
|
744
|
+
written here", and it is EDITABLE for the same reason the gist is:
|
|
745
|
+
somebody typed it.
|
|
746
|
+
|
|
747
|
+
The exception is a body that arrived FROM the counterparty — an email
|
|
748
|
+
they sent is a record of what they said, so it renders as markdown and
|
|
749
|
+
is not ours to rewrite. */}
|
|
750
|
+
{a.kind === "email" ? (
|
|
751
|
+
a.body ? <Markdown variant="embedded">{a.body}</Markdown> : null
|
|
752
|
+
) : editing ? (
|
|
753
|
+
<InlineTextInput
|
|
754
|
+
value={a.body ?? ""}
|
|
755
|
+
onSave={(v) => onEdit({ body: v.trim() || undefined })}
|
|
756
|
+
placeholder="Add a note…"
|
|
757
|
+
accessibilityLabel="Note"
|
|
758
|
+
numberOfLines={2}
|
|
759
|
+
autoGrow
|
|
760
|
+
/>
|
|
761
|
+
) : a.body ? (
|
|
762
|
+
<Markdown variant="embedded">{a.body}</Markdown>
|
|
763
|
+
) : null}
|
|
764
|
+
|
|
765
|
+
{/* PROSE A MODEL WROTE. Two devices carry the difference and neither is a
|
|
766
|
+
weight nudge: the line names the AUTHOR and the evidence it worked
|
|
767
|
+
from — naming only the source ("From the call") leaves the reader to
|
|
768
|
+
assume a person — and the recessed `Inset` says the text was not
|
|
769
|
+
written on this page. `embedded` stops the `##` headings a model emits
|
|
770
|
+
freely from outranking the section they were dropped inside. */}
|
|
771
|
+
{a.bodyByAi ? (
|
|
772
|
+
// The label sits INSIDE the panel it names. Floating above it, a 12px
|
|
773
|
+
// muted fragment over a tinted box reads as an orphan — the type was on
|
|
774
|
+
// the ladder and the PLACEMENT was the defect, which is why it looked
|
|
775
|
+
// wrong without looking measurably wrong.
|
|
776
|
+
//
|
|
777
|
+
// Two words, and they are the whole job: a model wrote this. It read
|
|
778
|
+
// "Written by AI from the recording" over markdown that then opened with
|
|
779
|
+
// its own `## Summary` — two labels for one thing, the longer one
|
|
780
|
+
// spending four words on evidence the reader can watch playing directly
|
|
781
|
+
// above. Provenance is weighted by consequence: name the source where it
|
|
782
|
+
// is NOT on screen.
|
|
783
|
+
<Inset>
|
|
784
|
+
<Text size="xs" color="muted" weight="medium">AI summary</Text>
|
|
785
|
+
<Markdown variant="embedded">{a.bodyByAi}</Markdown>
|
|
786
|
+
</Inset>
|
|
787
|
+
) : null}
|
|
788
|
+
|
|
789
|
+
{/* WHAT CAME WITH IT, split by what IDENTIFIES each file. A document is its
|
|
790
|
+
NAME — every PDF thumbnail is the same grey page — so documents are
|
|
791
|
+
rows. A photo is its CONTENT (`IMG_4471.jpg` tells nobody anything), so
|
|
792
|
+
images are tiles. One `files` array, two surfaces, decided by the data
|
|
793
|
+
rather than by a prop the caller has to remember. */}
|
|
794
|
+
{docs.length ? <FileRows files={docs} /> : null}
|
|
795
|
+
{images.length ? <FileThumbnailGrid files={images} itemSize={88} /> : null}
|
|
796
|
+
|
|
797
|
+
{/* PROVENANCE — where it happened, and who filed it. Two shapes for the
|
|
798
|
+
author because there are two kinds: a person gets a face, and an
|
|
799
|
+
automation gets `WaveAvatar`, the kit's mark for a non-human identity.
|
|
800
|
+
Giving a bot initials and an avatar is a small fabrication that every
|
|
801
|
+
reader notices and no test catches. */}
|
|
802
|
+
{/* THE FOOTER — provenance on the left, the entry's verbs on the right.
|
|
803
|
+
Two kinds of thing on one line, so they are two GROUPS pushed apart by
|
|
804
|
+
the space between them rather than a uniform gap that would make them
|
|
805
|
+
peers.
|
|
806
|
+
|
|
807
|
+
THE VERBS LIVE HERE, NOT ON THE ROW, and the reason is not tidiness.
|
|
808
|
+
A control in the row's trailing slot produced three separate defects in
|
|
809
|
+
a row: it nested a button inside the row's own button (invalid HTML,
|
|
810
|
+
one click reaching two handlers), it needed a fixed-height box of its
|
|
811
|
+
own to stay on the label's first line, and it sat close enough to the
|
|
812
|
+
disclosure chevron to read as one cluster with it. All three exist only
|
|
813
|
+
because something interactive shares the row with the press target.
|
|
814
|
+
Down here there is no press target to share with, and the whole class
|
|
815
|
+
is gone.
|
|
816
|
+
|
|
817
|
+
It also puts the verbs where the thing they act on IS. Editing a gist
|
|
818
|
+
you cannot read is not a real act, and a delete you reach without
|
|
819
|
+
opening the entry is a delete you make without looking at it — the
|
|
820
|
+
expansion is the confirmation step, which is why "two clicks" is the
|
|
821
|
+
feature rather than the cost.
|
|
822
|
+
|
|
823
|
+
They are ordinary `Button`s, not the quiet text controls used inside
|
|
824
|
+
prose above: these MUTATE, so they carry a control surface. Right-
|
|
825
|
+
aligned in a footer band, they establish no text edge to betray, which
|
|
826
|
+
is the condition the fill-less rungs need. */}
|
|
827
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
828
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
829
|
+
<Text size="xs" color="muted">Logged by</Text>
|
|
830
|
+
{a.by.kind === "member" ? (
|
|
831
|
+
<MemberChip name={a.by.name} image={a.by.image} size="sm" />
|
|
832
|
+
) : (
|
|
833
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
834
|
+
<WaveAvatar size={24} />
|
|
835
|
+
<Text size="sm">{a.by.name}</Text>
|
|
836
|
+
</View>
|
|
837
|
+
)}
|
|
838
|
+
</View>
|
|
839
|
+
{a.sourceUrl ? (
|
|
840
|
+
<Link size="xs" onPress={() => {}}>{a.sourceUrl}</Link>
|
|
841
|
+
) : null}
|
|
842
|
+
{/* Pushes the verbs to the far edge, so provenance and actions are two
|
|
843
|
+
groups rather than a run of four items on one gap. */}
|
|
844
|
+
<View style={{ flex: 1, minWidth: 24 }} />
|
|
845
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
846
|
+
<Button
|
|
847
|
+
title={editing ? "Done" : "Edit"}
|
|
848
|
+
color="muted"
|
|
849
|
+
onPress={onToggleEdit}
|
|
850
|
+
/>
|
|
851
|
+
<Button
|
|
852
|
+
title="Delete"
|
|
853
|
+
color="danger-secondary"
|
|
854
|
+
onPress={() =>
|
|
855
|
+
Alert.alert(
|
|
856
|
+
"Delete this entry?",
|
|
857
|
+
"It leaves the customer's history and stops counting toward the activity total. This cannot be undone.",
|
|
858
|
+
[
|
|
859
|
+
{ text: "Cancel", style: "cancel" },
|
|
860
|
+
{ text: "Delete", style: "destructive", onPress: onDelete },
|
|
861
|
+
],
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
/>
|
|
865
|
+
</View>
|
|
866
|
+
</View>
|
|
867
|
+
</View>
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** The glyph per medium. A medium is a CATEGORY, so it rides the icon and the
|
|
872
|
+
* supporting line — never a `Badge`, which this kit reserves for status. */
|
|
873
|
+
const ACTIVITY_ICON: Record<ActivityKind, IconName> = {
|
|
874
|
+
call: "phone",
|
|
875
|
+
video: "monitor",
|
|
876
|
+
email: "mail",
|
|
877
|
+
message: "message-circle",
|
|
878
|
+
note: "sticky-note",
|
|
879
|
+
};
|
|
880
|
+
|
|
298
881
|
// ── billing — charges grouped into issuable invoice DOCUMENTS on this record
|
|
299
882
|
type Method = "cash" | "transfer" | "card";
|
|
300
883
|
const METHODS: PickerOption<Method>[] = [
|
|
@@ -399,6 +982,19 @@ const PANEL_GUTTER = PANEL_W + RAIL_GAP;
|
|
|
399
982
|
* `Drawer`'s 600 it sat between two columns of its own order and stopped reading
|
|
400
983
|
* as the thing the page is about. */
|
|
401
984
|
const CONTENT_MAX = 680;
|
|
985
|
+
/** The page's own horizontal padding. Named because `groupW` subtracts it and
|
|
986
|
+
* the panel's fold threshold must add it back — they were written apart, and
|
|
987
|
+
* the threshold omitted it, so the reading column could sit a full 56px UNDER
|
|
988
|
+
* the floor the same code declared. A floor that is not enforced is not a
|
|
989
|
+
* floor; it is a comment. */
|
|
990
|
+
const PAGE_PAD = 28;
|
|
991
|
+
/** What the reading column needs to stay readable beside both gutters — a label
|
|
992
|
+
* column plus its values, not what it would like. Below this the discussion
|
|
993
|
+
* panel FOLDS to an inline section, which is the template's stated rule
|
|
994
|
+
* ("when the two compete the panel is what gives way") finally implemented:
|
|
995
|
+
* the panel's width is a constant, so without a floor on this side the reading
|
|
996
|
+
* column absorbed every squeeze and the rule ran backwards. */
|
|
997
|
+
const CONTENT_FLOOR = 420;
|
|
402
998
|
|
|
403
999
|
// ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
|
|
404
1000
|
// record's documents surface: files feed ONE "Use AI" entry that forks into
|
|
@@ -415,6 +1011,7 @@ function fmtSize(kb: number): string {
|
|
|
415
1011
|
if (kb <= 0) return "—";
|
|
416
1012
|
return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
|
417
1013
|
}
|
|
1014
|
+
const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
|
|
418
1015
|
const MOCK_PHOTO_URL =
|
|
419
1016
|
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNmNTllMGInLz48cmVjdCB4PScyNjInIHk9JzI0JyB3aWR0aD0nMTAnIGhlaWdodD0nMTg2JyBmaWxsPScjNTI1MjUyJy8+PHJlY3QgeD0nMTUwJyB5PScyNCcgd2lkdGg9JzEyMicgaGVpZ2h0PScxMCcgZmlsbD0nIzUyNTI1MicvPjwvc3ZnPg==";
|
|
420
1017
|
const DOCS: Doc[] = [
|
|
@@ -436,6 +1033,11 @@ const DOCS: Doc[] = [
|
|
|
436
1033
|
{ 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
1034
|
{ 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
1035
|
{ 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==" },
|
|
1036
|
+
// NOT an image, deliberately: the signed note is evidence of the same hand-over
|
|
1037
|
+
// as the frames around it, and it is the tile a fixture of eight JPGs never
|
|
1038
|
+
// rendered. `FileThumbnail` shows a doc tile (badge + name) — no code needed,
|
|
1039
|
+
// only a fixture honest enough to reach it.
|
|
1040
|
+
{ 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
1041
|
{ 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
1042
|
];
|
|
441
1043
|
|
|
@@ -607,14 +1209,30 @@ const PDF = "application/pdf";
|
|
|
607
1209
|
|
|
608
1210
|
/**
|
|
609
1211
|
* A file rendered as a table VALUE — the filename at the size every other cell
|
|
610
|
-
* uses.
|
|
1212
|
+
* uses. The ONE-FACT cell: the name IS the value, so it is rendered like the
|
|
1213
|
+
* quantity beside it and nothing else is added.
|
|
611
1214
|
*
|
|
612
1215
|
* It carried a `FileBadge` at first, which in a cell that size shrank its own
|
|
613
1216
|
* label to five pixels — off every rung of the type scale. Making it legible
|
|
614
1217
|
* means a 34px box setting the height of a 52px row, for a fact the extension
|
|
615
|
-
* already carries.
|
|
616
|
-
*
|
|
617
|
-
*
|
|
1218
|
+
* already carries.
|
|
1219
|
+
*
|
|
1220
|
+
* The discriminator is HOW MANY FACTS the cell owes the reader. This cell owes
|
|
1221
|
+
* ONE — which document — and the name is it.
|
|
1222
|
+
*
|
|
1223
|
+
* A cell owing TWO is a different shape and wants a mark this one must not
|
|
1224
|
+
* borrow: a reference the desk quotes (an invoice number) PLUS whether the
|
|
1225
|
+
* document behind it is on file, where nothing in the value answers the second
|
|
1226
|
+
* question. `tpl_item_list`'s register is the worked example — it is a
|
|
1227
|
+
* full-width surface with room for the column, which this page is not: measured
|
|
1228
|
+
* at 1280 the fee table already shows 3 of its 5 columns, because a record page
|
|
1229
|
+
* spends its width on a nav rail and a comments column. A column that always
|
|
1230
|
+
* drops teaches nothing, so the rule lives here and the example lives there.
|
|
1231
|
+
*
|
|
1232
|
+
* The mark is a monochrome clip, NOT a `FileBadge`: a badge's payload beyond
|
|
1233
|
+
* presence is the file's TYPE, its label is unreadable at row scale (5px
|
|
1234
|
+
* measured), and on a ledger where every document is a PDF it spends a hue on
|
|
1235
|
+
* something that never varies.
|
|
618
1236
|
*/
|
|
619
1237
|
function DocName({ doc }: { doc: LineDoc }) {
|
|
620
1238
|
return <Text size="sm" numberOfLines={1}>{doc.name}</Text>;
|
|
@@ -665,7 +1283,6 @@ const FINDINGS: Check[] = [
|
|
|
665
1283
|
];
|
|
666
1284
|
|
|
667
1285
|
// 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
1286
|
const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: d.url ?? MOCK_PDF_URL });
|
|
670
1287
|
/** A comment's ThreadFile (snake_case, the API shape) → the kit's DisplayFile. */
|
|
671
1288
|
const commentFileToDisplay = (f: ThreadFile): DisplayFile => ({ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.url ?? "", thumbnailUrl: f.thumbnail_url });
|
|
@@ -707,6 +1324,16 @@ const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
|
|
707
1324
|
// display VAT / Invoice no / Note — it drops them silently. And `note` is prose:
|
|
708
1325
|
// it has no column width at any measure.
|
|
709
1326
|
type FeeDirection = "charge" | "cost";
|
|
1327
|
+
/** A picked `File` as the kit's `DisplayFile`. A real host uploads and returns
|
|
1328
|
+
* the stored object; the object URL stands in for that here. */
|
|
1329
|
+
let feeDocSeq = 0;
|
|
1330
|
+
const asDisplayFile = (f: File): DisplayFile => ({
|
|
1331
|
+
id: `fee-doc-u${(feeDocSeq += 1)}`,
|
|
1332
|
+
filename: f.name,
|
|
1333
|
+
mimeType: f.type || "application/octet-stream",
|
|
1334
|
+
url: URL.createObjectURL(f),
|
|
1335
|
+
});
|
|
1336
|
+
|
|
710
1337
|
interface Fee {
|
|
711
1338
|
id: string;
|
|
712
1339
|
name: string;
|
|
@@ -717,25 +1344,41 @@ interface Fee {
|
|
|
717
1344
|
vat: number | null;
|
|
718
1345
|
due: string;
|
|
719
1346
|
invoiceNo: string;
|
|
1347
|
+
/** The supplier's original, filed against THIS fee. A number is what the field
|
|
1348
|
+
* claimed; these are what actually arrived, and the gap between them is the
|
|
1349
|
+
* work a payables desk does. */
|
|
1350
|
+
docs: DisplayFile[];
|
|
720
1351
|
paid: boolean;
|
|
721
1352
|
note: string;
|
|
722
1353
|
}
|
|
723
1354
|
let feeSeq = 4;
|
|
724
1355
|
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
|
-
|
|
1356
|
+
{ id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", docs: [], paid: true, note: "" },
|
|
1357
|
+
{ 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" },
|
|
1358
|
+
// UNPAID with a number claimed and nothing filed — the state the status column
|
|
1359
|
+
// exists to surface. It was `paid: true`, which meant "Awaiting invoice" could
|
|
1360
|
+
// never render and the design could not be seen at all.
|
|
1361
|
+
{ 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: "" },
|
|
1362
|
+
// A PHOTO of an invoice, not a PDF — the case the badge exists for. A supplier
|
|
1363
|
+
// who sends a phone snap has not sent a tax document, and the colour says so
|
|
1364
|
+
// before anyone opens it. A fixture of nothing but PDFs makes the badge look
|
|
1365
|
+
// like decoration, because the one thing it carries never varies.
|
|
1366
|
+
{ 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
1367
|
];
|
|
730
1368
|
// `priority` = the mobile contract: Party then Type drop first on a narrow
|
|
731
1369
|
// container, Amount + Status survive with the identity; at the floor the rows
|
|
732
1370
|
// STACK label-over-value — and the expansion always carries the full fee.
|
|
1371
|
+
// THREE columns, not five — the register's grammar. A column per FACT spends
|
|
1372
|
+
// width on separation the reader never asked for: the party belongs to the fee's
|
|
1373
|
+
// identity and the payment state belongs to its amount, so each rides line 2 of
|
|
1374
|
+
// the cell it qualifies. Grouping bought 246px (Party 150 + Status 96), which is
|
|
1375
|
+
// more than the document column costs — the table gained information and lost
|
|
1376
|
+
// two columns. `tpl_item_list` stacks both of these already; this table was the
|
|
1377
|
+
// outlier.
|
|
733
1378
|
const FEE_COLUMNS: TableColumn[] = [
|
|
734
1379
|
{ 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
1380
|
{ key: "amount", label: "Amount", width: 104, align: "right", priority: 1 },
|
|
738
|
-
{ key: "
|
|
1381
|
+
{ key: "invoice", label: "Invoice", width: 116, priority: 2 },
|
|
739
1382
|
];
|
|
740
1383
|
const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
|
|
741
1384
|
|
|
@@ -1162,7 +1805,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1162
1805
|
const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
|
|
1163
1806
|
const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
|
|
1164
1807
|
const addFee = () => {
|
|
1165
|
-
const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", paid: false, note: "" };
|
|
1808
|
+
const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", docs: [], paid: false, note: "" };
|
|
1166
1809
|
setFees((prev) => [...prev, f]);
|
|
1167
1810
|
setFeeView({ kind: "edit", id: f.id });
|
|
1168
1811
|
};
|
|
@@ -1173,12 +1816,34 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1173
1816
|
]);
|
|
1174
1817
|
};
|
|
1175
1818
|
// The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
|
|
1819
|
+
/**
|
|
1820
|
+
* MONEY state only — it rides under the amount it is about.
|
|
1821
|
+
*
|
|
1822
|
+
* "Awaiting docs" lived here while the document had no column of its own, and
|
|
1823
|
+
* it was a conflation: whether a supplier's original has arrived is not a fact
|
|
1824
|
+
* about the money, and putting it in the money's state meant a fee could not
|
|
1825
|
+
* report both at once. Now the Invoice column carries the document and this
|
|
1826
|
+
* carries the payment, and each says one thing.
|
|
1827
|
+
*/
|
|
1176
1828
|
const feeStatus = (f: Fee): { text: string; danger: boolean } =>
|
|
1177
1829
|
f.paid ? { text: "Paid", danger: false }
|
|
1178
1830
|
: feeOverdue(f) ? { text: "Overdue", danger: true }
|
|
1179
1831
|
: f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false }
|
|
1180
1832
|
: { text: "Unpaid", danger: false };
|
|
1181
1833
|
|
|
1834
|
+
// ── activity — the feed is state so a logged note appears at the top of it,
|
|
1835
|
+
// where it happened, rather than after a refetch.
|
|
1836
|
+
const [activity, setActivity] = useState<ActivityEntry[]>(ACTIVITY);
|
|
1837
|
+
// A record's feed is not a register: you do not PAGE it, you fold the tail.
|
|
1838
|
+
// The recent entries are what the reader came for, and an unbounded feed makes
|
|
1839
|
+
// every other section on the record unreachable by scroll.
|
|
1840
|
+
const [showAllActivity, setShowAllActivity] = useState(false);
|
|
1841
|
+
const ACTIVITY_FOLD = 3;
|
|
1842
|
+
// ONE entry at a time. A feed with several rows open in edit mode gives the
|
|
1843
|
+
// reader two half-finished sentences and no way to tell which one the next
|
|
1844
|
+
// keystroke lands in.
|
|
1845
|
+
const [editingActivity, setEditingActivity] = useState<string | null>(null);
|
|
1846
|
+
|
|
1182
1847
|
// ── the record — seeded mid-flight so every state is visible
|
|
1183
1848
|
const [stage, setStage] = useState<Stage>("sales");
|
|
1184
1849
|
// The customer book is STATE: the attached customer's tax ID edits inline in
|
|
@@ -1278,6 +1943,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1278
1943
|
const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
|
|
1279
1944
|
const seq = useRef(414);
|
|
1280
1945
|
const taxId = customer?.taxId ?? "";
|
|
1946
|
+
/** The ONE signed contract — a single-file field, not a document set. */
|
|
1947
|
+
const [contract, setContract] = useState<DisplayFile | null>(null);
|
|
1281
1948
|
const taxIdValid = TAX_ID_RE.test(taxId);
|
|
1282
1949
|
|
|
1283
1950
|
// ── the document desk (see the module banner above; the record's files ARE
|
|
@@ -1286,7 +1953,20 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1286
1953
|
// The photo subset of the SAME collection — one list, two surfaces. Derived
|
|
1287
1954
|
// rather than a second state, or the two sections would drift the moment
|
|
1288
1955
|
// anything is added to one of them.
|
|
1289
|
-
|
|
1956
|
+
/**
|
|
1957
|
+
* EVERYTHING on the record, not the image subset.
|
|
1958
|
+
*
|
|
1959
|
+
* It filtered `mimeType.startsWith("image/")`, which read well until you asked
|
|
1960
|
+
* what happens to the signed delivery note: it is evidence of the same
|
|
1961
|
+
* hand-over as the photos beside it, and a MIME test filed it somewhere else.
|
|
1962
|
+
* A reader looking for "what came back from the yard" then has to know which
|
|
1963
|
+
* of two lenses holds each artifact — a question about the FILE FORMAT, which
|
|
1964
|
+
* is never the question they actually have.
|
|
1965
|
+
*
|
|
1966
|
+
* `FileThumbnail` already renders a non-image as a doc tile (badge + name), so
|
|
1967
|
+
* nothing here had to change for it to work — only the filter that hid it.
|
|
1968
|
+
*/
|
|
1969
|
+
const photoDocs = files;
|
|
1290
1970
|
/**
|
|
1291
1971
|
* SELECT MODE — for the grid only, and only because a grid has nowhere free
|
|
1292
1972
|
* to put a checkbox.
|
|
@@ -1862,6 +2542,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1862
2542
|
// The rail lists sections in PAGE order, or the scroll-spy highlights one
|
|
1863
2543
|
// entry while the reader is looking at another.
|
|
1864
2544
|
{ key: "progress", label: "Progress", icon: "git-branch" },
|
|
2545
|
+
{ key: "activity", label: "Activity", icon: "message-square" },
|
|
1865
2546
|
{ key: "general", label: "General", icon: "file-text" },
|
|
1866
2547
|
// Only a SECTION when the page is too narrow to seat the panel — so the wide
|
|
1867
2548
|
// rail filters it out rather than offering a jump to something that is not
|
|
@@ -1884,7 +2565,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1884
2565
|
// by scroll and invisible in the outline. Bound to `SecKey`, a key here that
|
|
1885
2566
|
// `SECTIONS` does not carry stops being assignable, so the omission is a
|
|
1886
2567
|
// compile error instead of a missing row nobody notices.
|
|
1887
|
-
const nav = useSectionNav<SecKey>(["progress", "general", "comments", "files", "photos", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
|
|
2568
|
+
const nav = useSectionNav<SecKey>(["progress", "activity", "general", "comments", "files", "photos", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
|
|
1888
2569
|
// ONE record, ONE page. A section is a place you SCROLL to, never a
|
|
1889
2570
|
// destination you swap to: routing a record was tried here and lost, because
|
|
1890
2571
|
// every fix it needed rebuilt the whole-record view in miniature — a dot to
|
|
@@ -1934,7 +2615,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1934
2615
|
// two gutters. The floor is what the column needs to stay readable (a label
|
|
1935
2616
|
// column plus its values), not what it would like — hold out for the ceiling and
|
|
1936
2617
|
// the thread folds away on every screen under ~1220.
|
|
1937
|
-
|
|
2618
|
+
//
|
|
2619
|
+
// Every term `groupW` spends must appear here, PAGE_PAD included. It did not,
|
|
2620
|
+
// and the omission ran in the dangerous direction: the check passed while the
|
|
2621
|
+
// column was under its floor, so the panel kept its constant 300 and the record
|
|
2622
|
+
// — the thing the page is about — took the loss. The two lines are one
|
|
2623
|
+
// calculation and have to be read as one.
|
|
2624
|
+
const wide = pageWidth != null && pageWidth >= PAGE_PAD * 2 + GUTTER + PANEL_GUTTER + CONTENT_FLOOR;
|
|
1938
2625
|
// Narrow: the discussion is one tap off the same bar the sections are on —
|
|
1939
2626
|
// on the RIGHT, opposite the section picker, since it is about the record as a
|
|
1940
2627
|
// whole rather than a place in it.
|
|
@@ -1947,8 +2634,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1947
2634
|
// record scrolls. (Capping the ScrollView at the column's width instead makes
|
|
1948
2635
|
// the gutters dead zones.) Mirrors the flex maths the row does below: the
|
|
1949
2636
|
// group is centred inside the 28px content padding.
|
|
1950
|
-
const groupW = pageWidth == null ? 0 : Math.min(pageWidth -
|
|
1951
|
-
const railLeft = pageWidth == null ? 0 :
|
|
2637
|
+
const groupW = pageWidth == null ? 0 : Math.min(pageWidth - PAGE_PAD * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
|
|
2638
|
+
const railLeft = pageWidth == null ? 0 : PAGE_PAD + Math.max(0, (pageWidth - PAGE_PAD * 2 - groupW) / 2);
|
|
1952
2639
|
const panelRight = railLeft;
|
|
1953
2640
|
|
|
1954
2641
|
/**
|
|
@@ -2241,6 +2928,160 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2241
2928
|
</Section>
|
|
2242
2929
|
</View>
|
|
2243
2930
|
|
|
2931
|
+
{/* ACTIVITY — what has been SAID with the other party. It follows
|
|
2932
|
+
Progress because the two answer the reader's first two questions in
|
|
2933
|
+
order: where the record stands, then what passed between us to get
|
|
2934
|
+
it there.
|
|
2935
|
+
|
|
2936
|
+
It is NOT the handoff trail. Progress owns where the record SITS and
|
|
2937
|
+
the act that moves it; this owns COMMUNICATIONS with a counterparty,
|
|
2938
|
+
which a desk transition is not. Keeping that line is what stops this
|
|
2939
|
+
section becoming the audit log the Progress note deliberately
|
|
2940
|
+
refuses — an entry here is something a person said or sent, never a
|
|
2941
|
+
field that changed.
|
|
2942
|
+
|
|
2943
|
+
THE ROW ANATOMY, and the mistake it exists to prevent: the row's
|
|
2944
|
+
LABEL is the GIST — what came of the exchange, in the reader's own
|
|
2945
|
+
words. The medium and the direction are metadata and ride the muted
|
|
2946
|
+
supporting line under it. Putting the taxonomy in the label
|
|
2947
|
+
("Outbound Meeting") and the substance behind the chevron is the
|
|
2948
|
+
natural way to build this and it makes the feed unreadable: every
|
|
2949
|
+
row renders as a pair of enum values, the reader has to expand each
|
|
2950
|
+
one to learn anything, and a column of near-identical labels defeats
|
|
2951
|
+
the scan the feed exists for.
|
|
2952
|
+
|
|
2953
|
+
ONE anatomy, a body that VARIES. A touchpoint can be a call, a
|
|
2954
|
+
recorded demo, an email, a message or a note typed between meetings,
|
|
2955
|
+
and those differ in what they CARRY, not in what they are: all five
|
|
2956
|
+
answer what-came-of-it, which way, over what, and when. So the four
|
|
2957
|
+
invariants are the row and the body is a slot — audio and video get
|
|
2958
|
+
a real MediaPlayer, an email gets its subject, prose gets Markdown,
|
|
2959
|
+
attachments get FileRows. A row TYPE per medium would put those four
|
|
2960
|
+
facts in five places and let them drift. */}
|
|
2961
|
+
<View onLayout={nav.register("activity")}>
|
|
2962
|
+
<Section>
|
|
2963
|
+
<SectionHeading>
|
|
2964
|
+
<SectionHeadingTitle description="Calls, mail and messages with the customer, newest first.">
|
|
2965
|
+
Activity
|
|
2966
|
+
</SectionHeadingTitle>
|
|
2967
|
+
<SectionHeadingMeta>{activity.length}</SectionHeadingMeta>
|
|
2968
|
+
</SectionHeading>
|
|
2969
|
+
|
|
2970
|
+
{/* The capture sits AT the feed, not in a section of its own. Logging
|
|
2971
|
+
a touchpoint is this section's input, not a distinct AREA of the
|
|
2972
|
+
record — and a section costs a rail entry, which is a claim that
|
|
2973
|
+
there is somewhere else to go. The Composer is compact until
|
|
2974
|
+
typed into, so the resting cost of "you can add one" is one row
|
|
2975
|
+
rather than a form nobody is filling. */}
|
|
2976
|
+
<Composer
|
|
2977
|
+
placeholder="Log a call, an email, a note…"
|
|
2978
|
+
accessibilityLabel="Log an activity"
|
|
2979
|
+
sendLabel="Log"
|
|
2980
|
+
onSend={(text) => {
|
|
2981
|
+
const t = text.trim();
|
|
2982
|
+
if (!t) return;
|
|
2983
|
+
setActivity((prev) => [
|
|
2984
|
+
{
|
|
2985
|
+
key: `a-${prev.length + 1}-${t.length}`,
|
|
2986
|
+
kind: "note",
|
|
2987
|
+
gist: t,
|
|
2988
|
+
direction: "out",
|
|
2989
|
+
over: "Note",
|
|
2990
|
+
when: "Just now",
|
|
2991
|
+
by: { kind: "member", name: "Sarah Chen" },
|
|
2992
|
+
},
|
|
2993
|
+
...prev,
|
|
2994
|
+
]);
|
|
2995
|
+
}}
|
|
2996
|
+
/>
|
|
2997
|
+
|
|
2998
|
+
{activity.length === 0 ? (
|
|
2999
|
+
<EmptyState
|
|
3000
|
+
icon="message-circle"
|
|
3001
|
+
message="Nothing logged yet"
|
|
3002
|
+
hint="Add the first call, email or note above."
|
|
3003
|
+
/>
|
|
3004
|
+
) : (
|
|
3005
|
+
<>
|
|
3006
|
+
<Timeline
|
|
3007
|
+
items={(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map(
|
|
3008
|
+
(a): TimelineItem => ({
|
|
3009
|
+
id: a.key,
|
|
3010
|
+
icon: ACTIVITY_ICON[a.kind],
|
|
3011
|
+
// An unanswered outreach is the one state in this feed worth
|
|
3012
|
+
// a colour: it is the only entry that owes somebody
|
|
3013
|
+
// something. Everything else is history and reads neutral.
|
|
3014
|
+
iconColor: a.awaiting ? solid("amber") : colors.zinc[400],
|
|
3015
|
+
// NEVER a phrase assembled from the enums ("Inbound Call").
|
|
3016
|
+
// That renders in body ink and so claims somebody wrote it;
|
|
3017
|
+
// `placeholder` is how the row says the words are missing,
|
|
3018
|
+
// and the medium and direction are already on the line below.
|
|
3019
|
+
label: a.gist ?? "Not written up yet",
|
|
3020
|
+
placeholder: a.gist == null,
|
|
3021
|
+
// The metadata line — a real preposition and a comma, never
|
|
3022
|
+
// a middot standing in for the relation (§Microcopy) — and
|
|
3023
|
+
// then WHAT ARRIVED, because a closed row is the scanning
|
|
3024
|
+
// state and on an entry with no words the attachments are
|
|
3025
|
+
// the entire content. Written out rather than glyphed: an
|
|
3026
|
+
// icon run needs a key the reader has to learn first, and
|
|
3027
|
+
// this line is already here.
|
|
3028
|
+
description:
|
|
3029
|
+
`${a.direction === "in" ? "From them" : "From us"} over ${a.over}, ${a.when}.` +
|
|
3030
|
+
arrivedWith(a),
|
|
3031
|
+
// The row's verbs. `right` sits BESIDE the press target, so a
|
|
3032
|
+
// menu here is independently clickable and does not nest a
|
|
3033
|
+
// button inside the row's own button.
|
|
3034
|
+
//
|
|
3035
|
+
details: (
|
|
3036
|
+
<ActivityBody
|
|
3037
|
+
a={a}
|
|
3038
|
+
editing={editingActivity === a.key}
|
|
3039
|
+
onEdit={(patch) =>
|
|
3040
|
+
setActivity((prev) =>
|
|
3041
|
+
prev.map((e) => (e.key === a.key ? { ...e, ...patch } : e)),
|
|
3042
|
+
)
|
|
3043
|
+
}
|
|
3044
|
+
onToggleEdit={() =>
|
|
3045
|
+
setEditingActivity((cur) => (cur === a.key ? null : a.key))
|
|
3046
|
+
}
|
|
3047
|
+
onDelete={() => {
|
|
3048
|
+
setActivity((prev) => prev.filter((e) => e.key !== a.key));
|
|
3049
|
+
setEditingActivity((cur) => (cur === a.key ? null : cur));
|
|
3050
|
+
}}
|
|
3051
|
+
/>
|
|
3052
|
+
),
|
|
3053
|
+
}),
|
|
3054
|
+
)}
|
|
3055
|
+
/>
|
|
3056
|
+
{/* The tail FOLDS rather than paging. A record's feed is read
|
|
3057
|
+
newest-first and the old entries are reference — but they are
|
|
3058
|
+
still on the record, so the count says how much is behind the
|
|
3059
|
+
toggle instead of hiding that there is more. */}
|
|
3060
|
+
{activity.length > ACTIVITY_FOLD ? (
|
|
3061
|
+
// A REVEAL, so the same control as the transcript's: it shows
|
|
3062
|
+
// more of what is already here, in place. It was a
|
|
3063
|
+
// `Button color="muted"` — transparent, borderless and
|
|
3064
|
+
// undecorated at rest, so the affordance only arrived on hover
|
|
3065
|
+
// and it read as a stray line under the feed. It also sat at
|
|
3066
|
+
// 420: its own 10px padding, which matches neither the section's
|
|
3067
|
+
// edge nor the rows' text column, so it aligned to nothing.
|
|
3068
|
+
//
|
|
3069
|
+
// No wrapper and no indent: the 44px the rows are inset by is
|
|
3070
|
+
// the disc rail, and at this line the rail has ended. An indent
|
|
3071
|
+
// is legitimate only where something visible occupies it.
|
|
3072
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
3073
|
+
<TextDisclosure
|
|
3074
|
+
expanded={showAllActivity}
|
|
3075
|
+
onToggle={setShowAllActivity}
|
|
3076
|
+
label={`${activity.length - ACTIVITY_FOLD} earlier`}
|
|
3077
|
+
/>
|
|
3078
|
+
</View>
|
|
3079
|
+
) : null}
|
|
3080
|
+
</>
|
|
3081
|
+
)}
|
|
3082
|
+
</Section>
|
|
3083
|
+
</View>
|
|
3084
|
+
|
|
2244
3085
|
|
|
2245
3086
|
{/* FEES — the DETAILED money ledger, both directions (charge = billed
|
|
2246
3087
|
to the customer, cost = paid to a vendor), distinct from Billing's
|
|
@@ -2379,6 +3220,32 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2379
3220
|
<DetailRow label="Opened">
|
|
2380
3221
|
<InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
|
|
2381
3222
|
</DetailRow>
|
|
3223
|
+
{/* FILES AS A FIELD, the SINGLE case — one signed contract, not
|
|
3224
|
+
a document set.
|
|
3225
|
+
`multiple={false}` changes the verb, and the verb is the
|
|
3226
|
+
point: "add another" is not something a one-file field can
|
|
3227
|
+
do, and offering it produces a record holding two of a thing
|
|
3228
|
+
the schema says there is one of. So the CTA reads REPLACE,
|
|
3229
|
+
the picker takes one, and the host SETS instead of appending
|
|
3230
|
+
— the component gets the affordance right but cannot enforce
|
|
3231
|
+
the persistence, which is why the difference is visible right
|
|
3232
|
+
here at the call site.
|
|
3233
|
+
|
|
3234
|
+
`blockedReason` is the third state: a field with nothing to
|
|
3235
|
+
attach TO yet says which act unblocks it, rather than showing
|
|
3236
|
+
a disabled button that says only that you may not. */}
|
|
3237
|
+
<DetailRow label="Signed contract">
|
|
3238
|
+
<InlineFiles
|
|
3239
|
+
files={contract === null ? [] : [contract]}
|
|
3240
|
+
multiple={false}
|
|
3241
|
+
onAdd={([picked]) => setContract({ id: "doc-contract", filename: picked.name, mimeType: picked.type || PDF, url: URL.createObjectURL(picked) })}
|
|
3242
|
+
onRemove={() => setContract(null)}
|
|
3243
|
+
addLabel="Attach contract"
|
|
3244
|
+
replaceLabel="Replace contract"
|
|
3245
|
+
accept="application/pdf"
|
|
3246
|
+
blockedReason={taxId.trim() === "" ? "Add the tax ID before filing a contract." : undefined}
|
|
3247
|
+
/>
|
|
3248
|
+
</DetailRow>
|
|
2382
3249
|
{/* The countdown ANNOTATES the date it is about — the worked
|
|
2383
3250
|
example for @lotics/ui/deadline. It replaced a hand-rolled
|
|
2384
3251
|
`error={overdue ? … }`, which was binary: a date due
|
|
@@ -2689,20 +3556,39 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2689
3556
|
its NAME: a PDF thumbnail is a grey page identical to every other grey
|
|
2690
3557
|
page, so a grid of them is a wall of one tile. A photo is identified
|
|
2691
3558
|
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.
|
|
3559
|
+
the identity and a grid puts twelve on screen at once.
|
|
2694
3560
|
(AGENTS.md: documents get a LIST, images get a GRID.)
|
|
2695
3561
|
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
3562
|
+
BOTH SURFACES SHOW THE WHOLE COLLECTION — they are two LENSES over
|
|
3563
|
+
one set, not two sets. Files answers "what is on this record" in a
|
|
3564
|
+
scannable column; Photos answers "what did it LOOK like" at a glance.
|
|
3565
|
+
A reader who guesses wrong still finds the artifact instead of
|
|
3566
|
+
concluding it was never filed, and nobody has to know a file's FORMAT
|
|
3567
|
+
before they know where to look for it.
|
|
3568
|
+
|
|
3569
|
+
The grid used to filter to `image/*`. That made the split a fact about
|
|
3570
|
+
MIME rather than about reading, and it put the signed delivery note in
|
|
3571
|
+
a different section from the photos of the same hand-over — while this
|
|
3572
|
+
very comment promised that the odd `.docx` among forty phone photos
|
|
3573
|
+
would stay in the grid. It could not: the filter forbade what the
|
|
3574
|
+
sentence described. `FileThumbnail` already renders a non-image as a
|
|
3575
|
+
doc tile (badge + filename), so nothing had to be built — only the
|
|
3576
|
+
filter that hid it had to go.
|
|
3577
|
+
|
|
3578
|
+
THE COST IS REAL AND DELIBERATE: every file now appears twice. The
|
|
3579
|
+
trade is that duplication is cheap to skim past, whereas an artifact
|
|
3580
|
+
filed under the wrong lens looks like an artifact that was never
|
|
3581
|
+
filed. A record whose attachments are overwhelmingly paperwork should
|
|
3582
|
+
drop the grid entirely rather than carry a wall of identical tiles —
|
|
3583
|
+
the choice is per-record-TYPE, made once, not per file.
|
|
3584
|
+
|
|
3585
|
+
Ordering follows the collection, so a record heavy in documents leads
|
|
3586
|
+
this grid with grey tiles. If that reads badly for your record type,
|
|
3587
|
+
sort images first rather than reinstating the filter.
|
|
3588
|
+
|
|
3589
|
+
The tiles are mixed by construction now, so the FIXTURE has to be
|
|
3590
|
+
mixed too — a grid of eight JPGs never once renders the doc tile it
|
|
3591
|
+
has always been able to render, which is how the gap survived.
|
|
2706
3592
|
|
|
2707
3593
|
The ADD rides the heading row, right edge, exactly as it does on Files
|
|
2708
3594
|
and Fees — a section's add sits in the same spot whether the section
|
|
@@ -2777,7 +3663,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2777
3663
|
title="Add photos"
|
|
2778
3664
|
color="primary"
|
|
2779
3665
|
onPress={() => {
|
|
2780
|
-
void pickFiles({ accept: "image/*", multiple: true }).then(intakeFiles);
|
|
3666
|
+
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
2781
3667
|
}}
|
|
2782
3668
|
/>
|
|
2783
3669
|
)}
|
|
@@ -3058,6 +3944,35 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3058
3944
|
<DetailRow label="Invoice no" description="The party's invoice or debit note">
|
|
3059
3945
|
<InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
|
|
3060
3946
|
</DetailRow>
|
|
3947
|
+
{/* THE FILE, ON THE ROW THAT OWES IT — the shape a
|
|
3948
|
+
payables desk actually works in: a table of
|
|
3949
|
+
charges, each row expanding to the document that
|
|
3950
|
+
justifies it. The register cell above can only
|
|
3951
|
+
MARK whether a document is held (and on this page
|
|
3952
|
+
not even that — the fee table measures 400px and
|
|
3953
|
+
its declared columns already total 422, so two
|
|
3954
|
+
drop before the flex column gets a pixel). The
|
|
3955
|
+
managing happens here.
|
|
3956
|
+
|
|
3957
|
+
`multiple`, because a supplier invoice arrives as
|
|
3958
|
+
one PDF or as three scanned pages, and the second
|
|
3959
|
+
page must not replace the first.
|
|
3960
|
+
|
|
3961
|
+
`blockedReason` when no number has been claimed:
|
|
3962
|
+
there is nothing to file the scan AGAINST yet, and
|
|
3963
|
+
a sentence naming that act beats a disabled button
|
|
3964
|
+
that only says no. */}
|
|
3965
|
+
<DetailRow label="Supplier original">
|
|
3966
|
+
<InlineFiles
|
|
3967
|
+
files={f.docs}
|
|
3968
|
+
onAdd={(picked) => patchFee(f.id, { docs: [...f.docs, ...picked.map(asDisplayFile)] })}
|
|
3969
|
+
onRemove={(doc) => patchFee(f.id, { docs: f.docs.filter((d) => d.id !== doc.id) })}
|
|
3970
|
+
addLabel="Attach original"
|
|
3971
|
+
addMoreLabel="Add page"
|
|
3972
|
+
accept="application/pdf,image/*"
|
|
3973
|
+
blockedReason={f.invoiceNo.trim() === "" ? "Enter the invoice number first — the scan is filed against it." : undefined}
|
|
3974
|
+
/>
|
|
3975
|
+
</DetailRow>
|
|
3061
3976
|
<DetailRow label="Status">
|
|
3062
3977
|
<InlineSelect
|
|
3063
3978
|
value={f.paid ? "paid" : "unpaid"}
|
|
@@ -3096,11 +4011,51 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3096
4011
|
</View>
|
|
3097
4012
|
}
|
|
3098
4013
|
>
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
<TableCell
|
|
3103
|
-
|
|
4014
|
+
{/* IDENTITY over its PARTY — `tpl_item_list`'s customer cell
|
|
4015
|
+
exactly: the thing you scan for on line 1, what qualifies
|
|
4016
|
+
it on line 2, in one cell rather than two columns. */}
|
|
4017
|
+
<TableCell>
|
|
4018
|
+
<View style={{ gap: 2 }}>
|
|
4019
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text>
|
|
4020
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
4021
|
+
{f.party || (f.direction === "charge" ? "Charge" : "Cost")}
|
|
4022
|
+
</Text>
|
|
4023
|
+
</View>
|
|
4024
|
+
</TableCell>
|
|
4025
|
+
{/* AMOUNT over its PAYMENT state — the register's fee cell,
|
|
4026
|
+
same stack, same rungs. The state qualifies the number it
|
|
4027
|
+
sits under, so it needs no column and no repeated label. */}
|
|
4028
|
+
<TableCell>
|
|
4029
|
+
<View style={{ gap: 2, alignItems: "flex-end" }}>
|
|
4030
|
+
<Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text>
|
|
4031
|
+
<Text size="xs" tabular numberOfLines={1} color={st.danger ? "danger" : "muted"}>{st.text}</Text>
|
|
4032
|
+
</View>
|
|
4033
|
+
</TableCell>
|
|
4034
|
+
{/* THE DOCUMENT, with a column of its own at last.
|
|
4035
|
+
Held: a clip + the number, pressable to preview. Claimed
|
|
4036
|
+
but not arrived: the number in WARNING tone — the fee
|
|
4037
|
+
cannot be paid, and that now reads in the column the fact
|
|
4038
|
+
belongs to instead of borrowing the money's state. Never
|
|
4039
|
+
claimed (cash at the gate): an em dash, because nothing is
|
|
4040
|
+
owed and a permanent warning on every such row trains the
|
|
4041
|
+
eye straight past the column. */}
|
|
4042
|
+
<TableCell>
|
|
4043
|
+
{f.invoiceNo.trim() === "" ? (
|
|
4044
|
+
<Text size="sm" color="muted">—</Text>
|
|
4045
|
+
) : f.docs.length === 0 ? (
|
|
4046
|
+
<Text size="sm" color="muted" numberOfLines={1}>{f.invoiceNo}</Text>
|
|
4047
|
+
) : (
|
|
4048
|
+
<Pressable
|
|
4049
|
+
onPress={() => openPreview(DOCS, 0)}
|
|
4050
|
+
accessibilityRole="button"
|
|
4051
|
+
accessibilityLabel={`Open invoice ${f.invoiceNo}`}
|
|
4052
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
|
|
4053
|
+
>
|
|
4054
|
+
<FileBadge mimeType={f.docs[0].mimeType} size={22} />
|
|
4055
|
+
<TextLink size="sm">{f.invoiceNo}</TextLink>
|
|
4056
|
+
</Pressable>
|
|
4057
|
+
)}
|
|
4058
|
+
</TableCell>
|
|
3104
4059
|
</TableRow>
|
|
3105
4060
|
);
|
|
3106
4061
|
})}
|
|
@@ -3391,11 +4346,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3391
4346
|
})}
|
|
3392
4347
|
</Checklist>
|
|
3393
4348
|
{hidden > 0 ? (
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
4349
|
+
// The same reveal, the same control. The label is the NOUN
|
|
4350
|
+
// only — `TextDisclosure` supplies the verb, which is what
|
|
4351
|
+
// stops a pair drifting into two framings ("Show all forms
|
|
4352
|
+
// (3 hidden)" against "Show fewer forms" named different
|
|
4353
|
+
// things in each state, so pressing it twice taught the
|
|
4354
|
+
// reader nothing about what it does).
|
|
4355
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
4356
|
+
<TextDisclosure
|
|
4357
|
+
expanded={open}
|
|
4358
|
+
onToggle={() => toggleFold(g.id)}
|
|
4359
|
+
label={`${hidden} more forms`}
|
|
3399
4360
|
/>
|
|
3400
4361
|
</View>
|
|
3401
4362
|
) : null}
|