/** THE canonical register — search, filters, a table, a drawer onto one row. */ import { useEffect, useState, useRef } from "react"; import { Toolbar } from "@base-ui/react/toolbar"; import type { UIMessagePart, UIDataTypes, UITools } from "ai"; import { Text } from "@lotics/ui/text"; import { colors, solid, type ColorName } from "@lotics/ui/colors"; import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu"; import { Alert } from "@lotics/ui/alert"; import { Avatar } from "@lotics/ui/avatar"; import { Status } from "@lotics/ui/status"; import { Box } from "@lotics/ui/box"; import { Button } from "@lotics/ui/button"; import { Checkbox } from "@lotics/ui/checkbox"; import { DetailRow, DetailTable } from "@lotics/ui/detail_row"; import { Divider } from "@lotics/ui/divider"; import { Drawer, DrawerScrollArea, DrawerFooter } from "@lotics/ui/drawer"; import { RegionState } from "@lotics/ui/region_state"; import { SelectionBar } from "@lotics/ui/selection_bar"; import { FormField } from "@lotics/ui/form_field"; import { Icon, type IconName } from "@lotics/ui/icon"; import { IconButton } from "@lotics/ui/icon_button"; import { CommentsButton } from "@lotics/ui/comments_button"; import { CopyButton } from "@lotics/ui/copy_button"; import { ListItem } from "@lotics/ui/list_item"; import { TplRecord } from "./tpl_record"; import { Screen, ScreenRouter, useScreenRouter } from "@lotics/ui/screen_router"; import { TotalsLine } from "@lotics/ui/totals_line"; import { NumberInput } from "@lotics/ui/number_input"; import { Pagination } from "@lotics/ui/pagination"; import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table"; import { Select } from "@lotics/ui/select"; import { Stack } from "@lotics/ui/stack"; import { FilterBand } from "@lotics/ui/filter_band"; import { FilterChip, selectSummary } from "@lotics/ui/filter_chip"; import type { ColumnFilterValue } from "@lotics/ui/column_filter"; import { OptionList } from "@lotics/ui/option_list"; import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header"; import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox"; import { type SelectOption } from "@lotics/ui/select"; import { formatMoney } from "@lotics/ui/format_money"; /** The register's money, stated once — a numeric field's `format` prints AND parses. */ const MONEY: Intl.NumberFormatOptions = { style: "currency", currency: "VND" }; import { DatePicker } from "@lotics/ui/date_picker"; import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select"; import { FileThumbnailGrid } from "@lotics/ui/thumbnail_grid"; import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog"; import { AgentRun, AgentRunDialog, AgentRunActions, type AgentRunLike, type AgentRunQuestion } from "@lotics/ui/agent_run"; import { ResultHeader } from "@lotics/ui/result_header"; import { ConfidenceCallout } from "@lotics/ui/confidence_callout"; import { PressableRow } from "@lotics/ui/pressable_row"; import { FileDropzone } from "@lotics/ui/file_dropzone"; import { FileDropTarget } from "@lotics/ui/file_drop_target"; import { TextInput } from "@lotics/ui/text_input"; import type { DisplayFile } from "@lotics/ui/file_thumbnail"; import { InlineFiles } from "@lotics/ui/inline_files"; import { useSelection } from "@lotics/ui/use_selection"; import { DiffMark } from "@lotics/ui/diff_mark"; import { useChangeSet } from "@lotics/ui/use_change_set"; // ───────────────────────────────────────────────────────────────────────────── // Template, Register — THE canonical register, and the ONE work-execution list // shape. A flat, paginated, SELECTABLE list of records: one toolbar row (search + // consolidated filters LEFT, New CTA RIGHT), a light TotalsLine of the filtered // view, a sortable register whose rows are separated by their own height and the // hover wash rather than by a rule, where every row has a leading // checkbox (a row that can't take the bulk action — here an "Awaiting docs" case — // gets a DISABLED one), a per-row PRINT action button, and a ⋯ overflow (press the // row = the sequenced workspace Drawer), a select-all band, footer totals + // Pagination, a SelectionBar for the bulk action over the ticked rows. The // example is a documents-workflow register, but this one shape subsumes the old // approvals / dispatch / batch / run screens — register, per-row action, gated // selection, and act-on-many all in one. // ───────────────────────────────────────────────────────────────────────────── // The customer book the new-case form searches — find-or-create (pick an existing // customer or coin a new one) instead of typing a raw name that risks a duplicate. // The customer book. The import's preview asks it whether each drafted record's // customer already exists, so the two named in `PROPOSED_BY_CUSTOMER` that the // wizard's own question calls known ("Blue Harbor Foods isn't in the customer // book yet" — implying the other two are) have to actually BE here. They were // not, and the old receipt hid it by hardcoding the one new name instead of // asking the book; every drafted record now reads as a new customer the moment // anything checks. The fixture is the source, so the source has to agree. const CUSTOMER_OPTIONS: SelectOption[] = [ "Northwind Packaging", "Crestline Foods", "Atlas Components", "Summit Logistics", "Michael Torres", "Anna Whitfield", "Robert Hayes", "Karen Mitchell", "Meridian Trading Co.", "Northgate Textiles", ].map((name) => ({ value: name, label: name })); // The assignable roster — an app feeds `useMembers()` here. const MEMBERS: MemberSelectMember[] = [ { id: "m1", name: "Sarah Chen" }, { id: "m2", name: "David Park" }, { id: "m3", name: "Maria Lopez" }, { id: "m4", name: "James Walker" }, ]; const memberIdOf = (name: string) => MEMBERS.find((m) => m.name === name)?.id ?? null; let fileSeq = 0; const toDisplayFiles = (files: File[]): DisplayFile[] => files.map((f) => ({ id: `f_${(fileSeq += 1)}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) })); /** Fake persistence for the inline editors — real apps await their mutation. */ const persist = (set: (v: T) => void) => (v: T) => new Promise((resolve) => { setTimeout(() => { set(v); resolve(); }, 300); }); /** A PICKER'S ANSWER, for a field the record stores as a string. Every picker * can answer "nothing" — that is what the empty row and an emptied entry * commit — so a caller whose value has no null takes the empty value for it. */ const stated = (set: (v: string) => void) => (v: string | null) => persist(set)(v ?? ""); /** "02/06" (dd/MM in the register) → the ISO date the pickers edit. */ const isoReceived = (ddmm: string) => { const [d, m] = ddmm.split("/"); return `2026-${m}-${d}`; }; type TrangThai = "dangxuly" | "chobosung" | "hoantat"; type Tab = "all" | TrangThai; const TRANG_THAI: Record = { dangxuly: { label: "Processing", color: "blue", doc: { title: "Print receipt", icon: "file-text" } }, chobosung: { label: "Awaiting docs", color: "amber", doc: { title: "Print docs request", icon: "file-text" } }, hoantat: { label: "Complete", color: "emerald", doc: { title: "Print result", icon: "file-down" } }, }; interface HoSo { ma: string; /** How many comments the record carries — surfaced on the row so a thread is * discoverable from the book rather than only after opening. */ binhLuan: number; khach: string; dienThoai: string; trangThai: TrangThai; phi: number; daThu: boolean; ngayThu?: string; ngayNhan: string; phuTrach: string; } const HO_SO: HoSo[] = [ { ma: "RC-2026-0041", binhLuan: 2, khach: "Michael Torres", dienThoai: "(555) 384-7560", trangThai: "dangxuly", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "02/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0042", binhLuan: 0, khach: "Anna Whitfield", dienThoai: "(555) 220-4158", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "03/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0043", binhLuan: 5, khach: "Northwind Packaging", dienThoai: "(555) 558-2143", trangThai: "chobosung", phi: 4_200_000, daThu: true, ngayThu: "05/06", ngayNhan: "04/06", phuTrach: "David Park" }, { ma: "RC-2026-0044", binhLuan: 0, khach: "Robert Hayes", dienThoai: "(555) 471-8026", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "29/05", ngayNhan: "28/05", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0045", binhLuan: 1, khach: "Karen Mitchell", dienThoai: "(555) 605-3317", trangThai: "dangxuly", phi: 3_100_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "David Park" }, { ma: "RC-2026-0046", binhLuan: 0, khach: "Daniel Brooks", dienThoai: "(555) 112-9084", trangThai: "chobosung", phi: 1_800_000, daThu: false, ngayNhan: "06/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0047", binhLuan: 3, khach: "Crestline Foods", dienThoai: "(555) 446-2708", trangThai: "hoantat", phi: 5_600_000, daThu: true, ngayThu: "31/05", ngayNhan: "30/05", phuTrach: "David Park" }, { ma: "RC-2026-0048", binhLuan: 0, khach: "Laura Bennett", dienThoai: "(555) 833-5192", trangThai: "dangxuly", phi: 2_500_000, daThu: false, ngayNhan: "09/06", phuTrach: "James Walker" }, { ma: "RC-2026-0049", binhLuan: 2, khach: "Thomas Reed", dienThoai: "(555) 760-3844", trangThai: "hoantat", phi: 1_800_000, daThu: true, ngayThu: "02/06", ngayNhan: "27/05", phuTrach: "James Walker" }, { ma: "RC-2026-0050", binhLuan: 0, khach: "Kevin Doyle", dienThoai: "(555) 905-1273", trangThai: "dangxuly", phi: 4_200_000, daThu: true, ngayThu: "10/06", ngayNhan: "10/06", phuTrach: "James Walker" }, { ma: "RC-2026-0051", binhLuan: 5, khach: "Atlas Components", dienThoai: "(555) 271-6355", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0052", binhLuan: 0, khach: "Rachel Foster", dienThoai: "(555) 540-2866", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "04/06", ngayNhan: "03/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0053", binhLuan: 1, khach: "Patrick Shaw", dienThoai: "(555) 119-4727", trangThai: "hoantat", phi: 1_200_000, daThu: true, ngayThu: "30/05", ngayNhan: "26/05", phuTrach: "David Park" }, { ma: "RC-2026-0054", binhLuan: 0, khach: "Summit Logistics", dienThoai: "(555) 662-8059", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "09/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0055", binhLuan: 3, khach: "Brian Keller", dienThoai: "(555) 484-9131", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "07/06", phuTrach: "James Walker" }, { ma: "RC-2026-0056", binhLuan: 0, khach: "Emily Carter", dienThoai: "(555) 358-0274", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "29/05", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0057", binhLuan: 2, khach: "Jason Pratt", dienThoai: "(555) 705-4693", trangThai: "dangxuly", phi: 1_200_000, daThu: true, ngayThu: "08/06", ngayNhan: "08/06", phuTrach: "David Park" }, { ma: "RC-2026-0058", binhLuan: 0, khach: "Beacon Supplies", dienThoai: "(555) 813-2460", trangThai: "hoantat", phi: 4_200_000, daThu: true, ngayThu: "01/06", ngayNhan: "28/05", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0059", binhLuan: 5, khach: "Nina Alvarez", dienThoai: "(555) 270-1548", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "10/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0060", binhLuan: 0, khach: "Steven Drake", dienThoai: "(555) 593-7186", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "09/06", ngayNhan: "08/06", phuTrach: "James Walker" }, { ma: "RC-2026-0061", binhLuan: 1, khach: "Harbor Industries", dienThoai: "(555) 047-5829", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "11/06", ngayNhan: "10/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0062", binhLuan: 0, khach: "Grace Holloway", dienThoai: "(555) 826-3050", trangThai: "hoantat", phi: 3_100_000, daThu: true, ngayThu: "05/06", ngayNhan: "31/05", phuTrach: "David Park" }, { ma: "RC-2026-0063", binhLuan: 3, khach: "Olivia Manning", dienThoai: "(555) 631-2904", trangThai: "dangxuly", phi: 1_200_000, daThu: false, ngayNhan: "11/06", phuTrach: "Sarah Chen" }, // The book runs past one SCREEN on purpose, not just past one page. A register // whose page fits the viewport cannot exhibit the defect the paging controls // below are shaped around — the reader never has to scroll, so it never // matters where the viewport is left when the rows change. This example ran on // 23 records at 8 a page for exactly that reason, and the reset was missing // from the reference the whole time. { ma: "RC-2026-0064", binhLuan: 0, khach: "Marcus Webb", dienThoai: "(555) 318-7742", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "07/06", ngayNhan: "01/06", phuTrach: "David Park" }, { ma: "RC-2026-0065", binhLuan: 2, khach: "Ironwood Textiles", dienThoai: "(555) 924-6018", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "12/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0066", binhLuan: 0, khach: "Helen Vargas", dienThoai: "(555) 673-2295", trangThai: "chobosung", phi: 1_800_000, daThu: true, ngayThu: "11/06", ngayNhan: "10/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0067", binhLuan: 4, khach: "Peter Nakamura", dienThoai: "(555) 405-8830", trangThai: "dangxuly", phi: 3_100_000, daThu: true, ngayThu: "12/06", ngayNhan: "11/06", phuTrach: "James Walker" }, { ma: "RC-2026-0068", binhLuan: 0, khach: "Redstone Millwork", dienThoai: "(555) 217-5546", trangThai: "hoantat", phi: 6_800_000, daThu: true, ngayThu: "04/06", ngayNhan: "30/05", phuTrach: "David Park" }, { ma: "RC-2026-0069", binhLuan: 1, khach: "Sandra Piper", dienThoai: "(555) 860-3971", trangThai: "dangxuly", phi: 1_200_000, daThu: false, ngayNhan: "12/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0070", binhLuan: 0, khach: "Owen Fitzgerald", dienThoai: "(555) 592-1487", trangThai: "chobosung", phi: 2_500_000, daThu: false, ngayNhan: "13/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0071", binhLuan: 3, khach: "Lakeshore Dairy", dienThoai: "(555) 736-9204", trangThai: "hoantat", phi: 4_200_000, daThu: true, ngayThu: "02/06", ngayNhan: "27/05", phuTrach: "James Walker" }, { ma: "RC-2026-0072", binhLuan: 0, khach: "Deborah Quinn", dienThoai: "(555) 148-6673", trangThai: "dangxuly", phi: 1_800_000, daThu: true, ngayThu: "13/06", ngayNhan: "12/06", phuTrach: "David Park" }, { ma: "RC-2026-0073", binhLuan: 2, khach: "Vincent Osei", dienThoai: "(555) 389-2015", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "13/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0074", binhLuan: 0, khach: "Pinehill Ceramics", dienThoai: "(555) 651-4738", trangThai: "hoantat", phi: 5_600_000, daThu: true, ngayThu: "06/06", ngayNhan: "31/05", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0075", binhLuan: 1, khach: "Alan Whitcombe", dienThoai: "(555) 902-8564", trangThai: "dangxuly", phi: 2_500_000, daThu: false, ngayNhan: "14/06", phuTrach: "James Walker" }, { ma: "RC-2026-0076", binhLuan: 0, khach: "Priya Raghavan", dienThoai: "(555) 473-1290", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "14/06", ngayNhan: "13/06", phuTrach: "David Park" }, { ma: "RC-2026-0077", binhLuan: 5, khach: "Fairview Optics", dienThoai: "(555) 260-7451", trangThai: "chobosung", phi: 1_200_000, daThu: false, ngayNhan: "14/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0078", binhLuan: 0, khach: "Gregory Salazar", dienThoai: "(555) 815-3607", trangThai: "hoantat", phi: 3_100_000, daThu: true, ngayThu: "03/06", ngayNhan: "29/05", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0079", binhLuan: 2, khach: "Tessa Lindqvist", dienThoai: "(555) 547-9382", trangThai: "dangxuly", phi: 4_200_000, daThu: true, ngayThu: "15/06", ngayNhan: "14/06", phuTrach: "James Walker" }, { ma: "RC-2026-0080", binhLuan: 0, khach: "Copperfield Tools", dienThoai: "(555) 031-6825", trangThai: "chobosung", phi: 1_800_000, daThu: false, ngayNhan: "15/06", phuTrach: "David Park" }, { ma: "RC-2026-0081", binhLuan: 1, khach: "Naomi Ferrell", dienThoai: "(555) 794-2158", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "05/06", ngayNhan: "01/06", phuTrach: "Maria Lopez" }, { ma: "RC-2026-0082", binhLuan: 0, khach: "Bridgeport Marine", dienThoai: "(555) 368-5093", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "16/06", phuTrach: "Sarah Chen" }, { ma: "RC-2026-0083", binhLuan: 3, khach: "Ethan Castellanos", dienThoai: "(555) 620-4716", trangThai: "dangxuly", phi: 1_200_000, daThu: true, ngayThu: "16/06", ngayNhan: "15/06", phuTrach: "James Walker" }, { ma: "RC-2026-0084", binhLuan: 0, khach: "Willow Creek Farms", dienThoai: "(555) 187-9340", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "16/06", phuTrach: "David Park" }, { ma: "RC-2026-0085", binhLuan: 2, khach: "Simone Beaufort", dienThoai: "(555) 452-8671", trangThai: "hoantat", phi: 6_800_000, daThu: true, ngayThu: "07/06", ngayNhan: "02/06", phuTrach: "Maria Lopez" }, ]; const TABS: { value: Tab; label: string }[] = [ { value: "all", label: "All" }, { value: "dangxuly", label: "Processing" }, { value: "chobosung", label: "Awaiting docs" }, { value: "hoantat", label: "Complete" }, ]; // Fifteen rows at the register's rhythm is taller than the viewport, which is // the point: paging only has ergonomics once a page is something you SCROLL. const PAGE_SIZE = 15; // Distinct case handlers — the secondary filter dimension (a multi-select FilterChip). const ASSIGNEES = [...new Set(HO_SO.map((r) => r.phuTrach))].map((n) => ({ label: n, value: n })); // Columns defined ONCE — Table renders the header from these and each // TableCell takes its width/align by position; the ⋯ is the trailing gutter. // `priority` = the MOBILE contract: when the container can't fit every column // the highest numbers drop first (Received → Status), the identity + // Customer + Fee survive longest, and at the register floor rows STACK // (label-over-value) so nothing is lost — the drawer carries full detail // either way. const COLUMNS: TableColumn[] = [ // The SUBJECT leads. A reader scans this book by who the record is FOR, and // says "the Harbor Freight one" — never "RC-2026-0041". The key is how the // SYSTEM refers to the record, so it rides the identity cell's supporting line // where every other supporting value lives. It kept a 112px column of its own // while the thing everyone actually looks for sat second. { key: "khach", label: "Customer", flex: 1, sortable: true, priority: 1, lines: 2 }, { key: "trangThai", label: "Status", width: 112, sortable: true, priority: 3 }, { key: "phi", label: "Fee", width: 116, align: "right", sortable: true, priority: 2, lines: 2 }, { key: "ngayNhan", label: "Received", width: 72, sortable: true, priority: 5 }, ]; // DOCUMENT — the paper the row needs NOW: while the fee is unpaid the // paper that matters is the payment slip; once paid, the stage's own document. function docFor(r: HoSo, daThu: boolean): { title: string; icon: IconName } { return daThu ? TRANG_THAI[r.trangThai].doc : { title: "Print payment slip", icon: "receipt" }; } // The row's action prints the stage's document; the ⋯ menu carries the // secondary actions — payment (disabled once collected), edit, and the // destructive cancel last. function menuFor(r: HoSo, daThu: boolean): ActionMenuItem[] { return [ { key: "thanh_toan", label: "Record payment", icon: "credit-card", disabled: daThu, onPress: () => {} }, { key: "sua", label: "Edit record", icon: "pencil", onPress: () => {} }, { key: "huy", label: "Cancel record", icon: "trash", danger: true, onPress: () => Alert.confirm({ title: `Cancel record ${r.ma}?`, message: "The record is closed and removed from the active register.", confirmLabel: "Cancel record", cancelLabel: "Keep record", onConfirm: () => {} }) }, ]; } function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, onPress, onOpenComments }: { hs: HoSo; ordinal: number; daThu: boolean; selected: boolean; marked: boolean; selectable: boolean; onToggle: (on: boolean) => void; onPress: () => void; onOpenComments: () => void }) { const doc = docFor(hs, daThu); return ( } action={ {}} />} trailing={} > {/* The mark goes in the SUBJECT'S cell, not the `leading` slot — `leading` holds the selection checkbox, and "what this row is" and "something you are doing to it" in one slot read as a single compound control. Rung and shape per composition.md §"Marks and character". */} {/* The name takes the row, the thread pins right. A comment count is the one thing on this row that is about PEOPLE talking rather than the record's own state, so it sits apart from the values below it instead of joining them on the supporting line. */} {hs.khach} {/* Rendered only where a thread EXISTS — a zero on every quiet row is a column of noise that trains the eye to skip where the signal appears. */} {hs.binhLuan > 0 ? : null} {/* UNCONDITIONAL — the load-bearing part of this shape. A row centres its cells, so a supporting line that renders only when its field is set makes this cell two heights, and the name above it then sits at two different offsets down the column while every row still measures the same. So the values here are ones every record carries. */} {/* The key does NOT shrink. Quoted whole or it is not a key: a phone clipped to "(555) 384-7…" is still a number you can widen the column to finish, while "RC-2026-0…" is unusable. So the fixed-length identifier holds its width and the phone gives way — and the phone has a Copy control that hands over the full value. */} {hs.ma} {hs.dienThoai} {formatMoney(hs.phi)} {daThu ? (hs.ngayThu ? `Paid ${hs.ngayThu}` : "Paid today") : "Unpaid"} {hs.ngayNhan} ); } // The case workspace behind a register row — the drawer mirrors a REAL record // workspace: fields that save themselves (the bordered control surface; `InlineValue` for the // system-owned ones) and the record's CHARGES SUMMARY (grouped fee rows with // the status as a quiet suffix → divider → ONE emphasized closing number — the // same shape at popover density). Keyed by id from the caller so per-record // state resets as ◀ ▶ steps the sequence. /** The drawer shell that READS the navigation stack: at the root it titles the * customer and offers the ◀ ▶ sequence; on a pushed screen the header swaps * to a BACK button + the pushed case id (the proper home for back — drawer * chrome, not scroll content), and the sequence hides (stepping the root * record from inside a linked one would be disorienting). */ function RecordDrawer({ hs, daThu: _daThu, onPaid: _onPaid, onClose, onPrev, onNext, position, openSection }: { hs: HoSo; /** The section the record should land on — set when the row's comment bubble * opened it, so the reader arrives in the conversation they pressed. */ openSection?: string; daThu: boolean; onPaid: () => void; onClose: () => void; onPrev?: () => void; onNext?: () => void; position?: { index: number; total: number }; }) { const { canGoBack, goBack, params } = useScreenRouter(); return ( !o && onClose()} title={ canGoBack ? ( {params.ma ?? ""} ) : ( // The record CODE, not the customer name. The body is `TplRecord`, whose // customer is its own mock — titling this `hs.khach` put "Michael Torres" // above a record reading "Harbor Freight Lines", i.e. one surface // disagreeing with itself. The code is the one identity both sides share, // because it is the prop the drawer passes down. {hs.ma} ) } onPrev={canGoBack ? undefined : onPrev} onNext={canGoBack ? undefined : onNext} position={canGoBack ? undefined : position} > {/* THE RECORD SURFACE, not a copy of it — `tpl_record` in `drawer` chrome: same sections, same order, same rules. The rail and the docked discussion collapse (a Drawer has no gutters) and the thread joins the reading order as its last section. */} {(p) => } ); } /** The PUSHED linked-record screen — a REAL editable workspace, not a peek: * the drawer HEADER carries the back button (the proper place — see * RecordDrawer), the fields edit in place with their own state, and the footer * carries the pushed record's OWN CTAs. */ function LinkedRecordScreen({ ma }: { ma: string }) { const r = HO_SO.find((x) => x.ma === ma); const [khach, setKhach] = useState(r?.khach ?? ""); const [dienThoai, setDienThoai] = useState(r?.dienThoai ?? ""); const [assignee, setAssignee] = useState(memberIdOf(r?.phuTrach ?? "")); const [ngayNhan, setNgayNhan] = useState(() => (r ? isoReceived(r.ngayNhan) : "")); const [collected, setCollected] = useState(r?.daThu ? "yes" : "no"); const [fee, setFee] = useState(r?.phi ?? null); const [docs, setDocs] = useState([]); if (!r) return null; return ( <> {`Linked record: ${r.khach}`} {/* FILES AS A FIELD, the MANY case: `multiple` defaults true, the CTA becomes "Add document" once something is attached, and the host APPENDS. `tpl_record` shows the single-file case, where the verb is replace — the rule is `data_entry.md` §"Files". */} setDocs((p) => [...p, ...toDisplayFiles(picked)])} onRemove={(f) => setDocs((p) => p.filter((x) => x.id !== f.id))} addLabel="Attach document" addMoreLabel="Add document" /> ({ label: `${t.label} (${t.value === "all" ? preTab.length : dem(t.value)})`, value: t.value, }))} value={tab} onValueChange={(t) => { if (t !== null) chonTab(t); }} accessibilityLabel="Filter by status" /> { setAssignee([]); resetPaging(); }} clearLabel="Clear assignee filter" > { setAssignee(v); resetPaging(); }} /> {/* Single-select sibling — the render-prop `close` shuts the popover on pick (the multi above stays open to collect several values). */} { setFeeStatus(null); resetPaging(); }} clearLabel="Clear payment filter" > {({ close }) => ( { setFeeStatus(v); resetPaging(); }} onRequestClose={close} /> )} {/* the per-column filter pill — the NUMBER variant (min/max) over the Fee column; text/select variants live on the Tables gallery page. Filter changes reset paging like every other control. */} { setFeeAmount(v); resetPaging(); }} /> {/* THE SUMMARY READS THE FILTERED SET, so it sits BELOW the controls that produce it. These figures move every time the reader touches a chip: put them above, and the screen answers before it has been asked — the reader changes a filter, the numbers change behind their eye, and the causal order the layout states is backwards. Under the toolbar it reads as the result of what was just set, and it is the last thing before the rows it counts. It is not a page-band subtitle for the same reason. A subtitle describes the screen; these describe the QUERY, and the query is what the toolbar above just wrote. */} {/* THE SUMMARY ROW IS THE PAGING ROW. Both describe the window the toolbar above just set — what matched, and which slice of it is on screen — so they are one line, not two. PAGING SITS ABOVE THE ROWS, and there is only one of it. Under them is where it has always gone, and that is inherited from full page loads: the browser reset the scroll, so a reader who pressed Next at the bottom arrived at the top of the next page for free. An SPA swaps the rows under a viewport that does not move, so the same press now lands on that page's LAST rows with its first ones already scrolled past. `goToPage` restores the reset — and once the viewport returns here on every press, here is where the control should be: under the reader's cursor, not a page-height below it. A second copy at the bottom would be the same state said twice. It carries the RANGE and nothing else. The totals that used to ride the old footer (`45 records, ₫X in fees`) said what this summary already says, and `Table`'s own `count` prints the matched total in the overline below. The pager sizes to its CONTENT and the summary takes `flex: 1`. Reversed — the pager handed the full width — its own `flex: 1` range stretches and strands the range hundreds of pixels from the arrows it belongs to, two halves of one control reading as two things. Given a row of its own it read worse still: 236px of control against 778px of nothing. `flex-start` keeps the arrows on the summary's FIRST line when the summary wraps at a narrow width. */} {/* the register — overline band (Table header) → list rows */} {/* THE COUNTED REGISTER — `counted`, not `count`, because the PAGER states the total now (`16–30 of 45`) and a number printed twice a few rows apart is two numbers that can disagree. `counted` keeps the ordinal gutter reserved and leaves the figure to the row that owns it. Each row's `ordinal` still continues across pages rather than restarting at 1, so the last row of page 2 reads 30, not 15. */} sel.setAll(selectableIds, on), label: "Select all on this page", }} > {pageRows.map((r, i) => ( sel.toggle(r.ma, on)} onPress={() => setOpenMa(r.ma)} onOpenComments={() => { setOpenSection("comments"); setOpenMa(r.ma); }} /> ))}
{pageRows.length === 0 ? ( <> ) : null}
{/* the intake dialog — CONTROLLED, driven by the toolbar button AND the whole-register drop/paste above (opened seeded with the dropped files) */} {/* bulk action over the ticked cases — the shared floating bar */} (n === 1 ? `${n} record selected` : `${n} records selected`)} onClear={sel.clear} > {/* Every act renders THROUGH `Toolbar.Button`, which is what puts it in the bar's arrow-key group. One that skips it stays its own tab stop and the bar's `toolbar` role then promises a model it does not run. */}