@lotics/ui 7.8.0 → 7.10.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.
Files changed (63) hide show
  1. package/AGENTS.md +92 -43
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record_plain.tsx +121 -16
  26. package/examples/tpl_report.tsx +1 -1
  27. package/examples/tpl_rollup.tsx +1 -1
  28. package/examples/tpl_settings.tsx +1 -1
  29. package/examples/tpl_shifts.tsx +1 -1
  30. package/examples/tpl_stock.tsx +1 -1
  31. package/examples/tpl_task_board.tsx +2 -2
  32. package/examples/tpl_tasks.tsx +1 -1
  33. package/examples/tpl_timeline.tsx +1 -1
  34. package/examples/tpl_tower.tsx +1 -1
  35. package/examples/tpl_triage.tsx +1 -1
  36. package/examples/tpl_wizard.tsx +1 -1
  37. package/package.json +5 -1
  38. package/src/card.tsx +6 -13
  39. package/src/danger_zone.tsx +58 -0
  40. package/src/data_grid.tsx +1 -1
  41. package/src/detail_row.tsx +8 -1
  42. package/src/inline_edit.tsx +14 -4
  43. package/src/inline_select.tsx +9 -8
  44. package/src/inline_static.tsx +63 -0
  45. package/src/kpi_strip.tsx +5 -5
  46. package/src/pressable_row.tsx +22 -10
  47. package/src/sort_header.tsx +9 -4
  48. package/src/summary_line.tsx +76 -0
  49. package/src/table.tsx +27 -11
  50. package/src/use_selection.test.ts +66 -0
  51. package/src/use_selection.ts +75 -0
  52. package/examples/app_orders.tsx +0 -405
  53. package/examples/tpl_approvals.tsx +0 -375
  54. package/examples/tpl_batch.tsx +0 -234
  55. package/examples/tpl_callsheet.tsx +0 -481
  56. package/examples/tpl_convert.tsx +0 -478
  57. package/examples/tpl_crm_desk.tsx +0 -541
  58. package/examples/tpl_dispatch.tsx +0 -289
  59. package/examples/tpl_inventory.tsx +0 -299
  60. package/examples/tpl_pipeline.tsx +0 -400
  61. package/examples/tpl_reconcile.tsx +0 -275
  62. package/examples/tpl_record.tsx +0 -302
  63. package/examples/tpl_run.tsx +0 -235
@@ -6,36 +6,42 @@ import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
6
6
  import { Alert } from "@lotics/ui/alert";
7
7
  import { Badge } from "@lotics/ui/badge";
8
8
  import { Button } from "@lotics/ui/button";
9
- import { Card, CardFooter } from "@lotics/ui/card";
9
+ import { CardFooter } from "@lotics/ui/card";
10
+ import { CheckboxInput } from "@lotics/ui/checkbox_input";
10
11
  import { DetailRow } from "@lotics/ui/detail_row";
11
12
  import { Divider } from "@lotics/ui/divider";
12
13
  import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
13
14
  import { EmptyState } from "@lotics/ui/empty_state";
15
+ import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
14
16
  import { FormField } from "@lotics/ui/form_field";
15
17
  import { type IconName } from "@lotics/ui/icon";
16
- import { KPIStrip } from "@lotics/ui/kpi_strip";
18
+ import { SummaryLine } from "@lotics/ui/summary_line";
17
19
  import { NumberInput } from "@lotics/ui/number_input";
18
20
  import { Pagination } from "@lotics/ui/pagination";
19
21
  import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
20
22
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
21
23
  import { SearchInput } from "@lotics/ui/search_input";
22
- import { ChipGroup } from "@lotics/ui/chip_group";
24
+ import { Select } from "@lotics/ui/select";
23
25
  import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
24
26
  import { OptionList } from "@lotics/ui/option_list";
25
27
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
26
28
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
27
29
  import type { PickerOption } from "@lotics/ui/picker";
28
30
  import { formatMoney } from "@lotics/ui/format_money";
31
+ import { useSelection } from "@lotics/ui/use_selection";
29
32
 
