@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.
- package/AGENTS.md +323 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/callout.tsx +50 -17
- package/src/combobox.tsx +22 -6
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { ReactNode, 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 { Badge } from "@lotics/ui/badge";
|
|
6
|
+
import { Button } from "@lotics/ui/button";
|
|
7
|
+
import { Card } from "@lotics/ui/card";
|
|
8
|
+
import { DetailRow } from "@lotics/ui/detail_row";
|
|
9
|
+
import { Divider } from "@lotics/ui/divider";
|
|
10
|
+
import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
|
|
11
|
+
import { FileBadge } from "@lotics/ui/file_badge";
|
|
12
|
+
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
13
|
+
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
14
|
+
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
15
|
+
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
16
|
+
import { Icon, type IconName } from "@lotics/ui/icon";
|
|
17
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
18
|
+
import { Pagination } from "@lotics/ui/pagination";
|
|
19
|
+
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
20
|
+
import { Stepper } from "@lotics/ui/stepper";
|
|
21
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
22
|
+
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
23
|
+
import { DatePicker } from "@lotics/ui/date_picker";
|
|
24
|
+
import { FormField } from "@lotics/ui/form_field";
|
|
25
|
+
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
26
|
+
import { TimePicker } from "@lotics/ui/time_picker";
|
|
27
|
+
import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// Template · Conversion desk — the manager's back half. Reps hand off qualified
|
|
31
|
+
// leads (Call console's "Request to process"); here the manager runs each into an
|
|
32
|
+
// application through a STAGED process: receipt → payment → documents → collect →
|
|
33
|
+
// submit. The queue is a full-width PAGINATED list (ready for a long backlog);
|
|
34
|
+
// opening an applicant slides in a DRAWER showing the WHOLE process at once — a
|
|
35
|
+
// horizontal Stepper plus every stage stacked and visible (no click-to-reveal),
|
|
36
|
+
// the current stage's action pinned in the footer. The collect stage pairs a
|
|
37
|
+
// required-document checklist with a free FILE DUMP for anything extra. The
|
|
38
|
+
// drawer's ◀▶ keys walk the manager down the queue without closing it.
|
|
39
|
+
//
|
|
40
|
+
// Reference composition — Drawer (list nav) + Stepper + FileBadge (+ placeholder)
|
|
41
|
+
// + Pagination + DetailRow.
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const CODES: Record<string, string> = {
|
|
45
|
+
A1: "A1 · Low income",
|
|
46
|
+
B2: "B2 · Senior 60+",
|
|
47
|
+
C1: "C1 · Disability",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const STAGES = [
|
|
51
|
+
{ key: "receipt", label: "Receipt", sub: "Generate the application receipt" },
|
|
52
|
+
{ key: "payment", label: "Payment", sub: "Confirm the fee was paid" },
|
|
53
|
+
{ key: "documents", label: "Documents", sub: "Generate the application paperwork" },
|
|
54
|
+
{ key: "collect", label: "Collect", sub: "Collect the applicant's documents" },
|
|
55
|
+
{ key: "submit", label: "Submit", sub: "Submit to the housing authority" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const GENERATED_DOCS = ["Application form", "Eligibility declaration", "Cover letter to the authority"];
|
|
59
|
+
const REQUIRED = ["National ID copy", "Income statement (3 months)", "Household registration", "Proof of current residence"];
|
|
60
|
+
|
|
61
|
+
// A self-contained SVG data-URI "scan" so a seeded upload's thumbnail AND its
|
|
62
|
+
// preview render offline; dropped files are real blobs (createObjectURL).
|
|
63
|
+
const scan = (id: string, label: string, bg: string): DisplayFile => ({
|
|
64
|
+
id,
|
|
65
|
+
filename: `${label.toLowerCase().replace(/[^a-z0-9]+/g, "_")}.svg`,
|
|
66
|
+
mimeType: "image/svg+xml",
|
|
67
|
+
url:
|
|
68
|
+
"data:image/svg+xml," +
|
|
69
|
+
encodeURIComponent(
|
|
70
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="420" height="560"><rect width="100%" height="100%" fill="${bg}"/><text x="210" y="290" fill="#ffffff" font-family="sans-serif" font-size="28" text-anchor="middle">${label}</text></svg>`,
|
|
71
|
+
),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Call logging mirrors the Call console — same primitives, same look/feel, kept as
|
|
75
|
+
// the manager's own copy so each page iterates independently (not extracted).
|
|
76
|
+
type OutcomeKey = "interested" | "callback" | "not_interested" | "no_answer";
|
|
77
|
+
const OUTCOMES: Record<OutcomeKey, { label: string; icon: IconName; color: ColorName }> = {
|
|
78
|
+
interested: { label: "Interested", icon: "circle-check", color: "emerald" },
|
|
79
|
+
callback: { label: "Call back", icon: "calendar", color: "blue" },
|
|
80
|
+
not_interested: { label: "Not interested", icon: "circle-alert", color: "amber" },
|
|
81
|
+
no_answer: { label: "No answer", icon: "ban", color: "zinc" },
|
|
82
|
+
};
|
|
83
|
+
const TODAY = "12/06";
|
|
84
|
+
// yyyy-MM-dd → dd/MM (+ optional time).
|
|
85
|
+
const fmtCallback = (date: string, time: string) => {
|
|
86
|
+
if (!date) return "";
|
|
87
|
+
const [, m, d] = date.split("-");
|
|
88
|
+
return time ? `${d}/${m} · ${time}` : `${d}/${m}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface DocItem { name: string; received: boolean; file?: string }
|
|
92
|
+
interface CallEvent { id: string; time: string; outcome: OutcomeKey; notes?: string; callbackAt?: string }
|
|
93
|
+
interface Applicant {
|
|
94
|
+
id: string; name: string; phone: string; code: string; idNumber: string; household: number; income: number; fee: number;
|
|
95
|
+
requestedBy: string; requestedAt: string; note?: string;
|
|
96
|
+
stage: number;
|
|
97
|
+
docs: DocItem[];
|
|
98
|
+
uploads: DisplayFile[];
|
|
99
|
+
calls: CallEvent[];
|
|
100
|
+
callbackDue?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fileName = (name: string) => `${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}.pdf`;
|
|
104
|
+
const docList = (received = false): DocItem[] => REQUIRED.map((name) => ({ name, received, file: received ? fileName(name) : undefined }));
|
|
105
|
+
|
|
106
|
+
const REQUESTERS = ["Sarah Chen", "David Park", "Mai Tran"];
|
|
107
|
+
const FILLER_NAMES = ["James Carter", "Olivia Brooks", "Daniel Foster", "Sophia Reed", "William Hughes", "Grace Coleman", "Henry Mills", "Ava Sutton", "Lucas Grant", "Chloe Barrett", "Nathan Ross"];
|
|
108
|
+
const filler = (name: string, i: number): Applicant => {
|
|
109
|
+
const stage = i % 5;
|
|
110
|
+
return {
|
|
111
|
+
id: `AF${i}`, name, phone: `09${30 + i} ${410 + i * 3} ${220 + i * 7}`, code: ["A1", "B2", "C1"][i % 3],
|
|
112
|
+
idNumber: `0${55 + i} ${160 + i} ${305 + i} ${700 + i}`,
|
|
113
|
+
household: 2 + (i % 4), income: 5_000_000 + (i % 6) * 800_000, fee: 500_000,
|
|
114
|
+
requestedBy: REQUESTERS[i % 3], requestedAt: `${(i % 12) + 1}/06`,
|
|
115
|
+
stage, docs: docList(stage > 3), uploads: [], calls: [],
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const APPLICANTS: Applicant[] = [
|
|
120
|
+
{ id: "AP1", name: "Michael Turner", phone: "0912 384 756", code: "A1", idNumber: "060 199 305 778", household: 3, income: 7_200_000, fee: 500_000, requestedBy: "Sarah Chen", requestedAt: "12/06", note: "Income verified — wants to start this week.", stage: 0, docs: docList(), uploads: [], calls: [{ id: "cAP1a", time: "12/06 · 10:42", outcome: "interested", notes: "Confirmed income and household. Wants to convert and start documents." }] },
|
|
121
|
+
{ id: "AP2", name: "Emily Watson", phone: "0987 220 415", code: "A1", idNumber: "079 188 442 015", household: 4, income: 9_500_000, fee: 500_000, requestedBy: "Sarah Chen", requestedAt: "11/06", callbackDue: "12/06 · 15:00", stage: 3, docs: docList().map((d, i) => (i < 2 ? { ...d, received: true, file: fileName(d.name) } : d)), uploads: [scan("u1AP2", "Site photo", "#1e3a8a")], calls: [{ id: "cAP2a", 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" }, { id: "cAP2b", time: "06/06 · 15:30", outcome: "no_answer" }] },
|
|
122
|
+
{ id: "AP3", name: "Laura Bennett", phone: "0934 510 882", code: "B2", idNumber: "055 162 770 904", household: 2, income: 5_400_000, fee: 500_000, requestedBy: "David Park", requestedAt: "10/06", stage: 4, docs: docList(true), uploads: [], calls: [{ id: "cAP3a", time: "10/06 · 11:20", outcome: "interested", notes: "Senior — qualifies on B2. Daughter will help with the documents." }] },
|
|
123
|
+
...FILLER_NAMES.map(filler),
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const PAGE_SIZE = 8;
|
|
127
|
+
|
|
128
|
+
function QueueRow({ app, active, onPress }: { app: Applicant; active: boolean; onPress: () => void }) {
|
|
129
|
+
const submitted = app.stage >= STAGES.length;
|
|
130
|
+
return (
|
|
131
|
+
<PressableRow onPress={onPress} selected={active} marked={active} style={{ paddingVertical: 14 }}>
|
|
132
|
+
<View style={{ flex: 1, gap: 4 }}>
|
|
133
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
134
|
+
<Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{app.name}</Text>
|
|
135
|
+
<Badge label={app.code} color="blue" />
|
|
136
|
+
</View>
|
|
137
|
+
<Text size="xs" color="muted">{`Requested by ${app.requestedBy} · ${app.requestedAt}`}</Text>
|
|
138
|
+
</View>
|
|
139
|
+
<Badge variant="dot" label={submitted ? "Submitted" : STAGES[app.stage].label} color={submitted ? "emerald" : "blue"} />
|
|
140
|
+
</PressableRow>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A document line — a FileBadge (or a same-footprint placeholder) + name + meta.
|
|
145
|
+
function FileRow({ name, meta, mimeType, placeholder, tone, action }: {
|
|
146
|
+
name: string; meta?: string; mimeType?: string; placeholder?: boolean; tone?: "received" | "ready"; action?: ReactNode;
|
|
147
|
+
}) {
|
|
148
|
+
return (
|
|
149
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
150
|
+
<FileBadge size={30} mimeType={mimeType} placeholder={placeholder} />
|
|
151
|
+
<View style={{ flex: 1 }}>
|
|
152
|
+
<Text size="sm" weight="medium">{name}</Text>
|
|
153
|
+
{meta ? <Text size="xs" color="muted">{meta}</Text> : null}
|
|
154
|
+
</View>
|
|
155
|
+
{action ?? (tone === "received" ? <Badge variant="dot" label="Received" color="emerald" /> : tone === "ready" ? <Badge variant="dot" label="Generated" color="blue" /> : null)}
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The extra-documents zone — the data-capture attachment field: a thumbnail grid
|
|
161
|
+
// (click to preview in the gallery, ✕ to delete) above a dropzone. Read-only once
|
|
162
|
+
// the stage is done.
|
|
163
|
+
function UploadsField({ uploads, editable, onAdd, onRemove }: {
|
|
164
|
+
uploads: DisplayFile[]; editable: boolean; onAdd: (files: DisplayFile[]) => void; onRemove: (id: string) => void;
|
|
165
|
+
}) {
|
|
166
|
+
const [active, setActive] = useState<number | null>(null);
|
|
167
|
+
return (
|
|
168
|
+
<View style={{ gap: 10 }}>
|
|
169
|
+
{uploads.length > 0 ? (
|
|
170
|
+
<FileThumbnailGrid
|
|
171
|
+
files={uploads}
|
|
172
|
+
itemSize={76}
|
|
173
|
+
onFilePress={(f) => setActive(uploads.findIndex((x) => x.id === f.id))}
|
|
174
|
+
onRemove={editable ? onRemove : undefined}
|
|
175
|
+
/>
|
|
176
|
+
) : null}
|
|
177
|
+
{editable ? (
|
|
178
|
+
<FileDropzone
|
|
179
|
+
height={88}
|
|
180
|
+
label="Drop extra documents"
|
|
181
|
+
hint="or click to browse · PDF, images"
|
|
182
|
+
dropLabel="Release to attach"
|
|
183
|
+
accept="application/pdf,image/*"
|
|
184
|
+
accessibilityLabel="Attach extra documents"
|
|
185
|
+
onFiles={(dropped) =>
|
|
186
|
+
onAdd(dropped.map((f, i) => ({ id: `up_${f.name}_${uploads.length + i}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) })))
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
) : uploads.length === 0 ? (
|
|
190
|
+
<Text size="sm" color="muted">No extra files.</Text>
|
|
191
|
+
) : null}
|
|
192
|
+
<FileGalleryModal files={uploads} activeIndex={active} onIndexChange={setActive} captionHint="ESC to close · ←/→ to move" />
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// One stage, always rendered — done shows its result, current its work, later
|
|
198
|
+
// stages a muted preview. Nothing hides behind a click.
|
|
199
|
+
function StageSection({ app, index, onMarkDoc, onAddUploads, onRemoveUpload }: {
|
|
200
|
+
app: Applicant; index: number; onMarkDoc: (name: string, received: boolean) => void; onAddUploads: (files: DisplayFile[]) => void; onRemoveUpload: (id: string) => void;
|
|
201
|
+
}) {
|
|
202
|
+
const status = app.stage > index ? "done" : app.stage === index ? "current" : "pending";
|
|
203
|
+
const collected = app.docs.filter((d) => d.received).length;
|
|
204
|
+
|
|
205
|
+
const head = (
|
|
206
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
207
|
+
<Text size="xs" weight="semibold" color="muted" tabular>{`Step ${index + 1}`}</Text>
|
|
208
|
+
<Text size="sm" weight="semibold">{STAGES[index].label}</Text>
|
|
209
|
+
<View style={{ flex: 1 }} />
|
|
210
|
+
{status === "done" ? <Badge variant="dot" label="Done" color="emerald" />
|
|
211
|
+
: status === "current" ? <Badge variant="dot" label="Now" color="blue" />
|
|
212
|
+
: <Text size="xs" color="muted">Locked</Text>}
|
|
213
|
+
</View>
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
let body: ReactNode = null;
|
|
217
|
+
if (status === "pending") {
|
|
218
|
+
body = <Text size="sm" color="muted">{`Unlocks after ${STAGES[index - 1].label.toLowerCase()}.`}</Text>;
|
|
219
|
+
} else if (index === 0) {
|
|
220
|
+
body = <FileRow name="Application fee receipt" meta={status === "done" ? `${formatMoney(app.fee)} · issued` : formatMoney(app.fee)} mimeType="application/pdf" tone={status === "done" ? "ready" : undefined} />;
|
|
221
|
+
} else if (index === 1) {
|
|
222
|
+
body = (
|
|
223
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
224
|
+
<Icon name="circle-check" size={18} color={status === "done" ? colors.emerald[600] : colors.zinc[300]} />
|
|
225
|
+
<Text size="sm" color={status === "done" ? "default" : "muted"}>{status === "done" ? `Payment confirmed · ${formatMoney(app.fee)} received` : `Confirm the ${formatMoney(app.fee)} fee once it lands.`}</Text>
|
|
226
|
+
</View>
|
|
227
|
+
);
|
|
228
|
+
} else if (index === 2) {
|
|
229
|
+
body = (
|
|
230
|
+
<View style={{ gap: 10 }}>
|
|
231
|
+
{GENERATED_DOCS.map((d) => (
|
|
232
|
+
<FileRow key={d} name={d} mimeType="application/pdf" meta={status === "done" ? "generated" : "template"} tone={status === "done" ? "ready" : undefined} />
|
|
233
|
+
))}
|
|
234
|
+
</View>
|
|
235
|
+
);
|
|
236
|
+
} else if (index === 3) {
|
|
237
|
+
body = (
|
|
238
|
+
<View style={{ gap: 16 }}>
|
|
239
|
+
<View style={{ gap: 8 }}>
|
|
240
|
+
<Text size="xs" color="muted" transform="uppercase">{`Required documents · ${collected}/${app.docs.length}`}</Text>
|
|
241
|
+
{app.docs.map((d) => (
|
|
242
|
+
<FileRow
|
|
243
|
+
key={d.name}
|
|
244
|
+
name={d.name}
|
|
245
|
+
meta={d.received ? `${d.file} · received` : "Awaiting upload"}
|
|
246
|
+
mimeType={d.received ? "application/pdf" : undefined}
|
|
247
|
+
placeholder={!d.received}
|
|
248
|
+
action={status === "current"
|
|
249
|
+
? (d.received ? <Button title="Remove" color="muted" onPress={() => onMarkDoc(d.name, false)} /> : <Button title="Upload" color="secondary" onPress={() => onMarkDoc(d.name, true)} />)
|
|
250
|
+
: (d.received ? <Badge variant="dot" label="Received" color="emerald" /> : undefined)}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</View>
|
|
254
|
+
<View style={{ gap: 8 }}>
|
|
255
|
+
<Text size="xs" color="muted" transform="uppercase">Additional documents</Text>
|
|
256
|
+
<UploadsField uploads={app.uploads} editable={status === "current"} onAdd={onAddUploads} onRemove={onRemoveUpload} />
|
|
257
|
+
</View>
|
|
258
|
+
</View>
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
body = status === "done" ? (
|
|
262
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
263
|
+
<Icon name="circle-check" size={18} color={colors.emerald[600]} />
|
|
264
|
+
<Text size="sm">Submitted to the housing authority.</Text>
|
|
265
|
+
</View>
|
|
266
|
+
) : (
|
|
267
|
+
<View style={{ gap: 6, padding: 14, borderRadius: 10, backgroundColor: colors.zinc[50] }}>
|
|
268
|
+
<DetailRow label="Applicant" labelWidth={120}><Text size="sm" weight="medium">{app.name}</Text></DetailRow>
|
|
269
|
+
<DetailRow label="Eligibility" labelWidth={120}><Text size="sm">{CODES[app.code]}</Text></DetailRow>
|
|
270
|
+
<DetailRow label="Documents" labelWidth={120}><Text size="sm" tabular>{`${app.docs.length} collected · ${app.uploads.length} extra`}</Text></DetailRow>
|
|
271
|
+
</View>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<View style={{ gap: 10, opacity: status === "pending" ? 0.55 : 1 }}>
|
|
277
|
+
{head}
|
|
278
|
+
<View style={{ paddingLeft: 2 }}>{body}</View>
|
|
279
|
+
</View>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The call log — mirrors the Call console's composer + history, the manager's own
|
|
284
|
+
// copy. The manager can call a lead and read what the rep already discussed.
|
|
285
|
+
function CallLog({ calls, phone, callbackDue, onLog }: {
|
|
286
|
+
calls: CallEvent[]; phone: string; callbackDue?: string; onLog: (outcome: OutcomeKey, notes: string, callbackDue?: string) => void;
|
|
287
|
+
}) {
|
|
288
|
+
const [outcome, setOutcome] = useState<OutcomeKey | "">("");
|
|
289
|
+
const [notes, setNotes] = useState("");
|
|
290
|
+
const [cbDate, setCbDate] = useState("");
|
|
291
|
+
const [cbTime, setCbTime] = useState("");
|
|
292
|
+
const history: TimelineItem[] = calls.map((c) => ({
|
|
293
|
+
id: c.id,
|
|
294
|
+
icon: OUTCOMES[c.outcome].icon,
|
|
295
|
+
iconColor: solid(OUTCOMES[c.outcome].color),
|
|
296
|
+
label: c.outcome === "callback" && c.callbackAt ? `${OUTCOMES[c.outcome].label} · ${c.callbackAt}` : OUTCOMES[c.outcome].label,
|
|
297
|
+
description: c.notes,
|
|
298
|
+
right: <Text size="xs" color="muted" tabular>{c.time}</Text>,
|
|
299
|
+
}));
|
|
300
|
+
return (
|
|
301
|
+
<View style={{ gap: 14 }}>
|
|
302
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
303
|
+
<Button title={`Call ${phone}`} color="primary" onPress={() => {}} />
|
|
304
|
+
{callbackDue ? (
|
|
305
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
306
|
+
<Icon name="calendar" size={14} color={solid("blue")} />
|
|
307
|
+
<Text size="sm">{`Callback · ${callbackDue}`}</Text>
|
|
308
|
+
</View>
|
|
309
|
+
) : null}
|
|
310
|
+
</View>
|
|
311
|
+
<View style={{ gap: 12 }}>
|
|
312
|
+
<Text size="sm" weight="semibold">Log this call</Text>
|
|
313
|
+
<ChipGroup
|
|
314
|
+
accessibilityLabel="Call outcome"
|
|
315
|
+
options={(Object.keys(OUTCOMES) as OutcomeKey[]).map((k) => ({ label: OUTCOMES[k].label, value: k }))}
|
|
316
|
+
value={outcome}
|
|
317
|
+
onValueChange={(v) => setOutcome(v as OutcomeKey)}
|
|
318
|
+
/>
|
|
319
|
+
{outcome === "callback" ? (
|
|
320
|
+
<View style={{ flexDirection: "row", gap: 12, flexWrap: "wrap" }}>
|
|
321
|
+
<View style={{ flexGrow: 1, flexBasis: 160 }}>
|
|
322
|
+
<FormField label="Call back on"><DatePicker value={cbDate} onValueChange={setCbDate} format="date" /></FormField>
|
|
323
|
+
</View>
|
|
324
|
+
<View style={{ flexGrow: 1, flexBasis: 120 }}>
|
|
325
|
+
<FormField label="At (optional)"><TimePicker value={cbTime} onValueChange={setCbTime} /></FormField>
|
|
326
|
+
</View>
|
|
327
|
+
</View>
|
|
328
|
+
) : null}
|
|
329
|
+
<TextInputField value={notes} onChangeText={setNotes} multiline numberOfLines={3} autoGrow placeholder="Notes — what was discussed, and the next step" accessibilityLabel="Call notes" />
|
|
330
|
+
<View style={{ flexDirection: "row" }}>
|
|
331
|
+
<View style={{ flex: 1 }} />
|
|
332
|
+
<Button title="Log call" color="primary" disabled={outcome === ""} onPress={() => { if (outcome !== "") { onLog(outcome, notes, outcome === "callback" ? fmtCallback(cbDate, cbTime) : undefined); setOutcome(""); setNotes(""); setCbDate(""); setCbTime(""); } }} />
|
|
333
|
+
</View>
|
|
334
|
+
</View>
|
|
335
|
+
<View style={{ gap: 12 }}>
|
|
336
|
+
<Text size="sm" weight="semibold">{`Call history · ${calls.length}`}</Text>
|
|
337
|
+
{calls.length > 0 ? <Timeline items={history} /> : <Text size="sm" color="muted">No calls logged yet.</Text>}
|
|
338
|
+
</View>
|
|
339
|
+
</View>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function ConvertDetail({ app, onAdvance, onLogCall, onMarkDoc, onAddUploads, onRemoveUpload }: {
|
|
344
|
+
app: Applicant; onAdvance: () => void; onLogCall: (outcome: OutcomeKey, notes: string, callbackDue?: string) => void; onMarkDoc: (name: string, received: boolean) => void; onAddUploads: (files: DisplayFile[]) => void; onRemoveUpload: (id: string) => void;
|
|
345
|
+
}) {
|
|
346
|
+
const submitted = app.stage >= STAGES.length;
|
|
347
|
+
const allDocs = app.docs.every((d) => d.received);
|
|
348
|
+
|
|
349
|
+
const cta = submitted ? <Button title="Submitted" color="muted" disabled onPress={() => {}} />
|
|
350
|
+
: app.stage === 0 ? <Button title="Generate receipt" color="primary" onPress={onAdvance} />
|
|
351
|
+
: app.stage === 1 ? <Button title="Confirm payment received" color="primary" onPress={onAdvance} />
|
|
352
|
+
: app.stage === 2 ? <Button title="Generate documents" color="primary" onPress={onAdvance} />
|
|
353
|
+
: app.stage === 3 ? <Button title="Mark all collected" color="primary" disabled={!allDocs} onPress={onAdvance} />
|
|
354
|
+
: <Button title="Submit application" color="primary" onPress={onAdvance} />;
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<>
|
|
358
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 20, gap: 18 }}>
|
|
359
|
+
{app.note ? (
|
|
360
|
+
<View style={{ padding: 12, borderRadius: 10, backgroundColor: colors.amber[50] }}>
|
|
361
|
+
<Text size="sm">{`"${app.note}"`}</Text>
|
|
362
|
+
</View>
|
|
363
|
+
) : null}
|
|
364
|
+
<View style={{ gap: 6 }}>
|
|
365
|
+
<DetailRow label="Requested by" labelWidth={120}><Text size="sm">{`${app.requestedBy} · ${app.requestedAt}`}</Text></DetailRow>
|
|
366
|
+
<DetailRow label="National ID" labelWidth={120}><Text size="sm" tabular>{app.idNumber}</Text></DetailRow>
|
|
367
|
+
<DetailRow label="Household" labelWidth={120}><Text size="sm" tabular>{`${app.household} people · ${formatMoney(app.income)}/mo`}</Text></DetailRow>
|
|
368
|
+
</View>
|
|
369
|
+
|
|
370
|
+
<Divider />
|
|
371
|
+
<CallLog calls={app.calls} phone={app.phone} callbackDue={app.callbackDue} onLog={onLogCall} />
|
|
372
|
+
|
|
373
|
+
<Divider />
|
|
374
|
+
<View style={{ gap: 10 }}>
|
|
375
|
+
<Text size="sm" weight="semibold">Conversion</Text>
|
|
376
|
+
<Stepper steps={STAGES.map((s) => s.label)} current={app.stage} color={colors.blue[600]} accessibilityLabel="Conversion progress" />
|
|
377
|
+
</View>
|
|
378
|
+
<Divider />
|
|
379
|
+
|
|
380
|
+
<View style={{ gap: 18 }}>
|
|
381
|
+
{STAGES.map((s, i) => (
|
|
382
|
+
<View key={s.key} style={{ gap: 18 }}>
|
|
383
|
+
{i > 0 ? <Divider /> : null}
|
|
384
|
+
<StageSection app={app} index={i} onMarkDoc={onMarkDoc} onAddUploads={onAddUploads} onRemoveUpload={onRemoveUpload} />
|
|
385
|
+
</View>
|
|
386
|
+
))}
|
|
387
|
+
</View>
|
|
388
|
+
</ScrollView>
|
|
389
|
+
<DrawerFooter>
|
|
390
|
+
<Text size="sm" color="muted" numberOfLines={1} style={{ flex: 1 }}>
|
|
391
|
+
{submitted ? "Submitted to the housing authority" : `Step ${app.stage + 1} of ${STAGES.length} · ${STAGES[app.stage].label}`}
|
|
392
|
+
</Text>
|
|
393
|
+
{cta}
|
|
394
|
+
</DrawerFooter>
|
|
395
|
+
</>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function TplConvert() {
|
|
400
|
+
const [apps, setApps] = useState<Applicant[]>(APPLICANTS);
|
|
401
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
402
|
+
const [page, setPage] = useState(0);
|
|
403
|
+
|
|
404
|
+
const activeIdx = activeId ? apps.findIndex((a) => a.id === activeId) : -1;
|
|
405
|
+
const active = activeIdx >= 0 ? apps[activeIdx] : null;
|
|
406
|
+
|
|
407
|
+
const update = (patch: Partial<Applicant>) => setApps((prev) => prev.map((a) => (a.id === activeId ? { ...a, ...patch } : a)));
|
|
408
|
+
const advance = () => active && update({ stage: active.stage + 1 });
|
|
409
|
+
const logCall = (outcome: OutcomeKey, notes: string, callbackDue?: string) =>
|
|
410
|
+
active && update({
|
|
411
|
+
callbackDue: outcome === "callback" ? callbackDue || undefined : undefined,
|
|
412
|
+
calls: [{ id: `c${active.calls.length + 1}${active.id}`, time: `${TODAY} · just now`, outcome, notes: notes.trim() || undefined, callbackAt: outcome === "callback" ? callbackDue || undefined : undefined }, ...active.calls],
|
|
413
|
+
});
|
|
414
|
+
const markDoc = (name: string, received: boolean) =>
|
|
415
|
+
active && update({ docs: active.docs.map((d) => (d.name === name ? { ...d, received, file: received ? fileName(name) : undefined } : d)) });
|
|
416
|
+
const addUploads = (files: DisplayFile[]) => active && update({ uploads: [...active.uploads, ...files] });
|
|
417
|
+
const removeUpload = (id: string) => active && update({ uploads: active.uploads.filter((u) => u.id !== id) });
|
|
418
|
+
|
|
419
|
+
const inConversion = apps.filter((a) => a.stage < STAGES.length).length;
|
|
420
|
+
const awaitingPayment = apps.filter((a) => a.stage === 1).length;
|
|
421
|
+
const collecting = apps.filter((a) => a.stage === 3).length;
|
|
422
|
+
const submitted = apps.filter((a) => a.stage >= STAGES.length).length;
|
|
423
|
+
|
|
424
|
+
const pageApps = apps.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<View style={{ flex: 1 }}>
|
|
428
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
429
|
+
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
|
430
|
+
<View style={{ gap: 2 }}>
|
|
431
|
+
<Text size="xl" weight="semibold">Conversion desk</Text>
|
|
432
|
+
<Text size="sm" color="muted">Process each requested lead into an application — receipt, payment, documents, then submit</Text>
|
|
433
|
+
</View>
|
|
434
|
+
|
|
435
|
+
<KPIStrip
|
|
436
|
+
items={[
|
|
437
|
+
{ label: "In conversion", value: inConversion, format: "number" },
|
|
438
|
+
{ label: "Awaiting payment", value: awaitingPayment, format: "number", tone: awaitingPayment > 0 ? "warning" : "default" },
|
|
439
|
+
{ label: "Collecting docs", value: collecting, format: "number" },
|
|
440
|
+
{ label: "Submitted", value: submitted, format: "number", caption: "this period" },
|
|
441
|
+
]}
|
|
442
|
+
/>
|
|
443
|
+
|
|
444
|
+
<Card style={{ padding: 0 }}>
|
|
445
|
+
<View style={{ paddingHorizontal: 20, paddingVertical: 12 }}>
|
|
446
|
+
<Text size="xs" color="muted" transform="uppercase">{`Requested · ${apps.length}`}</Text>
|
|
447
|
+
</View>
|
|
448
|
+
<Divider />
|
|
449
|
+
{pageApps.map((a, i) => (
|
|
450
|
+
<View key={a.id}>
|
|
451
|
+
{i > 0 ? <Divider /> : null}
|
|
452
|
+
<QueueRow app={a} active={a.id === activeId} onPress={() => setActiveId(a.id)} />
|
|
453
|
+
</View>
|
|
454
|
+
))}
|
|
455
|
+
<Divider />
|
|
456
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 10 }}>
|
|
457
|
+
<Pagination
|
|
458
|
+
page={page}
|
|
459
|
+
pageSize={PAGE_SIZE}
|
|
460
|
+
rowCount={pageApps.length}
|
|
461
|
+
hasMore={(page + 1) * PAGE_SIZE < apps.length}
|
|
462
|
+
total={apps.length}
|
|
463
|
+
onPageChange={setPage}
|
|
464
|
+
/>
|
|
465
|
+
</View>
|
|
466
|
+
</Card>
|
|
467
|
+
</View>
|
|
468
|
+
</ScrollView>
|
|
469
|
+
|
|
470
|
+
<Drawer
|
|
471
|
+
open={active != null}
|
|
472
|
+
onOpenChange={(o) => { if (!o) setActiveId(null); }}
|
|
473
|
+
width={560}
|
|
474
|
+
title={active ? (
|
|
475
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
476
|
+
<Text size="md" weight="semibold">{active.name}</Text>
|
|
477
|
+
<Badge label={active.code} color="blue" />
|
|
478
|
+
</View>
|
|
479
|
+
) : undefined}
|
|
480
|
+
position={active ? `${activeIdx + 1}/${apps.length}` : undefined}
|
|
481
|
+
onPrev={active && activeIdx > 0 ? () => setActiveId(apps[activeIdx - 1].id) : undefined}
|
|
482
|
+
onNext={active && activeIdx < apps.length - 1 ? () => setActiveId(apps[activeIdx + 1].id) : undefined}
|
|
483
|
+
>
|
|
484
|
+
{active ? (
|
|
485
|
+
<ConvertDetail key={active.id} app={active} onAdvance={advance} onLogCall={logCall} onMarkDoc={markDoc} onAddUploads={addUploads} onRemoveUpload={removeUpload} />
|
|
486
|
+
) : null}
|
|
487
|
+
</Drawer>
|
|
488
|
+
</View>
|
|
489
|
+
);
|
|
490
|
+
}
|