@lotics/ui 21.0.0 → 21.2.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.
@@ -15,6 +15,8 @@ import { Callout, CalloutText } from "@lotics/ui/callout";
15
15
  import { Inset } from "@lotics/ui/inset";
16
16
  import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
17
17
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
18
+ import { Pipeline, PipelineActions, PipelineField, PipelineNote, PipelineStage } from "@lotics/ui/pipeline";
19
+ import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
18
20
  import { MenuButton } from "@lotics/ui/menu_button";
19
21
  import { Modal, ModalBody, ModalHeader } from "@lotics/ui/modal";
20
22
  import { useSectionNav } from "@lotics/ui/use_section_nav";
@@ -28,15 +30,7 @@ import { Composer } from "@lotics/ui/composer";
28
30
  import { IconButton } from "@lotics/ui/icon_button";
29
31
  import { FileRows } from "@lotics/ui/file_rows";
30
32
  import { FileGrid } from "@lotics/ui/file_grid";
31
- import { CheckCircle } from "@lotics/ui/check_circle";
32
- import { ProgressBar } from "@lotics/ui/progress_bar";
33
- import { FilterChip } from "@lotics/ui/filter_chip";
34
- import { CaptureRow } from "@lotics/ui/capture_row";
35
- import { TaskActions, TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskSubRow, TaskTitle } from "@lotics/ui/task";
36
33
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
37
- import { SuggestionChip } from "@lotics/ui/suggestion_chip";
38
- import { OptionList } from "@lotics/ui/option_list";
39
- import { MemberChip } from "@lotics/ui/member_chip";
40
34
  import { InlineStatic } from "@lotics/ui/inline_static";
41
35
  import { InlineTextInput } from "@lotics/ui/inline_text_input";
42
36
  import { InlineNumberInput } from "@lotics/ui/inline_number_input";
@@ -93,7 +87,9 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
93
87
  // groups + Classification (the right-input-per-field
94
88
  // showcase) + System ids. Always present, first in rail.
95
89
  // · Comments — the discussion thread. When people collaborate here.
96
- // · Tasks — the working checklist. Multi-step records with owners.
90
+ // · Progress WHERE the record sits: the desks as a `Pipeline`, each
91
+ // owning its own facts, the live one owning the act that
92
+ // leaves it. Records that move between owners.
97
93
  // · Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
98
94
  // fork (the Agents "Document desk" pattern — this template
99
95
  // is its worked example).
@@ -111,9 +107,10 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
111
107
  // table ONCE (creates + links the sibling; Recall undoes).
112
108
  // · Danger zone — destructive lifecycle. Always last.
113
109
  //
114
- // The lifecycle is the HANDOFF CHAIN per-desk task checklists inform the
115
- // handoff CTA (never blocking), and the gate is the page's LAST block before
116
- // the closing `DangerZone`; there is no separate submit step.
110
+ // The lifecycle is the HANDOFF CHAIN, and it reads in TWO places by design:
111
+ // `Progress` carries the position and the act that changes it; `Handoff` near
112
+ // the end carries the RESULT what each handoff created, and when. There is no
113
+ // separate submit step.
117
114
  // ─────────────────────────────────────────────────────────────────────────────
118
115
 
