@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.
- package/AGENTS.md +7 -0
- package/MIGRATION.md +16 -0
- package/docs/catalog.md +28 -8
- package/docs/templates.md +70 -21
- package/examples/tpl_record.tsx +181 -353
- package/package.json +2 -1
- package/src/data_grid.tsx +44 -11
- package/src/ledger.tsx +16 -3
- package/src/pipeline.tsx +156 -0
- package/src/step_progress.tsx +8 -0
- package/src/stepper.tsx +40 -20
package/examples/tpl_record.tsx
CHANGED
|
@@ -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
|
-
// ·
|
|
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
|
|
115
|
-
//
|
|
116
|
-
// the
|
|
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
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
// ──
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
const
|
|
1146
|
-
|
|
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 CUSTODY — what 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
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
|
|
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"
|
|
1185
|
+
? { cta: "Hand off to Operations", next: "operations", handoff: true }
|
|
1296
1186
|
: stage === "operations"
|
|
1297
|
-
? { cta: "Hand off to Accounting", next: "accounting"
|
|
1187
|
+
? { cta: "Hand off to Accounting", next: "accounting", handoff: true }
|
|
1298
1188
|
: stage === "accounting"
|
|
1299
|
-
? { cta: "Close record",
|
|
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 (
|
|
1215
|
+
if (handoffAssignee == null) return;
|
|
1318
1216
|
const to = TEAM.find((m) => m.id === handoffAssignee);
|
|
1319
1217
|
const at = new Date();
|
|
1320
|
-
const
|
|
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:
|
|
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 ${
|
|
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 ${
|
|
1337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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", "
|
|
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
|
|
1464
|
-
(
|
|
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:
|
|
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={
|
|
1509
|
-
onSave={persist(
|
|
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
|
-
{/*
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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="
|
|
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
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1930
|
-
|
|
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
|
-
</
|
|
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
|
-
{/*
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
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
|
-
{/*
|
|
2771
|
-
|
|
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>{
|
|
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={
|
|
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={
|
|
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
|