30
33
  // ─────────────────────────────────────────────────────────────────────────────
31
- // Template · Cases & documents the case register. Two jobs on one screen:
32
- // track each case through its statuses, AND generate/print the paper the
33
- // current stage needs (payment slip while the fee is unpaid, then the stage's
34
- // own document). Grammar: header band (+ New case popover form) · KPI strip
35
- // (counts drill into the status pills) · ChipGroup filters the register · ONE banded
36
- // card: search toolbar eyebrow band pressable list rows (press = the
37
- // sequenced workspace Drawer, = the row's real actions) → footer with the
38
- // filtered totals + Pagination over the filtered set (pageSize 8).
34
+ // Template · Item ListTHE canonical register, and the ONE work-execution list
35
+ // shape. A flat, paginated, SELECTABLE list of records: one toolbar row (search +
36
+ // consolidated filters LEFT, New CTA RIGHT) · a light SummaryLine of the filtered
37
+ // view · a sortable, Divider-separated register where every row has a leading
38
+ // checkbox (a row that can't take the bulk action here an "Awaiting docs" case
39
+ // gets a DISABLED one), a per-row PRINT action button, and a ⋯ overflow (press the
40
+ // row = the sequenced workspace Drawer) · a select-all band · footer totals +
41
+ // Pagination · a FloatingActionBar for the bulk action over the ticked rows. The
42
+ // example is a case-document register, but this one shape subsumes the old
43
+ // approvals / dispatch / batch / run screens — register, per-row action, gated
44
+ // selection, and act-on-many all in one.
39
45
  // ─────────────────────────────────────────────────────────────────────────────
40
46
 
41
47
  // The customer book the new-case form searches — find-or-create (pick an existing
@@ -120,26 +126,32 @@ function docFor(r: HoSo): { title: string; icon: IconName } {
120
126
  return r.daThu ? TRANG_THAI[r.trangThai].doc : { title: "Print payment slip", icon: "receipt" };
121
127
  }
122
128
 
123
- // The menu carries the row's REAL actions: the stage document first, then
124
- // payment (disabled once collected), edit, and the destructive cancel last.
129
+ // The per-row Print button (the trailing action column) prints the stage's
130
+ // document; the menu carries the secondary actions — payment (disabled once
131
+ // collected), edit, and the destructive cancel last.
125
132
  function menuFor(r: HoSo): ActionMenuItem[] {
126
- const doc = docFor(r);
127
133
  return [
128
- { key: "chung_tu", label: doc.title, icon: doc.icon, onPress: () => {} },
129
134
  { key: "thanh_toan", label: "Record payment", icon: "credit-card", disabled: r.daThu, onPress: () => {} },
130
135
  { key: "sua", label: "Edit case", icon: "pencil", onPress: () => {} },
131
136
  { key: "huy", label: "Cancel case", icon: "trash", danger: true, onPress: () => Alert.alert("Cancel this case?", "The case is closed and removed from the active register — it can't be undone.", [{ text: "Keep case", style: "cancel" }, { text: "Cancel case", style: "destructive", onPress: () => {} }]) },
132
137
  ];
133
138
  }
134
139
 