119
116
  interface Customer {
@@ -137,9 +134,9 @@ const KNOWN_CUSTOMERS: Customer[] = [
137
134
 
138
135
  const TAX_ID_RE = /^\d{10}(\d{3})?$/;
139
136
 
140
- // HANDOFF is a STAGE TRANSITION on the shared record — never a message or a
141
- // task. Each department owns its sections; the handoff CTA moves the record to
142
- // the next desk (spine + stage = handoff) open tasks warn, they never block.
137
+ // HANDOFF is a STAGE TRANSITION on the shared record — never a message. Each
138
+ // department owns its sections; the handoff CTA moves the record to the next
139
+ // desk, and it lives ON that desk's stage in the Progress pipeline.
143
140
  type Stage = "sales" | "operations" | "accounting" | "closed";
144
141
  type Desk = Exclude<Stage, "closed">;
145
142
  const DESKS: { key: Desk; label: string }[] = [
@@ -153,77 +150,15 @@ const STAGES: { key: Stage; label: string }[] = [
153
150
  ];
154
151
  const stageOf = (st: Stage) => STAGES.find((x) => x.key === st) ?? STAGES[0];
155
152
 
156
- // The handoff is MANAGED as tasks: each desk's checklist INFORMS its handoff
157
- // CTA (open tasks warn and carry over; the CTA never blocks) — actionable,
158
- // unlike a decorative progress strip.
159
- interface StageTask {
160
- id: string;
161
- label: string;
162
- stage: Desk;
163
- done: boolean;
164
- assignee: string | null;
165
- /** ISO date (`""` = no due). It hangs under the row as a named `TaskSubRow`
166
- * field, coloured by urgency (`tone`), press to edit. */
167
- due: string;
168
- /** The task's CHILD STEPS (`[]` = none). They render as a NESTED `TaskList`,
169
- * so a step is a task: give one a due date, a menu or children of its own and
170
- * it simply works. Collapsing is the app's call — render the list or don't. */
171
- subtasks: { key: string; label: string; done: boolean }[];
153
+ // How long the live desk has held the record the PipelineStage meta line.
154
+ // Prose, derived: the date itself is the stage's own editable field.
155
+ function heldFor(since: string): string | undefined {
156
+ if (since === "") return undefined;
157
+ const days = Math.floor((Date.now() - new Date(since).getTime()) / 86_400_000);
158
+ if (!Number.isFinite(days) || days < 0) return undefined;
159
+ return days === 0 ? "Arrived today" : days === 1 ? "Here 1 day" : `Here ${days} days`;
172
160
  }
173
161
 
174
- // A due date N days from today — keeps the seeded urgency states (overdue / soon /
175
- // later) correct whenever the gallery is opened.
176
- const dueIn = (n: number): string => {
177
- const d = new Date();
178
- d.setDate(d.getDate() + n);
179
- return d.toISOString().slice(0, 10);
180
- };
181
- // The urgency tone for a due date, fed to the cell's `tone`: red past due, amber within
182
- // 3 days, else muted — the date text itself is the signal (no separate dot). Done → muted.
183
- const dueTone = (due: string, done: boolean): "danger" | "warning" | "muted" => {
184
- if (!due || done) return "muted";
185
- const today = new Date();
186
- today.setHours(0, 0, 0, 0);
187
- const days = Math.round((new Date(due).getTime() - today.getTime()) / 86_400_000);
188
- return days < 0 ? "danger" : days <= 3 ? "warning" : "muted";
189
- };
190
- // Common-but-OPTIONAL tasks per desk — in a real app, the record type's
191
- // configured checklist. A NEW record starts with an EMPTY list (tasks truly
192
- // vary); these commons wait as dismissible suggestion CHIPS under each desk
193
- // (tap to materialize, ✕ to dismiss) — pills, never rows, so a
194
- // suggestion can't be mistaken for a task. MANDATORY tasks are a different
195
- // thing entirely: the app seeds those via a workflow on create — no human
196
- // types them.
197
- const SUGGESTED_TASKS: Record<Desk, string[]> = {
198
- sales: ["Confirm pricing with the customer", "Attach the signed quote", "Verify the customer's tax ID"],
199
- operations: ["Book the carrier", "Attach the delivery documents", "Confirm the delivery window"],
200
- accounting: ["Issue every invoice", "Reconcile the receipts"],
201
- };
202
-
203
- // Monotonic id for user-added tasks — a length-derived id would collide after
204
- // a removal (delete then add) and break keys + patch-by-id.
205
- let taskSeq = 0;
206
- const newTaskId = () => `t_${(taskSeq += 1)}`;
207
-
208
- // The in-flight demo record's existing checklist (a NEW record gets []).
209
- const TASK_SEEDS: StageTask[] = [
210
- { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), subtasks: [] },
211
- { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", subtasks: [] },
212
- { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), subtasks: [] },
213
- // the one task with SUBTASKS — a booking is a sequence, and its steps carry
214
- // nothing but a tick, so they stay child rows instead of four more tasks
215
- // (collapsing is the app's call — this template always renders them)
216
- { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), subtasks: [
217
- { key: "s1", label: "Confirm the pickup window", done: true },
218
- { key: "s2", label: "Send the packing list", done: false },
219
- { key: "s3", label: "Get the booking reference", done: false },
220
- { key: "s4", label: "Share the reference with the customer", done: false },
221
- ] },
222
- { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6), subtasks: [] },
223
- { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "", subtasks: [] },
224
- { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14), subtasks: [] },
225
- ];
226
-
227
162
  // The assignable roster — an app feeds `useMembers()` here.
