@lotics/ui 3.5.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +323 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/callout.tsx +50 -17
  35. package/src/combobox.tsx +22 -6
  36. package/src/form_date_picker.tsx +2 -0
  37. package/src/form_picker.tsx +1 -0
  38. package/src/form_switch.tsx +1 -0
  39. package/src/form_text_input.tsx +2 -0
  40. package/src/icon.tsx +2 -0
  41. package/src/icon_button.tsx +5 -2
  42. package/src/inline_date_picker.tsx +110 -0
  43. package/src/inline_edit.tsx +228 -0
  44. package/src/inline_number_input.tsx +70 -0
  45. package/src/inline_select.tsx +91 -0
  46. package/src/inline_text_input.tsx +71 -0
  47. package/src/inline_time_picker.tsx +64 -0
  48. package/src/line_chart.tsx +4 -0
  49. package/src/list_item.tsx +5 -0
  50. package/src/number_input.tsx +12 -1
  51. package/src/page_content.tsx +5 -0
  52. package/src/section_heading.tsx +43 -29
  53. package/src/tag_input.tsx +202 -0
  54. package/src/time_picker.tsx +15 -3
  55. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,481 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors, solid, type ColorName } from "@lotics/ui/colors";
5
+ import { Avatar } from "@lotics/ui/avatar";
6
+ import { Badge } from "@lotics/ui/badge";
7
+ import { Button } from "@lotics/ui/button";
8
+ import { Card } from "@lotics/ui/card";
9
+ import { ChipGroup } from "@lotics/ui/chip_group";
10
+ import { DatePicker } from "@lotics/ui/date_picker";
11
+ import { Divider } from "@lotics/ui/divider";
12
+ import { EmptyState } from "@lotics/ui/empty_state";
13
+ import { DetailRow } from "@lotics/ui/detail_row";
14
+ import { FormField } from "@lotics/ui/form_field";
15
+ import { formatMoney } from "@lotics/ui/format_money";
16
+ import { Icon, type IconName } from "@lotics/ui/icon";
17
+ import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
18
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
19
+ import { PressableRow } from "@lotics/ui/pressable_row";
20
+ import { Pagination } from "@lotics/ui/pagination";
21
+ import { SearchInput } from "@lotics/ui/search_input";
22
+ import { TextInputField } from "@lotics/ui/text_input_field";
23
+ import { TimePicker } from "@lotics/ui/time_picker";
24
+ import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
25
+ import { InlineSelect } from "@lotics/ui/inline_select";
26
+ import { InlineTextInput } from "@lotics/ui/inline_text_input";
27
+ import { InlineNumberInput } from "@lotics/ui/inline_number_input";
28
+
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+ // Template · Call console — a salesperson's full operator workspace. NOT the
31
+ // manager's assignment board ("Assign & work"): the rep sees only their leads and
32
+ // works each one end to end without leaving the screen. Two panes — a searchable,
33
+ // filterable QUEUE on the left, and on the right the full lead workspace: call +
34
+ // log the outcome, CAPTURE the applicant (eligibility code, national ID, address,
35
+ // household, income — the fields the application needs), read the history, and
36
+ // CONVERT once qualified. A KPIStrip tracks the rep's day.
37
+ //
38
+ // Reference composition — KPIStrip + SearchInput + ChipGroup (queue) + FormField/
39
+ // TextInputField/NumberInput (data capture) + Timeline (history). No bespoke
40
+ // "call-log" component: logging + capture are template recipes over primitives.
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ type OutcomeKey = "interested" | "callback" | "not_interested" | "no_answer";
44
+
45
+ const OUTCOMES: Record<OutcomeKey, { label: string; icon: IconName; color: ColorName; status: string }> = {
46
+ interested: { label: "Interested", icon: "circle-check", color: "emerald", status: "Warm" },
47
+ callback: { label: "Call back", icon: "calendar", color: "blue", status: "Following up" },
48
+ not_interested: { label: "Not interested", icon: "circle-alert", color: "amber", status: "Cold" },
49
+ no_answer: { label: "No answer", icon: "ban", color: "zinc", status: "No answer" },
50
+ };
51
+
52
+ // The "number" — the eligibility category the rep assigns to qualify a lead.
53
+ const CODES = [
54
+ { value: "A1", label: "A1 · Low income" },
55
+ { value: "B2", label: "B2 · Senior 60+" },
56
+ { value: "C1", label: "C1 · Disability" },
57
+ { value: "D1", label: "D1 · Single parent" },
58
+ ];
59
+
60
+ const TODAY = "12/06";
61
+ const TARGET = 15;
62
+
63
+ interface CallEvent { id: string; time: string; outcome: OutcomeKey; notes?: string; callbackAt?: string }
64
+ interface Lead {
65
+ id: string; name: string; phone: string; source: string; note: string;
66
+ code: string | null; idNumber: string; address: string;
67
+ household: number | null; income: number | null;
68
+ calls: CallEvent[];
69
+ /** A scheduled callback — set when logging a "Call back" outcome. */
70
+ callbackDue?: string;
71
+ /** Set when the rep hands a qualified lead to the manager to process. */
72
+ requested?: { at: string; note?: string };
73
+ }
74
+
75
+ const LEADS: Lead[] = [
76
+ { id: "L1", name: "Daniel Reyes", phone: "0903 558 214", source: "Facebook", note: "Commented on the June ad — asking about a 2-bedroom unit.", code: null, idNumber: "", address: "", household: null, income: null, calls: [] },
77
+ { id: "L2", name: "Priya Sharma", phone: "0915 027 884", source: "Hotline", note: "Called the hotline asking what documents she needs to apply.", code: null, idNumber: "", address: "", household: null, income: null, calls: [] },
78
+ { id: "L3", name: "Marcus Bell", phone: "0922 770 145", source: "Referral", note: "Referred by an existing applicant.", code: null, idNumber: "", address: "", household: null, income: null, calls: [] },
79
+ { id: "L4", name: "Emily Watson", phone: "0987 220 415", source: "Referral", note: "Wanted a callback after 3pm — she was in a meeting.", code: "A1", idNumber: "079 188 442 015", address: "44 Harbor Rd, District 4", household: 4, income: 9_500_000, callbackDue: "12/06 · 15:00", calls: [
80
+ { id: "c1", time: "12/06 · 09:18", outcome: "callback", notes: "In a meeting — call back after 3pm to finish the income check.", callbackAt: "12/06 · 15:00" },
81
+ { id: "c2", time: "06/06 · 15:30", outcome: "no_answer" },
82
+ ] },
83
+ { id: "L5", name: "Michael Turner", phone: "0912 384 756", source: "Facebook", note: "Qualifies on income — ready to start the application.", code: "A1", idNumber: "060 199 305 778", address: "12 Maple St, District 7", household: 3, income: 7_200_000, calls: [
84
+ { id: "c1", time: "12/06 · 10:42", outcome: "interested", notes: "Confirmed income and household. Wants to convert and start documents." },
85
+ { id: "c2", time: "06/06 · 09:05", outcome: "callback" },
86
+ ] },
87
+ { id: "L6", name: "James Holt", phone: "0918 736 245", source: "Website", note: "Downloaded the eligibility guide.", code: null, idNumber: "", address: "", household: null, income: null, calls: [
88
+ { id: "c1", time: "11/06 · 14:10", outcome: "not_interested", notes: "Found private housing instead." },
89
+ ] },
90
+ ];
91
+
92
+ const lastOf = (l: Lead) => l.calls[0];
93
+ const calledToday = (l: Lead) => l.calls.some((c) => c.time.startsWith(TODAY));
94
+ const isWarm = (l: Lead) => lastOf(l)?.outcome === "interested";
95
+ const isFollowUp = (l: Lead) => lastOf(l)?.outcome === "callback";
96
+
97
+ const FILTERS = [
98
+ { value: "all", label: "All", match: () => true },
99
+ { value: "tocall", label: "To call", match: (l: Lead) => !calledToday(l) },
100
+ { value: "followup", label: "Follow-up", match: isFollowUp },
101
+ { value: "warm", label: "Warm", match: isWarm },
102
+ ] as const;
103
+
104
+ // A picker date (yyyy-MM-dd) + optional time → "dd/MM" or "dd/MM · HH:mm".
105
+ const fmtCallback = (date: string, time: string) => {
106
+ if (!date) return "";
107
+ const p = date.split("-");
108
+ const d = p.length === 3 ? `${p[2]}/${p[1]}` : date;
109
+ return time ? `${d} · ${time}` : d;
110
+ };
111
+
112
+ function QueueRow({ lead, active, onPress }: { lead: Lead; active: boolean; onPress: () => void }) {
113
+ const last = lastOf(lead);
114
+ return (
115
+ <PressableRow onPress={onPress} selected={active} style={{ paddingVertical: 16 }}>
116
+ <View style={{ flex: 1, gap: 5 }}>
117
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
118
+ <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{lead.name}</Text>
119
+ {lead.code ? <Badge label={lead.code} color="blue" /> : null}
120
+ </View>
121
+ <Text size="xs" color="muted" tabular>{lead.phone}</Text>
122
+ <Text size="xs" color={lead.callbackDue ? "default" : "muted"}>
123
+ {lead.callbackDue ? `Due ${lead.callbackDue}` : last ? `${OUTCOMES[last.outcome].status} · ${last.time}` : "Not contacted yet"}
124
+ </Text>
125
+ </View>
126
+ <Badge
127
+ variant="dot"
128
+ label={lead.requested ? "Requested" : lead.callbackDue ? "Call back" : calledToday(lead) ? "Called" : "To call"}
129
+ color={lead.requested ? "amber" : lead.callbackDue ? "violet" : calledToday(lead) ? "emerald" : "blue"}
130
+ />
131
+ </PressableRow>
132
+ );
133
+ }
134
+
135
+ // The handoff — a qualified lead is REQUESTED for the manager to process (the rep
136
+ // qualifies + captures; the manager runs the receipt / payment / documents). Carries
137
+ // a recap + an optional message so the manager can pick it up cold.
138
+ function RequestGate({ lead, onRequest }: { lead: Lead; onRequest: (note: string) => void }) {
139
+ const [open, setOpen] = useState(false);
140
+ const [note, setNote] = useState("");
141
+ return (
142
+ <Popover open={open} onOpenChange={setOpen} side="top" align="end">
143
+ <PopoverTrigger>
144
+ <Button title="Request to process" color="primary" onPress={() => {}} />
145
+ </PopoverTrigger>
146
+ <PopoverContent style={{ width: 340 }} disableBodyScroll>
147
+ <View style={{ gap: 12 }}>
148
+ <View style={{ gap: 2 }}>
149
+ <Text size="sm" weight="semibold">{`Request processing — ${lead.name}`}</Text>
150
+ <Text size="xs" color="muted">The manager picks this up to generate the receipt, confirm payment, then build the application.</Text>
151
+ </View>
152
+ <View style={{ gap: 6 }}>
153
+ <DetailRow label="Eligibility"><Text size="sm">{CODES.find((c) => c.value === lead.code)?.label ?? "—"}</Text></DetailRow>
154
+ <DetailRow label="Household"><Text size="sm" tabular>{`${lead.household ?? "—"} people`}</Text></DetailRow>
155
+ <DetailRow label="Income"><Text size="sm" tabular>{lead.income != null ? formatMoney(lead.income) : "—"}</Text></DetailRow>
156
+ </View>
157
+ <FormField label="Message for the manager (optional)">
158
+ <TextInputField value={note} onChangeText={setNote} multiline numberOfLines={2} autoGrow placeholder="e.g. Wants to start this week — income verified" accessibilityLabel="Message for the manager" />
159
+ </FormField>
160
+ </View>
161
+ <PopoverFooter>
162
+ <Button title="Send request" color="primary" onPress={() => { setOpen(false); onRequest(note); }} />
163
+ </PopoverFooter>
164
+ </PopoverContent>
165
+ </Popover>
166
+ );
167
+ }
168
+
169
+ // Add a lead the rep sourced themselves — straight into their own queue.
170
+ function CreateLeadGate({ onCreate }: { onCreate: (name: string, phone: string, source: string) => void }) {
171
+ const [open, setOpen] = useState(false);
172
+ const [name, setName] = useState("");
173
+ const [phone, setPhone] = useState("");
174
+ const [source, setSource] = useState("");
175
+ const valid = name.trim() !== "" && phone.trim() !== "";
176
+ return (
177
+ <Popover open={open} onOpenChange={setOpen} side="bottom" align="end">
178
+ <PopoverTrigger>
179
+ <Button title="New lead" icon="plus" color="secondary" onPress={() => {}} />
180
+ </PopoverTrigger>
181
+ <PopoverContent style={{ width: 320 }} disableBodyScroll>
182
+ <View style={{ gap: 12 }}>
183
+ <Text size="sm" weight="semibold">Add a lead</Text>
184
+ <FormField label="Name"><TextInputField value={name} onChangeText={setName} placeholder="Full name" accessibilityLabel="Lead name" /></FormField>
185
+ <FormField label="Phone"><TextInputField value={phone} onChangeText={setPhone} placeholder="09xx xxx xxx" accessibilityLabel="Lead phone" /></FormField>
186
+ <FormField label="Source"><TextInputField value={source} onChangeText={setSource} placeholder="e.g. Walk-in, Referral" accessibilityLabel="Lead source" /></FormField>
187
+ </View>
188
+ <PopoverFooter>
189
+ <Button title="Add lead" color="primary" disabled={!valid} onPress={() => { setOpen(false); onCreate(name, phone, source); setName(""); setPhone(""); setSource(""); }} />
190
+ </PopoverFooter>
191
+ </PopoverContent>
192
+ </Popover>
193
+ );
194
+ }
195
+
196
+ function LeadWorkspace({ lead, onUpdate, onLog, onRequest, onWithdraw }: {
197
+ lead: Lead;
198
+ onUpdate: (patch: Partial<Lead>) => void;
199
+ onLog: (outcome: OutcomeKey, notes: string, callbackDue?: string) => void;
200
+ onRequest: (note: string) => void;
201
+ onWithdraw: () => void;
202
+ }) {
203
+ const [outcome, setOutcome] = useState<OutcomeKey | "">("");
204
+ const [notes, setNotes] = useState("");
205
+ const [cbDate, setCbDate] = useState("");
206
+ const [cbTime, setCbTime] = useState("");
207
+ const missing = [
208
+ lead.code === null ? "an eligibility code" : null,
209
+ !isWarm(lead) ? "a warm call outcome" : null,
210
+ lead.idNumber.trim() === "" ? "the national ID" : null,
211
+ lead.income === null ? "income" : null,
212
+ ].filter((m): m is string => m !== null);
213
+ const ready = missing.length === 0;
214
+ const history: TimelineItem[] = lead.calls.map((c) => ({
215
+ id: c.id,
216
+ icon: OUTCOMES[c.outcome].icon,
217
+ iconColor: solid(OUTCOMES[c.outcome].color),
218
+ label: c.outcome === "callback" && c.callbackAt ? `${OUTCOMES[c.outcome].label} · ${c.callbackAt}` : OUTCOMES[c.outcome].label,
219
+ description: c.notes,
220
+ right: <Text size="xs" color="muted" tabular>{c.time}</Text>,
221
+ }));
222
+ return (
223
+ <Card style={{ padding: 0 }}>
224
+ {lead.requested ? (
225
+ <>
226
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 14, backgroundColor: colors.amber[50] }}>
227
+ <View style={{ flex: 1, gap: 2 }}>
228
+ <Text size="sm" weight="medium">{`Requested to process · ${lead.requested.at}`}</Text>
229
+ <Text size="xs" color="muted">{lead.requested.note ? `Awaiting the manager — "${lead.requested.note}"` : "Awaiting the manager to pick this up"}</Text>
230
+ </View>
231
+ <Button title="Withdraw" color="muted" onPress={onWithdraw} />
232
+ </View>
233
+ <Divider />
234
+ </>
235
+ ) : null}
236
+ {/* identity + the call / handoff actions */}
237
+ <View style={{ padding: 20, gap: 14 }}>
238
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
239
+ <Avatar name={lead.name} size={44} />
240
+ <View style={{ flex: 1, gap: 1 }}>
241
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
242
+ <Text size="lg" weight="semibold">{lead.name}</Text>
243
+ {lead.code ? (
244
+ <Badge label={CODES.find((c) => c.value === lead.code)?.label ?? lead.code} color="blue" />
245
+ ) : (
246
+ <Badge label="No code yet" color="zinc" />
247
+ )}
248
+ </View>
249
+ <Text size="sm" color="muted" tabular>{`${lead.phone} · ${lead.source}`}</Text>
250
+ </View>
251
+ </View>
252
+ <Text size="sm" color="muted">{lead.note}</Text>
253
+ {lead.callbackDue ? (
254
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
255
+ <Icon name="calendar" size={14} color={solid("blue")} />
256
+ <Text size="sm">{`Scheduled callback · ${lead.callbackDue}`}</Text>
257
+ </View>
258
+ ) : null}
259
+ <View style={{ gap: 8 }}>
260
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
261
+ <Button title={`Call ${lead.phone}`} color="primary" onPress={() => {}} />
262
+ <View style={{ flex: 1 }} />
263
+ {lead.requested ? (
264
+ <Button title="Requested" color="secondary" disabled onPress={() => {}} />
265
+ ) : ready ? (
266
+ <RequestGate lead={lead} onRequest={onRequest} />
267
+ ) : (
268
+ <Button title="Request to process" color="secondary" disabled onPress={() => {}} />
269
+ )}
270
+ </View>
271
+ {!lead.requested ? (
272
+ ready ? (
273
+ <Text size="xs" color="success">Eligible — hand to the manager to process</Text>
274
+ ) : (
275
+ <Text size="xs" color="muted">{`To request processing: ${missing.join(", ")}`}</Text>
276
+ )
277
+ ) : null}
278
+ </View>
279
+ </View>
280
+ <Divider />
281
+
282
+ {/* log this call — the rep's main action */}
283
+ <View style={{ padding: 20, gap: 12 }}>
284
+ <Text size="sm" weight="semibold">Log this call</Text>
285
+ <ChipGroup
286
+ accessibilityLabel="Call outcome"
287
+ options={(Object.keys(OUTCOMES) as OutcomeKey[]).map((k) => ({ label: OUTCOMES[k].label, value: k }))}
288
+ value={outcome}
289
+ onValueChange={(v) => setOutcome(v as OutcomeKey)}
290
+ />
291
+ {outcome === "callback" ? (
292
+ <View style={{ flexDirection: "row", gap: 12, flexWrap: "wrap" }}>
293
+ <View style={{ flexGrow: 1, flexBasis: 160 }}>
294
+ <FormField label="Call back on">
295
+ <DatePicker value={cbDate} onValueChange={setCbDate} format="date" />
296
+ </FormField>
297
+ </View>
298
+ <View style={{ flexGrow: 1, flexBasis: 120 }}>
299
+ <FormField label="At (optional)">
300
+ <TimePicker value={cbTime} onValueChange={setCbTime} />
301
+ </FormField>
302
+ </View>
303
+ </View>
304
+ ) : null}
305
+ <TextInputField
306
+ value={notes}
307
+ onChangeText={setNotes}
308
+ multiline
309
+ numberOfLines={3}
310
+ autoGrow
311
+ placeholder="Notes — what was discussed, and the next step"
312
+ accessibilityLabel="Call notes"
313
+ />
314
+ <View style={{ flexDirection: "row" }}>
315
+ <View style={{ flex: 1 }} />
316
+ <Button
317
+ title="Log call"
318
+ color="primary"
319
+ disabled={outcome === ""}
320
+ onPress={() => { if (outcome !== "") { onLog(outcome, notes, outcome === "callback" ? fmtCallback(cbDate, cbTime) : undefined); setOutcome(""); setNotes(""); setCbDate(""); setCbTime(""); } }}
321
+ />
322
+ </View>
323
+ </View>
324
+ <Divider />
325
+
326
+ {/* call history — directly under the log so the two read as one block */}
327
+ <View style={{ padding: 20, gap: 12 }}>
328
+ <Text size="sm" weight="semibold">{`Call history · ${lead.calls.length}`}</Text>
329
+ {lead.calls.length > 0 ? <Timeline items={history} /> : <Text size="sm" color="muted">No calls logged yet.</Text>}
330
+ </View>
331
+ <Divider />
332
+
333
+ {/* applicant details — each field edits IN PLACE; no form mode, no Save */}
334
+ <View style={{ padding: 20, gap: 14 }}>
335
+ <Text size="sm" weight="semibold">Applicant details</Text>
336
+ <View style={{ gap: 2 }}>
337
+ <DetailRow label="Eligibility code" labelWidth={140} minHeight={40}>
338
+ <View style={{ flex: 1 }}>
339
+ <InlineSelect value={lead.code} onSave={(v) => onUpdate({ code: v })} options={CODES} placeholder="Assign a code…" accessibilityLabel="Eligibility code" />
340
+ </View>
341
+ </DetailRow>
342
+ <DetailRow label="National ID" labelWidth={140} minHeight={40}>
343
+ <View style={{ flex: 1 }}>
344
+ <InlineTextInput value={lead.idNumber} onSave={(v) => onUpdate({ idNumber: v })} placeholder="Add national ID…" accessibilityLabel="National ID" />
345
+ </View>
346
+ </DetailRow>
347
+ <DetailRow label="Address" labelWidth={140} minHeight={40}>
348
+ <View style={{ flex: 1 }}>
349
+ <InlineTextInput value={lead.address} onSave={(v) => onUpdate({ address: v })} placeholder="Add address…" accessibilityLabel="Address" />
350
+ </View>
351
+ </DetailRow>
352
+ <DetailRow label="Household size" labelWidth={140} minHeight={40}>
353
+ <View style={{ flex: 1 }}>
354
+ <InlineNumberInput value={lead.household} onSave={(v) => onUpdate({ household: v })} min={1} max={20} format={(n) => (n == null ? "" : `${n} people`)} placeholder="Add household size…" accessibilityLabel="Household size" />
355
+ </View>
356
+ </DetailRow>
357
+ <DetailRow label="Monthly income" labelWidth={140} minHeight={40}>
358
+ <View style={{ flex: 1 }}>
359
+ <InlineNumberInput value={lead.income} onSave={(v) => onUpdate({ income: v })} min={0} format={(n) => (n == null ? "" : formatMoney(n))} placeholder="Add monthly income…" accessibilityLabel="Monthly income" />
360
+ </View>
361
+ </DetailRow>
362
+ </View>
363
+ </View>
364
+ </Card>
365
+ );
366
+ }
367
+
368
+ const PAGE_SIZE = 8;
369
+ // Filler so the queue spans more than one page — a rep's list runs long.
370
+ const FILLER_LEADS: Lead[] = ["Aiden Cole", "Zoe Hart", "Felix Wood", "Iris Lane", "Owen Pike", "Ruby Shaw", "Leo Vance", "Nina Frost"].map((name, i) => ({
371
+ id: `LF${i}`, name, phone: `09${20 + i} ${330 + i * 3} ${140 + i * 7}`,
372
+ source: ["Facebook", "Hotline", "Referral", "Walk-in"][i % 4], note: "New lead — not contacted yet.",
373
+ code: null, idNumber: "", address: "", household: null, income: null, calls: [],
374
+ }));
375
+
376
+ export function TplCallsheet() {
377
+ const [leads, setLeads] = useState<Lead[]>([...LEADS, ...FILLER_LEADS]);
378
+ const [activeId, setActiveId] = useState<string>(LEADS[0].id);
379
+ const [filter, setFilter] = useState("all");
380
+ const [q, setQ] = useState("");
381
+ const [page, setPage] = useState(0);
382
+
383
+ const callsToday = leads.reduce((s, l) => s + l.calls.filter((c) => c.time.startsWith(TODAY)).length, 0);
384
+ const followUps = leads.filter(isFollowUp).length;
385
+ const warm = leads.filter(isWarm).length;
386
+
387
+ const query = q.trim().toLowerCase();
388
+ const matchFilter = FILTERS.find((f) => f.value === filter)?.match ?? (() => true);
389
+ const queue = leads
390
+ .filter((l) => matchFilter(l) && (query === "" || l.name.toLowerCase().includes(query) || l.phone.includes(query)))
391
+ .sort((a, b) => (calledToday(a) ? 1 : 0) - (calledToday(b) ? 1 : 0));
392
+ const safePage = Math.min(page, Math.max(0, Math.ceil(queue.length / PAGE_SIZE) - 1));
393
+ const pageQueue = queue.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE);
394
+ const active = leads.find((l) => l.id === activeId) ?? leads[0];
395
+
396
+ const update = (patch: Partial<Lead>) => setLeads((prev) => prev.map((l) => (l.id === activeId ? { ...l, ...patch } : l)));
397
+ const log = (outcome: OutcomeKey, notes: string, callbackDue?: string) =>
398
+ setLeads((prev) => prev.map((l) =>
399
+ l.id === activeId
400
+ ? {
401
+ ...l,
402
+ callbackDue: outcome === "callback" ? callbackDue || undefined : undefined,
403
+ calls: [{ id: `c${l.calls.length + 1}t`, time: `${TODAY} · just now`, outcome, notes: notes.trim() || undefined, callbackAt: outcome === "callback" ? callbackDue || undefined : undefined }, ...l.calls],
404
+ }
405
+ : l,
406
+ ));
407
+ const createLead = (name: string, phone: string, source: string) => {
408
+ const id = `new-${leads.length}`;
409
+ const lead: Lead = { id, name: name.trim(), phone: phone.trim(), source: source.trim() || "Manual", note: "Added manually", code: null, idNumber: "", address: "", household: null, income: null, calls: [] };
410
+ setLeads((prev) => [lead, ...prev]);
411
+ setActiveId(id);
412
+ };
413
+
414
+ return (
415
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
416
+ <View style={{ width: "100%", maxWidth: 1140, alignSelf: "center", gap: 16 }}>
417
+ {/* header */}
418
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12, flexWrap: "wrap" }}>
419
+ <View style={{ gap: 2, flex: 1 }}>
420
+ <Text size="xl" weight="semibold">My leads</Text>
421
+ <Text size="sm" color="muted">Sarah Chen · call each lead, capture their details, log the outcome — convert once they qualify</Text>
422
+ </View>
423
+ <CreateLeadGate onCreate={createLead} />
424
+ </View>
425
+
426
+ <KPIStrip
427
+ items={[
428
+ { label: "Assigned to me", value: leads.length, format: "number" },
429
+ { label: "Calls today", value: callsToday, format: "number", caption: `of ${TARGET} target` },
430
+ { label: "Follow-ups due", value: followUps, format: "number", tone: followUps > 0 ? "warning" : "default" },
431
+ { label: "Warm leads", value: warm, format: "number", caption: "ready to convert" },
432
+ ]}
433
+ />
434
+
435
+ {/* two panes — the filtered queue, and the lead being worked */}
436
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
437
+ <View style={{ flexGrow: 1, flexBasis: 360 }}>
438
+ <Card style={{ padding: 0 }}>
439
+ <View style={{ padding: 16, gap: 10 }}>
440
+ <SearchInput placeholder="Search name or phone" value={q} onChangeText={(v) => { setQ(v); setPage(0); }} accessibilityLabel="Search my leads" />
441
+ <ChipGroup
442
+ accessibilityLabel="Filter leads"
443
+ options={FILTERS.map((f) => ({ label: `${f.label} · ${leads.filter(f.match).length}`, value: f.value }))}
444
+ value={filter}
445
+ onValueChange={(v) => { setFilter(v); setPage(0); }}
446
+ />
447
+ </View>
448
+ <Divider />
449
+ {queue.length > 0 ? (
450
+ <>
451
+ {pageQueue.map((l, i) => (
452
+ <View key={l.id}>
453
+ {i > 0 ? <Divider /> : null}
454
+ <QueueRow lead={l} active={l.id === activeId} onPress={() => setActiveId(l.id)} />
455
+ </View>
456
+ ))}
457
+ <Divider />
458
+ <View style={{ paddingHorizontal: 16, paddingVertical: 10 }}>
459
+ <Pagination page={safePage} pageSize={PAGE_SIZE} rowCount={pageQueue.length} hasMore={(safePage + 1) * PAGE_SIZE < queue.length} total={queue.length} onPageChange={setPage} />
460
+ </View>
461
+ </>
462
+ ) : (
463
+ <View style={{ padding: 24 }}><EmptyState message="No leads match" hint="Clear the search or pick another filter" /></View>
464
+ )}
465
+ </Card>
466
+ </View>
467
+ <View style={{ flexGrow: 1.7, flexBasis: 520 }}>
468
+ <LeadWorkspace
469
+ key={active.id}
470
+ lead={active}
471
+ onUpdate={update}
472
+ onLog={log}
473
+ onRequest={(note) => update({ requested: { at: TODAY, note: note.trim() || undefined } })}
474
+ onWithdraw={() => update({ requested: undefined })}
475
+ />
476
+ </View>
477
+ </View>
478
+ </View>
479
+ </ScrollView>
480
+ );
481
+ }