135
- function HoSoRow({ hs, selected, onPress }: { hs: HoSo; selected: boolean; onPress: () => void }) {
140
+ function HoSoRow({ hs, selected, marked, selectable, onToggle, onPress }: { hs: HoSo; selected: boolean; marked: boolean; selectable: boolean; onToggle: (on: boolean) => void; onPress: () => void }) {
136
141
  return (
137
142
  <TableRow
138
143
  onPress={onPress}
139
144
  selected={selected}
145
+ marked={marked}
140
146
  minHeight={56}
141
147
  accessibilityLabel={`Open case ${hs.ma} — ${hs.khach}`}
142
- trailing={<ActionMenu items={menuFor(hs)} accessibilityLabel={`Actions for ${hs.ma}`} />}
148
+ leading={<CheckboxInput accessibilityLabel={selectable ? `Select ${hs.ma}` : `${hs.ma} — awaiting docs, not ready to export`} checked={marked} disabled={!selectable} onChange={onToggle} />}
149
+ trailing={
150
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
151
+ <Button title="Print" icon={docFor(hs).icon} color="secondary" onPress={() => {}} />
152
+ <ActionMenu items={menuFor(hs)} accessibilityLabel={`Actions for ${hs.ma}`} />
153
+ </View>
154
+ }
143
155
  >
144
156
  <TableCell>
145
157
  <Text size="sm" weight="semibold" tabular>{hs.ma}</Text>
@@ -244,7 +256,7 @@ function TaoHoSoForm() {
244
256
  );
245
257
  }
246
258
 
247
- export function TplDossier() {
259
+ export function TplItemList() {
248
260
  const [tab, setTab] = useState<Tab>("all");
249
261
  const [search, setSearch] = useState("");
250
262
  const [page, setPage] = useState(0);
@@ -252,6 +264,7 @@ export function TplDossier() {
252
264
  const [sort, setSort] = useState<SortState | null>(null);
253
265
  const [assignee, setAssignee] = useState<string[]>([]);
254
266
  const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
267
+ const sel = useSelection();
255
268
 
256
269
  // Filter / sort changes reset paging — page 3 of the old ordering is
257
270
  // meaningless under the new one.
@@ -271,7 +284,6 @@ export function TplDossier() {
271
284
 
272
285
  const dem = (t: TrangThai) => HO_SO.filter((r) => r.trangThai === t).length;
273
286
  const phiDaThu = HO_SO.filter((r) => r.daThu).reduce((s, r) => s + r.phi, 0);
274
- const chuaThu = HO_SO.filter((r) => !r.daThu);
275
287
 
276
288
  // Search + status tab filter FIRST, then paging over the filtered ordering.
277
289
  const q = search.trim().toLowerCase();
@@ -296,99 +308,118 @@ export function TplDossier() {
296
308
  const pageRows = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
297
309
  const hasMore = (page + 1) * PAGE_SIZE < sorted.length;
298
310
 
311
+ // Bulk selection (the shared `useSelection` hook) — tick rows for the floating
312
+ // action bar; select-all spans the visible page and ticks persist across pages,
313
+ // so the bar's count is the running total. Selectability GATING stays here (the
314
+ // hook is agnostic): an "Awaiting docs" case is still gathering paperwork, so it
315
+ // can't be exported — only the selectable ids drive select-all, the rest get a
316
+ // DISABLED checkbox (the same shape as a blocked run line).
317
+ const isSelectable = (r: HoSo) => r.trangThai !== "chobosung";
318
+ const selectableIds = pageRows.filter(isSelectable).map((r) => r.ma);
319
+
299
320
  // The drawer sequences over the CURRENTLY-VISIBLE page slice. If the open
300
321
  // case falls out of view, the chevrons disappear but the drawer stays.
301
322
  const openRow = openMa === null ? null : HO_SO.find((r) => r.ma === openMa) ?? null;
302
323
  const seqIndex = openRow ? pageRows.findIndex((r) => r.ma === openRow.ma) : -1;
303
324
 
304
325
  return (
305
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
326
+ <View style={{ flex: 1 }}>
327
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
306
328
  <View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
307
- {/* header band one primary action: the New case popover form */}
308
- <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
309
- <View style={{ gap: 2, flex: 1 }}>
310
- <Text size="xl" weight="semibold">Cases & documents</Text>
311
- <Text size="sm" color="muted">Track each case's status and print the right document for its stage</Text>
312
- </View>
313
- <TaoHoSoForm />
329
+ {/* header — title only; the New case action lives on the toolbar row */}
330
+ <View style={{ gap: 2 }}>
331
+ <Text size="xl" weight="semibold">Cases & documents</Text>
332
+ <Text size="sm" color="muted">Track each case's status and print the right document for its stage</Text>
314
333
  </View>
315
334
 
316
- {/* each count drills into the matching tab below; Fees collected carries
317
- the chase ("n still unpaid") as its caption */}
318
- <KPIStrip
319
- items={[
320
- { label: "Processing", value: dem("dangxuly") },
321
- { label: "Awaiting docs", value: dem("chobosung"), tone: "warning", info: "Cases waiting on customer paperwork — chase these first; the tab below filters to them." },
322
- { label: "Complete", value: dem("hoantat") },
323
- {
324
- label: "Fees collected",
325
- value: phiDaThu,
326
- format: "currency",
327
- compact: true,
328
- caption: `${chuaThu.length} cases unpaid · ${formatMoney(chuaThu.reduce((s, r) => s + r.phi, 0), { compact: true })}`,
329
- },
330
- ]}
331
- />
332
-
333
- {/* status chips (the hot dimension, inline) + a secondary Assignee
334
- multi-select FilterChip — the same faceted band as Approvals */}
335
- <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
336
- <View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
337
- <SearchInput
338
- placeholder="Search by ID, name, or phone…"
339
- value={search}
340
- onChangeText={doiTimKiem}
341
- accessibilityLabel="Search cases"
342
- />
343
- </View>
344
- <ChipGroup
345
- accessibilityLabel="Filter by status"
346
- options={TABS.map((t) => ({
347
- label: `${t.label} · ${t.value === "all" ? HO_SO.length : dem(t.value)}`,
348
- value: t.value,
349
- }))}
350
- value={tab}
351
- onValueChange={chonTab}
352
- />
353
- <FilterChip
354
- label="Assignee"
355
- summary={assigneeSummary}
356
- onClear={() => { setAssignee([]); setPage(0); }}
357
- clearLabel="Clear assignee filter"
358
- >
359
- <OptionList
360
- search={{ mode: "none" }}
361
- multi
362
- options={ASSIGNEES}
363
- value={assignee}
364
- onValueChange={(v) => { setAssignee(v); setPage(0); }}
335
+ {/* toolbar search + consolidated filters LEFT, New case RIGHT, one row.
336
+ A search is present, so status is a dropdown (Select), not pills. */}
337
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
338
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center", flex: 1 }}>
339
+ <View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
340
+ <SearchInput
341
+ placeholder="Search by ID, name, or phone…"
342
+ value={search}
343
+ onChangeText={doiTimKiem}
344
+ accessibilityLabel="Search cases"
345
+ />
346
+ </View>
347
+ <Select
348
+ placeholder="Status"
349
+ options={TABS.map((t) => ({
350
+ label: `${t.label} · ${t.value === "all" ? HO_SO.length : dem(t.value)}`,
351
+ value: t.value,
352
+ }))}
353
+ value={tab}
354
+ onValueChange={chonTab}
355
+ accessibilityLabel="Filter by status"
365
356
  />
366
- </FilterChip>
367
- {/* Single-select sibling — the render-prop `close` shuts the popover on
368
- pick (the multi above stays open to collect several values). */}
369
- <FilterChip
370
- label="Fee"
371
- summary={feeStatus === "paid" ? "Paid" : feeStatus === "unpaid" ? "Unpaid" : undefined}
372
- onClear={() => { setFeeStatus(null); setPage(0); }}
373
- clearLabel="Clear fee filter"
374
- >
375
- {({ close }) => (
357
+ <FilterChip
358
+ label="Assignee"
359
+ summary={assigneeSummary}
360
+ onClear={() => { setAssignee([]); setPage(0); }}
361
+ clearLabel="Clear assignee filter"
362
+ >
376
363
  <OptionList
377
364
  search={{ mode: "none" }}
378
- options={[{ value: "paid", label: "Paid" }, { value: "unpaid", label: "Unpaid" }]}
379
- value={feeStatus}
380
- onValueChange={(v) => { setFeeStatus(v); setPage(0); }}
381
- onRequestClose={close}
365
+ multi
366
+ options={ASSIGNEES}
367
+ value={assignee}
368
+ onValueChange={(v) => { setAssignee(v); setPage(0); }}
382
369
  />
383
- )}
384
- </FilterChip>
370
+ </FilterChip>
371
+ {/* Single-select sibling — the render-prop `close` shuts the popover on
372
+ pick (the multi above stays open to collect several values). */}
373
+ <FilterChip
374
+ label="Fee"
375
+ summary={feeStatus === "paid" ? "Paid" : feeStatus === "unpaid" ? "Unpaid" : undefined}
376
+ onClear={() => { setFeeStatus(null); setPage(0); }}
377
+ clearLabel="Clear fee filter"
378
+ >
379
+ {({ close }) => (
380
+ <OptionList
381
+ search={{ mode: "none" }}
382
+ options={[{ value: "paid", label: "Paid" }, { value: "unpaid", label: "Unpaid" }]}
383
+ value={feeStatus}
384
+ onValueChange={(v) => { setFeeStatus(v); setPage(0); }}
385
+ onRequestClose={close}
386
+ />
387
+ )}
388
+ </FilterChip>
389
+ </View>
390
+ <TaoHoSoForm />
385
391
  </View>
386
392
 
393
+ {/* light summary of the filtered register — each count drills into the matching tab above */}
394
+ <SummaryLine
395
+ items={[
396
+ { label: "processing", value: dem("dangxuly") },
397
+ { label: "awaiting docs", value: dem("chobosung"), tone: "warning", info: "Cases waiting on customer paperwork — their record isn't complete, so they can't be ticked for export until the docs arrive." },
398
+ { label: "complete", value: dem("hoantat") },
399
+ { label: "fees collected", value: phiDaThu, format: "currency", compact: true },
400
+ ]}
401
+ />
402
+
387
403
  {/* the register — eyebrow band (Table header) → list rows → totals + paging */}
388
- <Card style={{ padding: 0 }}>
389
- <Table columns={COLUMNS} trailing={40} sort={sort} onSort={onSort}>
404
+ <View>
405
+ <Table
406
+ columns={COLUMNS}
407
+ leading={24}
408
+ trailing={132}
409
+ sort={sort}
410
+ onSort={onSort}
411
+ selectAll={
412
+ <CheckboxInput
413
+ accessibilityLabel="Select all cases on this page"
414
+ checked={sel.allSelected(selectableIds)}
415
+ indeterminate={sel.indeterminate(selectableIds)}
416
+ disabled={selectableIds.length === 0}
417
+ onChange={(on) => sel.setAll(selectableIds, on)}
418
+ />
419
+ }
420
+ >
390
421
  {pageRows.map((r) => (
391
- <HoSoRow key={r.ma} hs={r} selected={r.ma === openMa} onPress={() => setOpenMa(r.ma)} />
422
+ <HoSoRow key={r.ma} hs={r} selected={r.ma === openMa} marked={sel.has(r.ma)} selectable={isSelectable(r)} onToggle={(on) => sel.toggle(r.ma, on)} onPress={() => setOpenMa(r.ma)} />
392
423
  ))}
393
424
  </Table>
394
425
  {pageRows.length === 0 ? (
@@ -410,8 +441,18 @@ export function TplDossier() {
410
441
  onPageChange={setPage}
411
442
  />
412
443
  </CardFooter>
413
- </Card>
444
+ </View>
414
445
  </View>
446
+ </ScrollView>
447
+
448
+ {/* bulk action over the ticked cases — the shared floating bar */}
449
+ <FloatingActionBar
450
+ count={sel.count}
451
+ label={sel.count === 1 ? "case selected" : "cases selected"}
452
+ onClear={sel.clear}
453
+ >
454
+ <Button title="Export" color="primary" onPress={() => {}} />
455
+ </FloatingActionBar>
415
456
 
416
457
  {/* the case workspace — sequenced over the visible page ordering */}
417
458
  {openRow ? (
@@ -427,6 +468,6 @@ export function TplDossier() {
427
468
  <HoSoWorkspace key={openRow.ma} hs={openRow} />
428
469
  </Drawer>
429
470
  ) : null}
430
- </ScrollView>
471
+ </View>
431
472
  );
432
473
  }
@@ -191,7 +191,7 @@ export function TplLookup() {
191
191
  }));
192
192
 
193
193
  return (
194
- <View style={{ flex: 1, flexDirection: "row", backgroundColor: colors.zinc[50] }}>
194
+ <View style={{ flex: 1, flexDirection: "row", backgroundColor: colors.white }}>
195
195
  {/* ── LEFT · command + session ───────────────────────────────── */}
196
196
  <View style={{ width: 380, backgroundColor: colors.white, borderRightWidth: 1, borderRightColor: colors.zinc[200] }}>
197
197
  <View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 12, gap: 2 }}>
@@ -249,7 +249,7 @@ export function TplLookup() {
249
249
  <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
250
250
  <View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 16, backgroundColor: colors.white, overflow: "hidden" }}>
251
251
  {/* verdict header */}
252
- <View style={{ padding: 22, gap: 10, backgroundColor: colors.zinc[50], borderBottomWidth: 1, borderBottomColor: colors.zinc[200] }}>
252
+ <View style={{ padding: 22, gap: 10, backgroundColor: colors.white, borderBottomWidth: 1, borderBottomColor: colors.zinc[200] }}>
253
253
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
254
254
  <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Suggested HS code</Text>
255
255
  <Confidence level={active.confidence} />
@@ -16,7 +16,7 @@ import type { ConfidenceLevel } from "@lotics/ui/confidence";
16
16
  // (a known item ↔ the agent's proposed counterpart) with a confidence on the
17
17
  // bridge. High-confidence pairs batch-accept; the rest are worked one by one.
18
18
  // Same shape for bank-line ↔ invoice, invoice ↔ PO, dedup A ↔ B, entity
19
- // resolution. The deterministic cousin (tpl_reconcile) has no agent reasoning.
19
+ // resolution. Strip the agent's reasoning and it's just a deterministic match register.
20
20
  // ─────────────────────────────────────────────────────────────────────────────
21
21
 
22
22
  interface M {
@@ -64,7 +64,7 @@ export function TplMatch() {
64
64
  const highOpen = open.filter((r) => r.confidence === "high" && r.match).length;
65
65
 
66
66
  return (
67
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
67
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
68
68
  <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
69
69
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
70
70
  <View style={{ flex: 1, gap: 2 }}>
@@ -213,7 +213,7 @@ export function TplOrder() {
213
213
  const ready = customer !== null && lines.length > 0 && lines.every((l) => l.product !== "" && !l.editing);
214
214
 
215
215
  return (
216
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
216
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
217
217
  <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
218
218
  {/* header band */}
219
219
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
@@ -2,9 +2,9 @@ import { useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors, solid } from "@lotics/ui/colors";
5
+ import { Accordion, AccordionContent, AccordionHeader, AccordionMeta, AccordionTitle } from "@lotics/ui/accordion";
5
6
  import { Badge } from "@lotics/ui/badge";
6
7
  import { Button } from "@lotics/ui/button";
7
- import { Card } from "@lotics/ui/card";
8
8
  import { ChipGroup } from "@lotics/ui/chip_group";
9
9
  import { Counter } from "@lotics/ui/counter";
10
10
  import { Divider } from "@lotics/ui/divider";
@@ -18,11 +18,11 @@ import { CompletionState } from "@lotics/ui/completion_state";
18
18
  // Template · Guided execution — a warehouse PICK RUN. The work is a SEQUENCE of
19
19
  // physical tasks, not a list to browse: the run hands the operator the next bin,
20
20
  // they scan to verify they're at the right shelf, confirm the count, and the
21
- // next task takes its place. Two panes use the screen the way the work wants
22
- // the CURRENT task large on the left (where to walk, what to take, how many), the
23
- // whole PICK PATH on the right (done · now · up next) so position is always in
24
- // view. Scan-to-verify, can't-skip ordering, and a short-pick exception that
25
- // flags-and-advances without stalling the run.
21
+ // next task takes its place. A single focus column the CURRENT task is the only
22
+ // thing in view (where to walk, what to take, how many); the whole PICK PATH is a
23
+ // COLLAPSIBLE section below, collapsed by default, so position is one tap away and
24
+ // never competes with the task for the screen. Scan-to-verify, can't-skip
25
+ // ordering, and a short-pick exception that flags-and-advances without stalling.
26
26
  // ─────────────────────────────────────────────────────────────────────────────
27
27
 
28
28
  interface PickLine {
@@ -92,7 +92,7 @@ function CurrentTask({ line, index, total, onConfirm, onShort }: {
92
92
  const [scan, setScan] = useState("");
93
93
  const matched = scan.trim().toUpperCase() === line.bin.toUpperCase();
94
94
  return (
95
- <Card style={{ padding: 0 }}>
95
+ <View>
96
96
  <View style={{ padding: 24, gap: 18 }}>
97
97
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
98
98
  <Badge label={line.zone} color="blue" />
@@ -132,7 +132,7 @@ function CurrentTask({ line, index, total, onConfirm, onShort }: {
132
132
  <View style={{ flex: 1 }} />
133
133
  <Button title={`Confirm ${line.qty}`} color="primary" onPress={onConfirm} />
134
134
  </View>
135
- </Card>
135
+ </View>
136
136
  );
137
137
  }
138
138
 
@@ -140,51 +140,42 @@ function Complete({ lines, onReset }: { lines: PickLine[]; onReset: () => void }
140
140
  const shorts = lines.filter((l) => l.status === "short").length;
141
141
  const units = lines.reduce((s, l) => s + (l.status === "short" ? l.picked ?? 0 : l.qty), 0);
142
142
  return (
143
- <Card style={{ padding: 0 }}>
144
- <CompletionState
145
- title="Wave picked"
146
- summary={`${lines.length} lines · ${units} units${shorts > 0 ? ` · ${shorts} short` : ""}`}
147
- >
148
- <Button title="Start next wave" color="muted" onPress={onReset} />
149
- <Button title="Hand to packing" color="primary" onPress={() => {}} />
150
- </CompletionState>
151
- </Card>
143
+ <CompletionState
144
+ title="Wave picked"
145
+ summary={`${lines.length} lines · ${units} units${shorts > 0 ? ` · ${shorts} short` : ""}`}
146
+ >
147
+ <Button title="Start next wave" color="muted" onPress={onReset} />
148
+ <Button title="Hand to packing" color="primary" onPress={() => {}} />
149
+ </CompletionState>
152
150
  );
153
151
  }
154
152
 
155
- // The right rail — the whole run at a glance: done · now · up next. Read-only;
156
- // the run is sequential, so position is shown, not chosen.
153
+ // The whole run at a glance: done · now · up next. Read-only — the run is
154
+ // sequential, so position is shown, not chosen. Lives in a collapsed disclosure
155
+ // below the task so the current step keeps the focus.
157
156
  function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: number }) {
158
157
  return (
159
- <Card style={{ padding: 0 }}>
160
- <View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
161
- <Text size="xs" color="muted" transform="uppercase">{`Pick path · ${lines.length} lines`}</Text>
162
- </View>
163
- <Divider />
164
- <View style={{ paddingHorizontal: 16, paddingVertical: 14 }}>
165
- <Stepper orientation="vertical" accessibilityLabel="Pick path">
166
- {lines.map((l, i) => {
167
- const status: StepStatus =
168
- l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "upcoming";
169
- return (
170
- <Step key={l.id} status={status}>
171
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 }}>
172
- <View style={{ flex: 1, gap: 1 }}>
173
- <Text size="sm">{l.bin}</Text>
174
- <Text size="xs" color="muted" numberOfLines={1}>{l.item}</Text>
175
- </View>
176
- {l.status === "short" ? (
177
- <Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
178
- ) : (
179
- <Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
180
- )}
181
- </View>
182
- </Step>
183
- );
184
- })}
185
- </Stepper>
186
- </View>
187
- </Card>
158
+ <Stepper orientation="vertical" accessibilityLabel="Pick path">
159
+ {lines.map((l, i) => {
160
+ const status: StepStatus =
161
+ l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "upcoming";
162
+ return (
163
+ <Step key={l.id} status={status}>
164
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 }}>
165
+ <View style={{ flex: 1, gap: 1 }}>
166
+ <Text size="sm">{l.bin}</Text>
167
+ <Text size="xs" color="muted" numberOfLines={1}>{l.item}</Text>
168
+ </View>
169
+ {l.status === "short" ? (
170
+ <Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
171
+ ) : (
172
+ <Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
173
+ )}
174
+ </View>
175
+ </Step>
176
+ );
177
+ })}
178
+ </Stepper>
188
179
  );
189
180
  }
190
181
 
@@ -203,8 +194,8 @@ export function TplPick() {
203
194
  setLines((prev) => prev.map((l) => (l.id === id ? { ...l, status, picked } : l)));
204
195
 
205
196
  return (
206
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
207
- <View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
197
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
198
+ <View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 16 }}>
208
199
  {/* header — the wave + the cart it's filling */}
209
200
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12, flexWrap: "wrap" }}>
210
201
  <View style={{ gap: 2, flex: 1 }}>
@@ -223,25 +214,30 @@ export function TplPick() {
223
214
  <ProgressBar value={done.length} max={lines.length} format="none" color={solid("blue")} />
224
215
  </View>
225
216
 
226
- {/* two panes — the current task large, the whole path alongside it */}
227
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
228
- <View style={{ flexGrow: 1.6, flexBasis: 440 }}>
229
- {current ? (
230
- <CurrentTask
231
- line={current}
232
- index={idx}
233
- total={lines.length}
234
- onConfirm={() => setStatus(current.id, "picked", current.qty)}
235
- onShort={(actual) => setStatus(current.id, actual >= current.qty ? "picked" : "short", actual)}
236
- />
237
- ) : (
238
- <Complete lines={lines} onReset={() => setLines(WAVE)} />
239
- )}
240
- </View>
241
- <View style={{ flexGrow: 1, flexBasis: 320 }}>
217
+ {/* single focus — the current task is the only thing in view */}
218
+ {current ? (
219
+ <CurrentTask
220
+ line={current}
221
+ index={idx}
222
+ total={lines.length}
223
+ onConfirm={() => setStatus(current.id, "picked", current.qty)}
224
+ onShort={(actual) => setStatus(current.id, actual >= current.qty ? "picked" : "short", actual)}
225
+ />
226
+ ) : (
227
+ <Complete lines={lines} onReset={() => setLines(WAVE)} />
228
+ )}
229
+
230
+ {/* the whole route, secondary — a collapsed disclosure under the task,
231
+ never a persistent side panel */}
232
+ <Accordion defaultExpanded={false}>
233
+ <AccordionHeader accessibilityLabel="Pick path">
234
+ <AccordionTitle>Pick path</AccordionTitle>
235
+ <AccordionMeta>{`${done.length} of ${lines.length} done`}</AccordionMeta>
236
+ </AccordionHeader>
237
+ <AccordionContent>
242
238
  <RunPath lines={lines} currentIndex={idx} />
243
- </View>
244
- </View>
239
+ </AccordionContent>
240
+ </Accordion>
245
241
  </View>
246
242
  </ScrollView>
247
243
  );
@@ -125,7 +125,7 @@ export function TplPivot() {
125
125
  const lowOrOut = SKUS.filter((s) => s.status !== "in_stock").length;
126
126
 
127
127
  return (
128
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
128
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
129
129
  <View style={{ width: "100%", maxWidth: 960, alignSelf: "center", gap: 16 }}>
130
130
  <View style={{ gap: 2 }}>
131
131
  <Text size="xl" weight="semibold">Stock distribution</Text>
@@ -104,7 +104,7 @@ export function TplQuick() {
104
104
  };
105
105
 
106
106
  return (
107
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
107
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
108
108
  <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
109
109
  <View style={{ gap: 2 }}>
110
110
  <Text size="xl" weight="semibold">Log activity</Text>
@@ -174,7 +174,7 @@ export function TplRatedesk() {
174
174
 
175
175
  return (
176
176
  <>
177
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 20, paddingBottom: 72, gap: 18 }}>
177
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 20, paddingBottom: 72, gap: 18 }}>
178
178
  {/* HEADER BAND — the register is the page; updating prices is one CTA */}
179
179
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
180
180
  <View style={{ gap: 2, flex: 1 }}>