228
163
  const TEAM: MemberSelectMember[] = [
229
164
  { id: "mem_01", name: "Sarah Chen" },
@@ -552,6 +487,9 @@ const SEED_COMMENTS: ThreadComment[] = [
552
487
  // ITS table (OP-…/AC-…) and links it here for reference.
553
488
  let siblingSeq = 90;
554
489
 
490
+ /** "2026-07-18" — the wire shape every date FIELD on this surface stores. */
491
+ const iso = (d: Date): string => d.toISOString().slice(0, 10);
492
+
555
493
  /** "18 Jul, 4:05 PM" — the trail/audit timestamp format. */
556
494
  const stamp = (d: Date): string =>
557
495
  `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`;
@@ -561,6 +499,10 @@ const stamp = (d: Date): string =>
561
499
  interface SiblingRecord {
562
500
  id: string;
563
501
  code: string;
502
+ /** The desk this handoff OPENED — the key, not just its label, because recall
503
+ * has to undo exactly that step: return to the desk before it and clear the
504
+ * custody stamp it wrote. A label can't be mapped back without a lookup. */
505
+ deskKey: Desk;
564
506
  desk: string;
565
507
  assignee: string;
566
508
  note: string;
@@ -835,7 +777,6 @@ export function TplRecord() {
835
777
  const [warehouse, setWarehouse] = useState("central");
836
778
  const [reference, setReference] = useState("");
837
779
  const [weight, setWeight] = useState<number | null>(1250);
838
- const [salesOwner, setSalesOwner] = useState<string | null>("mem_01");
839
780
 
840
781
  // ── customer section — TWO states: attached (read-only card, Remove
841
782
  // detaches) or empty (the find-or-create search). The "create" branch opens
@@ -1137,71 +1078,13 @@ export function TplRecord() {
1137
1078
  }, 900);
1138
1079
  };
1139
1080
 
1140
- // ── the handoff tasks the current desk's checklist gates its handoff
1141
- const [tasks, setTasks] = useState<StageTask[]>(TASK_SEEDS);
1142
- const [newTask, setNewTask] = useState("");
1143
- const toggleTask = (tid: string, done: boolean) =>
1144
- setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, done } : t)));
1145
- const renameTask = (tid: string, label: string) =>
1146
- setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, label } : t)));
1147
- // The capture desk: adds land on the current desk; a CLOSED record's adds
1148
- // land on the last desk (the stage never gates task editing).
1149
- const captureDesk: Desk = stage === "closed" ? "accounting" : stage;
1150
- const addTask = () => {
1151
- const label = newTask.trim();
1152
- if (!label) return;
1153
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "", subtasks: [] }]);
1154
- setNewTask("");
1155
- };
1156
- const addSuggested = (desk: Desk, label: string) =>
1157
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "", subtasks: [] }]);
1158
- const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
1159
- const moveTask = (tid: string, desk: Desk) =>
1160
- setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
1161
- const nextDeskOf = (desk: Desk): Desk | null =>
1162
- desk === "sales" ? "operations" : desk === "operations" ? "accounting" : null;
1163
- const [dismissed, setDismissed] = useState<string[]>([]);
1164
- const assignTask = (tid: string, member: string | null) =>
1165
- setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
1166
- const dueTask = (tid: string, due: string) =>
1167
- setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
1168
- // A child step patches by key — the kit owns the expander and the count, the
1169
- // app owns nothing but the tick.
1170
- const toggleSubtask = (tid: string, key: string, done: boolean) =>
1171
- setTasks((prev) =>
1172
- prev.map((t) => (t.id === tid ? { ...t, subtasks: t.subtasks.map((s) => (s.key === key ? { ...s, done } : s)) } : t)),
1173
- );
1174
- // The Task-list grammar: a clearable Group-by + filter chips derive the
1175
- // sections; empty groups drop (except desks — the journey stays visible).
1176
- const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
1177
- const [assigneeFilter, setAssigneeFilter] = useState<string[]>([]);
1178
- const [statusFilter, setStatusFilter] = useState<"open" | "done" | null>(null);
1179
- const taskPool = tasks.filter((t) => {
1180
- if (assigneeFilter.length > 0 && !assigneeFilter.includes(t.assignee ?? "none")) return false;
1181
- if (statusFilter === "open" && t.done) return false;
1182
- if (statusFilter === "done" && !t.done) return false;
1183
- return true;
1184
- });
1185
- const suggesting = assigneeFilter.length === 0 && statusFilter === null;
1186
- const taskSections: { key: string; head: React.ReactNode; items: StageTask[]; desk: Desk | null; ghosts: string[] }[] =
1187
- taskGroup === "desk"
1188
- ? DESKS.map((st) => ({
1189
- key: st.key,
1190
- head: <Text size="xs" weight="semibold" color="muted">{st.label}</Text>,
1191
- items: taskPool.filter((t) => t.stage === st.key),
1192
- desk: st.key,
1193
- ghosts: suggesting ? SUGGESTED_TASKS[st.key].filter((l) => !tasks.some((t) => t.label === l) && !dismissed.includes(l)) : [],
1194
- }))
1195
- : taskGroup === "assignee"
1196
- ? [...TEAM.map((m) => ({ key: m.id, head: <MemberChip name={m.name ?? m.id} />, items: taskPool.filter((t) => t.assignee === m.id), desk: null, ghosts: [] })),
1197
- { key: "none", head: <Text size="xs" weight="semibold" color="muted">Unassigned</Text>, items: taskPool.filter((t) => t.assignee == null), desk: null, ghosts: [] },
1198
- ].filter((g) => g.items.length > 0)
1199
- : taskGroup === "status"
1200
- ? [
1201
- { key: "open", head: <Text size="xs" weight="semibold" color="muted">Open</Text>, items: taskPool.filter((t) => !t.done), desk: null, ghosts: [] },
1202
- { key: "done", head: <Text size="xs" weight="semibold" color="muted">Done</Text>, items: taskPool.filter((t) => t.done), desk: null, ghosts: [] },
1203
- ].filter((g) => g.items.length > 0)
1204
- : [{ key: "all", head: null, items: taskPool, desk: null, ghosts: [] }];
1081
+ // ── desk CUSTODYwhat each desk stamps about its own turn
1082
+ // Each desk owns two facts about its own custody: when it took the record and
1083
+ // who holds it. Stored per desk so a passed desk keeps them editable — a date
1084
+ // typed wrong must not become unreachable the moment the record moves on.
1085
+ const [deskSince, setDeskSince] = useState<Record<Desk, string>>({ sales: "2026-06-15", operations: "", accounting: "" });
1086
+ const [deskOwner, setDeskOwner] = useState<Record<Desk, string | null>>({ sales: "mem_01", operations: null, accounting: null });
1087
+
1205
1088
  const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
1206
1089
  value: c.id,
1207
1090
  label: c.name,
@@ -1288,20 +1171,31 @@ export function TplRecord() {
1288
1171
  Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
1289
1172
  };
1290
1173
 
1291
- // Open tasks INFORM the handoff, they never block it the count warns and
1292
- // carries over to the next desk (or stays open on the closed record).
1293
- const gate =
1174
+ // Where the record SITS the one index the pipeline reads off, and the act
1175
+ // that leaves that desk. A closed record is past the last desk, so no stage
1176
+ // is current and no gate is offered.
1177
+ const deskIndex = stage === "closed" ? DESKS.length : DESKS.findIndex((x) => x.key === stage);
1178
+ // `handoff` separates the two acts that LOOK alike, as a DISCRIMINATED union
1179
+ // so the difference is in the type and not just a runtime branch: handing off
1180
+ // has a receiving DESK (it asks whom, then stamps that desk), closing has no
1181
+ // receiver at all — hence no `next` to mis-read. Sharing one shape is how it
1182
+ // came to ask "Assignee at Closed" and then confirm nothing.
1183
+ const gate: { cta: string; handoff: true; next: Desk } | { cta: string; handoff: false } | null =
1294
1184
  stage === "sales"
1295
- ? { cta: "Hand off to Operations", next: "operations" as Stage }
1185
+ ? { cta: "Hand off to Operations", next: "operations", handoff: true }
1296
1186
  : stage === "operations"
