@lotics/ui 41.1.0 → 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 +83 -0
- package/docs/ai_patterns.md +36 -0
- package/docs/catalog.md +50 -11
- package/docs/composition.md +89 -11
- package/docs/data_entry.md +63 -0
- package/docs/templates.md +120 -9
- package/examples/tpl_record.tsx +763 -9
- package/package.json +3 -1
- 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,20 +1,24 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
|
|
2
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";
|
|
11
15
|
import { TextLink } from "@lotics/ui/text_link";
|
|
12
16
|
import { Alert } from "@lotics/ui/alert";
|
|
13
17
|
import type { PickerOption } from "@lotics/ui/picker";
|
|
14
18
|
import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
|
|
15
19
|
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
16
20
|
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
17
|
-
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";
|
|
18
22
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
19
23
|
import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
|
|
20
24
|
import { DateStamp } from "@lotics/ui/date_stamp";
|
|
@@ -50,6 +54,10 @@ import { FormField } from "@lotics/ui/form_field";
|
|
|
50
54
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
51
55
|
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
52
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";
|
|
53
61
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
54
62
|
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
55
63
|
import { FileRow } from "@lotics/ui/file_row";
|
|
@@ -298,6 +306,578 @@ const PRIORITY_DOT: Record<string, string> = {
|
|
|
298
306
|
rush: colors.amber[500],
|
|
299
307
|
};
|
|
300
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
|
+
|
|
301
881
|
// ── billing — charges grouped into issuable invoice DOCUMENTS on this record
|
|
302
882
|
type Method = "cash" | "transfer" | "card";
|
|
303
883
|
const METHODS: PickerOption<Method>[] = [
|
|
@@ -1251,6 +1831,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1251
1831
|
: f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false }
|
|
1252
1832
|
: { text: "Unpaid", danger: false };
|
|
1253
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
|
+
|
|
1254
1847
|
// ── the record — seeded mid-flight so every state is visible
|
|
1255
1848
|
const [stage, setStage] = useState<Stage>("sales");
|
|
1256
1849
|
// The customer book is STATE: the attached customer's tax ID edits inline in
|
|
@@ -1949,6 +2542,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1949
2542
|
// The rail lists sections in PAGE order, or the scroll-spy highlights one
|
|
1950
2543
|
// entry while the reader is looking at another.
|
|
1951
2544
|
{ key: "progress", label: "Progress", icon: "git-branch" },
|
|
2545
|
+
{ key: "activity", label: "Activity", icon: "message-square" },
|
|
1952
2546
|
{ key: "general", label: "General", icon: "file-text" },
|
|
1953
2547
|
// Only a SECTION when the page is too narrow to seat the panel — so the wide
|
|
1954
2548
|
// rail filters it out rather than offering a jump to something that is not
|
|
@@ -1971,7 +2565,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1971
2565
|
// by scroll and invisible in the outline. Bound to `SecKey`, a key here that
|
|
1972
2566
|
// `SECTIONS` does not carry stops being assignable, so the omission is a
|
|
1973
2567
|
// compile error instead of a missing row nobody notices.
|
|
1974
|
-
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);
|
|
1975
2569
|
// ONE record, ONE page. A section is a place you SCROLL to, never a
|
|
1976
2570
|
// destination you swap to: routing a record was tried here and lost, because
|
|
1977
2571
|
// every fix it needed rebuilt the whole-record view in miniature — a dot to
|
|
@@ -2334,6 +2928,160 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2334
2928
|
</Section>
|
|
2335
2929
|
</View>
|
|
2336
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
|
+
|
|
2337
3085
|
|
|
2338
3086
|
{/* FEES — the DETAILED money ledger, both directions (charge = billed
|
|
2339
3087
|
to the customer, cost = paid to a vendor), distinct from Billing's
|
|
@@ -3598,11 +4346,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3598
4346
|
})}
|
|
3599
4347
|
</Checklist>
|
|
3600
4348
|
{hidden > 0 ? (
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
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`}
|
|
3606
4360
|
/>
|
|
3607
4361
|
</View>
|
|
3608
4362
|
) : null}
|