@lotics/ui 23.1.1 → 24.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 -1
- package/MIGRATION.md +37 -0
- package/docs/catalog.md +69 -14
- package/docs/composition.md +63 -3
- package/docs/data_entry.md +19 -1
- package/docs/templates.md +24 -28
- package/examples/tpl_item_list.tsx +19 -768
- package/examples/tpl_record.tsx +515 -415
- package/examples/tpl_task_board.tsx +2 -2
- package/package.json +5 -2
- package/src/breakdown.tsx +3 -1
- package/src/choice_list.tsx +3 -1
- package/src/date_calendar.tsx +3 -1
- package/src/deadline.ts +157 -0
- package/src/filter_chip.tsx +2 -2
- package/src/funnel.tsx +3 -1
- package/src/heatmap.tsx +3 -1
- package/src/locale.tsx +16 -0
- package/src/matrix.tsx +3 -1
- package/src/option_list.tsx +3 -3
- package/src/press_door.tsx +12 -1
- package/src/reference_field.tsx +174 -0
- package/src/status_grid.tsx +3 -1
- package/src/table.tsx +56 -4
- package/src/text_button.tsx +139 -0
- package/src/text_link.tsx +24 -14
package/examples/tpl_record.tsx
CHANGED
|
@@ -13,17 +13,20 @@ 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
15
|
import { Inset } from "@lotics/ui/inset";
|
|
16
|
-
import {
|
|
16
|
+
import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
17
17
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
18
18
|
import { Pipeline, PipelineActions, PipelineField, PipelineNote, PipelineStage } from "@lotics/ui/pipeline";
|
|
19
19
|
import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
|
|
20
20
|
import { MenuButton } from "@lotics/ui/menu_button";
|
|
21
|
-
import {
|
|
21
|
+
import { Popover, PopoverContent } from "@lotics/ui/popover";
|
|
22
22
|
import { useSectionNav } from "@lotics/ui/use_section_nav";
|
|
23
23
|
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
|
|
24
24
|
import { formatMoney } from "@lotics/ui/format_money";
|
|
25
25
|
import { RecordSummary } from "@lotics/ui/record_summary";
|
|
26
26
|
import { SummaryLine } from "@lotics/ui/summary_line";
|
|
27
|
+
import { Badge } from "@lotics/ui/badge";
|
|
28
|
+
import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
|
|
29
|
+
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
27
30
|
import { Skeleton } from "@lotics/ui/skeleton";
|
|
28
31
|
import { CommentList, type CommentEditFormProps, type ThreadComment, type ThreadFile } from "@lotics/ui/comments_thread";
|
|
29
32
|
import { Composer } from "@lotics/ui/composer";
|
|
@@ -38,7 +41,7 @@ import { InlineSelect } from "@lotics/ui/inline_select";
|
|
|
38
41
|
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
39
42
|
import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
|
|
40
43
|
import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select";
|
|
41
|
-
import { Drawer
|
|
44
|
+
import { Drawer } from "@lotics/ui/drawer";
|
|
42
45
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
43
46
|
import { FormField } from "@lotics/ui/form_field";
|
|
44
47
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
@@ -53,13 +56,11 @@ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
|
53
56
|
import { Finding, FindingComparison } from "@lotics/ui/finding";
|
|
54
57
|
import { FormTextInput } from "@lotics/ui/form_text_input";
|
|
55
58
|
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
56
|
-
import {
|
|
57
|
-
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
59
|
+
import { ChipGroup, type ChipOption } from "@lotics/ui/chip_group";
|
|
58
60
|
import { Sequence, SequenceItem, SEQUENCE_INSET } from "@lotics/ui/sequence";
|
|
59
|
-
import {
|
|
61
|
+
import { ReferenceField } from "@lotics/ui/reference_field";
|
|
62
|
+
import { TextButton } from "@lotics/ui/text_button";
|
|
60
63
|
import { InlineButton } from "@lotics/ui/inline_button";
|
|
61
|
-
import { InlineEditView } from "@lotics/ui/inline_edit";
|
|
62
|
-
import { RadioPicker } from "@lotics/ui/radio_picker";
|
|
63
64
|
import { useSelection } from "@lotics/ui/use_selection";
|
|
64
65
|
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
65
66
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
@@ -88,7 +89,7 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
|
88
89
|
// THE SECTION PATTERNS (what each is, when to use it):
|
|
89
90
|
// - General — the CORE FACTS: a headingless key-facts lead + named
|
|
90
91
|
// groups + Classification (the right-input-per-field
|
|
91
|
-
// showcase)
|
|
92
|
+
// showcase). Always present, first in rail.
|
|
92
93
|
// - Comments — the discussion thread. When people collaborate here.
|
|
93
94
|
// - Progress — WHERE the record sits: the desks as a `Pipeline`, each
|
|
94
95
|
// owning its own facts, the live one owning the act that
|
|
@@ -437,9 +438,21 @@ const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
|
|
437
438
|
|
|
438
439
|
// ── the FEES ledger — the DETAILED money view (both directions), distinct
|
|
439
440
|
// from Billing's invoice DOCUMENTS: every fee the record incurs or charges,
|
|
440
|
-
// with its party, due date, proof reference and paid state.
|
|
441
|
-
//
|
|
442
|
-
//
|
|
441
|
+
// with its party, due date, proof reference and paid state.
|
|
442
|
+
//
|
|
443
|
+
// The drill-down law for a CHILD COLLECTION inside a record: the row EXPANDS
|
|
444
|
+
// (`TableRow` `detail` + `expanded`), it does not open a drawer. A register
|
|
445
|
+
// screen's row still docks a workspace Drawer — on a list screen there is
|
|
446
|
+
// nowhere else for the detail to go — but here the record page IS the context
|
|
447
|
+
// the drawer was recreating, so docking one charged for navigation twice and
|
|
448
|
+
// took the surrounding rows away to show nine fields.
|
|
449
|
+
//
|
|
450
|
+
// This is also why the ledger is not simply WIDER. All nine fields as columns
|
|
451
|
+
// demand 946px + 112 gap + 40 gutter = 1098px against a 720px reading column;
|
|
452
|
+
// over budget `table_fit` drops droppable columns by priority until the rest
|
|
453
|
+
// fit, which at 720 leaves about the five below. Widening the register does not
|
|
454
|
+
// display VAT / Invoice no / Note — it drops them silently. And `note` is prose:
|
|
455
|
+
// it has no column width at any measure.
|
|
443
456
|
type FeeDirection = "charge" | "cost";
|
|
444
457
|
interface Fee {
|
|
445
458
|
id: string;
|
|
@@ -463,7 +476,7 @@ const FEE_SEED: Fee[] = [
|
|
|
463
476
|
];
|
|
464
477
|
// `priority` = the mobile contract: Party then Type drop first on a narrow
|
|
465
478
|
// container, Amount + Status survive with the identity; at the floor the rows
|
|
466
|
-
// STACK label-over-value — and the
|
|
479
|
+
// STACK label-over-value — and the expansion always carries the full fee.
|
|
467
480
|
const FEE_COLUMNS: TableColumn[] = [
|
|
468
481
|
{ key: "fee", label: "Fee", flex: 1 },
|
|
469
482
|
{ key: "type", label: "Type", width: 72, priority: 3 },
|
|
@@ -565,6 +578,23 @@ const SERVICE_LEVELS: PickerOption<string>[] = [
|
|
|
565
578
|
{ value: "economy", label: "Economy" },
|
|
566
579
|
{ value: "scheduled", label: "Scheduled" },
|
|
567
580
|
];
|
|
581
|
+
// Both of these were RadioPickers rendered INLINE on the record: 4 and 3 options
|
|
582
|
+
// permanently expanded, 232px and 156px against a 40px field grid — 49% of the
|
|
583
|
+
// Classification group, spent on values a reader almost never changes. A pick from a
|
|
584
|
+
// short list is the SAME SHAPE as Service level above, so it gets the same control;
|
|
585
|
+
// the gloss that justified the radio rides into the dropdown as `data.hint`, which is
|
|
586
|
+
// where a description is read anyway — while CHOOSING, not on every later read.
|
|
587
|
+
const FULFILMENT_STATUSES: PickerOption<string, { hint?: string }>[] = [
|
|
588
|
+
{ value: "ready", label: "Ready at the warehouse" },
|
|
589
|
+
{ value: "production", label: "In production", data: { hint: "Confirmed ready by the ship date." } },
|
|
590
|
+
{ value: "awaiting", label: "Awaiting inbound supply" },
|
|
591
|
+
{ value: "partial", label: "Partial", data: { hint: "Ships in two lots — the rest follows on the next run." } },
|
|
592
|
+
];
|
|
593
|
+
const INSURANCE_LEVELS: ChipOption<string>[] = [
|
|
594
|
+
{ value: "none", label: "None" },
|
|
595
|
+
{ value: "standard", label: "Standard" },
|
|
596
|
+
{ value: "all_risk", label: "All-risk" },
|
|
597
|
+
];
|
|
568
598
|
const SERVICE_LEVEL_DESC: Record<string, string> = {
|
|
569
599
|
standard: "Door to door on the regular schedule.",
|
|
570
600
|
express: "Priority handling — the first available departure.",
|
|
@@ -582,134 +612,6 @@ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
|
|
|
582
612
|
{ value: "dubai", label: "Dubai", data: { country: "United Arab Emirates" } },
|
|
583
613
|
];
|
|
584
614
|
|
|
585
|
-
/**
|
|
586
|
-
* A REFERENCE to another record, rendered as a FIELD VALUE.
|
|
587
|
-
*
|
|
588
|
-
* It wears the inline editor's resting surface — same 40px band, same zinc-50
|
|
589
|
-
* chip, same radius — so a pointer at another record sits in the value column
|
|
590
|
-
* exactly like the editors above and below it, rather than announcing itself as
|
|
591
|
-
* a different species of thing. (The chip's usual promise is "editable"; here it
|
|
592
|
-
* reads as "pressable", which is what `InlineSelect` and `InlineDatePicker`
|
|
593
|
-
* already mean by it — they open a popover too.)
|
|
594
|
-
*
|
|
595
|
-
* THREE acts, placed by how OFTEN they happen and how much they cost to undo:
|
|
596
|
-
* press the value → the facts, in a popover (free, so it's the whole surface)
|
|
597
|
-
* Open, inside → the record itself (the common act, so it stays in sight)
|
|
598
|
-
* Remove, in the peek → detach (destructive and rare, so it sits one layer in)
|
|
599
|
-
*
|
|
600
|
-
* Remove earns its depth twice over. It is the answer to the question the PEEK
|
|
601
|
-
* asks — "is this the right one?" — so the check and the correction are the same
|
|
602
|
-
* gesture; and a detach link repeated down a column of references is noise in the
|
|
603
|
-
* scan path for an act most readers never perform. Depth is not a hiding place
|
|
604
|
-
* here: pressing a field-shaped value is the first thing anyone tries, and the
|
|
605
|
-
* verb is plainly visible once open.
|
|
606
|
-
*
|
|
607
|
-
* The anatomy is the one a container-with-controls REQUIRES and that hand-rolling
|
|
608
|
-
* reliably gets wrong: the surface is a role-less, non-focusable `Pressable` (a
|
|
609
|
-
* button must never contain a button), an empty `PressDoor` sibling carries the
|
|
610
|
-
* tab stop / accessible name / focus ring, and the interior verb lifts above the
|
|
611
|
-
* door on `zIndex`. Hover is tracked on the container itself so the border stays
|
|
612
|
-
* lit while the pointer crosses Open — react-native-web otherwise releases the
|
|
613
|
-
* parent's hover to the innermost pressable and the edge flickers out.
|
|
614
|
-
*
|
|
615
|
-
* This is a TEMPLATE composition, not yet a kit primitive: it earns extraction on
|
|
616
|
-
* its second consumer, and that extraction is about this ANATOMY, not the layout.
|
|
617
|
-
*/
|
|
618
|
-
function ReferenceValue(props: {
|
|
619
|
-
name: string;
|
|
620
|
-
code?: string;
|
|
621
|
-
facts: { label: string; value: string }[];
|
|
622
|
-
/** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
|
|
623
|
-
accessibilityLabel: string;
|
|
624
|
-
onOpen: () => void;
|
|
625
|
-
openLabel: string;
|
|
626
|
-
/** Detach — omit where the link cannot be broken (a handoff's sibling record). */
|
|
627
|
-
onRemove?: () => void;
|
|
628
|
-
}) {
|
|
629
|
-
const { name, code, facts, accessibilityLabel, onOpen, openLabel, onRemove } = props;
|
|
630
|
-
const anchor = useRef<View>(null);
|
|
631
|
-
const [peekOpen, setPeekOpen] = useState(false);
|
|
632
|
-
return (
|
|
633
|
-
<Popover open={peekOpen} onOpenChange={setPeekOpen} triggerRef={anchor} side="bottom" align="start">
|
|
634
|
-
{/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
|
|
635
|
-
wearing copies of the field's border, radius, height and hover — and it
|
|
636
|
-
promptly fell out of step the moment the kit's field changed: it kept a
|
|
637
|
-
pointer cursor no editor has, and missed the hover TINT that every other
|
|
638
|
-
field gained. A copied surface always drifts; the fix is to stop copying.
|
|
639
|
-
|
|
640
|
-
`InlineEditView` gives all of it: the surface and its states, the verb as
|
|
641
|
-
a SIBLING of the press target (a button must not contain a button), hover
|
|
642
|
-
tracked on the box so the border holds while the pointer crosses the
|
|
643
|
-
verb, and focus-within. `anchorRef` hands the popover the FIELD's box —
|
|
644
|
-
the press target inside is narrower, and a peek anchored there would
|
|
645
|
-
open misaligned with the row it describes.
|
|
646
|
-
|
|
647
|
-
OPEN, never toggle: the press target and the popover's own dismissal
|
|
648
|
-
would otherwise fight, and idempotent `true` makes the pair safe. The
|
|
649
|
-
popover closes on outside press / Escape. */}
|
|
650
|
-
<InlineEditView
|
|
651
|
-
anchorRef={anchor}
|
|
652
|
-
accessibilityLabel={accessibilityLabel}
|
|
653
|
-
onPress={() => setPeekOpen(true)}
|
|
654
|
-
active={peekOpen}
|
|
655
|
-
display={
|
|
656
|
-
<View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
657
|
-
<Text size="sm" numberOfLines={1}>{name}</Text>
|
|
658
|
-
{code ? <Text size="sm" color="muted" numberOfLines={1}>{code}</Text> : null}
|
|
659
|
-
</View>
|
|
660
|
-
}
|
|
661
|
-
actions={
|
|
662
|
-
/* Dismiss the peek on the way out: the verb sits inside the popover's
|
|
663
|
-
own trigger and stops propagation, so nothing else will close it —
|
|
664
|
-
and a summary left hanging over the record you just opened describes
|
|
665
|
-
what you are now looking at. */
|
|
666
|
-
<InlineButton title="Open" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
|
|
667
|
-
}
|
|
668
|
-
/>
|
|
669
|
-
{/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. A popover
|
|
670
|
-
is dialog-scale, so the identity takes the ramp's dialog rung
|
|
671
|
-
(`DialogSectionHeadingTitle`, ####) with the code as its description
|
|
672
|
-
rather than a hand-picked font weight; the facts are `DetailRow`s, which
|
|
673
|
-
is what label-beside-value IS everywhere else on this page; and the
|
|
674
|
-
destructive verb is fenced off by a `Divider` instead of floating after
|
|
675
|
-
the last fact. Width matches `Peek`'s own content width so every peek in
|
|
676
|
-
an app is the same object.
|
|
677
|
-
`labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
|
|
678
|
-
STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
|
|
679
|
-
page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
|
|
680
|
-
the summary side-by-side, which is the whole point of a glance. */}
|
|
681
|
-
<PopoverContent style={{ width: 320 }} disableBodyScroll>
|
|
682
|
-
<View style={{ gap: 12 }}>
|
|
683
|
-
<DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
|
|
684
|
-
<DetailTable labelWidth={88}>
|
|
685
|
-
{facts.map((f) => (
|
|
686
|
-
<DetailRow key={f.label} label={f.label} flat>
|
|
687
|
-
<InlineStatic value={f.value || "—"} />
|
|
688
|
-
</DetailRow>
|
|
689
|
-
))}
|
|
690
|
-
</DetailTable>
|
|
691
|
-
{/* THE FOOTER — destructive LEFT, go-to RIGHT, the kit's convention.
|
|
692
|
-
Open repeats the verb on the surface deliberately: the popover now
|
|
693
|
-
covers the button that opened it, and having read the facts, "take me
|
|
694
|
-
there" is the next move. That is the shape `Peek` prescribes — a
|
|
695
|
-
summary with one action to the full record. Remove closes the peek
|
|
696
|
-
first, because the surface it is anchored to is about to stop
|
|
697
|
-
existing. */}
|
|
698
|
-
<Divider />
|
|
699
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
700
|
-
{onRemove ? (
|
|
701
|
-
// No icon: an ✕ beside "Remove" says it twice, and the glyph alone
|
|
702
|
-
// made this read as the popover's dismiss rather than a decision.
|
|
703
|
-
<Button title="Remove" color="danger" onPress={() => { setPeekOpen(false); onRemove(); }} />
|
|
704
|
-
) : null}
|
|
705
|
-
<View style={{ flex: 1 }} />
|
|
706
|
-
<Button title="Open" color="secondary" onPress={() => { setPeekOpen(false); onOpen(); }} />
|
|
707
|
-
</View>
|
|
708
|
-
</View>
|
|
709
|
-
</PopoverContent>
|
|
710
|
-
</Popover>
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
615
|
|
|
714
616
|
/** One stop on a route. `place` + when the movement is expected there; the ROLE
|
|
715
617
|
* (origin, via, destination) is DERIVED from position, never stored — a stored
|
|
@@ -783,8 +685,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
|
|
|
783
685
|
<View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
|
|
784
686
|
{/* A stop is added BEFORE the destination — a new leg is always in the
|
|
785
687
|
middle of a journey; nobody adds one past the end. */}
|
|
786
|
-
<
|
|
787
|
-
size="sm"
|
|
688
|
+
<TextButton
|
|
788
689
|
onPress={() =>
|
|
789
690
|
onChange([
|
|
790
691
|
...stops.slice(0, -1),
|
|
@@ -794,7 +695,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
|
|
|
794
695
|
}
|
|
795
696
|
>
|
|
796
697
|
Add stop
|
|
797
|
-
</
|
|
698
|
+
</TextButton>
|
|
798
699
|
</View>
|
|
799
700
|
</View>
|
|
800
701
|
);
|
|
@@ -815,7 +716,7 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }:
|
|
|
815
716
|
return (
|
|
816
717
|
<DetailRow label={role}>
|
|
817
718
|
{rec ? (
|
|
818
|
-
<
|
|
719
|
+
<ReferenceField
|
|
819
720
|
name={rec.name}
|
|
820
721
|
code={rec.code}
|
|
821
722
|
facts={[
|
|
@@ -838,7 +739,21 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }:
|
|
|
838
739
|
);
|
|
839
740
|
}
|
|
840
741
|
|
|
841
|
-
|
|
742
|
+
/** `page` (default) = the record's own screen: an outline rail in the left gutter
|
|
743
|
+
* and the discussion docked in the right. `drawer` = the same CONTENT inside a
|
|
744
|
+
* register's workspace `Drawer`, which owns its own header, close and sequencing.
|
|
745
|
+
*
|
|
746
|
+
* It is a MODE, not a second component, and that is the point: a register's
|
|
747
|
+
* drawer showing a COPY of this surface is how the two drifted apart the first
|
|
748
|
+
* time — a checklist here, a pipeline there. Rendering one component in two
|
|
749
|
+
* chromes makes them identical by construction.
|
|
750
|
+
*
|
|
751
|
+
* Most of the difference needs no flag: at a drawer's width `wide` is already
|
|
752
|
+
* false, so the rail, the panel and the mirrored gutters collapse on their own.
|
|
753
|
+
* What the flag decides is what a nested surface must NOT do — open comments in
|
|
754
|
+
* a Drawer inside a Drawer, or draw a back button over the one the Drawer has. */
|
|
755
|
+
export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?: "page" | "drawer"; code?: string } = {}) {
|
|
756
|
+
const isDrawer = chrome === "drawer";
|
|
842
757
|
const id = useRef(1);
|
|
843
758
|
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
844
759
|
const { small } = useScreenSize();
|
|
@@ -870,9 +785,13 @@ export function TplRecord() {
|
|
|
870
785
|
// ── the audit trail — real actions on this surface append to it. An entry
|
|
871
786
|
// DRILLS DOWN via the Timeline `details` chevron: a field change carries its
|
|
872
787
|
// From → To diff, a document entry the pressable file, an issue its amount.
|
|
873
|
-
// ── the fees ledger — register
|
|
874
|
-
//
|
|
788
|
+
// ── the fees ledger — a register whose rows EXPAND (create-then-refine: Add
|
|
789
|
+
// appends a blank fee already open; every field refines in the expansion)
|
|
875
790
|
const [fees, setFees] = useState<Fee[]>(FEE_SEED);
|
|
791
|
+
// Which fee is expanded — ONE at a time, the caller's rule (`TableRow.expanded`
|
|
792
|
+
// is controlled precisely so this is a decision and not a default). Two open
|
|
793
|
+
// 380px details would push the third row off screen, and a register that can't
|
|
794
|
+
// be scanned has stopped being a register.
|
|
876
795
|
const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
|
|
877
796
|
const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
|
|
878
797
|
const addFee = () => {
|
|
@@ -880,8 +799,6 @@ export function TplRecord() {
|
|
|
880
799
|
setFees((prev) => [...prev, f]);
|
|
881
800
|
setFeeView({ kind: "edit", id: f.id });
|
|
882
801
|
};
|
|
883
|
-
const openFee = feeView ? fees.find((f) => f.id === feeView.id) ?? null : null;
|
|
884
|
-
const feeIdx = openFee ? fees.findIndex((f) => f.id === openFee.id) : -1;
|
|
885
802
|
const deleteFee = (f: Fee) => {
|
|
886
803
|
Alert.alert(`Remove ${f.name || "this fee"}?`, "The fee comes off the record's ledger. This can't be undone.", [
|
|
887
804
|
{ text: "Cancel", style: "cancel" },
|
|
@@ -896,7 +813,6 @@ export function TplRecord() {
|
|
|
896
813
|
: { text: "Unpaid", danger: false };
|
|
897
814
|
|
|
898
815
|
// ── the record — seeded mid-flight so every state is visible
|
|
899
|
-
const code = "RC-2026-0418";
|
|
900
816
|
const [stage, setStage] = useState<Stage>("sales");
|
|
901
817
|
// The customer book is STATE: the attached customer's tax ID edits inline in
|
|
902
818
|
// the Customer section (that's what un-gates invoicing).
|
|
@@ -1357,8 +1273,6 @@ export function TplRecord() {
|
|
|
1357
1273
|
const [handoffTo, setHandoffTo] = useState<Desk>("operations");
|
|
1358
1274
|
const [handoffAssignee, setHandoffAssignee] = useState<string | null>(null);
|
|
1359
1275
|
const [handoffNote, setHandoffNote] = useState("");
|
|
1360
|
-
// ── the customer detail drawer (the linked record's full info)
|
|
1361
|
-
const [customerOpen, setCustomerOpen] = useState(false);
|
|
1362
1276
|
// Confirm MARKS the handoff — whom, where, the note.
|
|
1363
1277
|
//
|
|
1364
1278
|
// It commits what the DIALOG captured (`handoffTo`), never what `stage` implies
|
|
@@ -1395,21 +1309,81 @@ export function TplRecord() {
|
|
|
1395
1309
|
// GENERAL leads the rail — the item's core details (the customer among them,
|
|
1396
1310
|
// since a reference is a field); everything after it is supplementary
|
|
1397
1311
|
// (discussion, work, documents, money, lifecycle).
|
|
1312
|
+
//
|
|
1313
|
+
// THE SECTION NAV IS HAND-ROLLED ON PURPOSE, for now. `useSectionNav` is the kit
|
|
1314
|
+
// half — the scroll-spy, `activeKey`, `jumpTo`, unit-tested RN-free — and the
|
|
1315
|
+
// CHROME below is this template's. It has exactly one consumer, and the kit's bar
|
|
1316
|
+
// is the SECOND hand-roll, so lifting it today would ship an abstraction shaped by
|
|
1317
|
+
// a single surface. `ReferenceField` sat here under this same note until a
|
|
1318
|
+
// register's drawer needed it, and that is what let the extraction land on the
|
|
1319
|
+
// PEEK contract instead of on the layout.
|
|
1320
|
+
//
|
|
1321
|
+
// When a second surface wants this, the contract worth lifting is: one section
|
|
1322
|
+
// list, rendered as a RAIL where the container affords a gutter and as a pinned
|
|
1323
|
+
// CURRENT-SECTION bar where it does not, the switch reading the CONTAINER (never
|
|
1324
|
+
// the window); the picker anchored to the control that opens it, never a Modal
|
|
1325
|
+
// that escapes its container; and the per-section attention dot, which is what
|
|
1326
|
+
// makes the rail answer "where is the problem" and not merely "where am I".
|
|
1327
|
+
//
|
|
1328
|
+
// What is NOT the contract, and would poison it if lifted along: the issue-tone
|
|
1329
|
+
// MAP below (its keys are this record's domain), the comments toggle (drawer
|
|
1330
|
+
// chrome), the BackButton (page chrome), the gutter arithmetic (28 page / 20
|
|
1331
|
+
// drawer, minus the trigger's padding) and the `wide` threshold. One consumer
|
|
1332
|
+
// cannot tell those apart from the contract — two can.
|
|
1398
1333
|
const SECTIONS = [
|
|
1399
1334
|
{ key: "general", label: "General", icon: "file-text" },
|
|
1335
|
+
// Files sits SECOND: what arrived on the record is read before what the
|
|
1336
|
+
// record is doing, and it puts the intake affordance near the top of the
|
|
1337
|
+
// scroll instead of eight sections down.
|
|
1338
|
+
{ key: "files", label: "Files", icon: "folder-closed" },
|
|
1400
1339
|
{ key: "transport", label: "Transport", icon: "map-pin" },
|
|
1401
1340
|
{ key: "progress", label: "Progress", icon: "git-branch" },
|
|
1402
|
-
{ key: "documents", label: "Documents", icon: "folder-closed" },
|
|
1403
1341
|
{ key: "fees", label: "Fees", icon: "receipt" },
|
|
1404
1342
|
{ key: "billing", label: "Billing", icon: "credit-card" },
|
|
1405
|
-
{ key: "docset", label: "
|
|
1343
|
+
{ key: "docset", label: "Documents", icon: "file-stack" },
|
|
1406
1344
|
{ key: "receipt", label: "Delivery receipt", icon: "log-in" },
|
|
1407
1345
|
{ key: "danger", label: "Danger zone", icon: "circle-alert" },
|
|
1408
1346
|
] as const;
|
|
1409
|
-
const nav = useSectionNav(["general", "
|
|
1347
|
+
const nav = useSectionNav(["general", "files", "transport", "progress", "fees", "billing", "docset", "receipt", "danger"] as const);
|
|
1348
|
+
// ONE record, ONE page. A section is a place you SCROLL to, never a
|
|
1349
|
+
// destination you swap to: routing a record was tried here and lost, because
|
|
1350
|
+
// every fix it needed rebuilt the whole-record view in miniature — a dot to
|
|
1351
|
+
// say which hidden section changed, a header action to re-surface an
|
|
1352
|
+
// affordance a section had swallowed, an "all" route that is just this page.
|
|
1353
|
+
// The tax it charges is real and permanent (browser find reaches only the
|
|
1354
|
+
// open section, two sections can never be read together, a cross-section
|
|
1355
|
+
// check costs a click each way); the length it fixes is a DENSITY problem,
|
|
1356
|
+
// and density is cuttable. So: cut the sections, keep the page.
|
|
1357
|
+
type SecKey = (typeof SECTIONS)[number]["key"];
|
|
1358
|
+
const activeSection = nav.activeKey;
|
|
1359
|
+
// WHICH sections have something wrong — one map, read by the rail. Each section
|
|
1360
|
+
// still states the condition itself beside the fields it concerns; this only says
|
|
1361
|
+
// where to look, which is the job the removed banner was doing badly.
|
|
1362
|
+
// ONE clock per render. Two rows each calling `new Date()` can land on
|
|
1363
|
+
// opposite sides of midnight and disagree about what "today" is.
|
|
1364
|
+
const now = new Date();
|
|
1365
|
+
const words = useLoticsLocale();
|
|
1366
|
+
// The countdown for any date field on this record: "2 days overdue" / "Today"
|
|
1367
|
+
// / "5 days left", routed to the annotation slot its urgency earns
|
|
1368
|
+
// (error / warning / description) — @lotics/ui/deadline owns the wording and
|
|
1369
|
+
// the thresholds so this screen cannot disagree with the register beside it.
|
|
1370
|
+
const dueAnnotation = (iso: string, open: boolean) =>
|
|
1371
|
+
open && iso !== "" ? deadlineAnnotation(daysUntil(new Date(iso), now), words.deadline) : {};
|
|
1372
|
+
const overdue = stage !== "closed" && deliverBy !== "" && daysUntil(new Date(deliverBy), now) < 0;
|
|
1373
|
+
const sectionIssue: Partial<Record<SecKey, "warning" | "danger">> = {
|
|
1374
|
+
general: overdue ? "warning" : undefined,
|
|
1375
|
+
transport: scheduleNote !== "on-time" ? "warning" : undefined,
|
|
1376
|
+
billing: customer === null ? "warning" : undefined,
|
|
1377
|
+
};
|
|
1410
1378
|
// Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
|
|
1411
1379
|
// it opens a full-page section picker (Escape / the close control dismiss).
|
|
1412
1380
|
const [sectionsOpen, setSectionsOpen] = useState(false);
|
|
1381
|
+
// The picker anchors to the BAR BUTTON, so it is scoped to whatever container
|
|
1382
|
+
// that button sits in — the page, or a drawer. A Modal escaped the container
|
|
1383
|
+
// and took the whole window, which inside a drawer covered the register behind
|
|
1384
|
+
// it. `triggerRef` (not `PopoverTrigger`) because the trigger IS a Button and a
|
|
1385
|
+
// button must not nest in another pressable.
|
|
1386
|
+
const sectionsAnchor = useRef<View>(null);
|
|
1413
1387
|
const [pageWidth, setPageWidth] = useState<number | null>(null);
|
|
1414
1388
|
// THREE COLUMNS: the outline rail in the left gutter, the record, and the
|
|
1415
1389
|
// discussion panel in the right. The right gutter used to be empty — reserved
|
|
@@ -1421,6 +1395,11 @@ export function TplRecord() {
|
|
|
1421
1395
|
// on the RIGHT, opposite the section picker, since it is about the record as a
|
|
1422
1396
|
// whole rather than a place in it.
|
|
1423
1397
|
const [commentsOpen, setCommentsOpen] = useState(false);
|
|
1398
|
+
// Drawer chrome switches the BODY between the record and the discussion. As a
|
|
1399
|
+
// section it competed with nine others for one scroll — first it pushed the
|
|
1400
|
+
// record down, last it sat 7,466px from the top. They are two things a reader
|
|
1401
|
+
// alternates between, so the bar names both and the body shows one.
|
|
1402
|
+
const [commentsView, setCommentsView] = useState(false);
|
|
1424
1403
|
// The rail floats over its RESERVED gutter rather than sitting in the scroll
|
|
1425
1404
|
// row, so the ScrollView keeps the FULL page width — wheel anywhere and the
|
|
1426
1405
|
// record scrolls. (Capping the ScrollView at the column's width instead makes
|
|
@@ -1438,31 +1417,88 @@ export function TplRecord() {
|
|
|
1438
1417
|
// Narrow: a pinned bar names the CURRENT section (the spy keeps it
|
|
1439
1418
|
// honest while scrolling); tapping opens the full-page section picker —
|
|
1440
1419
|
// no tab strip the thumb has to scroll.
|
|
1441
|
-
|
|
1420
|
+
// 18, not 12: the bar's gutter is derived from the CONTENT's (28) minus the
|
|
1421
|
+
// trigger's own horizontal padding (10), so the section NAME starts on the
|
|
1422
|
+
// same left edge as every section title below it. A fill-less button shows
|
|
1423
|
+
// no box, so its ink is what the eye lines up — 18 + 10 = 28. Set the bar,
|
|
1424
|
+
// not a negative margin on the button: the gutter belongs to the bar, and
|
|
1425
|
+
// one number here cannot drift out of step with itself.
|
|
1426
|
+
<View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ? 20 : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
1442
1427
|
{/* narrow keeps the standard too — the bare back glyph beside the
|
|
1443
|
-
section bar (the rail that would carry it is collapsed).
|
|
1444
|
-
|
|
1445
|
-
<
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1428
|
+
section bar (the rail that would carry it is collapsed). A DRAWER
|
|
1429
|
+
already has one in its own header; two would be two exits. */}
|
|
1430
|
+
{isDrawer ? null : <BackButton onPress={() => {}} />}
|
|
1431
|
+
<Popover open={sectionsOpen} onOpenChange={setSectionsOpen} triggerRef={sectionsAnchor} side="bottom" align="start">
|
|
1432
|
+
<View ref={sectionsAnchor}>
|
|
1433
|
+
{/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
|
|
1434
|
+
position readout that happens to be pressable, and a filled chip
|
|
1435
|
+
competes with the record's own actions for the eye. The usual cost
|
|
1436
|
+
of a fill-less button does not apply here — it is chrome in a bar
|
|
1437
|
+
with its own gutter, not a verb aligned to a column of text, so
|
|
1438
|
+
there is nothing below it for its ink to line up with. The glyph
|
|
1439
|
+
carries the affordance: `list-collapse` says WHAT the list is,
|
|
1440
|
+
where `Switcher` uses a chevron for the same shape. */}
|
|
1441
|
+
<Button
|
|
1442
|
+
title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
|
|
1443
|
+
color="muted"
|
|
1444
|
+
icon="list-collapse"
|
|
1445
|
+
onPress={() => setSectionsOpen(true)}
|
|
1446
|
+
/>
|
|
1447
|
+
</View>
|
|
1448
|
+
<PopoverContent style={{ width: 248 }} disableBodyScroll>
|
|
1449
|
+
<View style={{ gap: 2 }}>
|
|
1450
|
+
{SECTIONS.map((sec) => (
|
|
1451
|
+
<MenuButton
|
|
1452
|
+
key={sec.key}
|
|
1453
|
+
icon={sec.icon}
|
|
1454
|
+
title={sec.label}
|
|
1455
|
+
current={nav.activeKey === sec.key}
|
|
1456
|
+
onPress={() => {
|
|
1457
|
+
setSectionsOpen(false);
|
|
1458
|
+
// Leaving comments is part of jumping: in comments view the
|
|
1459
|
+
// sections are not rendered, so a jump with no exit was a
|
|
1460
|
+
// control that did nothing.
|
|
1461
|
+
setCommentsView(false);
|
|
1462
|
+
nav.jumpTo(sec.key);
|
|
1463
|
+
}}
|
|
1464
|
+
/>
|
|
1465
|
+
))}
|
|
1466
|
+
</View>
|
|
1467
|
+
</PopoverContent>
|
|
1468
|
+
</Popover>
|
|
1451
1469
|
<View style={{ flex: 1 }} />
|
|
1452
1470
|
{/* WHERE it sits carries the meaning: sections on the left (where you
|
|
1453
1471
|
ARE), the discussion on the right (what is being SAID) — the same
|
|
1454
1472
|
two poles the wide layout puts in its two gutters. */}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1473
|
+
{/* A LABEL with its count, not a bare glyph: this is one of the two
|
|
1474
|
+
things the body can show, so it has to say which — and how much is
|
|
1475
|
+
waiting, which is what decides whether you look now. In drawer chrome
|
|
1476
|
+
it toggles the body; on a narrow page it still opens the sheet, which
|
|
1477
|
+
is that layout's panel. The pair reads as a switch: whichever side the
|
|
1478
|
+
body is showing wears the filled `secondary`. */}
|
|
1479
|
+
{isDrawer ? (
|
|
1480
|
+
<Button
|
|
1481
|
+
title={commentsView ? "Close comments" : `Comments (${comments.length})`}
|
|
1482
|
+
color="muted"
|
|
1483
|
+
icon="message-square"
|
|
1484
|
+
onPress={() => setCommentsView((v) => !v)}
|
|
1485
|
+
/>
|
|
1486
|
+
) : (
|
|
1487
|
+
<Button
|
|
1488
|
+
title={`Comments (${comments.length})`}
|
|
1489
|
+
color="muted"
|
|
1490
|
+
icon="message-square"
|
|
1491
|
+
onPress={() => setCommentsOpen(true)}
|
|
1492
|
+
/>
|
|
1493
|
+
)}
|
|
1461
1494
|
</View>
|
|
1462
1495
|
)}
|
|
1463
1496
|
<View style={{ flex: 1 }}>
|
|
1464
1497
|
{/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
|
|
1465
|
-
|
|
1498
|
+
{/* The gutter is CHROME's, not the record's: 28 is the page canvas, 20 is what
|
|
1499
|
+
a `Drawer` pads its header with — so in a drawer the content lines up with
|
|
1500
|
+
the record NAME in the header above it instead of sitting 8px inside it. */}
|
|
1501
|
+
<ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: isDrawer ? 20 : 28, paddingBottom: 96 }}>
|
|
1466
1502
|
{/* The centred group: a gutter RESERVED for the rail, the reading column,
|
|
1467
1503
|
and an equal empty gutter that balances it — so the column's centre is
|
|
1468
1504
|
the window's centre. Both gutters live INSIDE the scroller, so they
|
|
@@ -1500,32 +1536,82 @@ export function TplRecord() {
|
|
|
1500
1536
|
{/* the page column is a SectionStack — it owns the 56px beat + the
|
|
1501
1537
|
hairline BETWEEN top-level blocks (a conditional block that renders
|
|
1502
1538
|
null never leaves a stray divider) */}
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1539
|
+
{/* Routed shows ONE section, so there is nothing to separate — and the
|
|
1540
|
+
hairlines are owned by SectionStack, which draws one per non-null child.
|
|
1541
|
+
Hidden-but-mounted children still count, so leaving them on would stack a
|
|
1542
|
+
divider above the section for every section before it. */}
|
|
1543
|
+
{/* The header band sits ABOVE the stack, on the canvas beat — it is the
|
|
1544
|
+
record's IDENTITY, not a section, so the 56px between-sections rhythm
|
|
1545
|
+
(and, in scroll mode, the hairline that comes with it) does not apply.
|
|
1546
|
+
Every condition it used to announce now lives on the field it concerns,
|
|
1547
|
+
with a dot on the rail saying which section to look in. */}
|
|
1548
|
+
<View style={{ flex: 1, minWidth: 0, gap: 28 }}>
|
|
1549
|
+
{/* The record's own ACTS live here, beside its identity — not inside a
|
|
1550
|
+
section. Dropping a file is something you do TO THE RECORD, but its
|
|
1551
|
+
only affordance used to sit in the Files section, which scoped the
|
|
1552
|
+
discoverability to that section: from anywhere else on the page
|
|
1553
|
+
(or from 3,975px above it) nothing said the record took files at
|
|
1554
|
+
all, and a whole-record drop target nobody can see is an
|
|
1555
|
+
accelerator, never an entry point. The Files register keeps its own
|
|
1556
|
+
Add — a register's Add belongs under its register — this is the
|
|
1557
|
+
record-level way in. A template composition for now; it earns a kit
|
|
1558
|
+
slot on RecordSummary at its second consumer. */}
|
|
1559
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
|
|
1560
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
1561
|
+
<RecordSummary
|
|
1562
|
+
title={code}
|
|
1563
|
+
subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(", ") : "No customer"}
|
|
1564
|
+
/>
|
|
1565
|
+
</View>
|
|
1566
|
+
</View>
|
|
1567
|
+
{isDrawer && commentsView ? (
|
|
1568
|
+
<View style={{ gap: 28 }}>
|
|
1569
|
+
<Section>
|
|
1570
|
+
<SectionHeading>
|
|
1571
|
+
<SectionHeadingTitle description="Notes on this record — everyone with access sees them.">Comments</SectionHeadingTitle>
|
|
1572
|
+
<SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
|
|
1573
|
+
</SectionHeading>
|
|
1574
|
+
<CommentList
|
|
1575
|
+
comments={comments}
|
|
1576
|
+
currentMemberId="mem_01"
|
|
1577
|
+
resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
|
|
1578
|
+
onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
|
|
1579
|
+
onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
|
|
1580
|
+
/* the file-capable edit form — the same injection the product uses */
|
|
1581
|
+
renderEditForm={(p) => <CommentFileEditForm {...p} />}
|
|
1582
|
+
/* attachments render as the SAME thumbnail grid the product uses —
|
|
1583
|
+
press previews in the gallery (the desk's preview state) */
|
|
1584
|
+
renderFiles={(files) => (
|
|
1585
|
+
<FileGrid
|
|
1586
|
+
files={files.map(commentFileToDisplay)}
|
|
1587
|
+
itemSize={84}
|
|
1588
|
+
onFilePress={(f) => setPreview({ files: [f], index: 0 })}
|
|
1589
|
+
/>
|
|
1590
|
+
)}
|
|
1591
|
+
/* compact timestamp — the product formats RELATIVE ("2 days ago",
|
|
1592
|
+
date-fns); a template stays dependency-free and deterministic */
|
|
1593
|
+
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" })}`; }}
|
|
1512
1594
|
/>
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
{
|
|
1526
|
-
|
|
1595
|
+
{/* The pane the toggle switches TO has to be the WHOLE discussion, not
|
|
1596
|
+
half of it: reading without a reply is a dead end, and this is the
|
|
1597
|
+
only surface where the thread has no panel or sheet to fall back on. */}
|
|
1598
|
+
<Composer
|
|
1599
|
+
onSend={(content) => {
|
|
1600
|
+
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() }]);
|
|
1601
|
+
setPendingFiles([]);
|
|
1602
|
+
}}
|
|
1603
|
+
placeholder="Write a comment…"
|
|
1604
|
+
sendLabel="Send"
|
|
1605
|
+
actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
|
|
1606
|
+
files={pendingFiles.length > 0 ? (
|
|
1607
|
+
<FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
|
|
1608
|
+
) : undefined}
|
|
1527
1609
|
/>
|
|
1528
|
-
</
|
|
1610
|
+
</Section>
|
|
1611
|
+
</View>
|
|
1612
|
+
) : (
|
|
1613
|
+
<SectionStack>
|
|
1614
|
+
|
|
1529
1615
|
|
|
1530
1616
|
{/* GENERAL — the MAIN details, FIRST section: the core facts of the
|
|
1531
1617
|
item; every other section is supplementary. The body is a
|
|
@@ -1536,6 +1622,8 @@ export function TplRecord() {
|
|
|
1536
1622
|
the grid stays aligned through the groups; read-only values are
|
|
1537
1623
|
InlineStatic in the same column, aligned to the pixel. */}
|
|
1538
1624
|
<View onLayout={nav.register("general")}>
|
|
1625
|
+
{/* the record's own overdue state — co-located with the Due field it reads,
|
|
1626
|
+
never as a banner above every section */}
|
|
1539
1627
|
<Section>
|
|
1540
1628
|
<SectionHeading>
|
|
1541
1629
|
<SectionHeadingTitle description="The core facts of the order — what every desk reads first.">General</SectionHeadingTitle>
|
|
@@ -1552,7 +1640,7 @@ export function TplRecord() {
|
|
|
1552
1640
|
its picker, and its annotation. So it sits among the order's
|
|
1553
1641
|
other core facts, where a reader looks for the bill-to party.
|
|
1554
1642
|
|
|
1555
|
-
The value is a `
|
|
1643
|
+
The value is a `ReferenceField` (the kit primitive): the inline editor's
|
|
1556
1644
|
own resting surface, so the reference sits in the value column
|
|
1557
1645
|
like every editor around it rather than as a card that
|
|
1558
1646
|
outweighs the fields it sits among. Its three verbs are three
|
|
@@ -1564,7 +1652,7 @@ export function TplRecord() {
|
|
|
1564
1652
|
warning={customer && !taxIdValid ? "No valid tax ID — invoicing stays blocked until this customer carries one." : undefined}
|
|
1565
1653
|
>
|
|
1566
1654
|
{customer ? (
|
|
1567
|
-
<
|
|
1655
|
+
<ReferenceField
|
|
1568
1656
|
name={customer.name}
|
|
1569
1657
|
code={customer.code}
|
|
1570
1658
|
facts={[
|
|
@@ -1574,7 +1662,13 @@ export function TplRecord() {
|
|
|
1574
1662
|
]}
|
|
1575
1663
|
accessibilityLabel={`${customer.name} — details`}
|
|
1576
1664
|
openLabel="Open customer"
|
|
1577
|
-
|
|
1665
|
+
/* A real app navigates to the customer's OWN record page —
|
|
1666
|
+
same as Ship to / Notify / Carrier below. This used to
|
|
1667
|
+
open a local drawer that re-showed the five facts the
|
|
1668
|
+
peek had just shown, so "Open" resolved to "read that
|
|
1669
|
+
again, wider" and the customer's record was unreachable
|
|
1670
|
+
from the one control that promised it. */
|
|
1671
|
+
onOpen={() => {}}
|
|
1578
1672
|
onRemove={() => setCustomerId(null)}
|
|
1579
1673
|
/>
|
|
1580
1674
|
) : (
|
|
@@ -1618,7 +1712,11 @@ export function TplRecord() {
|
|
|
1618
1712
|
<DetailRow label="Opened">
|
|
1619
1713
|
<InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
|
|
1620
1714
|
</DetailRow>
|
|
1621
|
-
|
|
1715
|
+
{/* The countdown ANNOTATES the date it is about — the worked
|
|
1716
|
+
example for @lotics/ui/deadline. It replaced a hand-rolled
|
|
1717
|
+
`error={overdue ? … }`, which was binary: a date due
|
|
1718
|
+
tomorrow read exactly like one due in three months. */}
|
|
1719
|
+
<DetailRow label="Due" {...dueAnnotation(deliverBy, stage !== "closed")}>
|
|
1622
1720
|
<InlineDatePicker value={deliverBy} onSave={persist(setDeliverBy)} locale="en-US" accessibilityLabel="Due date" placeholder="Set a due date…" />
|
|
1623
1721
|
</DetailRow>
|
|
1624
1722
|
{/* a LONG description — it wraps in the value column, the
|
|
@@ -1703,8 +1801,8 @@ export function TplRecord() {
|
|
|
1703
1801
|
</Subsection>
|
|
1704
1802
|
{/* CLASSIFICATION — the right-input-per-field law, worked. A field
|
|
1705
1803
|
gets the control its SHAPE wants, not a default text box:
|
|
1706
|
-
,
|
|
1707
|
-
|
|
1804
|
+
, 2-3 SHORT options switched often → ChipGroup (every choice
|
|
1805
|
+
visible, switching is ONE press, still one row)
|
|
1708
1806
|
, a longer closed list → InlineSelect (rich options carry a
|
|
1709
1807
|
description line — it shows in the resting row too)
|
|
1710
1808
|
, a pick from a big REGISTRY → InlineSelect searchable (the
|
|
@@ -1741,28 +1839,26 @@ export function TplRecord() {
|
|
|
1741
1839
|
/>
|
|
1742
1840
|
</DetailRow>
|
|
1743
1841
|
<DetailRow label="Fulfilment status">
|
|
1744
|
-
<
|
|
1745
|
-
accessibilityLabel="Fulfilment status"
|
|
1842
|
+
<InlineSelect
|
|
1746
1843
|
value={fulfilmentStatus}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
{
|
|
1752
|
-
|
|
1753
|
-
|
|
1844
|
+
options={FULFILMENT_STATUSES}
|
|
1845
|
+
onSave={persist(setFulfilmentStatus)}
|
|
1846
|
+
accessibilityLabel="Fulfilment status"
|
|
1847
|
+
renderOptionContent={(o) => (
|
|
1848
|
+
<View style={{ gap: 1, flexShrink: 1 }}>
|
|
1849
|
+
<Text size="sm">{o.label}</Text>
|
|
1850
|
+
{o.data?.hint ? <Text size="xs" color="muted">{o.data.hint}</Text> : null}
|
|
1851
|
+
</View>
|
|
1852
|
+
)}
|
|
1853
|
+
renderSelected={(o) => <Text size="sm">{o.label}</Text>}
|
|
1754
1854
|
/>
|
|
1755
1855
|
</DetailRow>
|
|
1756
1856
|
<DetailRow label="Insurance">
|
|
1757
|
-
<
|
|
1857
|
+
<ChipGroup
|
|
1758
1858
|
accessibilityLabel="Insurance"
|
|
1759
1859
|
value={insurance}
|
|
1760
1860
|
onValueChange={setInsurance}
|
|
1761
|
-
options={
|
|
1762
|
-
{ value: "none", label: "None" },
|
|
1763
|
-
{ value: "standard", label: "Standard" },
|
|
1764
|
-
{ value: "all_risk", label: "All-risk" },
|
|
1765
|
-
]}
|
|
1861
|
+
options={INSURANCE_LEVELS}
|
|
1766
1862
|
/>
|
|
1767
1863
|
</DetailRow>
|
|
1768
1864
|
{insurance !== "none" ? (
|
|
@@ -1814,21 +1910,87 @@ export function TplRecord() {
|
|
|
1814
1910
|
) : null}
|
|
1815
1911
|
</DetailTable>
|
|
1816
1912
|
</Subsection>
|
|
1817
|
-
{/*
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
</DetailRow>
|
|
1826
|
-
</DetailTable>
|
|
1827
|
-
</Subsection>
|
|
1913
|
+
{/* No System group. It held ONE row — "Record ID", printing the
|
|
1914
|
+
value already set in xxl semibold at the top of the page — and
|
|
1915
|
+
spent a subsection heading plus 193px restating the title. A
|
|
1916
|
+
system identifier the record is NAMED BY needs no field: the
|
|
1917
|
+
identity band IS its display, and copying it belongs to that
|
|
1918
|
+
band, not to a row 1,400px below. Genuine system metadata
|
|
1919
|
+
(created, last updated) is audit data and reads in the
|
|
1920
|
+
Activity timeline, where it has a time axis to sit on. */}
|
|
1828
1921
|
</SubsectionStack>
|
|
1829
1922
|
</Section>
|
|
1830
1923
|
</View>
|
|
1831
1924
|
|
|
1925
|
+
{/* DOCUMENTS — the document desk: the register Table (search, Create
|
|
1926
|
+
documents, Add files) whose selection feeds the Use-AI fork below.
|
|
1927
|
+
No FileDropTarget of its OWN — the WHOLE-RECORD target above already
|
|
1928
|
+
captures a drop/paste here (and everywhere else on the surface); this
|
|
1929
|
+
section keeps only its explicit Add-files CTA and the affordance line. */}
|
|
1930
|
+
<View onLayout={nav.register("files")}>
|
|
1931
|
+
<Section>
|
|
1932
|
+
<SectionHeading>
|
|
1933
|
+
{/* The standard files-section affordance line — drag / paste / click
|
|
1934
|
+
stay discoverable even though the drop target is the whole record. */}
|
|
1935
|
+
<SectionHeadingTitle description="Everything that ARRIVED on this record. Drag, paste, or click to add.">Files</SectionHeadingTitle>
|
|
1936
|
+
</SectionHeading>
|
|
1937
|
+
<Table
|
|
1938
|
+
columns={DOC_COLUMNS}
|
|
1939
|
+
leading={24}
|
|
1940
|
+
trailing={44}
|
|
1941
|
+
sort={docSort}
|
|
1942
|
+
onSort={(key) => setDocSort(cycleSort(docSort, key))}
|
|
1943
|
+
selectAll={
|
|
1944
|
+
<CheckboxInput
|
|
1945
|
+
accessibilityLabel="Select all documents"
|
|
1946
|
+
checked={sel.allSelected(visibleFiles.map((f) => f.id))}
|
|
1947
|
+
indeterminate={sel.indeterminate(visibleFiles.map((f) => f.id))}
|
|
1948
|
+
onChange={(on) => sel.setAll(visibleFiles.map((f) => f.id), on)}
|
|
1949
|
+
/>
|
|
1950
|
+
}
|
|
1951
|
+
>
|
|
1952
|
+
{visibleFiles.map((f, fi) => (
|
|
1953
|
+
<TableRow
|
|
1954
|
+
key={f.id}
|
|
1955
|
+
onPress={() => openPreview(visibleFiles, fi)}
|
|
1956
|
+
marked={sel.has(f.id)}
|
|
1957
|
+
accessibilityLabel={`Open ${f.name}`}
|
|
1958
|
+
leading={<CheckboxInput accessibilityLabel={`Select ${f.name}`} checked={sel.has(f.id)} onChange={(on) => sel.toggle(f.id, on)} />}
|
|
1959
|
+
trailing={<ActionMenu items={fileMenuFor(f)} accessibilityLabel={`Actions for ${f.name}`} />}
|
|
1960
|
+
>
|
|
1961
|
+
<TableCell>
|
|
1962
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
1963
|
+
{/* ONE square 32px slot for every type: an image fills it as a
|
|
1964
|
+
real thumbnail, a document centers its badge in it */}
|
|
1965
|
+
<FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
|
|
1966
|
+
<Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
|
|
1967
|
+
</View>
|
|
1968
|
+
</TableCell>
|
|
1969
|
+
<TableCell>
|
|
1970
|
+
<Text size="sm" tabular>{fmtSize(f.sizeKB)}</Text>
|
|
1971
|
+
</TableCell>
|
|
1972
|
+
<TableCell>
|
|
1973
|
+
<Text size="sm" tabular>{f.added}</Text>
|
|
1974
|
+
</TableCell>
|
|
1975
|
+
</TableRow>
|
|
1976
|
+
))}
|
|
1977
|
+
</Table>
|
|
1978
|
+
{/* the Add, under the register it extends — see the Fees section for
|
|
1979
|
+
why it is not in the heading. PENDING until the user chooses:
|
|
1980
|
+
saving is a decision the dialog asks for (save only / run a task),
|
|
1981
|
+
never a side effect of picking — closing the dialog discards. */}
|
|
1982
|
+
<View style={{ flexDirection: "row" }}>
|
|
1983
|
+
<Button
|
|
1984
|
+
title="Add files"
|
|
1985
|
+
color="primary"
|
|
1986
|
+
onPress={() => {
|
|
1987
|
+
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
1988
|
+
}}
|
|
1989
|
+
/>
|
|
1990
|
+
</View>
|
|
1991
|
+
</Section>
|
|
1992
|
+
</View>
|
|
1993
|
+
|
|
1832
1994
|
{/* TRANSPORT — the DENSE section, and the honest one. Two groups of ~10
|
|
1833
1995
|
fields, three record references, mixed editor types, a derived
|
|
1834
1996
|
warning, one "Show more".
|
|
@@ -2059,81 +2221,14 @@ export function TplRecord() {
|
|
|
2059
2221
|
</Section>
|
|
2060
2222
|
</View>
|
|
2061
2223
|
|
|
2062
|
-
{/* DOCUMENTS — the document desk: the register Table (search, Create
|
|
2063
|
-
documents, Add files) whose selection feeds the Use-AI fork below.
|
|
2064
|
-
No FileDropTarget of its OWN — the WHOLE-RECORD target above already
|
|
2065
|
-
captures a drop/paste here (and everywhere else on the surface); this
|
|
2066
|
-
section keeps only its explicit Add-files CTA and the affordance line. */}
|
|
2067
|
-
<View onLayout={nav.register("documents")}>
|
|
2068
|
-
<Section>
|
|
2069
|
-
<SectionHeading>
|
|
2070
|
-
{/* The standard files-section affordance line — drag / paste / click
|
|
2071
|
-
stay discoverable even though the drop target is the whole record. */}
|
|
2072
|
-
<SectionHeadingTitle description="Drag, paste, or click to add files">Documents</SectionHeadingTitle>
|
|
2073
|
-
</SectionHeading>
|
|
2074
|
-
<Table
|
|
2075
|
-
columns={DOC_COLUMNS}
|
|
2076
|
-
leading={24}
|
|
2077
|
-
trailing={44}
|
|
2078
|
-
sort={docSort}
|
|
2079
|
-
onSort={(key) => setDocSort(cycleSort(docSort, key))}
|
|
2080
|
-
selectAll={
|
|
2081
|
-
<CheckboxInput
|
|
2082
|
-
accessibilityLabel="Select all documents"
|
|
2083
|
-
checked={sel.allSelected(visibleFiles.map((f) => f.id))}
|
|
2084
|
-
indeterminate={sel.indeterminate(visibleFiles.map((f) => f.id))}
|
|
2085
|
-
onChange={(on) => sel.setAll(visibleFiles.map((f) => f.id), on)}
|
|
2086
|
-
/>
|
|
2087
|
-
}
|
|
2088
|
-
>
|
|
2089
|
-
{visibleFiles.map((f, fi) => (
|
|
2090
|
-
<TableRow
|
|
2091
|
-
key={f.id}
|
|
2092
|
-
onPress={() => openPreview(visibleFiles, fi)}
|
|
2093
|
-
marked={sel.has(f.id)}
|
|
2094
|
-
accessibilityLabel={`Open ${f.name}`}
|
|
2095
|
-
leading={<CheckboxInput accessibilityLabel={`Select ${f.name}`} checked={sel.has(f.id)} onChange={(on) => sel.toggle(f.id, on)} />}
|
|
2096
|
-
trailing={<ActionMenu items={fileMenuFor(f)} accessibilityLabel={`Actions for ${f.name}`} />}
|
|
2097
|
-
>
|
|
2098
|
-
<TableCell>
|
|
2099
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
2100
|
-
{/* ONE square 32px slot for every type: an image fills it as a
|
|
2101
|
-
real thumbnail, a document centers its badge in it */}
|
|
2102
|
-
<FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
|
|
2103
|
-
<Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
|
|
2104
|
-
</View>
|
|
2105
|
-
</TableCell>
|
|
2106
|
-
<TableCell>
|
|
2107
|
-
<Text size="sm" tabular>{fmtSize(f.sizeKB)}</Text>
|
|
2108
|
-
</TableCell>
|
|
2109
|
-
<TableCell>
|
|
2110
|
-
<Text size="sm" tabular>{f.added}</Text>
|
|
2111
|
-
</TableCell>
|
|
2112
|
-
</TableRow>
|
|
2113
|
-
))}
|
|
2114
|
-
</Table>
|
|
2115
|
-
{/* the Add, under the register it extends — see the Fees section for
|
|
2116
|
-
why it is not in the heading. PENDING until the user chooses:
|
|
2117
|
-
saving is a decision the dialog asks for (save only / run a task),
|
|
2118
|
-
never a side effect of picking — closing the dialog discards. */}
|
|
2119
|
-
<View style={{ flexDirection: "row" }}>
|
|
2120
|
-
<Button
|
|
2121
|
-
title="Add files"
|
|
2122
|
-
color="primary"
|
|
2123
|
-
onPress={() => {
|
|
2124
|
-
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
2125
|
-
}}
|
|
2126
|
-
/>
|
|
2127
|
-
</View>
|
|
2128
|
-
</Section>
|
|
2129
|
-
</View>
|
|
2130
2224
|
|
|
2131
2225
|
{/* FEES — the DETAILED money ledger, both directions (charge = billed
|
|
2132
2226
|
to the customer, cost = paid to a vendor), distinct from Billing's
|
|
2133
|
-
invoice DOCUMENTS. The drill-down law
|
|
2134
|
-
EVERY row
|
|
2135
|
-
|
|
2136
|
-
|
|
2227
|
+
invoice DOCUMENTS. The drill-down law for a child collection: a
|
|
2228
|
+
compact register row, and EVERY row EXPANDS to its full detail in
|
|
2229
|
+
place — one at a time, no drawer, so the surrounding rows stay
|
|
2230
|
+
readable and coming back costs nothing. Add is create-then-refine —
|
|
2231
|
+
a blank fee appends already open, where it will live. */}
|
|
2137
2232
|
<View onLayout={nav.register("fees")}>
|
|
2138
2233
|
<Section>
|
|
2139
2234
|
<SectionHeading>
|
|
@@ -2147,13 +2242,114 @@ export function TplRecord() {
|
|
|
2147
2242
|
]}
|
|
2148
2243
|
/>
|
|
2149
2244
|
{fees.length === 0 ? (
|
|
2150
|
-
<EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it
|
|
2245
|
+
<EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it expands ready to fill in." action={<Button title="Add fee" color="primary" onPress={addFee} />} />
|
|
2151
2246
|
) : (
|
|
2152
2247
|
<Table columns={FEE_COLUMNS}>
|
|
2153
2248
|
{fees.map((f) => {
|
|
2154
2249
|
const st = feeStatus(f);
|
|
2155
2250
|
return (
|
|
2156
|
-
<TableRow
|
|
2251
|
+
<TableRow
|
|
2252
|
+
key={f.id}
|
|
2253
|
+
/* TOGGLE, unlike the peek's open-never-toggle: a popover
|
|
2254
|
+
fights its own outside-press dismissal, an expansion has
|
|
2255
|
+
none, so the row that opened it must also close it. */
|
|
2256
|
+
onPress={() => setFeeView((cur) => (cur?.id === f.id ? null : { kind: "edit", id: f.id }))}
|
|
2257
|
+
expanded={feeView?.id === f.id}
|
|
2258
|
+
/* `— details`, the peek's convention: the door is a disclosure,
|
|
2259
|
+
not a trip, so "Open" would name the wrong act. `PressDoor`
|
|
2260
|
+
carries the state as aria-expanded. */
|
|
2261
|
+
accessibilityLabel={`${f.name || "New fee"} — details`}
|
|
2262
|
+
/* NO `selected`: `TableRow` already derives it —
|
|
2263
|
+
`selected={selected || showDetail}` — so the expanded row
|
|
2264
|
+
wears the wash without being told, and passing it here
|
|
2265
|
+
would only restate the kit. */
|
|
2266
|
+
detail={
|
|
2267
|
+
/* The detail carries ALL NINE fields, including the five the
|
|
2268
|
+
row already shows — because the row's cells are read-only
|
|
2269
|
+
Text, and because tier 2 DROPS Party and Type on a narrow
|
|
2270
|
+
container. `table_fit` licenses that drop on the promise
|
|
2271
|
+
that "the door opens the record where dropped values
|
|
2272
|
+
live"; the expansion is now that door, so anything it
|
|
2273
|
+
omitted would be unreachable, not merely inconvenient.
|
|
2274
|
+
Table does not expose its fit result, so this cannot be
|
|
2275
|
+
conditional — nor should it be. */
|
|
2276
|
+
/* 24 (`SPACE.lg`), not the 12 this file uses for intra-block
|
|
2277
|
+
gaps: the field grid's own row gap is `SPACE.md` (16), so
|
|
2278
|
+
anything separating a DIFFERENT block has to beat it or
|
|
2279
|
+
the verb below reads as one more field. Same rung logic
|
|
2280
|
+
`DetailRow` uses for its stacked rows. */
|
|
2281
|
+
<View style={{ gap: 24 }}>
|
|
2282
|
+
{/* labelWidth 130 (the kit default) is NOT the page's 150,
|
|
2283
|
+
and must not be "fixed" to it. The page's invariant is
|
|
2284
|
+
one pixel-aligned VALUE column: General sits at x=0 and
|
|
2285
|
+
puts values at 0+150, this detail is inset by
|
|
2286
|
+
ROW_GUTTER and puts them at 20+130 — the same 150. At
|
|
2287
|
+
150 they would land at 170 and break the column. In an
|
|
2288
|
+
inset field grid the label width is 150 − inset. */}
|
|
2289
|
+
<DetailTable labelWidth={130}>
|
|
2290
|
+
<DetailRow label="Fee">
|
|
2291
|
+
<InlineTextInput value={f.name} onSave={persist((v: string) => patchFee(f.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
|
|
2292
|
+
</DetailRow>
|
|
2293
|
+
<DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
|
|
2294
|
+
<InlineSelect
|
|
2295
|
+
value={f.direction}
|
|
2296
|
+
options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
|
|
2297
|
+
onSave={persist((v: FeeDirection) => patchFee(f.id, { direction: v }))}
|
|
2298
|
+
accessibilityLabel="Fee type"
|
|
2299
|
+
/>
|
|
2300
|
+
</DetailRow>
|
|
2301
|
+
<DetailRow label="Party">
|
|
2302
|
+
<InlineTextInput value={f.party} onSave={persist((v: string) => patchFee(f.id, { party: v }))} placeholder="Who pays, or is paid…" accessibilityLabel="Party" />
|
|
2303
|
+
</DetailRow>
|
|
2304
|
+
<DetailRow label="Amount">
|
|
2305
|
+
<InlineNumberInput value={f.amount || null} onSave={persist((v: number | null) => patchFee(f.id, { amount: v ?? 0 }))} min={0} format={money} placeholder="—" accessibilityLabel="Amount" />
|
|
2306
|
+
</DetailRow>
|
|
2307
|
+
<DetailRow label="VAT (%)">
|
|
2308
|
+
<InlineNumberInput value={f.vat} onSave={persist((v: number | null) => patchFee(f.id, { vat: v }))} min={0} placeholder="—" accessibilityLabel="VAT percent" />
|
|
2309
|
+
</DetailRow>
|
|
2310
|
+
<DetailRow label="Due" {...dueAnnotation(f.due, !f.paid)}>
|
|
2311
|
+
<InlineDatePicker value={f.due} onSave={persist((v: string) => patchFee(f.id, { due: v }))} locale="en-US" placeholder="Set a due date…" accessibilityLabel="Fee due date" />
|
|
2312
|
+
</DetailRow>
|
|
2313
|
+
<DetailRow label="Invoice no" description="The party's invoice or debit note">
|
|
2314
|
+
<InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
|
|
2315
|
+
</DetailRow>
|
|
2316
|
+
<DetailRow label="Status">
|
|
2317
|
+
<InlineSelect
|
|
2318
|
+
value={f.paid ? "paid" : "unpaid"}
|
|
2319
|
+
options={[{ value: "unpaid", label: "Unpaid" }, { value: "paid", label: "Paid" }]}
|
|
2320
|
+
onSave={persist((v: string) => patchFee(f.id, { paid: v === "paid" }))}
|
|
2321
|
+
accessibilityLabel="Paid status"
|
|
2322
|
+
/>
|
|
2323
|
+
</DetailRow>
|
|
2324
|
+
<DetailRow label="Note">
|
|
2325
|
+
<InlineTextInput value={f.note} onSave={persist((v: string) => patchFee(f.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
|
|
2326
|
+
</DetailRow>
|
|
2327
|
+
</DetailTable>
|
|
2328
|
+
{/* The destructive verb, LEFT — the peek footer's
|
|
2329
|
+
convention, which is the kit's. It replaces the
|
|
2330
|
+
DrawerFooter that used to pin it.
|
|
2331
|
+
|
|
2332
|
+
`TextButton`, same as the peek's detach: a text-weight
|
|
2333
|
+
destructive act aligned to a column of text. It lands its
|
|
2334
|
+
ink ON the label column by construction, keeps a 40px
|
|
2335
|
+
target via hitSlop, and drops the trash glyph — "Remove
|
|
2336
|
+
fee" already names the object, which is the safeguard the
|
|
2337
|
+
icon was only decorating.
|
|
2338
|
+
|
|
2339
|
+
NO Divider. Every hairline a `Table` draws is
|
|
2340
|
+
full-bleed (the row `Divider`s carry no padding, the
|
|
2341
|
+
header band's border spans its whole box), so one
|
|
2342
|
+
inset by ROW_GUTTER puts two hairline lengths in the
|
|
2343
|
+
same vertical run — and it would be saying twice what
|
|
2344
|
+
the expanded row's own wash already says. The row
|
|
2345
|
+
wrapper is required, not decoration: a `Button` alone
|
|
2346
|
+
in a column View stretches to full width. */}
|
|
2347
|
+
<View style={{ flexDirection: "row" }}>
|
|
2348
|
+
<TextButton color="danger" onPress={() => deleteFee(f)}>Remove fee</TextButton>
|
|
2349
|
+
</View>
|
|
2350
|
+
</View>
|
|
2351
|
+
}
|
|
2352
|
+
>
|
|
2157
2353
|
<TableCell><Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text></TableCell>
|
|
2158
2354
|
<TableCell><Text size="sm" color="muted">{f.direction === "charge" ? "Charge" : "Cost"}</Text></TableCell>
|
|
2159
2355
|
<TableCell><Text size="sm" color="muted" numberOfLines={1}>{f.party || "—"}</Text></TableCell>
|
|
@@ -2334,7 +2530,7 @@ export function TplRecord() {
|
|
|
2334
2530
|
<View onLayout={nav.register("docset")}>
|
|
2335
2531
|
<Section>
|
|
2336
2532
|
<SectionHeading>
|
|
2337
|
-
<SectionHeadingTitle description="The paperwork this record
|
|
2533
|
+
<SectionHeadingTitle description="The paperwork this record PRODUCES — pick each party's forms, generate on demand.">Documents</SectionHeadingTitle>
|
|
2338
2534
|
</SectionHeading>
|
|
2339
2535
|
<SubsectionStack>
|
|
2340
2536
|
{FORM_GROUPS.map((g) => {
|
|
@@ -2580,33 +2776,14 @@ export function TplRecord() {
|
|
|
2580
2776
|
</DangerZone>
|
|
2581
2777
|
</Section>
|
|
2582
2778
|
</View>
|
|
2779
|
+
|
|
2583
2780
|
</SectionStack>
|
|
2781
|
+
)}
|
|
2782
|
+
</View>
|
|
2584
2783
|
</FileDropTarget>
|
|
2585
2784
|
</>
|
|
2586
2785
|
)}
|
|
2587
2786
|
|
|
2588
|
-
{/* the narrow-mode section picker — a full-page takeover: pick a section
|
|
2589
|
-
to jump there; Escape or the close control dismisses */}
|
|
2590
|
-
<Modal open={sectionsOpen} onClose={() => setSectionsOpen(false)}>
|
|
2591
|
-
<ModalHeader title="Sections" onClose={() => setSectionsOpen(false)} />
|
|
2592
|
-
<ModalBody>
|
|
2593
|
-
<View style={{ gap: 2 }}>
|
|
2594
|
-
{SECTIONS.map((sec) => (
|
|
2595
|
-
<MenuButton
|
|
2596
|
-
key={sec.key}
|
|
2597
|
-
icon={sec.icon}
|
|
2598
|
-
title={sec.label}
|
|
2599
|
-
current={nav.activeKey === sec.key}
|
|
2600
|
-
onPress={() => {
|
|
2601
|
-
setSectionsOpen(false);
|
|
2602
|
-
nav.jumpTo(sec.key);
|
|
2603
|
-
}}
|
|
2604
|
-
/>
|
|
2605
|
-
))}
|
|
2606
|
-
</View>
|
|
2607
|
-
</ModalBody>
|
|
2608
|
-
</Modal>
|
|
2609
|
-
|
|
2610
2787
|
{/* find-or-create's CREATE branch — a focused Dialog (4 fields is
|
|
2611
2788
|
dialog-weight; an inline swap shifts the page). Essentials only: the
|
|
2612
2789
|
dialog's Fetch fills contact + city off the tax ID. */}
|
|
@@ -2711,93 +2888,6 @@ export function TplRecord() {
|
|
|
2711
2888
|
</DialogFooter>
|
|
2712
2889
|
</Dialog>
|
|
2713
2890
|
|
|
2714
|
-
{/* the fee ENTITY DRAWER — the drill-down law: a register row opens its
|
|
2715
|
-
full detail right-docked; ◀ ▶ + the position caption step the rows
|
|
2716
|
-
without closing. Every field refines inline; Remove is confirmed. */}
|
|
2717
|
-
<Drawer
|
|
2718
|
-
open={openFee !== null}
|
|
2719
|
-
onOpenChange={(o) => { if (!o) setFeeView(null); }}
|
|
2720
|
-
title={openFee?.name || "New fee"}
|
|
2721
|
-
width={480}
|
|
2722
|
-
onPrev={feeIdx > 0 ? () => setFeeView({ kind: "edit", id: fees[feeIdx - 1].id }) : undefined}
|
|
2723
|
-
onNext={feeIdx >= 0 && feeIdx < fees.length - 1 ? () => setFeeView({ kind: "edit", id: fees[feeIdx + 1].id }) : undefined}
|
|
2724
|
-
position={feeIdx >= 0 ? `${feeIdx + 1}/${fees.length}` : undefined}
|
|
2725
|
-
>
|
|
2726
|
-
{openFee ? (
|
|
2727
|
-
<View style={{ padding: 20, gap: 8 }}>
|
|
2728
|
-
<DetailTable labelWidth={130}>
|
|
2729
|
-
<DetailRow label="Fee">
|
|
2730
|
-
<InlineTextInput value={openFee.name} onSave={persist((v: string) => patchFee(openFee.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
|
|
2731
|
-
</DetailRow>
|
|
2732
|
-
<DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
|
|
2733
|
-
<InlineSelect
|
|
2734
|
-
value={openFee.direction}
|
|
2735
|
-
options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
|
|
2736
|
-
onSave={persist((v: FeeDirection) => patchFee(openFee.id, { direction: v }))}
|
|
2737
|
-
accessibilityLabel="Fee type"
|
|
2738
|
-
/>
|
|
2739
|
-
</DetailRow>
|
|
2740
|
-
<DetailRow label="Party">
|
|
2741
|
-
<InlineTextInput value={openFee.party} onSave={persist((v: string) => patchFee(openFee.id, { party: v }))} placeholder="Who pays, or is paid…" accessibilityLabel="Party" />
|
|
2742
|
-
</DetailRow>
|
|
2743
|
-
<DetailRow label="Amount">
|
|
2744
|
-
<InlineNumberInput value={openFee.amount || null} onSave={persist((v: number | null) => patchFee(openFee.id, { amount: v ?? 0 }))} min={0} format={money} placeholder="—" accessibilityLabel="Amount" />
|
|
2745
|
-
</DetailRow>
|
|
2746
|
-
<DetailRow label="VAT (%)">
|
|
2747
|
-
<InlineNumberInput value={openFee.vat} onSave={persist((v: number | null) => patchFee(openFee.id, { vat: v }))} min={0} placeholder="—" accessibilityLabel="VAT percent" />
|
|
2748
|
-
</DetailRow>
|
|
2749
|
-
<DetailRow label="Due" error={feeOverdue(openFee) ? "Overdue — chase the payment or move the date." : undefined}>
|
|
2750
|
-
<InlineDatePicker value={openFee.due} onSave={persist((v: string) => patchFee(openFee.id, { due: v }))} locale="en-US" placeholder="Set a due date…" accessibilityLabel="Fee due date" />
|
|
2751
|
-
</DetailRow>
|
|
2752
|
-
<DetailRow label="Invoice no" description="The party's invoice or debit note">
|
|
2753
|
-
<InlineTextInput value={openFee.invoiceNo} onSave={persist((v: string) => patchFee(openFee.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
|
|
2754
|
-
</DetailRow>
|
|
2755
|
-
<DetailRow label="Status">
|
|
2756
|
-
<InlineSelect
|
|
2757
|
-
value={openFee.paid ? "paid" : "unpaid"}
|
|
2758
|
-
options={[{ value: "unpaid", label: "Unpaid" }, { value: "paid", label: "Paid" }]}
|
|
2759
|
-
onSave={persist((v: string) => patchFee(openFee.id, { paid: v === "paid" }))}
|
|
2760
|
-
accessibilityLabel="Paid status"
|
|
2761
|
-
/>
|
|
2762
|
-
</DetailRow>
|
|
2763
|
-
<DetailRow label="Note">
|
|
2764
|
-
<InlineTextInput value={openFee.note} onSave={persist((v: string) => patchFee(openFee.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
|
|
2765
|
-
</DetailRow>
|
|
2766
|
-
</DetailTable>
|
|
2767
|
-
</View>
|
|
2768
|
-
) : (
|
|
2769
|
-
<View />
|
|
2770
|
-
)}
|
|
2771
|
-
{openFee ? (
|
|
2772
|
-
<DrawerFooter>
|
|
2773
|
-
<Button title="Remove fee" color="danger-secondary" icon="trash" onPress={() => deleteFee(openFee)} />
|
|
2774
|
-
</DrawerFooter>
|
|
2775
|
-
) : null}
|
|
2776
|
-
</Drawer>
|
|
2777
|
-
|
|
2778
|
-
{/* the CUSTOMER detail drawer — the linked record's full info, read-only
|
|
2779
|
-
(it's ANOTHER record; editing happens on its own page). The footer
|
|
2780
|
-
carries the record-level verbs: navigate there, or detach. */}
|
|
2781
|
-
<Drawer open={customerOpen && customer !== null} onOpenChange={(o) => { if (!o) setCustomerOpen(false); }} title={customer?.name ?? ""} width={440}>
|
|
2782
|
-
{customer ? (
|
|
2783
|
-
<View style={{ padding: 20 }}>
|
|
2784
|
-
<DetailTable labelWidth={110}>
|
|
2785
|
-
<DetailRow label="Name"><InlineStatic value={customer.name} /></DetailRow>
|
|
2786
|
-
<DetailRow label="Code"><InlineStatic value={customer.code} tabular /></DetailRow>
|
|
2787
|
-
<DetailRow label="Tax ID"><InlineStatic value={customer.taxId || "—"} tabular /></DetailRow>
|
|
2788
|
-
<DetailRow label="Contact"><InlineStatic value={customer.contact || "—"} /></DetailRow>
|
|
2789
|
-
<DetailRow label="City"><InlineStatic value={customer.city || "—"} /></DetailRow>
|
|
2790
|
-
</DetailTable>
|
|
2791
|
-
</View>
|
|
2792
|
-
) : (
|
|
2793
|
-
<View />
|
|
2794
|
-
)}
|
|
2795
|
-
<DrawerFooter>
|
|
2796
|
-
<Button title="Remove from record" color="danger-secondary" icon="x" onPress={() => { setCustomerOpen(false); setCustomerId(null); }} />
|
|
2797
|
-
{/* a real app navigates to the customer's own record page */}
|
|
2798
|
-
<Button title="Open record" color="secondary" onPress={() => {}} />
|
|
2799
|
-
</DrawerFooter>
|
|
2800
|
-
</Drawer>
|
|
2801
2891
|
|
|
2802
2892
|
{/* the handoff dialog — who receives the record + a note; the rows name
|
|
2803
2893
|
exactly WHAT is being handed off. Confirm writes the trail entry. */}
|
|
@@ -2825,8 +2915,10 @@ export function TplRecord() {
|
|
|
2825
2915
|
</ScrollView>
|
|
2826
2916
|
|
|
2827
2917
|
{/* Narrow: the same thread, in the panel shape a small screen has — a
|
|
2828
|
-
right-docked Drawer off the bar's own comments control.
|
|
2829
|
-
|
|
2918
|
+
right-docked Drawer off the bar's own comments control. Never in drawer
|
|
2919
|
+
mode: a Drawer inside a Drawer traps the reader one layer deeper than the
|
|
2920
|
+
record they opened. */}
|
|
2921
|
+
<Drawer open={commentsOpen && !isDrawer} onOpenChange={setCommentsOpen} title="Comments" width={380}>
|
|
2830
2922
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 20, gap: 12 }}>
|
|
2831
2923
|
<CommentList
|
|
2832
2924
|
comments={comments}
|
|
@@ -3157,6 +3249,7 @@ export function TplRecord() {
|
|
|
3157
3249
|
it was written to mean. */}
|
|
3158
3250
|
<SectionHeading>
|
|
3159
3251
|
<SectionHeadingTitle>Comments</SectionHeadingTitle>
|
|
3252
|
+
<SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
|
|
3160
3253
|
</SectionHeading>
|
|
3161
3254
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12, paddingBottom: 8 }}>
|
|
3162
3255
|
<CommentList
|
|
@@ -3211,7 +3304,14 @@ export function TplRecord() {
|
|
|
3211
3304
|
<BackButton label="Records" onPress={() => {}} />
|
|
3212
3305
|
<View style={{ gap: 2 }}>
|
|
3213
3306
|
{SECTIONS.map((sec) => (
|
|
3214
|
-
<MenuButton
|
|
3307
|
+
<MenuButton
|
|
3308
|
+
key={sec.key}
|
|
3309
|
+
icon={sec.icon}
|
|
3310
|
+
title={sec.label}
|
|
3311
|
+
current={activeSection === sec.key}
|
|
3312
|
+
right={sectionIssue[sec.key] ? <Badge variant="dot" color={sectionIssue[sec.key] === "danger" ? "red" : "amber"} /> : undefined}
|
|
3313
|
+
onPress={() => nav.jumpTo(sec.key)}
|
|
3314
|
+
/>
|
|
3215
3315
|
))}
|
|
3216
3316
|
</View>
|
|
3217
3317
|
</View>
|