@lotics/ui 27.17.0 → 28.0.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 +13 -10
- package/MIGRATION.md +64 -0
- package/docs/catalog.md +97 -140
- package/docs/data_entry.md +57 -87
- package/docs/templates.md +95 -57
- package/examples/tpl_item_list.tsx +77 -36
- package/examples/tpl_record.tsx +372 -460
- package/package.json +3 -3
- package/src/checklist.tsx +339 -0
- package/src/comments_button.tsx +108 -0
- package/src/control_surface.ts +13 -0
- package/src/detail_row.tsx +2 -2
- package/src/field_annotations.tsx +1 -1
- package/src/icon.tsx +10 -0
- package/src/icon_button.tsx +17 -1
- package/src/locale.tsx +12 -1
- package/src/stepper.tsx +107 -18
- package/src/stepper_layout.ts +21 -1
- package/src/table.tsx +101 -11
- package/src/pipeline.tsx +0 -231
- package/src/task.tsx +0 -518
- package/src/task_metrics.ts +0 -55
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
5
5
|
import { Button } from "@lotics/ui/button";
|
|
@@ -12,14 +12,10 @@ import type { PickerOption } from "@lotics/ui/picker";
|
|
|
12
12
|
import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
|
|
13
13
|
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
14
14
|
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
15
|
-
import { Inset } from "@lotics/ui/inset";
|
|
16
15
|
import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
17
16
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
18
|
-
import {
|
|
17
|
+
import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
|
|
19
18
|
import { DateStamp } from "@lotics/ui/date_stamp";
|
|
20
|
-
import { TextLink } from "@lotics/ui/text_link";
|
|
21
|
-
import { MemberChip } from "@lotics/ui/member_chip";
|
|
22
|
-
import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
|
|
23
19
|
import { MenuButton } from "@lotics/ui/menu_button";
|
|
24
20
|
import { Popover, PopoverContent } from "@lotics/ui/popover";
|
|
25
21
|
import { useSectionNav } from "@lotics/ui/use_section_nav";
|
|
@@ -45,7 +41,6 @@ import { InlineSelect } from "@lotics/ui/inline_select";
|
|
|
45
41
|
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
46
42
|
import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
|
|
47
43
|
import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select";
|
|
48
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
49
44
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
50
45
|
import { FormField } from "@lotics/ui/form_field";
|
|
51
46
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
@@ -169,19 +164,32 @@ const DESKS: { key: Desk; label: string }[] = [
|
|
|
169
164
|
* a reader wants to see, and one that gives every step an owner field asks four
|
|
170
165
|
* questions nobody has answers to.
|
|
171
166
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
167
|
+
* So the ladder has TWO LEVELS, not one flat run of eight. Flat, a desk and a
|
|
168
|
+
* milestone sat at the same altitude while being different kinds of thing — the
|
|
169
|
+
* ladder claimed "Operations" and "Collected" were peers, and the reader had to
|
|
170
|
+
* know which names a PLACE and which names an EVENT. Grouping the milestones
|
|
171
|
+
* under the desk that produces them says it in the shape instead: the desk is
|
|
172
|
+
* where the record sits, the milestones are what happens while it is there.
|
|
173
|
+
*
|
|
174
|
+
* `RUNGS` is the flat ordered spine — the ONLY things that carry a stamp, so
|
|
175
|
+
* "how far along" has exactly one source. A PHASE has no stamp of its own: its
|
|
176
|
+
* ring SUMMARISES its rungs (full when all are ticked, half while some are), and
|
|
177
|
+
* its own facts are the owner and the handoff act.
|
|
174
178
|
*/
|
|
175
|
-
const
|
|
176
|
-
{ key: "sales", label: "Sales", desk: "sales" },
|
|
179
|
+
const RUNGS: { key: string; label: string }[] = [
|
|
177
180
|
{ key: "quoted", label: "Quote sent" },
|
|
178
|
-
{ key: "operations", label: "Operations", desk: "operations" },
|
|
179
181
|
{ key: "collected", label: "Collected" },
|
|
180
182
|
{ key: "cleared", label: "Customs cleared" },
|
|
181
183
|
{ key: "delivered", label: "Delivered" },
|
|
182
|
-
{ key: "accounting", label: "Accounting", desk: "accounting" },
|
|
183
184
|
{ key: "pod", label: "POD received" },
|
|
184
185
|
];
|
|
186
|
+
const rungAt = (key: string) => RUNGS.findIndex((r) => r.key === key);
|
|
187
|
+
|
|
188
|
+
const PHASES: { desk: Desk; label: string; rungs: string[] }[] = [
|
|
189
|
+
{ desk: "sales", label: "Sales", rungs: ["quoted"] },
|
|
190
|
+
{ desk: "operations", label: "Operations", rungs: ["collected", "cleared", "delivered"] },
|
|
191
|
+
{ desk: "accounting", label: "Accounting", rungs: ["pod"] },
|
|
192
|
+
];
|
|
185
193
|
|
|
186
194
|
const STAGES: { key: Stage; label: string }[] = [
|
|
187
195
|
...DESKS,
|
|
@@ -365,15 +373,27 @@ const RAIL_GAP = 48;
|
|
|
365
373
|
/** The reserved gutter: the rail plus its gap. Mirrored empty on the right, this
|
|
366
374
|
* is what centres the column. */
|
|
367
375
|
const GUTTER = RAIL_W + RAIL_GAP;
|
|
368
|
-
/** The discussion panel's column. A record's comments are not a SECTION
|
|
369
|
-
* are about the whole record, so they do not
|
|
370
|
-
* top-to-bottom reading order, and a coworker's note
|
|
371
|
-
* you work anywhere on the page. It takes the right
|
|
372
|
-
* reserved purely to balance the rail.
|
|
373
|
-
|
|
376
|
+
/** The discussion panel's column. A record's comments are not a SECTION when
|
|
377
|
+
* there is room beside it — they are about the whole record, so they do not
|
|
378
|
+
* belong at one position in a top-to-bottom reading order, and a coworker's note
|
|
379
|
+
* has to stay readable while you work anywhere on the page. It takes the right
|
|
380
|
+
* gutter, which was otherwise reserved purely to balance the rail.
|
|
381
|
+
*
|
|
382
|
+
* NARROWER than the rail's side and much narrower than the record: the record is
|
|
383
|
+
* the SUBJECT and the discussion supports it, so when the two compete the panel
|
|
384
|
+
* is what gives way. A column flanked by two of its own width reads as one of
|
|
385
|
+
* three equals rather than as the thing the page is about. 300 still seats a
|
|
386
|
+
* comment's line comfortably at `sm`. */
|
|
387
|
+
const PANEL_W = 300;
|
|
374
388
|
const PANEL_GUTTER = PANEL_W + RAIL_GAP;
|
|
375
|
-
/** The reading column's ceiling.
|
|
376
|
-
|
|
389
|
+
/** The reading column's ceiling.
|
|
390
|
+
*
|
|
391
|
+
* Narrower than the 720 it started at — which is what buys the discussion its
|
|
392
|
+
* column on the widths a laptop actually has, instead of folding the thread away
|
|
393
|
+
* — but still comfortably wider than either gutter. Taken all the way down to a
|
|
394
|
+
* `Drawer`'s 600 it sat between two columns of its own order and stopped reading
|
|
395
|
+
* as the thing the page is about. */
|
|
396
|
+
const CONTENT_MAX = 680;
|
|
377
397
|
|
|
378
398
|
// ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
|
|
379
399
|
// record's documents surface: files feed ONE "Use AI" entry that forks into
|
|
@@ -568,14 +588,14 @@ const DOC_COLUMNS: TableColumn[] = [
|
|
|
568
588
|
// need, grouped PER PARTY; the common ones ship pre-checked, the long tail
|
|
569
589
|
// folds behind "Show all forms". (The Documents section above is the INTAKE
|
|
570
590
|
// register — received files; this registry is what the record can PRODUCE.)
|
|
571
|
-
// READINESS: a form declares which RECORD FIELDS it reads (`needs`). The
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
591
|
+
// READINESS: a form declares which RECORD FIELDS it reads (`needs`). The row
|
|
592
|
+
// NAMES what is missing and says WHERE it lives (muted at rest so gaps show
|
|
593
|
+
// without checking, warning once checked — it now blocks); a ready form stays
|
|
594
|
+
// silent. It does NOT collect: the fields' home is their own DATA section, and a
|
|
595
|
+
// checklist that also edited them made every row two things at once — a status
|
|
596
|
+
// and a form — with nothing to say which one the reader was in, and put an editor
|
|
597
|
+
// beside the field's real home where its description and its neighbours live.
|
|
598
|
+
// A checklist REPORTS; the section that owns a value COLLECTS it.
|
|
579
599
|
type NeedKey = "delivery_address" | "commodity_code" | "hazard_class";
|
|
580
600
|
const NEEDS: Record<NeedKey, { label: string; hint: string }> = {
|
|
581
601
|
delivery_address: { label: "Delivery address", hint: "The consignee's dock — printed on the delivery note" },
|
|
@@ -891,7 +911,12 @@ function PartyRow({ role, noun, rec, options, onPick, onOpen, onUnset, onSaveFac
|
|
|
891
911
|
* false, so the rail, the panel and the mirrored gutters collapse on their own.
|
|
892
912
|
* What the flag decides is what a nested surface must NOT do — open comments in
|
|
893
913
|
* a Drawer inside a Drawer, or draw a back button over the one the Drawer has. */
|
|
894
|
-
export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?: "page" | "drawer"; code?: string
|
|
914
|
+
export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection }: { chrome?: "page" | "drawer"; code?: string;
|
|
915
|
+
/** Land on this section instead of the top — a register row's comment bubble
|
|
916
|
+
* opens the record ON its discussion, because that is the only reason the
|
|
917
|
+
* reader pressed it. Applied once the skeleton clears: a jump at mount scrolls
|
|
918
|
+
* a layout that is still a placeholder and lands nowhere. */
|
|
919
|
+
openSection?: string } = {}) {
|
|
895
920
|
const isDrawer = chrome === "drawer";
|
|
896
921
|
const id = useRef(1);
|
|
897
922
|
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
@@ -905,6 +930,22 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
905
930
|
return () => clearTimeout(t);
|
|
906
931
|
}, []);
|
|
907
932
|
|
|
933
|
+
/**
|
|
934
|
+
* Land on a section when the caller named one — a register row's comment
|
|
935
|
+
* bubble opens the record ON its discussion.
|
|
936
|
+
*
|
|
937
|
+
* AFTER the skeleton clears, never at mount: the placeholder has different
|
|
938
|
+
* heights from the real layout, so a jump measured against it scrolls to a
|
|
939
|
+
* position that stops existing the moment the record renders. The section
|
|
940
|
+
* registers its offset on layout, so the frame after `booting` flips is the
|
|
941
|
+
* first one where the target is real.
|
|
942
|
+
*/
|
|
943
|
+
useEffect(() => {
|
|
944
|
+
if (booting || !openSection) return;
|
|
945
|
+
const t = setTimeout(() => nav.jumpTo(openSection as Parameters<typeof nav.jumpTo>[0]), 0);
|
|
946
|
+
return () => clearTimeout(t);
|
|
947
|
+
}, [booting, openSection]);
|
|
948
|
+
|
|
908
949
|
// ── the discussion thread (CommentList + Composer; author-only edit/delete)
|
|
909
950
|
const [comments, setComments] = useState<ThreadComment[]>(SEED_COMMENTS);
|
|
910
951
|
// Files staged on the NEXT comment — consumer-owned, exactly how the
|
|
@@ -1270,15 +1311,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1270
1311
|
const isEligible = (f: SetForm) => f.id !== "c3" || hazardousGoods;
|
|
1271
1312
|
const checkedForms = ALL_FORMS.filter((f) => isEligible(f) && chosenForms.has(f.id));
|
|
1272
1313
|
const blockingNeeds = [...new Set(checkedForms.flatMap(missingOf))];
|
|
1273
|
-
// the per-row fills' DRAFTS — keyed by field, SHARED between the rows of forms that
|
|
1274
|
-
// need the same field, so filling it under one form resolves it for all; Save commits
|
|
1275
|
-
// onto the record and the field's home row shows the value (the need resolves, the
|
|
1276
|
-
// row's mark clears, the fill drops away).
|
|
1277
|
-
const [fillDrafts, setFillDrafts] = useState<Record<string, string>>({});
|
|
1278
|
-
const needSetters: Record<NeedKey, (v: string) => void> = { delivery_address: setDeliveryAddress, commodity_code: setCommodityCode, hazard_class: setHazardClass };
|
|
1279
|
-
const saveNeeds = (keys: NeedKey[]) => {
|
|
1280
|
-
for (const k of keys) needSetters[k]((fillDrafts[k] ?? "").trim());
|
|
1281
|
-
};
|
|
1282
1314
|
// Generation is DETERMINISTIC template fill — NOT AI, so no AgentRun
|
|
1283
1315
|
// theater: the CTA carries a brief loading state and the whole set
|
|
1284
1316
|
// appears at once (a real app awaits its render call the same way).
|
|
@@ -1332,22 +1364,77 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1332
1364
|
};
|
|
1333
1365
|
|
|
1334
1366
|
// ── desk CUSTODY — what each desk stamps about its own turn
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
// typed wrong must not become unreachable the moment
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
// shows its facts and nothing else — the ladder is read far more often than it
|
|
1341
|
-
// is corrected, and a column of live editors on history invites changing it by
|
|
1342
|
-
// accident. "Edit" is the deliberate step that turns one stage back into a form.
|
|
1343
|
-
//
|
|
1344
|
-
// ONE key, so opening a second stage closes the first. That is the intent, not a
|
|
1345
|
-
// limitation: the editors commit as you leave them, so nothing is lost by the
|
|
1346
|
-
// close, and two open stages would put two versions of the same ladder on screen
|
|
1347
|
-
// — one being read, one being changed — with no cue for which is which.
|
|
1348
|
-
const [editingDesk, setEditingDesk] = useState("");
|
|
1367
|
+
// The MILESTONE stamps, keyed by rung. Only rungs are stamped: a phase's ring
|
|
1368
|
+
// is derived from these, so "how far along" cannot be answered two ways.
|
|
1369
|
+
// Editable forever — a date typed wrong must not become unreachable the moment
|
|
1370
|
+
// the record moves on.
|
|
1371
|
+
const [deskSince, setDeskSince] = useState<Record<string, string>>({});
|
|
1349
1372
|
const [deskOwner, setDeskOwner] = useState<Record<string, string | null>>({ sales: "mem_01" });
|
|
1350
1373
|
|
|
1374
|
+
/**
|
|
1375
|
+
* WHERE THE RECORD IS: the highest rung anyone has stamped. Everything below it
|
|
1376
|
+
* is REACHED — you cannot have cleared customs without collecting — so the run
|
|
1377
|
+
* reads done from POSITION, not from each rung carrying its own date.
|
|
1378
|
+
*
|
|
1379
|
+
* That is what lets one click say "everything up to here". Ticking `Delivered`
|
|
1380
|
+
* puts the record there and the two rungs under it read done immediately. The
|
|
1381
|
+
* alternative — writing today onto every rung below — marks the same three
|
|
1382
|
+
* boxes but INVENTS two dates: a record imported in June would show milestones
|
|
1383
|
+
* dated today, and nothing on screen would admit they were guessed.
|
|
1384
|
+
*
|
|
1385
|
+
* A reached rung with no stamp of its own shows a BLANK date, which is the
|
|
1386
|
+
* honest answer to "when" — and it stays one click from being filled in.
|
|
1387
|
+
*/
|
|
1388
|
+
const topRung = RUNGS.reduce((top, r, i) => (deskSince[r.key] ? i : top), -1);
|
|
1389
|
+
const rungDone = (i: number) => i <= topRung;
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Which phases the reader has COLLAPSED. Everything is open by default: a
|
|
1393
|
+
* record ladder is read to see the whole route, and a run that opens one phase
|
|
1394
|
+
* at a time answers "where am I" while hiding "what is left". Collapsing is for
|
|
1395
|
+
* folding away a phase you are done with, not a mode you navigate in — so any
|
|
1396
|
+
* number may be closed at once, and none is the resting state.
|
|
1397
|
+
*/
|
|
1398
|
+
const [closedPhases, setClosedPhases] = useState<Set<string>>(new Set());
|
|
1399
|
+
const togglePhase = (desk: string) =>
|
|
1400
|
+
setClosedPhases((prev) => {
|
|
1401
|
+
const next = new Set(prev);
|
|
1402
|
+
if (!next.delete(desk)) next.add(desk);
|
|
1403
|
+
return next;
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
/** Tick rung `i` — ONLY rung `i`. Position does the rest. */
|
|
1407
|
+
const stampRung = (i: number) => {
|
|
1408
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1409
|
+
setDeskSince((prev) => ({ ...prev, [RUNGS[i].key]: today }));
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Un-tick rung `i`: it and everything above walk back.
|
|
1414
|
+
*
|
|
1415
|
+
* Destructive and invisible from the ring, so it is NAMED before it happens —
|
|
1416
|
+
* which milestones, by their own labels. A rung with nothing recorded above it
|
|
1417
|
+
* needs no warning: there is nothing to lose.
|
|
1418
|
+
*/
|
|
1419
|
+
const clearRungsFrom = (i: number, what: string) => {
|
|
1420
|
+
const above = RUNGS.slice(i + 1).filter((x) => deskSince[x.key]);
|
|
1421
|
+
const revert = () =>
|
|
1422
|
+
setDeskSince((prev) => {
|
|
1423
|
+
const next = { ...prev };
|
|
1424
|
+
RUNGS.slice(i).forEach((x) => { next[x.key] = ""; });
|
|
1425
|
+
return next;
|
|
1426
|
+
});
|
|
1427
|
+
if (above.length === 0) { revert(); return; }
|
|
1428
|
+
Alert.alert(
|
|
1429
|
+
`Reopen ${what}?`,
|
|
1430
|
+
`${above.map((x) => x.label).join(" and ")} ${above.length > 1 ? "are" : "is"} recorded after it. Reopening clears their dates. This can't be undone.`,
|
|
1431
|
+
[
|
|
1432
|
+
{ text: "Cancel", style: "cancel" },
|
|
1433
|
+
{ text: "Reopen and clear", style: "destructive", onPress: revert },
|
|
1434
|
+
],
|
|
1435
|
+
);
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1351
1438
|
const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
|
|
1352
1439
|
value: c.id,
|
|
1353
1440
|
label: c.name,
|
|
@@ -1553,13 +1640,25 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1553
1640
|
// drawer, minus the trigger's padding) and the `wide` threshold. One consumer
|
|
1554
1641
|
// cannot tell those apart from the contract — two can.
|
|
1555
1642
|
const SECTIONS = [
|
|
1643
|
+
// PROGRESS leads. The header already IDENTIFIES the record — it titles the
|
|
1644
|
+
// customer and carries the code and city beneath it — so the first section
|
|
1645
|
+
// does not have to introduce it, and what a reader opens a work record to
|
|
1646
|
+
// learn is where it stands and what is owed. General became reference the
|
|
1647
|
+
// moment the identity band stopped leading with the code, and reference
|
|
1648
|
+
// follows state. The facts that GATE progress travel to it anyway: the
|
|
1649
|
+
// overdue warning renders on the row that owes the work, not in a section
|
|
1650
|
+
// above it.
|
|
1651
|
+
//
|
|
1652
|
+
// The rail lists sections in PAGE order, or the scroll-spy highlights one
|
|
1653
|
+
// entry while the reader is looking at another.
|
|
1654
|
+
{ key: "progress", label: "Progress", icon: "git-branch" },
|
|
1556
1655
|
{ key: "general", label: "General", icon: "file-text" },
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1656
|
+
// Only a SECTION when the page is too narrow to seat the panel — so the wide
|
|
1657
|
+
// rail filters it out rather than offering a jump to something that is not
|
|
1658
|
+
// there.
|
|
1659
|
+
{ key: "comments", label: "Comments", icon: "message-square" },
|
|
1560
1660
|
{ key: "files", label: "Files", icon: "folder-closed" },
|
|
1561
1661
|
{ key: "transport", label: "Transport", icon: "map-pin" },
|
|
1562
|
-
{ key: "progress", label: "Progress", icon: "git-branch" },
|
|
1563
1662
|
{ key: "fees", label: "Fees", icon: "receipt" },
|
|
1564
1663
|
{ key: "billing", label: "Billing", icon: "credit-card" },
|
|
1565
1664
|
{ key: "docset", label: "Documents", icon: "file-stack" },
|
|
@@ -1568,7 +1667,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1568
1667
|
] as const;
|
|
1569
1668
|
// Kept in step with `SECTIONS` above by hand — a key added there and forgotten
|
|
1570
1669
|
// here type-errors at every `nav.register` call, which is the cheap failure.
|
|
1571
|
-
const nav = useSectionNav(["general", "
|
|
1670
|
+
const nav = useSectionNav(["progress", "general", "comments", "files", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
|
|
1572
1671
|
// ONE record, ONE page. A section is a place you SCROLL to, never a
|
|
1573
1672
|
// destination you swap to: routing a record was tried here and lost, because
|
|
1574
1673
|
// every fix it needed rebuilt the whole-record view in miniature — a dot to
|
|
@@ -1614,16 +1713,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1614
1713
|
// only to centre the column — so the panel costs no width the page did not
|
|
1615
1714
|
// already spend. Both only earn their place while a readable column survives
|
|
1616
1715
|
// between them; below that they collapse to the pinned bar and its drawer.
|
|
1617
|
-
|
|
1716
|
+
// The panel appears once the reading column can still hold its FLOOR beside the
|
|
1717
|
+
// two gutters. The floor is what the column needs to stay readable (a label
|
|
1718
|
+
// column plus its values), not what it would like — hold out for the ceiling and
|
|
1719
|
+
// the thread folds away on every screen under ~1220.
|
|
1720
|
+
const wide = pageWidth != null && pageWidth >= GUTTER + PANEL_GUTTER + 420;
|
|
1618
1721
|
// Narrow: the discussion is one tap off the same bar the sections are on —
|
|
1619
1722
|
// on the RIGHT, opposite the section picker, since it is about the record as a
|
|
1620
1723
|
// whole rather than a place in it.
|
|
1621
|
-
const [commentsOpen, setCommentsOpen] = useState(false);
|
|
1622
1724
|
// Drawer chrome switches the BODY between the record and the discussion. As a
|
|
1623
1725
|
// section it competed with nine others for one scroll — first it pushed the
|
|
1624
1726
|
// record down, last it sat 7,466px from the top. They are two things a reader
|
|
1625
1727
|
// alternates between, so the bar names both and the body shows one.
|
|
1626
|
-
const [commentsView, setCommentsView] = useState(false);
|
|
1627
1728
|
// The rail floats over its RESERVED gutter rather than sitting in the scroll
|
|
1628
1729
|
// row, so the ScrollView keeps the FULL page width — wheel anywhere and the
|
|
1629
1730
|
// record scrolls. (Capping the ScrollView at the column's width instead makes
|
|
@@ -1633,6 +1734,53 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1633
1734
|
const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
|
|
1634
1735
|
const panelRight = railLeft;
|
|
1635
1736
|
|
|
1737
|
+
/**
|
|
1738
|
+
* The discussion, defined ONCE and rendered wherever it lands — beside the
|
|
1739
|
+
* record when the page can hold a panel, under General when it cannot, and in
|
|
1740
|
+
* place of the body inside a drawer. Two copies of a thread is how two
|
|
1741
|
+
* surfaces drift apart on the day one of them gains attachments.
|
|
1742
|
+
*/
|
|
1743
|
+
const commentsThread = (
|
|
1744
|
+
<>
|
|
1745
|
+
<CommentList
|
|
1746
|
+
comments={comments}
|
|
1747
|
+
currentMemberId="mem_01"
|
|
1748
|
+
resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
|
|
1749
|
+
onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
|
|
1750
|
+
onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
|
|
1751
|
+
/* the file-capable edit form — the same injection the product uses */
|
|
1752
|
+
renderEditForm={(p) => <CommentFileEditForm {...p} />}
|
|
1753
|
+
/* attachments render as the SAME thumbnail grid the product uses —
|
|
1754
|
+
press previews in the gallery (the desk's preview state) */
|
|
1755
|
+
renderFiles={(files) => (
|
|
1756
|
+
<FileGrid
|
|
1757
|
+
files={files.map(commentFileToDisplay)}
|
|
1758
|
+
itemSize={84}
|
|
1759
|
+
onFilePress={(f) => setPreview({ files: [f], index: 0 })}
|
|
1760
|
+
/>
|
|
1761
|
+
)}
|
|
1762
|
+
/* compact timestamp — the product formats RELATIVE ("2 days ago",
|
|
1763
|
+
date-fns); a template stays dependency-free and deterministic */
|
|
1764
|
+
formatTimestamp={(iso) => { const d = new Date(iso); return `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }}
|
|
1765
|
+
/>
|
|
1766
|
+
{/* The pane the toggle switches TO has to be the WHOLE discussion, not
|
|
1767
|
+
half of it: reading without a reply is a dead end, and this is the
|
|
1768
|
+
only surface where the thread has no panel or sheet to fall back on. */}
|
|
1769
|
+
<Composer
|
|
1770
|
+
onSend={(content) => {
|
|
1771
|
+
setComments((prev) => [...prev, { id: `cm_${(commentSeq += 1)}`, member_id: "mem_01", content, files: pendingFiles.length > 0 ? pendingFiles : undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]);
|
|
1772
|
+
setPendingFiles([]);
|
|
1773
|
+
}}
|
|
1774
|
+
placeholder="Write a comment…"
|
|
1775
|
+
sendLabel="Send"
|
|
1776
|
+
actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
|
|
1777
|
+
files={pendingFiles.length > 0 ? (
|
|
1778
|
+
<FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
|
|
1779
|
+
) : undefined}
|
|
1780
|
+
/>
|
|
1781
|
+
</>
|
|
1782
|
+
);
|
|
1783
|
+
|
|
1636
1784
|
return (
|
|
1637
1785
|
// Measure-then-reveal: hidden until the width is known, so the rail/bar
|
|
1638
1786
|
// choice never visibly reshuffles the page on first paint.
|
|
@@ -1665,7 +1813,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1665
1813
|
<Button
|
|
1666
1814
|
title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
|
|
1667
1815
|
color="muted"
|
|
1668
|
-
icon="list-collapse"
|
|
1669
1816
|
onPress={() => setSectionsOpen(true)}
|
|
1670
1817
|
/>
|
|
1671
1818
|
</View>
|
|
@@ -1679,10 +1826,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1679
1826
|
current={nav.activeKey === sec.key}
|
|
1680
1827
|
onPress={() => {
|
|
1681
1828
|
setSectionsOpen(false);
|
|
1682
|
-
// Leaving comments is part of jumping: in comments view the
|
|
1683
|
-
// sections are not rendered, so a jump with no exit was a
|
|
1684
|
-
// control that did nothing.
|
|
1685
|
-
setCommentsView(false);
|
|
1686
1829
|
nav.jumpTo(sec.key);
|
|
1687
1830
|
}}
|
|
1688
1831
|
/>
|
|
@@ -1700,21 +1843,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1700
1843
|
it toggles the body; on a narrow page it still opens the sheet, which
|
|
1701
1844
|
is that layout's panel. The pair reads as a switch: whichever side the
|
|
1702
1845
|
body is showing wears the filled `secondary`. */}
|
|
1703
|
-
{isDrawer ? (
|
|
1704
|
-
<Button
|
|
1705
|
-
title={commentsView ? "Close comments" : `Comments (${comments.length})`}
|
|
1706
|
-
color="muted"
|
|
1707
|
-
icon="message-square"
|
|
1708
|
-
onPress={() => setCommentsView((v) => !v)}
|
|
1709
|
-
/>
|
|
1710
|
-
) : (
|
|
1711
|
-
<Button
|
|
1712
|
-
title={`Comments (${comments.length})`}
|
|
1713
|
-
color="muted"
|
|
1714
|
-
icon="message-square"
|
|
1715
|
-
onPress={() => setCommentsOpen(true)}
|
|
1716
|
-
/>
|
|
1717
|
-
)}
|
|
1718
1846
|
</View>
|
|
1719
1847
|
)}
|
|
1720
1848
|
<View style={{ flex: 1 }}>
|
|
@@ -1782,61 +1910,123 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1782
1910
|
slot on RecordSummary at its second consumer. */}
|
|
1783
1911
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
|
|
1784
1912
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
1913
|
+
{/* The SUBJECT leads, the key goes under it. A reader talks about
|
|
1914
|
+
"the Harbor Freight order", never "RC-2026-0418" — the code is how
|
|
1915
|
+
the SYSTEM refers to the record, which makes it a supporting value.
|
|
1916
|
+
The register's identity cell makes the same trade, so the two
|
|
1917
|
+
surfaces name a record the same way. */}
|
|
1785
1918
|
<RecordSummary
|
|
1786
|
-
title={
|
|
1787
|
-
subtitle={
|
|
1919
|
+
title={customer ? customer.name : "No customer"}
|
|
1920
|
+
subtitle={[code, customer?.city].filter(Boolean).join(" · ")}
|
|
1788
1921
|
/>
|
|
1789
1922
|
</View>
|
|
1790
1923
|
</View>
|
|
1791
|
-
|
|
1792
|
-
|
|
1924
|
+
<SectionStack>
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
{/* PROGRESS — the desk handoff, as the ordered positions it is. Sales →
|
|
1928
|
+
Operations → Accounting: one desk owns the record at a time, the live
|
|
1929
|
+
one carries the act that leaves it, and a passed desk keeps its own
|
|
1930
|
+
facts editable (a date typed wrong must stay reachable). This replaced
|
|
1931
|
+
a per-desk CHECKLIST: ticking boxes described the work, never the
|
|
1932
|
+
record's POSITION, and it put the handoff CTA a section away from the
|
|
1933
|
+
desk it belonged to. */}
|
|
1934
|
+
<View onLayout={nav.register("progress")}>
|
|
1793
1935
|
<Section>
|
|
1794
1936
|
<SectionHeading>
|
|
1795
|
-
<SectionHeadingTitle description="
|
|
1796
|
-
<SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
|
|
1937
|
+
<SectionHeadingTitle description="One desk owns the record at a time. The live desk carries the handoff.">Progress</SectionHeadingTitle>
|
|
1797
1938
|
</SectionHeading>
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1939
|
+
<Checklist connected accessibilityLabel="Desk handoff progress">
|
|
1940
|
+
{PHASES.flatMap((p, pi) => {
|
|
1941
|
+
const idxs = p.rungs.map(rungAt);
|
|
1942
|
+
const allDone = idxs.every(rungDone);
|
|
1943
|
+
// The live phase is the first with anything still owed — derived,
|
|
1944
|
+
// like every other position here, from the stamps alone.
|
|
1945
|
+
const firstOpen = PHASES.findIndex((x) => x.rungs.some((k) => !deskSince[k]));
|
|
1946
|
+
const live = !allDone && pi === firstOpen;
|
|
1947
|
+
const shown = !closedPhases.has(p.desk);
|
|
1948
|
+
const owner = deskOwner[p.desk];
|
|
1949
|
+
return [
|
|
1950
|
+
// A PHASE is a name and nothing else: no control, because it owns no
|
|
1951
|
+
// completion of its own, and no body, because a condition hung off a
|
|
1952
|
+
// heading leaves the reader working out which row it was about.
|
|
1953
|
+
<ChecklistGroup
|
|
1954
|
+
key={p.desk}
|
|
1955
|
+
title={p.label}
|
|
1956
|
+
open={shown}
|
|
1957
|
+
onToggleOpen={() => togglePhase(p.desk)}
|
|
1958
|
+
/>,
|
|
1959
|
+
...(shown ? p.rungs : []).map((k) => {
|
|
1960
|
+
const i = rungAt(k);
|
|
1961
|
+
const stamped = deskSince[k] ?? "";
|
|
1962
|
+
// The one still owed — the rung past the position, read off the
|
|
1963
|
+
// SAME number everything else here reads.
|
|
1964
|
+
const isNext = live && i === topRung + 1;
|
|
1965
|
+
return (
|
|
1966
|
+
<ChecklistItem
|
|
1967
|
+
key={k}
|
|
1968
|
+
title={RUNGS[i].label}
|
|
1969
|
+
done={rungDone(i)}
|
|
1970
|
+
current={isNext}
|
|
1971
|
+
onToggle={(next) => (next ? stampRung(i) : clearRungsFrom(i, RUNGS[i].label))}
|
|
1972
|
+
// Offered on every REACHED rung, stamped or not: a rung the
|
|
1973
|
+
// record passed without anyone recording the day is exactly the
|
|
1974
|
+
// one worth filling in, and a blank date is the honest report
|
|
1975
|
+
// until someone does. An unreached rung shows nothing — its
|
|
1976
|
+
// empty ring already says "not yet".
|
|
1977
|
+
trailing={
|
|
1978
|
+
rungDone(i) || isNext ? (
|
|
1979
|
+
<DateStamp
|
|
1980
|
+
value={stamped || null}
|
|
1981
|
+
onChange={(iso) => setDeskSince((prev) => ({ ...prev, [k]: iso }))}
|
|
1982
|
+
locale="en-GB"
|
|
1983
|
+
accessibilityLabel={"Date for " + RUNGS[i].label}
|
|
1984
|
+
/>
|
|
1985
|
+
) : undefined
|
|
1986
|
+
}
|
|
1987
|
+
// How long THIS rung has been owed — since the one before it was
|
|
1988
|
+
// stamped, or since the record opened. Measured at the row rather
|
|
1989
|
+
// than the phase, because that is the wait a reader asks about.
|
|
1990
|
+
meta={isNext ? heldFor(i > 0 ? deskSince[RUNGS[i - 1].key] ?? "" : orderDate) : undefined}
|
|
1991
|
+
>
|
|
1992
|
+
{isNext ? (
|
|
1993
|
+
<>
|
|
1994
|
+
{stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
|
|
1995
|
+
<ChecklistNote tone="warning">Past the due date — chase it or move the date.</ChecklistNote>
|
|
1996
|
+
) : null}
|
|
1997
|
+
{!owner ? (
|
|
1998
|
+
<ChecklistNote tone="warning" action={{ label: "Set in General", onPress: () => nav.jumpTo("general") }}>
|
|
1999
|
+
Nobody is assigned to this desk.
|
|
2000
|
+
</ChecklistNote>
|
|
2001
|
+
) : null}
|
|
2002
|
+
{p.desk === stage && gate ? (
|
|
2003
|
+
<ChecklistActions>
|
|
2004
|
+
<Button
|
|
2005
|
+
title={gate.cta}
|
|
2006
|
+
color="primary"
|
|
2007
|
+
onPress={gate.handoff ? () => { setHandoffTo(gate.next); setHandoffOpen(true); } : closeRecord}
|
|
2008
|
+
/>
|
|
2009
|
+
</ChecklistActions>
|
|
2010
|
+
) : null}
|
|
2011
|
+
</>
|
|
2012
|
+
) : null}
|
|
2013
|
+
</ChecklistItem>
|
|
2014
|
+
);
|
|
2015
|
+
}),
|
|
2016
|
+
];
|
|
2017
|
+
})}
|
|
2018
|
+
</Checklist>
|
|
1834
2019
|
</Section>
|
|
1835
|
-
|
|
1836
|
-
) : (
|
|
1837
|
-
<SectionStack>
|
|
2020
|
+
</View>
|
|
1838
2021
|
|
|
1839
2022
|
|
|
2023
|
+
{/* FEES — the DETAILED money ledger, both directions (charge = billed
|
|
2024
|
+
to the customer, cost = paid to a vendor), distinct from Billing's
|
|
2025
|
+
invoice DOCUMENTS. The drill-down law for a child collection: a
|
|
2026
|
+
compact register row, and EVERY row EXPANDS to its full detail in
|
|
2027
|
+
place — one at a time, no drawer, so the surrounding rows stay
|
|
2028
|
+
readable and coming back costs nothing. Add is create-then-refine —
|
|
2029
|
+
a blank fee appends already open, where it will live. */}
|
|
1840
2030
|
{/* GENERAL — the MAIN details, FIRST section: the core facts of the
|
|
1841
2031
|
item; every other section is supplementary. The body is a
|
|
1842
2032
|
SubsectionStack (space-only beat): a headingless LEAD group (the
|
|
@@ -2177,6 +2367,29 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2177
2367
|
</Section>
|
|
2178
2368
|
</View>
|
|
2179
2369
|
|
|
2370
|
+
{/* NO ROOM FOR THE PANEL — so the thread reads as a SECTION, directly
|
|
2371
|
+
under General, because a coworker's note is the first thing to read on
|
|
2372
|
+
a shared record.
|
|
2373
|
+
|
|
2374
|
+
The question is the CONTAINER's width and nothing else: a narrow page
|
|
2375
|
+
and a drawer answer it the same way, and a drawer is simply always
|
|
2376
|
+
narrow. It was briefly a body-SWAP there, behind a toggle — the same
|
|
2377
|
+
fault as the old top-right button in a different shape, since either
|
|
2378
|
+
way the reader has to know a control exists, press it, and give up the
|
|
2379
|
+
record to read a note. Wide keeps the thread beside the record; every
|
|
2380
|
+
other width puts it here. */}
|
|
2381
|
+
{!wide ? (
|
|
2382
|
+
<View onLayout={nav.register("comments")}>
|
|
2383
|
+
<Section>
|
|
2384
|
+
<SectionHeading>
|
|
2385
|
+
<SectionHeadingTitle description="Notes on this record — everyone with access sees them.">Comments</SectionHeadingTitle>
|
|
2386
|
+
<SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
|
|
2387
|
+
</SectionHeading>
|
|
2388
|
+
{commentsThread}
|
|
2389
|
+
</Section>
|
|
2390
|
+
</View>
|
|
2391
|
+
) : null}
|
|
2392
|
+
|
|
2180
2393
|
{/* DOCUMENTS — the document desk: the register Table (search, Create
|
|
2181
2394
|
documents, Add files) whose selection feeds the Use-AI fork below.
|
|
2182
2395
|
No FileDropTarget of its OWN — the WHOLE-RECORD target above already
|
|
@@ -2382,7 +2595,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2382
2595
|
<DetailRow label="Trip no.">
|
|
2383
2596
|
<InlineTextInput value={trip} onSave={persist(setTrip)} placeholder="Add trip number…" accessibilityLabel="Trip number" />
|
|
2384
2597
|
</DetailRow>
|
|
2385
|
-
|
|
2598
|
+
{/* A ROUTE IS NOT FIELDS — see `RouteStops` above. It lives in
|
|
2386
2599
|
a `DetailRow` so it keeps the section's label column and the
|
|
2387
2600
|
reader's eye stays on one x; what sits in the VALUE is a list,
|
|
2388
2601
|
because that is what a route is. */}
|
|
@@ -2402,192 +2615,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2402
2615
|
</View>
|
|
2403
2616
|
|
|
2404
2617
|
|
|
2405
|
-
{/* PROGRESS — the desk handoff, as the ordered positions it is. Sales →
|
|
2406
|
-
Operations → Accounting: one desk owns the record at a time, the live
|
|
2407
|
-
one carries the act that leaves it, and a passed desk keeps its own
|
|
2408
|
-
facts editable (a date typed wrong must stay reachable). This replaced
|
|
2409
|
-
a per-desk CHECKLIST: ticking boxes described the work, never the
|
|
2410
|
-
record's POSITION, and it put the handoff CTA a section away from the
|
|
2411
|
-
desk it belonged to. */}
|
|
2412
|
-
<View onLayout={nav.register("progress")}>
|
|
2413
|
-
<Section>
|
|
2414
|
-
<SectionHeading>
|
|
2415
|
-
<SectionHeadingTitle description="One desk owns the record at a time. The live desk carries the handoff.">Progress</SectionHeadingTitle>
|
|
2416
|
-
</SectionHeading>
|
|
2417
|
-
<Pipeline accessibilityLabel="Desk handoff progress">
|
|
2418
|
-
{/* The ladder reads its own STAMPS, and nothing else. Driving `status`
|
|
2419
|
-
off the record's `stage` while the tick wrote a date left two
|
|
2420
|
-
sources of truth for one position: the ring could never change
|
|
2421
|
-
state, so pressing it did nothing visible — no fill, no spring, no
|
|
2422
|
-
movement — which is what "the progress is broken" looks like.
|
|
2423
|
-
A rung is done when IT carries a date, independently of its
|
|
2424
|
-
neighbours, so any rung can be ticked and un-ticked on its own. */}
|
|
2425
|
-
{STEPS.map((d, i) => {
|
|
2426
|
-
const stampedAt = deskSince[d.key] ?? "";
|
|
2427
|
-
const firstOpen = STEPS.findIndex((x) => !deskSince[x.key]);
|
|
2428
|
-
const status: "done" | "current" | "upcoming" = stampedAt ? "done" : i === firstOpen ? "current" : "upcoming";
|
|
2429
|
-
const owner = deskOwner[d.key];
|
|
2430
|
-
return (
|
|
2431
|
-
<PipelineStage
|
|
2432
|
-
key={d.key}
|
|
2433
|
-
status={status}
|
|
2434
|
-
title={d.label}
|
|
2435
|
-
// Prose the reader can't set — how long it has sat here.
|
|
2436
|
-
meta={status === "current" ? heldFor(stampedAt) : undefined}
|
|
2437
|
-
// The tick IS the stamp: pressing the ring records today, pressing
|
|
2438
|
-
// it again clears the stage. ANY ring takes a press (`Pipeline`'s
|
|
2439
|
-
// default) because this ladder is a RECORD — entering three stages
|
|
2440
|
-
// that already happened must not cost three presses plus three
|
|
2441
|
-
// date corrections.
|
|
2442
|
-
onToggle={(next) => {
|
|
2443
|
-
if (next) {
|
|
2444
|
-
// Ticking AHEAD fills the gap. A ladder is a run, not a set
|
|
2445
|
-
// of independent boxes: leaving 3 and 4 blank because someone
|
|
2446
|
-
// ticked 5 renders "done, blank, blank, done", which claims a
|
|
2447
|
-
// record reached the end without passing through the middle.
|
|
2448
|
-
// Every rung at or below the one pressed that has no stamp
|
|
2449
|
-
// gets today — the day this is being recorded, which is the
|
|
2450
|
-
// same thing a single tick already means. Each one is then
|
|
2451
|
-
// one click away from its real date, and the ones that
|
|
2452
|
-
// already carry a date are left exactly as they are.
|
|
2453
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
2454
|
-
setDeskSince((prev) => {
|
|
2455
|
-
const filled = { ...prev };
|
|
2456
|
-
STEPS.slice(0, i + 1).forEach((x) => { if (!filled[x.key]) filled[x.key] = today; });
|
|
2457
|
-
return filled;
|
|
2458
|
-
});
|
|
2459
|
-
return;
|
|
2460
|
-
}
|
|
2461
|
-
// Un-ticking walks the record BACK, and everything the stages
|
|
2462
|
-
// above it recorded goes with it — their dates and the facts
|
|
2463
|
-
// they own. That is destructive and invisible from the ring,
|
|
2464
|
-
// so it is named before it happens: which stages, and that it
|
|
2465
|
-
// is their data too, not just their dates. A stage with
|
|
2466
|
-
// nothing above it needs no warning — there is nothing to lose.
|
|
2467
|
-
const above = STEPS.slice(i + 1).filter((x) => deskSince[x.key] || deskOwner[x.key]);
|
|
2468
|
-
const revert = () =>
|
|
2469
|
-
STEPS.slice(i).forEach((x) => {
|
|
2470
|
-
setDeskSince((prev) => ({ ...prev, [x.key]: "" }));
|
|
2471
|
-
setDeskOwner((prev) => ({ ...prev, [x.key]: "" }));
|
|
2472
|
-
});
|
|
2473
|
-
if (above.length === 0) { revert(); return; }
|
|
2474
|
-
Alert.alert(
|
|
2475
|
-
`Reopen ${d.label}?`,
|
|
2476
|
-
`${above.map((x) => x.label).join(" and ")} ${above.length > 1 ? "are" : "is"} recorded after it. Reopening clears their dates and everything they hold — owner included. This can't be undone.`,
|
|
2477
|
-
[
|
|
2478
|
-
{ text: "Cancel", style: "cancel" },
|
|
2479
|
-
{ text: "Reopen and clear", style: "destructive", onPress: revert },
|
|
2480
|
-
],
|
|
2481
|
-
);
|
|
2482
|
-
}}
|
|
2483
|
-
// The date rides the title's own row and READS as text — `bare`
|
|
2484
|
-
// shows nothing at rest and fades its border in on hover, so the
|
|
2485
|
-
// ladder stays a column of dates to scan rather than a column of
|
|
2486
|
-
// controls, and the one that needs correcting is still one click.
|
|
2487
|
-
//
|
|
2488
|
-
// That click is how a stamp gets BACKDATED, which the tick alone
|
|
2489
|
-
// cannot do: ticking always writes today, and a ladder filled in
|
|
2490
|
-
// after the fact is full of days that already passed. Rendering
|
|
2491
|
-
// the date as inert text would have cost exactly that.
|
|
2492
|
-
//
|
|
2493
|
-
// An unstamped rung shows nothing — its empty ring already says
|
|
2494
|
-
// "not yet", and a "Not recorded" placeholder on every rung ahead
|
|
2495
|
-
// says the same thing again in more words.
|
|
2496
|
-
// What the stage HOLDS, beside its name: the date it was
|
|
2497
|
-
// stamped, and any value it owns that has actually been set.
|
|
2498
|
-
// A stage's facts are otherwise only visible once you read down
|
|
2499
|
-
// into its body, so a ladder of five says nothing but five
|
|
2500
|
-
// names — the reader has to open each to learn anything. Only
|
|
2501
|
-
// values that EXIST are shown; an empty one would print a label
|
|
2502
|
-
// with nothing after it on every rung ahead.
|
|
2503
|
-
// The stage's own line carries what is ABOUT the stage — when it
|
|
2504
|
-
// happened, and the way into changing it — while the body holds
|
|
2505
|
-
// what it CONTAINS. Edit sat under the values, which put the verb
|
|
2506
|
-
// below the thing it acts on and moved it down every time a stage
|
|
2507
|
-
// gained a field.
|
|
2508
|
-
trailing={
|
|
2509
|
-
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 10 }}>
|
|
2510
|
-
<DateStamp
|
|
2511
|
-
value={stampedAt || null}
|
|
2512
|
-
onChange={(iso) => setDeskSince((prev) => ({ ...prev, [d.key]: iso }))}
|
|
2513
|
-
locale="en-GB"
|
|
2514
|
-
accessibilityLabel={"Date for " + d.label}
|
|
2515
|
-
/>
|
|
2516
|
-
{/* Edit ⇄ Done, one control in one place. It is a text LINK
|
|
2517
|
-
and not a button on purpose: the inline editors commit on
|
|
2518
|
-
their own (blur, Enter, a pick), so nothing is pending when
|
|
2519
|
-
you press this — it only puts the stage back to reading.
|
|
2520
|
-
A `Button` would promise a SAVE that already happened and
|
|
2521
|
-
make a reader wonder what they lose by navigating away. */}
|
|
2522
|
-
{d.desk && status !== "upcoming" && status !== "current" ? (
|
|
2523
|
-
<Pressable
|
|
2524
|
-
onPress={() => setEditingDesk(editingDesk === d.key ? "" : d.key)}
|
|
2525
|
-
accessibilityRole="button"
|
|
2526
|
-
accessibilityLabel={`${editingDesk === d.key ? "Done editing" : "Edit"} ${d.label}`}
|
|
2527
|
-
>
|
|
2528
|
-
<TextLink size="xs">{editingDesk === d.key ? "Done" : "Edit"}</TextLink>
|
|
2529
|
-
</Pressable>
|
|
2530
|
-
) : null}
|
|
2531
|
-
</View>
|
|
2532
|
-
}
|
|
2533
|
-
>
|
|
2534
|
-
{/* A reached desk owns its own facts — and keeps owning them
|
|
2535
|
-
once the record has moved on. */}
|
|
2536
|
-
{status !== "upcoming" && d.desk ? (
|
|
2537
|
-
status === "current" || editingDesk === d.key ? (
|
|
2538
|
-
<PipelineField label="Owner" maxWidth={260}>
|
|
2539
|
-
<InlineMemberSelect
|
|
2540
|
-
members={TEAM}
|
|
2541
|
-
value={owner}
|
|
2542
|
-
onSave={(v) => setDeskOwner((p) => ({ ...p, [d.key]: v }))}
|
|
2543
|
-
placeholder="Unassigned"
|
|
2544
|
-
accessibilityLabel={`Owner for ${d.label}`}
|
|
2545
|
-
/>
|
|
2546
|
-
</PipelineField>
|
|
2547
|
-
) : (
|
|
2548
|
-
// Read-only, but still SHOWN — and shown by whatever renders
|
|
2549
|
-
// that kind of value properly: a member as a `MemberChip`, a
|
|
2550
|
-
// select as its `Badge`. Collapsing them to muted text would
|
|
2551
|
-
// make the same fact look different depending on whether the
|
|
2552
|
-
// stage happened to be the live one, and a reader would have
|
|
2553
|
-
// to decode two renderings of one field. Only what is SET
|
|
2554
|
-
// appears; an empty field has nothing to display read-only.
|
|
2555
|
-
// No label. A label tells you what you are FILLING IN —
|
|
2556
|
-
// read-only, the value says what it is: a face and a name is
|
|
2557
|
-
// an owner, a badge is a status. Printing "Owner" over a
|
|
2558
|
-
// member chip is the field talking about itself, and down a
|
|
2559
|
-
// ladder it doubles the lines for nothing.
|
|
2560
|
-
owner ? <MemberChip name={TEAM.find((x) => x.id === owner)?.name ?? owner} size={22} /> : null
|
|
2561
|
-
)
|
|
2562
|
-
) : null}
|
|
2563
|
-
{/* A condition belongs to the desk it is about. */}
|
|
2564
|
-
{status === "current" && stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
|
|
2565
|
-
<PipelineNote tone="warning">Past the due date — chase it or move the date.</PipelineNote>
|
|
2566
|
-
) : null}
|
|
2567
|
-
{status === "current" && d.desk === stage && gate ? (
|
|
2568
|
-
<PipelineActions>
|
|
2569
|
-
<Button
|
|
2570
|
-
title={gate.cta}
|
|
2571
|
-
color="primary"
|
|
2572
|
-
onPress={gate.handoff ? () => { setHandoffTo(gate.next); setHandoffOpen(true); } : closeRecord}
|
|
2573
|
-
/>
|
|
2574
|
-
</PipelineActions>
|
|
2575
|
-
) : null}
|
|
2576
|
-
</PipelineStage>
|
|
2577
|
-
);
|
|
2578
|
-
})}
|
|
2579
|
-
</Pipeline>
|
|
2580
|
-
</Section>
|
|
2581
|
-
</View>
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
{/* FEES — the DETAILED money ledger, both directions (charge = billed
|
|
2585
|
-
to the customer, cost = paid to a vendor), distinct from Billing's
|
|
2586
|
-
invoice DOCUMENTS. The drill-down law for a child collection: a
|
|
2587
|
-
compact register row, and EVERY row EXPANDS to its full detail in
|
|
2588
|
-
place — one at a time, no drawer, so the surrounding rows stay
|
|
2589
|
-
readable and coming back costs nothing. Add is create-then-refine —
|
|
2590
|
-
a blank fee appends already open, where it will live. */}
|
|
2591
2618
|
<View onLayout={nav.register("fees")}>
|
|
2592
2619
|
<Section>
|
|
2593
2620
|
<SectionHeading>
|
|
@@ -2987,74 +3014,40 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2987
3014
|
<SubsectionHeading>
|
|
2988
3015
|
<SubsectionHeadingTitle>{g.party(customer?.name ?? null)}</SubsectionHeadingTitle>
|
|
2989
3016
|
</SubsectionHeading>
|
|
2990
|
-
{/*
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
3017
|
+
{/* A SELECTION, not a run of work: which forms to produce. So the
|
|
3018
|
+
mark is the SQUARE the kit spends on picking — a filled RING
|
|
3019
|
+
here would read as "already done" to anyone who has learned
|
|
3020
|
+
what a ring means one section above. And UNCONNECTED, because
|
|
3021
|
+
a line between them would claim an order the work does not
|
|
3022
|
+
have. Same compound as the Progress ladder, two axes apart —
|
|
3023
|
+
and the compound owns the geometry (the row band, the marker
|
|
3024
|
+
gutter, the indent anything hung under a row takes), because
|
|
3025
|
+
hand-rolling that anatomy is how alignment drifts. */}
|
|
3026
|
+
<Checklist connected={false} mark="select">
|
|
2998
3027
|
{shown.map((f) => {
|
|
2999
|
-
// READINESS per row
|
|
3000
|
-
//
|
|
3001
|
-
//
|
|
3002
|
-
// `expansion` — an `Inset` (a FORM surface, never a Callout, which is an
|
|
3003
|
-
// ARIA alert) with the editor for each gap, saved onto the record in
|
|
3004
|
-
// place. Drafts are keyed BY FIELD, so a field two checked forms share
|
|
3005
|
-
// is entered once and one Save resolves both. A ready form stays SILENT.
|
|
3028
|
+
// READINESS per row: a not-ready row NAMES what is missing and
|
|
3029
|
+
// points at the section that owns it. Nothing is collected here
|
|
3030
|
+
// — see the section header for why a checklist reports.
|
|
3006
3031
|
const missing = missingOf(f);
|
|
3007
3032
|
const checked = chosenForms.has(f.id);
|
|
3008
3033
|
return (
|
|
3009
|
-
<
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
{
|
|
3014
|
-
|
|
3015
|
-
the page and the checkbox stays beside it above its caption. */}
|
|
3016
|
-
<TaskTitle>{f.label}</TaskTitle>
|
|
3017
|
-
{/* "Needs: Tax ID, Delivery address" is a SENTENCE about this row, so it
|
|
3018
|
-
is a `TaskCaption` — its own line under the title. Not a
|
|
3019
|
-
`TaskSubRow`, which is a NAMED value the reader SETS: prose given a
|
|
3020
|
-
label reads as a field nobody can edit. */}
|
|
3034
|
+
<ChecklistItem
|
|
3035
|
+
key={f.id}
|
|
3036
|
+
title={f.label}
|
|
3037
|
+
done={checked}
|
|
3038
|
+
onToggle={(on) => toggleForm(f.id, on)}
|
|
3039
|
+
>
|
|
3021
3040
|
{missing.length > 0 ? (
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
</TaskCaption>
|
|
3028
|
-
) : null}
|
|
3029
|
-
{checked && missing.length > 0 ? (
|
|
3030
|
-
<TaskDetail>
|
|
3031
|
-
<Inset>
|
|
3032
|
-
<Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
|
|
3033
|
-
{missing.map((k) => (
|
|
3034
|
-
<FormTextInput
|
|
3035
|
-
key={k}
|
|
3036
|
-
label={NEEDS[k].label}
|
|
3037
|
-
description={NEEDS[k].hint}
|
|
3038
|
-
value={fillDrafts[k] ?? ""}
|
|
3039
|
-
onChangeText={(t) => setFillDrafts((d) => ({ ...d, [k]: t }))}
|
|
3040
|
-
accessibilityLabel={NEEDS[k].label}
|
|
3041
|
-
/>
|
|
3042
|
-
))}
|
|
3043
|
-
<View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
|
|
3044
|
-
<Button
|
|
3045
|
-
title="Save fields"
|
|
3046
|
-
color="secondary"
|
|
3047
|
-
disabled={missing.every((k) => !(fillDrafts[k] ?? "").trim())}
|
|
3048
|
-
onPress={() => saveNeeds(missing)}
|
|
3049
|
-
/>
|
|
3050
|
-
</View>
|
|
3051
|
-
</Inset>
|
|
3052
|
-
</TaskDetail>
|
|
3041
|
+
// Muted at rest — discoverable without checking; WARNING
|
|
3042
|
+
// once checked, because it now blocks.
|
|
3043
|
+
<ChecklistNote tone={checked ? "warning" : "muted"}>
|
|
3044
|
+
{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")} — fill these in Customer`}
|
|
3045
|
+
</ChecklistNote>
|
|
3053
3046
|
) : null}
|
|
3054
|
-
</
|
|
3047
|
+
</ChecklistItem>
|
|
3055
3048
|
);
|
|
3056
3049
|
})}
|
|
3057
|
-
</
|
|
3050
|
+
</Checklist>
|
|
3058
3051
|
{hidden > 0 ? (
|
|
3059
3052
|
<View style={{ flexDirection: "row" }}>
|
|
3060
3053
|
<Button
|
|
@@ -3087,7 +3080,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
3087
3080
|
{blockingNeeds.length > 0 ? (
|
|
3088
3081
|
<Callout tone="warning">
|
|
3089
3082
|
<CalloutText>
|
|
3090
|
-
{`The checked forms still need ${blockingNeeds.map((k) => NEEDS[k].label.toLowerCase()).join(" and ")} — ${blockingNeeds.length === 1 ? "it prints" : "they print"} blank
|
|
3083
|
+
{`The checked forms still need ${blockingNeeds.map((k) => NEEDS[k].label.toLowerCase()).join(" and ")} — ${blockingNeeds.length === 1 ? "it prints" : "they print"} blank until filled on the record.`}
|
|
3091
3084
|
</CalloutText>
|
|
3092
3085
|
</Callout>
|
|
3093
3086
|
) : null}
|
|
@@ -3222,7 +3215,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
3222
3215
|
</View>
|
|
3223
3216
|
|
|
3224
3217
|
</SectionStack>
|
|
3225
|
-
)}
|
|
3226
3218
|
</View>
|
|
3227
3219
|
</FileDropTarget>
|
|
3228
3220
|
</>
|
|
@@ -3358,53 +3350,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
3358
3350
|
</View>
|
|
3359
3351
|
</ScrollView>
|
|
3360
3352
|
|
|
3361
|
-
{/* Narrow: the same thread, in the panel shape a small screen has — a
|
|
3362
|
-
right-docked Drawer off the bar's own comments control. Never in drawer
|
|
3363
|
-
mode: a Drawer inside a Drawer traps the reader one layer deeper than the
|
|
3364
|
-
record they opened. */}
|
|
3365
|
-
<Drawer open={commentsOpen && !isDrawer} onOpenChange={setCommentsOpen} title="Comments" width={380}>
|
|
3366
|
-
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 20, gap: 12 }}>
|
|
3367
|
-
<CommentList
|
|
3368
|
-
comments={comments}
|
|
3369
|
-
currentMemberId="mem_01"
|
|
3370
|
-
resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
|
|
3371
|
-
onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
|
|
3372
|
-
onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
|
|
3373
|
-
/* the file-capable edit form — the same injection the product uses */
|
|
3374
|
-
renderEditForm={(p) => <CommentFileEditForm {...p} />}
|
|
3375
|
-
/* attachments render as the SAME thumbnail grid the product uses —
|
|
3376
|
-
press previews in the gallery (the desk's preview state) */
|
|
3377
|
-
renderFiles={(files) => (
|
|
3378
|
-
<FileGrid
|
|
3379
|
-
files={files.map(commentFileToDisplay)}
|
|
3380
|
-
itemSize={84}
|
|
3381
|
-
onFilePress={(f) => setPreview({ files: [f], index: 0 })}
|
|
3382
|
-
/>
|
|
3383
|
-
)}
|
|
3384
|
-
/* compact timestamp — the product formats RELATIVE ("2 days ago",
|
|
3385
|
-
date-fns); a template stays dependency-free and deterministic */
|
|
3386
|
-
formatTimestamp={(iso) => { const d = new Date(iso); return `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }}
|
|
3387
|
-
/>
|
|
3388
|
-
|
|
3389
|
-
</ScrollView>
|
|
3390
|
-
<View style={{ padding: 20, paddingTop: 0 }}>
|
|
3391
|
-
{/* THE kit Composer — the one composing surface everywhere (chat,
|
|
3392
|
-
agent prompts, comments): attach via actionsButton, the staged
|
|
3393
|
-
files (removable until Send) ride the files slot. */}
|
|
3394
|
-
<Composer
|
|
3395
|
-
onSend={(content) => {
|
|
3396
|
-
setComments((prev) => [...prev, { id: `cm_${(commentSeq += 1)}`, member_id: "mem_01", content, files: pendingFiles.length > 0 ? pendingFiles : undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]);
|
|
3397
|
-
setPendingFiles([]);
|
|
3398
|
-
}}
|
|
3399
|
-
placeholder="Write a comment…"
|
|
3400
|
-
sendLabel="Send"
|
|
3401
|
-
actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
|
|
3402
|
-
files={pendingFiles.length > 0 ? (
|
|
3403
|
-
<FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
|
|
3404
|
-
) : undefined}
|
|
3405
|
-
/>
|
|
3406
|
-
</View>
|
|
3407
|
-
</Drawer>
|
|
3408
3353
|
|
|
3409
3354
|
{/* ── the document desk's overlays */}
|
|
3410
3355
|
<FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
|
|
@@ -3695,45 +3640,12 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
3695
3640
|
<SectionHeadingTitle>Comments</SectionHeadingTitle>
|
|
3696
3641
|
<SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
|
|
3697
3642
|
</SectionHeading>
|
|
3643
|
+
{/* The panel's OWN scroller, so a long thread scrolls inside itself and
|
|
3644
|
+
never lengthens the record. The thread inside it is the same one the
|
|
3645
|
+
section renders — defined once, so the two cannot drift. */}
|
|
3698
3646
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12, paddingBottom: 8 }}>
|
|
3699
|
-
|
|
3700
|
-
comments={comments}
|
|
3701
|
-
currentMemberId="mem_01"
|
|
3702
|
-
resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
|
|
3703
|
-
onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
|
|
3704
|
-
onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
|
|
3705
|
-
/* the file-capable edit form — the same injection the product uses */
|
|
3706
|
-
renderEditForm={(p) => <CommentFileEditForm {...p} />}
|
|
3707
|
-
/* attachments render as the SAME thumbnail grid the product uses —
|
|
3708
|
-
press previews in the gallery (the desk's preview state) */
|
|
3709
|
-
renderFiles={(files) => (
|
|
3710
|
-
<FileGrid
|
|
3711
|
-
files={files.map(commentFileToDisplay)}
|
|
3712
|
-
itemSize={84}
|
|
3713
|
-
onFilePress={(f) => setPreview({ files: [f], index: 0 })}
|
|
3714
|
-
/>
|
|
3715
|
-
)}
|
|
3716
|
-
/* compact timestamp — the product formats RELATIVE ("2 days ago",
|
|
3717
|
-
date-fns); a template stays dependency-free and deterministic */
|
|
3718
|
-
formatTimestamp={(iso) => { const d = new Date(iso); return `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }}
|
|
3719
|
-
/>
|
|
3720
|
-
|
|
3647
|
+
{commentsThread}
|
|
3721
3648
|
</ScrollView>
|
|
3722
|
-
{/* THE kit Composer — the one composing surface everywhere (chat,
|
|
3723
|
-
agent prompts, comments): attach via actionsButton, the staged
|
|
3724
|
-
files (removable until Send) ride the files slot. */}
|
|
3725
|
-
<Composer
|
|
3726
|
-
onSend={(content) => {
|
|
3727
|
-
setComments((prev) => [...prev, { id: `cm_${(commentSeq += 1)}`, member_id: "mem_01", content, files: pendingFiles.length > 0 ? pendingFiles : undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]);
|
|
3728
|
-
setPendingFiles([]);
|
|
3729
|
-
}}
|
|
3730
|
-
placeholder="Write a comment…"
|
|
3731
|
-
sendLabel="Send"
|
|
3732
|
-
actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
|
|
3733
|
-
files={pendingFiles.length > 0 ? (
|
|
3734
|
-
<FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
|
|
3735
|
-
) : undefined}
|
|
3736
|
-
/>
|
|
3737
3649
|
</View>
|
|
3738
3650
|
)}
|
|
3739
3651
|
|
|
@@ -3747,7 +3659,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
3747
3659
|
real app), visually apart from the outline items. */}
|
|
3748
3660
|
<BackButton label="Records" onPress={() => {}} />
|
|
3749
3661
|
<View style={{ gap: 2 }}>
|
|
3750
|
-
{SECTIONS.map((sec) => (
|
|
3662
|
+
{SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => (
|
|
3751
3663
|
<MenuButton
|
|
3752
3664
|
key={sec.key}
|
|
3753
3665
|
icon={sec.icon}
|