1297
- ? { cta: "Hand off to Accounting", next: "accounting" as Stage }
1187
+ ? { cta: "Hand off to Accounting", next: "accounting", handoff: true }
1298
1188
  : stage === "accounting"
1299
- ? { cta: "Close record", next: "closed" as Stage }
1189
+ ? { cta: "Close record", handoff: false }
1300
1190
  : null;
1301
1191
 
1302
1192
  // ── the handoff flow — the dialog's fields, the TRAIL of marks, and the
1303
1193
  // SIBLING records the handoffs created (shown as LinkedRecordCards)
1304
1194
  const [handoffOpen, setHandoffOpen] = useState(false);
1195
+ // The desk the OPEN dialog is handing to, captured on press. Not re-derived
1196
+ // from `stage`: confirming advances the stage while the dialog is still fading
1197
+ // out, so a derived title would flip to the NEXT desk's wording on the way out.
1198
+ const [handoffTo, setHandoffTo] = useState<Desk>("operations");
1305
1199
  const [handoffAssignee, setHandoffAssignee] = useState<string | null>(null);
1306
1200
  const [handoffNote, setHandoffNote] = useState("");
1307
1201
  const [handoffs, setHandoffs] = useState<TimelineItem[]>([]);
@@ -1313,13 +1207,18 @@ export function TplRecord() {
1313
1207
  // Confirm MARKS the handoff — whom · where · the note — and, in a real app,
1314
1208
  // CREATES the next desk's record on ITS table, linked here. The mark shows
1315
1209
  // that linked sibling as a reference; its summary lives on the sibling.
1210
+ //
1211
+ // It commits what the DIALOG captured (`handoffTo`), never what `stage` implies
1212
+ // right now: the dialog is a statement about one handoff, and re-deriving the
1213
+ // target at confirm time would give two sources for the same fact.
1316
1214
  const confirmHandoff = () => {
1317
- if (!gate || gate.next === "closed" || handoffAssignee == null) return;
1215
+ if (handoffAssignee == null) return;
1318
1216
  const to = TEAM.find((m) => m.id === handoffAssignee);
1319
1217
  const at = new Date();
1320
- const siblingCode = `${gate.next === "operations" ? "OP" : "AC"}-2026-00${(siblingSeq += 1)}`;
1218
+ const deskName = stageOf(handoffTo).label;
1219
+ const siblingCode = `${handoffTo === "operations" ? "OP" : "AC"}-2026-00${(siblingSeq += 1)}`;
1321
1220
  setSiblings((prev) => [
1322
- { id: siblingCode, code: siblingCode, desk: stageOf(gate.next).label, assignee: to?.name ?? "", note: handoffNote.trim(), at: stamp(at) },
1221
+ { id: siblingCode, code: siblingCode, deskKey: handoffTo, desk: deskName, assignee: to?.name ?? "", note: handoffNote.trim(), at: stamp(at) },
1323
1222
  ...prev,
1324
1223
  ]);
1325
1224
  setHandoffs((prev) => [
@@ -1327,24 +1226,51 @@ export function TplRecord() {
1327
1226
  id: `h_${prev.length + 1}`,
1328
1227
  icon: "send",
1329
1228
  iconColor: colors.blue[500],
1330
- label: `Handed off to ${stageOf(gate.next).label} — ${to?.name ?? ""}`,
1229
+ label: `Handed off to ${deskName} — ${to?.name ?? ""}`,
1331
1230
  description: handoffNote.trim() || undefined,
1332
1231
  right: <Text size="xs" color="muted">{stamp(at)}</Text>,
1333
1232
  },
1334
1233
  ...prev,
1335
1234
  ]);
1336
- logActivity("send", `Handed off to ${stageOf(gate.next).label} — ${to?.name ?? ""}`, { iconColor: colors.blue[500], description: `${siblingCode} created and linked` });
1337
- setStage(gate.next);
1235
+ logActivity("send", `Handed off to ${deskName} — ${to?.name ?? ""}`, { iconColor: colors.blue[500], description: `${siblingCode} created and linked` });
1236
+ // The handoff STAMPS the desk it opens — the dialog already asked whom and
1237
+ // it is happening now, so the receiving stage's own fields must not come up
1238
+ // blank and re-ask. They stay editable there; this is the default, not a lock.
1239
+ setDeskSince((p) => ({ ...p, [handoffTo]: iso(at) }));
1240
+ setDeskOwner((p) => ({ ...p, [handoffTo]: handoffAssignee }));
1241
+ setStage(handoffTo);
1338
1242
  setHandoffOpen(false);
1339
1243
  setHandoffAssignee(null);
1340
1244
  setHandoffNote("");
1341
1245
  };
1246
+ // CLOSING is the pipeline's terminal act, not a handoff — there is no next
1247
+ // desk and nobody to name, so it asks for CONFIRMATION and nothing else. The
1248
+ // record leaves the pipeline: every stage reads done and no stage is current.
1249
+ const closeRecord = () => {
1250
+ Alert.alert("Close this record?", "Accounting is the last desk. The record leaves the pipeline — its stages stay editable.", [
1251
+ { text: "Cancel", style: "cancel" },
1252
+ {
1253
+ text: "Close record",
1254
+ onPress: () => {
1255
+ logActivity("circle-check", "Record closed", { iconColor: colors.emerald[500] });
1256
+ setStage("closed");
1257
+ },
1258
+ },
1259
+ ]);
1260
+ };
1342
1261
  // RECALL — the undo: withdraws the sibling (a real app cancels the sibling
1343
1262
  // record, and gates recall on it being UNTOUCHED at its desk) and returns
1344
1263
  // the desk. History is never erased — the trail keeps the original mark and
1345
1264
  // gains a recall mark; the handoff CTA comes back, so redo is possible.
1265
+ //
1266
+ // It undoes exactly ONE step: back to the desk BEFORE the one this handoff
1267
+ // opened, and it clears that desk's custody stamp. Rewinding to the first desk
1268
+ // instead would silently drop the desks in between, and leaving the stamp
1269
+ // behind would leave `stage` and the custody map disagreeing — an unreached
1270
+ // desk still holding a taken-on date and an owner.
1346
1271
  const recallHandoff = (sb: SiblingRecord) => {
1347
- Alert.alert(`Recall the handoff?`, `${sb.code} is withdrawn from ${sb.desk} and the record returns to Sales. The trail keeps both marks.`, [
1272
+ const backTo = DESKS[DESKS.findIndex((d) => d.key === sb.deskKey) - 1]?.key ?? "sales";
1273
+ Alert.alert(`Recall the handoff?`, `${sb.code} is withdrawn from ${sb.desk} and the record returns to ${stageOf(backTo).label}. The trail keeps both marks.`, [
1348
1274
  { text: "Cancel", style: "cancel" },
1349
1275
  {
1350
1276
  text: "Recall",
@@ -1352,7 +1278,9 @@ export function TplRecord() {
1352
1278
  onPress: () => {
1353
1279
  setSiblings((prev) => prev.filter((x) => x.id !== sb.id));
1354
1280
  setSiblingOpen(null);
1355
- setStage("sales");
1281
+ setDeskSince((p) => ({ ...p, [sb.deskKey]: "" }));
1282
+ setDeskOwner((p) => ({ ...p, [sb.deskKey]: null }));
1283
+ setStage(backTo);
1356
1284
  setHandoffs((prev) => [
1357
1285
  { id: `h_${prev.length + 1}`, icon: "x", iconColor: colors.zinc[500], label: `Handoff recalled — ${sb.code} withdrawn`, right: <Text size="xs" color="muted">{stamp(new Date())}</Text> },
1358
1286
  ...prev,
@@ -1369,7 +1297,7 @@ export function TplRecord() {
1369
1297
  const SECTIONS = [
1370
1298
  { key: "general", label: "General", icon: "file-text" },
1371
1299
  { key: "comments", label: "Comments", icon: "message-square" },
1372
- { key: "tasks", label: "Tasks", icon: "list-checks" },
1300
+ { key: "progress", label: "Progress", icon: "git-branch" },
1373
1301
  { key: "documents", label: "Documents", icon: "folder-closed" },
1374
1302
  { key: "customer", label: "Customer", icon: "building-2" },
1375
1303
  { key: "fees", label: "Fees", icon: "receipt" },
@@ -1380,7 +1308,7 @@ export function TplRecord() {
1380
1308
  { key: "handoff", label: "Handoff", icon: "send" },
1381
1309
  { key: "danger", label: "Danger zone", icon: "circle-alert" },
1382
1310
  ] as const;
1383
- const nav = useSectionNav(["general", "comments", "tasks", "documents", "customer", "fees", "billing", "docset", "receipt", "activity", "handoff", "danger"] as const);
1311
+ const nav = useSectionNav(["general", "comments", "progress", "documents", "customer", "fees", "billing", "docset", "receipt", "activity", "handoff", "danger"] as const);
1384
1312
  // Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
1385
1313
  // it opens a full-page section picker (Escape / the close control dismiss).
1386
1314
  const [sectionsOpen, setSectionsOpen] = useState(false);
@@ -1460,8 +1388,8 @@ export function TplRecord() {
1460
1388
  hairline BETWEEN top-level blocks (a conditional block that renders
1461
1389
  null never leaves a stray divider) */}
1462
1390
  <SectionStack style={{ flex: 1, minWidth: 0 }}>
1463
- {/* header band — identity · attention · a light summary. No stage here
1464
- (Tasks carries progress, Handoff carries the desk), no breadcrumb,
1391
+ {/* header band — identity · attention · a light summary. No stage chip
1392
+ here (Progress carries the desk and the act), no breadcrumb,
1465
1393
  no create CTA: back lives in the panel, creation belongs to the
1466
1394
  REGISTER (the list owns "new"), the record page only edits. */}
1467
1395
  <View style={{ gap: 16 }}>
@@ -1479,7 +1407,7 @@ export function TplRecord() {
1479
1407
  {/* the light summary — what's on this record, one quiet line */}
1480
1408
  <SummaryLine
1481
1409
  items={[
1482
- { label: `of ${tasks.length} tasks done`, value: tasks.filter((t) => t.done).length },
1410
+ { label: "desk", value: stageOf(stage).label, format: "none" },
1483
1411
  { label: "documents", value: files.length },
1484
1412
  { label: "to collect", value: grandTotal, format: "currency", compact: true },
1485
1413
  ]}
@@ -1502,11 +1430,15 @@ export function TplRecord() {
1502
1430
  <SubsectionStack>
1503
1431
  <Subsection>
1504
1432
  <DetailTable labelWidth={150} trailingWidth={88}>
1433
+ {/* The SAME fact the Sales stage owns in Progress, shown where
1434
+ a reader looks for it first — bound to the one source, never
1435
+ a second copy: two member selects over their own state would
1436
+ disagree the moment either is edited. */}
1505
1437
  <DetailRow label="Sales owner">
1506
1438
  <InlineMemberSelect
1507
1439
  members={TEAM}
1508
- value={salesOwner}
1509
- onSave={persist(setSalesOwner)}
1440
+ value={deskOwner.sales}
1441
+ onSave={persist((v: string | null) => setDeskOwner((p) => ({ ...p, sales: v })))}
1510
1442
  placeholder="Assign…"
1511
1443
  accessibilityLabel="Sales owner"
1512
1444
  />
@@ -1752,185 +1684,72 @@ export function TplRecord() {
1752
1684
  </Section>
1753
1685
  </View>
1754
1686
 
1755
- {/* TASKS — the handoff checklist, grouped by the desk that owns
1756
- them; every row edits (the stage gates the handoff and Billing,
1757
- never task editing). The CURRENT desk's open tasks warn beside
1758
- the handoff CTA below. */}
1759
- <View onLayout={nav.register("tasks")}>
1687
+ {/* PROGRESS — the desk handoff, as the ordered positions it is. Sales →
1688
+ Operations Accounting: one desk owns the record at a time, the live
1689
+ one carries the act that leaves it, and a passed desk keeps its own
1690
+ facts editable (a date typed wrong must stay reachable). This replaced
1691
+ a per-desk CHECKLIST: ticking boxes described the work, never the
1692
+ record's POSITION, and it put the handoff CTA a section away from the
1693
+ desk it belonged to. */}
1694
+ <View onLayout={nav.register("progress")}>
1760
1695
  <Section>
1761
1696
  <SectionHeading>
1762
- <SectionHeadingTitle description="Each desk clears its checklist to hand the record off.">Tasks</SectionHeadingTitle>
1763
- {/* the same compact meter as the register's Tasks column */}
1764
- {tasks.length > 0 ? (
1765
- <View style={{ width: 120 }}>
1766
- <ProgressBar compact value={tasks.filter((t) => t.done).length} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
1767
- </View>
1768
- ) : null}
1697
+ <SectionHeadingTitle description="One desk owns the record at a time. The live desk carries the handoff.">Progress</SectionHeadingTitle>
1769
1698
  </SectionHeading>
1770
- {/* the Task-list toolbar grammar: a clearable Group-by FilterChip +
1771
- filter chips not a bespoke pill row */}
1772
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
1773
- <FilterChip
1774
- label="Group by"
1775
- summary={taskGroup === "desk" ? "Desk" : taskGroup === "assignee" ? "Assignee" : taskGroup === "status" ? "Status" : undefined}
1776
- onClear={() => setTaskGroup(null)}
1777
- clearLabel="Clear grouping"
1778
- >
1779
- {({ close }) => (
1780
- <OptionList
1781
- search={{ mode: "none" }}
1782
- options={[
1783
- { value: "desk", label: "Desk" },
1784
- { value: "assignee", label: "Assignee" },
1785
- { value: "status", label: "Status" },
1786
- ]}
1787
- value={taskGroup ?? undefined}
1788
- onValueChange={(v) => {
1789
- if (v) setTaskGroup(v);
1790
- close();
1791
- }}
1792
- onRequestClose={close}
1793
- />
1794
- )}
1795
- </FilterChip>
1796
- <FilterChip
1797
- label="Assignee"
1798
- summary={assigneeFilter.length > 0 ? `${assigneeFilter.length}` : undefined}
1799
- onClear={() => setAssigneeFilter([])}
1800
- clearLabel="Clear assignee filter"
1801
- >
1802
- <OptionList
1803
- search={{ mode: "none" }}
1804
- multi
1805
- options={[...TEAM.map((m) => ({ value: m.id, label: m.name ?? m.id })), { value: "none", label: "Unassigned" }]}
1806
- value={assigneeFilter}
1807
- onValueChange={setAssigneeFilter}
1808
- />
1809
- </FilterChip>
1810
- <FilterChip
1811
- label="Status"
1812
- summary={statusFilter === "open" ? "Open" : statusFilter === "done" ? "Done" : undefined}
1813
- onClear={() => setStatusFilter(null)}
1814
- clearLabel="Clear status filter"
1815
- >
1816
- {({ close }) => (
1817
- <OptionList
1818
- search={{ mode: "none" }}
1819
- options={[
1820
- { value: "open", label: "Open" },
1821
- { value: "done", label: "Done" },
1822
- ]}
1823
- value={statusFilter ?? undefined}
1824
- onValueChange={(v) => {
1825
- if (v) setStatusFilter(v);
1826
- close();
1827
- }}
1828
- onRequestClose={close}
1829
- />
1830
- )}
1831
- </FilterChip>
1832
- </View>
1833
- {/* the capture row at the TOP — a new task lands on the capture desk
1834
- (the current one; the last desk once closed) */}
1835
- <CaptureRow value={newTask} onChangeText={setNewTask} onSubmit={addTask} placeholder={`Add a task for ${stageOf(captureDesk).label}…`} accessibilityLabel="Add a task" />
1836
- {/* the groups divide via the SubsectionStack beat (space-only) —
1837
- the head is a SubsectionHeading carrying the group's name + count,
1838
- the "all" (ungrouped) view a headingless Subsection */}
1839
- <SubsectionStack>
1840
- {taskSections.map((g) => {
1841
- const desk = g.desk;
1699
+ <Pipeline accessibilityLabel="Desk handoff progress">
1700
+ {DESKS.map((d, i) => {
1701
+ const status: "done" | "current" | "upcoming" = i < deskIndex ? "done" : i === deskIndex ? "current" : "upcoming";
1702
+ const owner = deskOwner[d.key];
1842
1703
  return (
1843
- <Subsection key={g.key}>
1844
- {g.head ? (
1845
- <SubsectionHeading>
1846
- {g.head}
1847
- {g.items.length > 0 ? (
1848
- <Text size="xs" color="muted" tabular>{`${g.items.filter((t) => t.done).length}/${g.items.length}`}</Text>
1849
- ) : null}
1850
- </SubsectionHeading>
1851
- ) : null}
1852
- {/* rows ride the Task compound one geometry, EVERY
1853
- desk's rows fully editable (the stage gates the handoff
1854
- and Billing, never task editing — planning ahead on a
1855
- later desk is normal work) */}
1856
- <TaskList>
1857
- {g.items.map((t) => {
1858
- const next = nextDeskOf(t.stage);
1859
- const menuItems: ActionMenuItem[] = [
1860
- ...(next !== null
1861
- ? [{ key: "chuyen", label: `Move to ${stageOf(next).label}`, icon: "arrow-right" as const, onPress: () => moveTask(t.id, next) }]
1862
- : []),
1863
- { key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => removeTask(t.id) },
1864
- ];
1865
- return (
1866
- <TaskItem key={t.id}>
1867
- <TaskStatus>
1868
- {/* Half-filled once any subtask is in: progress the row already
1869
- holds, that a binary ring would have hidden. Display only —
1870
- the parent's own `done` still wins, and still only it. */}
1871
- <CheckCircle
1872
- state={t.done ? "done" : t.subtasks.some((s) => s.done) ? "partial" : "none"}
1873
- onChange={(on) => toggleTask(t.id, on)}
1874
- accessibilityLabel={t.label}
1875
- />
1876
- </TaskStatus>
1877
- <TaskTitle>
1878
- <InlineTextInput variant="cell" value={t.label} onSave={(v) => renameTask(t.id, v)} struck={t.done} accessibilityLabel="Task title" />
1879
- </TaskTitle>
1880
- <TaskActions>
1881
- <ActionMenu items={menuItems} accessibilityLabel={`Task options: ${t.label}`} />
1882
- </TaskActions>
1883
- {/* Due + assignee are the task's own FIELDS, so they hang UNDER it —
1884
- each name beside the control it names, indented one step. They were
1885
- cells in a value column the LIST declared, which is what put a
1886
- two-word label a quarter of the record away from its own editor.
1887
- The due keeps its urgency tone; the assignee names the member
1888
- rather than showing a bare avatar, because a labelled field has the
1889
- room for it. */}
1890
- <TaskSubRow label="Due">
1891
- <InlineDatePicker tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Not set" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
1892
- </TaskSubRow>
1893
- <TaskSubRow label="Assignee">
1894
- <InlineMemberSelect members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} placeholder="Unassigned" accessibilityLabel={`Assignee · ${t.label}`} />
1895
- </TaskSubRow>
1896
- {t.subtasks.length > 0 ? (
1897
- <TaskList>
1898
- {t.subtasks.map((sub) => (
1899
- <TaskItem key={sub.key}>
1900
- <TaskStatus>
1901
- <CheckCircle state={sub.done ? "done" : "none"} onChange={(on) => toggleSubtask(t.id, sub.key, on)} accessibilityLabel={sub.label} />
1902
- </TaskStatus>
1903
- {/* A STRING title: the compound insets it like the parent's
1904
- inline editor and seats it in the row's band, so the two
1905
- columns of titles line up and the ring stays beside the
1906
- words even when a long subtask wraps. `struck` for done. */}
1907
- <TaskTitle struck={sub.done}>{sub.label}</TaskTitle>
1908
- </TaskItem>
1909
- ))}
1910
- </TaskList>
1911
- ) : null}
1912
- </TaskItem>
1913
- );
1914
- })}
1915
- {/* SUGGESTIONS are pills, not rows — tap to materialize,
1916
- ✕ dismisses for this record; never counted */}
1917
- {desk != null && g.ghosts.length > 0 ? (
1918
- <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, paddingTop: 2, paddingBottom: 4 }}>
1919
- {g.ghosts.map((label) => (
1920
- <SuggestionChip
1921
- key={label}
1922
- label={label}
1923
- onAdd={() => addSuggested(desk, label)}
1924
- onDismiss={() => setDismissed((prev) => (prev.includes(label) ? prev : [...prev, label]))}
1704
+ <PipelineStage
1705
+ key={d.key}
1706
+ status={status}
1707
+ title={d.label}
1708
+ // Prose the reader can't set — how long it has sat here. The DATE
1709
+ // is the field below; repeating it up here would say it twice.
1710
+ meta={status === "current" ? heldFor(deskSince[d.key]) : undefined}
1711
+ >
1712
+ {/* A reached desk owns its own facts — and keeps owning them
1713
+ once the record has moved on. */}
1714
+ {i <= deskIndex ? (
1715
+ <>
1716
+ <PipelineField label="Taken on" maxWidth={200}>
1717
+ <InlineDatePicker
1718
+ value={deskSince[d.key] || null}
1719
+ onSave={(v) => setDeskSince((p) => ({ ...p, [d.key]: v ?? "" }))}
1720
+ onClear={() => setDeskSince((p) => ({ ...p, [d.key]: "" }))}
1721
+ placeholder="Not recorded"
1722
+ accessibilityLabel={`Taken on · ${d.label}`}
1925
1723
  />
1926
- ))}
1927
- </View>
1724
+ </PipelineField>
1725
+ <PipelineField label="Owner" maxWidth={260}>
1726
+ <InlineMemberSelect
1727
+ members={TEAM}
1728
+ value={owner}
1729
+ onSave={(v) => setDeskOwner((p) => ({ ...p, [d.key]: v }))}
1730
+ placeholder="Unassigned"
1731
+ accessibilityLabel={`Owner · ${d.label}`}
1732
+ />
1733
+ </PipelineField>
1734
+ </>
1735
+ ) : null}
1736
+ {/* A condition belongs to the desk it is about. */}
1737
+ {status === "current" && stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
1738
+ <PipelineNote tone="warning">Past the due date — chase it or move the date.</PipelineNote>
1928
1739
  ) : null}
1929
- </TaskList>
1930
- </Subsection>
1740
+ {status === "current" && gate ? (
1741
+ <PipelineActions>
1742
+ <Button
1743
+ title={gate.cta}
1744
+ color="primary"
1745
+ onPress={gate.handoff ? () => { setHandoffTo(gate.next); setHandoffOpen(true); } : closeRecord}
1746
+ />
1747
+ </PipelineActions>
1748
+ ) : null}
1749
+ </PipelineStage>
1931
1750
  );
1932
1751
  })}
1933
- </SubsectionStack>
1752
+ </Pipeline>
1934
1753
  </Section>
1935
1754
  </View>
1936
1755
 
@@ -2510,13 +2329,17 @@ export function TplRecord() {
2510
2329
  </View>
2511
2330
  ) : null}
2512
2331
  {handoffs.length > 0 ? <Timeline items={handoffs} /> : null}
2513
- {/* the handoff happens ONCE before it, the single CTA; after it,
2514
- the mark + the linked record ARE the section, no further CTAs */}
2515
- {siblings.length === 0 && gate ? (
2516
- <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
2517
- <Button title={gate.cta} color="primary" onPress={() => setHandoffOpen(true)} />
2518
- </View>
2332
+ {/* Nothing handed off YET is a real state, not an absent section — and
2333
+ its empty state carries NO action: the act lives on the live stage
2334
+ in Progress, so a CTA here would be the second copy this section
2335
+ exists to avoid. */}
2336
+ {siblings.length === 0 && handoffs.length === 0 ? (
2337
+ <EmptyState icon="send" message="Not handed off yet" hint={`${stageOf(stage).label} still holds this record — hand it off from Progress above.`} />
2519
2338
  ) : null}
2339
+ {/* No CTA here. The act that leaves a desk belongs ON that desk — it
2340
+ rides the live stage in the Progress pipeline. This section is the
2341
+ RESULT: what the handoff created and when. A second button here
2342
+ would offer the same act twice, in the place with less context. */}
2520
2343
  </Section>
2521
2344
  </View>
2522
2345
 
@@ -2767,8 +2590,13 @@ export function TplRecord() {
2767
2590
  <View />
2768
2591
  )}
2769
2592
  <DrawerFooter>
2770
- {/* recall = the semi-destructive undo — behind the details, confirmed */}
2771
- {openSibling ? <Button title="Recall handoff" color="danger-secondary" onPress={() => recallHandoff(openSibling)} /> : null}
2593
+ {/* Recall = the semi-destructive undo — behind the details, confirmed.
2594
+ Offered ONLY on the handoff that put the record where it is. A
2595
+ superseded one cannot be undone in place: withdrawing the middle
2596
+ step would leave the record behind a sibling that still exists, so
2597
+ `stage` and the linked records would disagree. Walk it back one
2598
+ handoff at a time. */}
2599
+ {openSibling && openSibling.deskKey === stage ? <Button title="Recall handoff" color="danger-secondary" onPress={() => recallHandoff(openSibling)} /> : null}
2772
2600
  {/* a real app navigates to the sibling record's page */}
2773
2601
  <Button title="Open record" color="secondary" onPress={() => {}} />
2774
2602
  </DrawerFooter>
@@ -2778,10 +2606,10 @@ export function TplRecord() {
2778
2606
  exactly WHAT is being handed off. Confirm writes the trail entry. */}
2779
2607
  <Dialog width={480} open={handoffOpen} onOpenChange={(o) => { if (!o) { setHandoffOpen(false); setHandoffAssignee(null); setHandoffNote(""); } }}>
2780
2608
  <DialogHeader>
2781
- <DialogHeaderTitle>{gate ? gate.cta : "Hand off"}</DialogHeaderTitle>
2609
+ <DialogHeaderTitle>{`Hand off to ${stageOf(handoffTo).label}`}</DialogHeaderTitle>
2782
2610
  </DialogHeader>
2783
2611
  <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 14 }}>
2784
- <FormField label={gate ? `Assignee at ${stageOf(gate.next).label}` : "Assignee"}>
2612
+ <FormField label={`Assignee at ${stageOf(handoffTo).label}`}>
2785
2613
  <MemberSelect members={TEAM} value={handoffAssignee} onValueChange={setHandoffAssignee} placeholder="Who receives the record…" />
2786
2614
  </FormField>
2787
2615
  <FormTextInput label="Note" optional placeholder="What the next desk should know…" value={handoffNote} onChangeText={setHandoffNote} multiline accessibilityLabel="Handoff note" />
@@ -2790,7 +2618,7 @@ export function TplRecord() {
2790
2618
  <Button title="Cancel" color="muted" onPress={() => { setHandoffOpen(false); setHandoffAssignee(null); setHandoffNote(""); }} />
2791
2619
  {/* one required field, right there — the adjacent-input exception:
2792
2620
  disabled until the receiver is chosen */}
2793
- <Button title={gate ? gate.cta : "Hand off"} color="primary" disabled={handoffAssignee == null} onPress={confirmHandoff} />
2621
+ <Button title={`Hand off to ${stageOf(handoffTo).label}`} color="primary" disabled={handoffAssignee == null} onPress={confirmHandoff} />
2794
2622
  </DialogFooter>
2795
2623
  </Dialog>
2796
2624
  {/* Balances the rail so the reading column lands dead-centre. In the docs