@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,541 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, 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, CardFooter, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
|
|
9
|
+
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
10
|
+
import { DetailRow } from "@lotics/ui/detail_row";
|
|
11
|
+
import { Divider } from "@lotics/ui/divider";
|
|
12
|
+
import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
|
|
13
|
+
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
14
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
15
|
+
import { FormField } from "@lotics/ui/form_field";
|
|
16
|
+
import { type IconName } from "@lotics/ui/icon";
|
|
17
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
18
|
+
import { MenuListItem } from "@lotics/ui/menu_list_item";
|
|
19
|
+
import { NumberInput } from "@lotics/ui/number_input";
|
|
20
|
+
import { Peek } from "@lotics/ui/peek";
|
|
21
|
+
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
22
|
+
import { DatePicker } from "@lotics/ui/date_picker";
|
|
23
|
+
import { FilterPill } from "@lotics/ui/filter_pill";
|
|
24
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@lotics/ui/popover";
|
|
25
|
+
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
26
|
+
import { SearchInput } from "@lotics/ui/search_input";
|
|
27
|
+
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
28
|
+
import { TimePicker } from "@lotics/ui/time_picker";
|
|
29
|
+
import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Template · CRM — workbench. The whole CRM app as ONE register: the
|
|
33
|
+
// "Unassigned" pile (checkboxes → bulk-assign) stacked above the per-rep KPI
|
|
34
|
+
// cards, all over ONE shared lead dataset filtered by one search + one chip
|
|
35
|
+
// row. The centerpiece: any lead row opens the shared workspace Drawer —
|
|
36
|
+
// an inline log-call composer + call history, and ◀ ▶ / ←→ sequencing over
|
|
37
|
+
// the screen's flat visible ordering (pile first, then each rep's rows).
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const HOM_NAY = "12/06";
|
|
41
|
+
|
|
42
|
+
const TRANG_THAI = {
|
|
43
|
+
moi: { label: "New", color: "blue", rank: 0 },
|
|
44
|
+
dang_theo: { label: "Following", color: "amber", rank: 1 },
|
|
45
|
+
khong_quan_tam: { label: "Not interested", color: "zinc", rank: 2 },
|
|
46
|
+
tiem_nang: { label: "Interested", color: "emerald", rank: 3 },
|
|
47
|
+
} as const satisfies Record<string, { label: string; color: ColorName; rank: number }>;
|
|
48
|
+
|
|
49
|
+
type TrangThaiKey = keyof typeof TRANG_THAI;
|
|
50
|
+
const TRANG_THAI_KEYS = Object.keys(TRANG_THAI) as TrangThaiKey[];
|
|
51
|
+
|
|
52
|
+
// Chip row over the whole screen: All · Unassigned (the pile only) · one
|
|
53
|
+
// chip per status.
|
|
54
|
+
type FilterKey = "all" | "chua_giao" | TrangThaiKey;
|
|
55
|
+
const FILTER_KEYS: FilterKey[] = ["all", "chua_giao", ...TRANG_THAI_KEYS];
|
|
56
|
+
|
|
57
|
+
type KetQua = "quan_tam" | "hen_goi_lai" | "chua_quan_tam" | "khong_nghe";
|
|
58
|
+
|
|
59
|
+
// Each outcome carries the status it implies; logging only ever upgrades
|
|
60
|
+
// (rank-monotonic), so one "no answer" never erases an "interested".
|
|
61
|
+
const KET_QUA: Record<KetQua, { label: string; icon: IconName; color: string; trangThai: TrangThaiKey }> = {
|
|
62
|
+
quan_tam: { label: "Interested", icon: "circle-check", color: solid("emerald"), trangThai: "tiem_nang" },
|
|
63
|
+
hen_goi_lai: { label: "Call back", icon: "calendar", color: solid("blue"), trangThai: "dang_theo" },
|
|
64
|
+
chua_quan_tam: { label: "Not interested", icon: "circle-alert", color: solid("amber"), trangThai: "khong_quan_tam" },
|
|
65
|
+
khong_nghe: { label: "No answer", icon: "ban", color: colors.zinc[400], trangThai: "dang_theo" },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// yyyy-MM-dd → dd/MM (+ optional time).
|
|
69
|
+
const fmtCallback = (date: string, time: string) => {
|
|
70
|
+
if (!date) return "";
|
|
71
|
+
const [, m, d] = date.split("-");
|
|
72
|
+
return time ? `${d}/${m} · ${time}` : `${d}/${m}`;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
interface CallEvent { id: string; time: string; nguoiGoi: string; ketQua: KetQua; ghiChu?: string; callbackAt?: string }
|
|
76
|
+
|
|
77
|
+
interface Rep { id: string; ten: string; vaiTro: string; dienThoai: string }
|
|
78
|
+
|
|
79
|
+
const REPS: Rep[] = [
|
|
80
|
+
{ id: "nv1", ten: "Sarah Chen", vaiTro: "Telesales — South team", dienThoai: "0905 332 718" },
|
|
81
|
+
{ id: "nv2", ten: "David Park", vaiTro: "Telesales — North team", dienThoai: "0934 661 027" },
|
|
82
|
+
{ id: "nv3", ten: "Maria Lopez", vaiTro: "Telesales — afternoon shift", dienThoai: "0917 482 305" },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
interface Lead {
|
|
86
|
+
id: string; ten: string; dienThoai: string; nguon: string; ngayTao: string;
|
|
87
|
+
ghiChuTiepNhan: string; phuTrach: string | null; trangThai: TrangThaiKey; calls: CallEvent[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const LEADS: Lead[] = [
|
|
91
|
+
{ id: "kh01", ten: "Michael Turner", dienThoai: "0912 384 756", nguon: "Facebook", ngayTao: "02/06", ghiChuTiepNhan: "Asked about a bulk order", phuTrach: "nv1", trangThai: "tiem_nang", calls: [
|
|
92
|
+
{ id: "c1", time: "12/06 · 10:42", nguoiGoi: "Sarah Chen", ketQua: "quan_tam", ghiChu: "Needs 2,000 cartons/month, asked about volume discounts. Send price list via WhatsApp before 13/06." },
|
|
93
|
+
{ id: "c2", time: "06/06 · 09:05", nguoiGoi: "Sarah Chen", ketQua: "hen_goi_lai" },
|
|
94
|
+
{ id: "c3", time: "04/06 · 16:40", nguoiGoi: "Sarah Chen", ketQua: "khong_nghe" },
|
|
95
|
+
] },
|
|
96
|
+
{ id: "kh02", ten: "Emily Watson", dienThoai: "0987 220 415", nguon: "Referral", ngayTao: "03/06", ghiChuTiepNhan: "Referred by a current client — needs a quote this week", phuTrach: "nv1", trangThai: "dang_theo", calls: [
|
|
97
|
+
{ id: "c1", time: "12/06 · 09:18", nguoiGoi: "Sarah Chen", ketQua: "hen_goi_lai", ghiChu: "In a meeting — call back after 3pm.", callbackAt: "12/06 · 15:00" },
|
|
98
|
+
{ id: "c2", time: "06/06 · 15:30", nguoiGoi: "Sarah Chen", ketQua: "khong_nghe" },
|
|
99
|
+
] },
|
|
100
|
+
{ id: "kh03", ten: "James Holt", dienThoai: "0918 736 245", nguon: "Website", ngayTao: "30/05", ghiChuTiepNhan: "Downloaded the price list from the homepage", phuTrach: "nv1", trangThai: "khong_quan_tam", calls: [
|
|
101
|
+
{ id: "c1", time: "02/06 · 10:40", nguoiGoi: "Sarah Chen", ketQua: "chua_quan_tam", ghiChu: "Has an in-house packaging line — no need to buy externally." },
|
|
102
|
+
] },
|
|
103
|
+
{ id: "kh04", ten: "Rachel Kim", dienThoai: "0936 471 802", nguon: "Hotline", ngayTao: "01/06", ghiChuTiepNhan: "Called in asking about the warranty policy", phuTrach: "nv2", trangThai: "dang_theo", calls: [
|
|
104
|
+
{ id: "c1", time: "08/06 · 11:00", nguoiGoi: "David Park", ketQua: "hen_goi_lai", ghiChu: "Comparing us with their current supplier — call back once they have the competitor's price." },
|
|
105
|
+
{ id: "c2", time: "03/06 · 14:45", nguoiGoi: "David Park", ketQua: "khong_nghe" },
|
|
106
|
+
] },
|
|
107
|
+
{ id: "kh05", ten: "Laura Bennett", dienThoai: "0945 112 908", nguon: "Referral", ngayTao: "05/06", ghiChuTiepNhan: "Referred by a past client — call early", phuTrach: "nv2", trangThai: "tiem_nang", calls: [
|
|
108
|
+
{ id: "c1", time: "10/06 · 16:10", nguoiGoi: "David Park", ketQua: "quan_tam", ghiChu: "Trial order of 500 cartons, delivery before 20/06. Needs a quote including shipping to Portland." },
|
|
109
|
+
{ id: "c2", time: "07/06 · 09:30", nguoiGoi: "David Park", ketQua: "hen_goi_lai" },
|
|
110
|
+
] },
|
|
111
|
+
{ id: "kh06", ten: "Daniel Reyes", dienThoai: "0903 558 214", nguon: "Facebook", ngayTao: "09/06", ghiChuTiepNhan: "Commented on the June ad post", phuTrach: "nv3", trangThai: "moi", calls: [] },
|
|
112
|
+
{ id: "kh07", ten: "Hannah Cole", dienThoai: "0908 336 415", nguon: "WhatsApp", ngayTao: "08/06", ghiChuTiepNhan: "Interested in the combo bundle, comparing two price tiers", phuTrach: "nv3", trangThai: "dang_theo", calls: [
|
|
113
|
+
{ id: "c1", time: "12/06 · 11:05", nguoiGoi: "Maria Lopez", ketQua: "hen_goi_lai", ghiChu: "Line busy — try again early afternoon." },
|
|
114
|
+
] },
|
|
115
|
+
{ id: "kh08", ten: "Peter Vance", dienThoai: "0912 445 873", nguon: "Facebook", ngayTao: "12/06", ghiChuTiepNhan: "Asked for wholesale pricing, call back in the afternoon", phuTrach: null, trangThai: "moi", calls: [] },
|
|
116
|
+
{ id: "kh09", ten: "Olivia Grant", dienThoai: "0987 305 214", nguon: "WhatsApp", ngayTao: "12/06", ghiChuTiepNhan: "Messaged about rigid boxes with a printed logo", phuTrach: null, trangThai: "moi", calls: [] },
|
|
117
|
+
{ id: "kh10", ten: "Victor Lane", dienThoai: "0903 771 458", nguon: "Website", ngayTao: "11/06", ghiChuTiepNhan: "Left a consultation form on the homepage", phuTrach: null, trangThai: "moi", calls: [] },
|
|
118
|
+
{ id: "kh11", ten: "Nora Walsh", dienThoai: "0936 208 117", nguon: "Referral", ngayTao: "11/06", ghiChuTiepNhan: "Referred by a past client — call early", phuTrach: null, trangThai: "moi", calls: [] },
|
|
119
|
+
{ id: "kh12", ten: "Adam Foster", dienThoai: "0978 605 332", nguon: "Hotline", ngayTao: "10/06", ghiChuTiepNhan: "", phuTrach: null, trangThai: "moi", calls: [] },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const goiHomNay = (lead: Lead) => lead.calls.filter((c) => c.time.startsWith(HOM_NAY)).length;
|
|
123
|
+
const goiHomNayCuaRep = (leads: Lead[], repId: string) =>
|
|
124
|
+
leads.filter((l) => l.phuTrach === repId).reduce((sum, l) => sum + goiHomNay(l), 0);
|
|
125
|
+
|
|
126
|
+
// Member dossier behind the owner name in the drawer — a SECONDARY
|
|
127
|
+
// reference (the primary row press opens the lead workspace, never a peek).
|
|
128
|
+
function PhuTrachPeek({ rep, dangGiu }: { rep: Rep; dangGiu: number }) {
|
|
129
|
+
return (
|
|
130
|
+
<View style={{ gap: 10 }}>
|
|
131
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
132
|
+
<Avatar name={rep.ten} size={32} />
|
|
133
|
+
<View style={{ gap: 1, flex: 1 }}>
|
|
134
|
+
<Text size="sm" weight="semibold">{rep.ten}</Text>
|
|
135
|
+
<Text size="xs" color="muted">{rep.vaiTro}</Text>
|
|
136
|
+
</View>
|
|
137
|
+
</View>
|
|
138
|
+
<Divider />
|
|
139
|
+
{[["Phone", rep.dienThoai], ["Holding", `${dangGiu} leads`]].map(([label, value]) => (
|
|
140
|
+
<DetailRow key={label} label={label}>
|
|
141
|
+
<Text size="sm" tabular>{value}</Text>
|
|
142
|
+
</DetailRow>
|
|
143
|
+
))}
|
|
144
|
+
<Divider />
|
|
145
|
+
<Button title="Open staff profile" color="secondary" onPress={() => {}} />
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The append-only target setter: a new target is written as a new row, the
|
|
151
|
+
// old history stays — past per-day verdicts never re-compute.
|
|
152
|
+
function ChiTieuGate({ chiTieu, onSave }: { chiTieu: number; onSave: (n: number) => void }) {
|
|
153
|
+
const [open, setOpen] = useState(false);
|
|
154
|
+
const [draft, setDraft] = useState<number | null>(chiTieu);
|
|
155
|
+
const [apDung, setApDung] = useState("In effect since 08/06 · set by Alex Morgan");
|
|
156
|
+
const confirm = () => {
|
|
157
|
+
if (draft === null) return;
|
|
158
|
+
onSave(draft); setApDung("In effect from today · set by you"); setOpen(false);
|
|
159
|
+
};
|
|
160
|
+
return (
|
|
161
|
+
<FilterPill
|
|
162
|
+
label="Target"
|
|
163
|
+
summary={String(chiTieu)}
|
|
164
|
+
align="end"
|
|
165
|
+
open={open}
|
|
166
|
+
onOpenChange={setOpen}
|
|
167
|
+
footer={
|
|
168
|
+
<>
|
|
169
|
+
<Button title="Cancel" color="secondary" onPress={() => setOpen(false)} />
|
|
170
|
+
<Button title="Save target" color="primary" disabled={draft === null || draft < 1} onPress={confirm} />
|
|
171
|
+
</>
|
|
172
|
+
}
|
|
173
|
+
>
|
|
174
|
+
<View style={{ gap: 12 }}>
|
|
175
|
+
<View style={{ gap: 2 }}>
|
|
176
|
+
<Text size="sm" weight="semibold">Daily call target</Text>
|
|
177
|
+
<Text size="xs" color="muted">A new target applies from today onward — past days' results stay unchanged.</Text>
|
|
178
|
+
</View>
|
|
179
|
+
<FormField label="Calls/day/rep">
|
|
180
|
+
<NumberInput value={draft} onValueChange={setDraft} min={1} accessibilityLabel="Calls per day per rep" />
|
|
181
|
+
</FormField>
|
|
182
|
+
<Text size="xs" color="muted">{apDung}</Text>
|
|
183
|
+
</View>
|
|
184
|
+
</FilterPill>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The drawer body — keyed by lead id from the caller, so the inline composer's
|
|
189
|
+
// draft state resets when sequencing steps. The lead itself lives in the page's
|
|
190
|
+
// shared state: logging here moves every number on the register.
|
|
191
|
+
function LeadWorkspace(props: { lead: Lead; rep: Rep | null; dangGiu: number; onLog: (ketQua: KetQua, ghiChu: string, callbackAt?: string) => void }) {
|
|
192
|
+
const { lead, rep, dangGiu, onLog } = props;
|
|
193
|
+
const [ketQua, setKetQua] = useState<KetQua | "">("");
|
|
194
|
+
const [ghiChu, setGhiChu] = useState("");
|
|
195
|
+
const [cbDate, setCbDate] = useState("");
|
|
196
|
+
const [cbTime, setCbTime] = useState("");
|
|
197
|
+
const tt = TRANG_THAI[lead.trangThai];
|
|
198
|
+
const lienHeCuoi = lead.calls[0]?.time ?? null;
|
|
199
|
+
const timelineItems: TimelineItem[] = lead.calls.map((c) => ({
|
|
200
|
+
id: c.id, icon: KET_QUA[c.ketQua].icon, iconColor: KET_QUA[c.ketQua].color,
|
|
201
|
+
label: c.ketQua === "hen_goi_lai" && c.callbackAt ? `${KET_QUA[c.ketQua].label} · ${c.callbackAt} — ${c.nguoiGoi}` : `${KET_QUA[c.ketQua].label} — ${c.nguoiGoi}`,
|
|
202
|
+
description: c.ghiChu,
|
|
203
|
+
right: <Text size="xs" color="muted" tabular>{c.time}</Text>,
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<>
|
|
208
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 24 }}>
|
|
209
|
+
{/* hero: the phone is what a caller needs first — selectable, no fake CTA */}
|
|
210
|
+
<View style={{ gap: 10 }}>
|
|
211
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
212
|
+
<Text size="xl" weight="semibold" tabular userSelect="auto">{lead.dienThoai}</Text>
|
|
213
|
+
<Badge label={tt.label} color={tt.color} />
|
|
214
|
+
</View>
|
|
215
|
+
<View style={{ gap: 6 }}>
|
|
216
|
+
<DetailRow label="Source" labelWidth={120} minHeight={24}><Text size="sm">{lead.nguon}</Text></DetailRow>
|
|
217
|
+
<DetailRow label="Created" labelWidth={120} minHeight={24}><Text size="sm" tabular>{lead.ngayTao}</Text></DetailRow>
|
|
218
|
+
<DetailRow label="Owner" labelWidth={120} minHeight={24}>
|
|
219
|
+
{rep ? (
|
|
220
|
+
<Peek accessibilityLabel={`Staff profile for ${rep.ten}`} content={<PhuTrachPeek rep={rep} dangGiu={dangGiu} />}>
|
|
221
|
+
<Text size="sm">{rep.ten}</Text>
|
|
222
|
+
</Peek>
|
|
223
|
+
) : (
|
|
224
|
+
<Text size="sm" color="muted">Unassigned — tick rows in the Unassigned section to assign</Text>
|
|
225
|
+
)}
|
|
226
|
+
</DetailRow>
|
|
227
|
+
<DetailRow label="Last contact" labelWidth={120} minHeight={24}><Text size="sm" color={lienHeCuoi ? "default" : "muted"} tabular>{lienHeCuoi ?? "Not contacted yet"}</Text></DetailRow>
|
|
228
|
+
{lead.ghiChuTiepNhan ? (
|
|
229
|
+
<DetailRow label="Intake note" labelWidth={120} minHeight={24}><Text size="sm" style={{ flex: 1 }} numberOfLines={2}>{lead.ghiChuTiepNhan}</Text></DetailRow>
|
|
230
|
+
) : null}
|
|
231
|
+
</View>
|
|
232
|
+
</View>
|
|
233
|
+
|
|
234
|
+
<Divider />
|
|
235
|
+
|
|
236
|
+
{/* log this call — inline composer, matching the Call console */}
|
|
237
|
+
<View style={{ gap: 12 }}>
|
|
238
|
+
<Text size="sm" weight="semibold">Log this call</Text>
|
|
239
|
+
<ChipGroup
|
|
240
|
+
accessibilityLabel="Call outcome"
|
|
241
|
+
options={(Object.keys(KET_QUA) as KetQua[]).map((k) => ({ label: KET_QUA[k].label, value: k }))}
|
|
242
|
+
value={ketQua}
|
|
243
|
+
onValueChange={(v) => setKetQua(v as KetQua)}
|
|
244
|
+
/>
|
|
245
|
+
{ketQua === "hen_goi_lai" ? (
|
|
246
|
+
<View style={{ flexDirection: "row", gap: 12, flexWrap: "wrap" }}>
|
|
247
|
+
<View style={{ flexGrow: 1, flexBasis: 160 }}>
|
|
248
|
+
<FormField label="Call back on"><DatePicker value={cbDate} onValueChange={setCbDate} format="date" /></FormField>
|
|
249
|
+
</View>
|
|
250
|
+
<View style={{ flexGrow: 1, flexBasis: 120 }}>
|
|
251
|
+
<FormField label="At (optional)"><TimePicker value={cbTime} onValueChange={setCbTime} /></FormField>
|
|
252
|
+
</View>
|
|
253
|
+
</View>
|
|
254
|
+
) : null}
|
|
255
|
+
<TextInputField value={ghiChu} onChangeText={setGhiChu} multiline numberOfLines={3} autoGrow placeholder="Notes — what was discussed, and the next step" accessibilityLabel="Call notes" />
|
|
256
|
+
<View style={{ flexDirection: "row" }}>
|
|
257
|
+
<View style={{ flex: 1 }} />
|
|
258
|
+
<Button title="Log call" color="primary" disabled={ketQua === ""} onPress={() => { if (ketQua !== "") { onLog(ketQua, ghiChu, ketQua === "hen_goi_lai" ? fmtCallback(cbDate, cbTime) : undefined); setKetQua(""); setGhiChu(""); setCbDate(""); setCbTime(""); } }} />
|
|
259
|
+
</View>
|
|
260
|
+
</View>
|
|
261
|
+
|
|
262
|
+
<Divider />
|
|
263
|
+
|
|
264
|
+
{/* contact history */}
|
|
265
|
+
<View style={{ gap: 12 }}>
|
|
266
|
+
<Text size="sm" weight="semibold">{`Contact history · ${lead.calls.length}`}</Text>
|
|
267
|
+
{timelineItems.length === 0 ? (
|
|
268
|
+
<EmptyState message="No calls yet" hint="Log the first call above" />
|
|
269
|
+
) : (
|
|
270
|
+
<Timeline items={timelineItems} />
|
|
271
|
+
)}
|
|
272
|
+
</View>
|
|
273
|
+
</ScrollView>
|
|
274
|
+
|
|
275
|
+
{/* pinned footer — the drawer's escalation, commit action at the right edge */}
|
|
276
|
+
<DrawerFooter>
|
|
277
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>When a lead agrees to buy, convert them into a case — the order continues in the Cases module.</Text>
|
|
278
|
+
<Button title="Create case" color="primary" onPress={() => {}} />
|
|
279
|
+
</DrawerFooter>
|
|
280
|
+
</>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function TplCrmDesk() {
|
|
285
|
+
const [leads, setLeads] = useState<Lead[]>(LEADS);
|
|
286
|
+
const [search, setSearch] = useState("");
|
|
287
|
+
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
|
288
|
+
const [assignOpen, setAssignOpen] = useState(false);
|
|
289
|
+
const [filter, setFilter] = useState<FilterKey>("all");
|
|
290
|
+
const [chiTieu, setChiTieu] = useState(10);
|
|
291
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
292
|
+
|
|
293
|
+
// ONE dataset, ONE search + chip row over every row on screen: the pile is
|
|
294
|
+
// phuTrach === null; the rep cards group the rest. "Unassigned" shows the
|
|
295
|
+
// pile alone; a status chip narrows pile AND rep rows to that status.
|
|
296
|
+
const tu = search.trim().toLowerCase();
|
|
297
|
+
const matchSearch = (l: Lead) => tu === "" || l.ten.toLowerCase().includes(tu) || l.dienThoai.replace(/\s/g, "").includes(tu.replace(/\s/g, ""));
|
|
298
|
+
const matchStatus = (l: Lead) => filter === "all" || filter === "chua_giao" || l.trangThai === filter;
|
|
299
|
+
const pile = leads.filter((l) => l.phuTrach === null);
|
|
300
|
+
const visiblePile = pile.filter((l) => matchSearch(l) && matchStatus(l));
|
|
301
|
+
const giuBoi = (repId: string) => leads.filter((l) => l.phuTrach === repId);
|
|
302
|
+
const rowsOf = (repId: string) => giuBoi(repId).filter((l) => matchSearch(l) && matchStatus(l));
|
|
303
|
+
const showReps = filter !== "chua_giao";
|
|
304
|
+
|
|
305
|
+
// The drawer sequences over the FLAT visible ordering — pile rows first,
|
|
306
|
+
// then each rep card's rows — so ◀ ▶ walks the screen top to bottom. When
|
|
307
|
+
// the open lead falls out (a logged call moved it past the status filter),
|
|
308
|
+
// the chevrons disappear but the drawer stays on the lead.
|
|
309
|
+
const sequence = [...visiblePile, ...(showReps ? REPS.flatMap((r) => rowsOf(r.id)) : [])];
|
|
310
|
+
const openLead = leads.find((l) => l.id === openId) ?? null;
|
|
311
|
+
const seqIndex = openLead ? sequence.findIndex((l) => l.id === openLead.id) : -1;
|
|
312
|
+
const rep = openLead?.phuTrach ? REPS.find((r) => r.id === openLead.phuTrach) ?? null : null;
|
|
313
|
+
|
|
314
|
+
const tongGoiHomNay = leads.reduce((sum, l) => sum + goiHomNay(l), 0);
|
|
315
|
+
const datKpi = REPS.filter((r) => goiHomNayCuaRep(leads, r.id) >= chiTieu).length;
|
|
316
|
+
const countFor = (key: FilterKey) =>
|
|
317
|
+
key === "all" ? leads.length : key === "chua_giao" ? pile.length : leads.filter((l) => l.trangThai === key).length;
|
|
318
|
+
const labelFor = (key: FilterKey) =>
|
|
319
|
+
key === "all" ? "All" : key === "chua_giao" ? "Unassigned" : TRANG_THAI[key].label;
|
|
320
|
+
|
|
321
|
+
const allVisibleSelected = visiblePile.length > 0 && visiblePile.every((l) => selected.has(l.id));
|
|
322
|
+
const someVisibleSelected = visiblePile.some((l) => selected.has(l.id));
|
|
323
|
+
|
|
324
|
+
function toggleMany(ids: string[], on: boolean) {
|
|
325
|
+
const next = new Set(selected);
|
|
326
|
+
ids.forEach((id) => (on ? next.add(id) : next.delete(id)));
|
|
327
|
+
setSelected(next);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function toggleOne(id: string, on: boolean) {
|
|
331
|
+
const next = new Set(selected);
|
|
332
|
+
if (on) next.add(id); else next.delete(id);
|
|
333
|
+
setSelected(next);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Sets the owner of every selected lead — the SAME action assigns an
|
|
337
|
+
// unassigned pile lead AND reassigns a rep's lead to another rep (every
|
|
338
|
+
// checkbox, pile or rep, feeds this one shared selection).
|
|
339
|
+
function giaoCho(repId: string) {
|
|
340
|
+
setLeads((prev) => prev.map((l) => (selected.has(l.id) ? { ...l, phuTrach: repId } : l)));
|
|
341
|
+
setSelected(new Set()); setAssignOpen(false);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Logging a call prepends the event AND upgrades the lead's status — never
|
|
345
|
+
// downgrades — so every number on the register (chips, strip, badges) moves live.
|
|
346
|
+
function ghiCuocGoi(leadId: string, ketQua: KetQua, ghiChu: string, callbackAt?: string) {
|
|
347
|
+
setLeads((prev) => prev.map((l) => {
|
|
348
|
+
if (l.id !== leadId) return l;
|
|
349
|
+
const moi = KET_QUA[ketQua].trangThai;
|
|
350
|
+
const trangThai = TRANG_THAI[moi].rank > TRANG_THAI[l.trangThai].rank ? moi : l.trangThai;
|
|
351
|
+
const nguoiGoi = REPS.find((r) => r.id === l.phuTrach)?.ten ?? "Alex Morgan";
|
|
352
|
+
const call: CallEvent = { id: `c_moi_${l.calls.length + 1}`, time: `${HOM_NAY} · just now`, nguoiGoi, ketQua, ghiChu: ghiChu || undefined, callbackAt: callbackAt || undefined };
|
|
353
|
+
return { ...l, trangThai, calls: [call, ...l.calls] };
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
|
|
359
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 28 }}>
|
|
360
|
+
<View style={{ width: "100%", maxWidth: 960, alignSelf: "center", gap: 16 }}>
|
|
361
|
+
{/* header band — one primary; it cedes primary to "Assign to…" while a selection is live */}
|
|
362
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
363
|
+
<View style={{ gap: 2, flex: 1 }}>
|
|
364
|
+
<Text size="xl" weight="semibold">CRM — workbench</Text>
|
|
365
|
+
<Text size="sm" color="muted">One list: assign new leads to reps and track who hits today's call target</Text>
|
|
366
|
+
</View>
|
|
367
|
+
<Button title="Add lead" color={selected.size > 0 ? "secondary" : "primary"} onPress={() => {}} />
|
|
368
|
+
</View>
|
|
369
|
+
|
|
370
|
+
{/* the strip is informational — the chips below own filtering */}
|
|
371
|
+
<KPIStrip
|
|
372
|
+
items={[
|
|
373
|
+
{ label: "Total leads", value: leads.length, caption: `${REPS.length} reps calling` },
|
|
374
|
+
{ label: "Calls today", value: tongGoiHomNay, caption: "logged from the lead drawer" },
|
|
375
|
+
{ label: "Hit KPI today", value: `${datKpi}/${REPS.length}`, tone: datKpi < REPS.length ? "warning" : "default", caption: `target ${chiTieu} calls/rep` },
|
|
376
|
+
{ label: "Unassigned", value: pile.length, tone: pile.length > 0 ? "warning" : "default", caption: "tick rows to bulk-assign", info: "Leads nobody owns yet — filter with the Unassigned chip below and assign them in bulk." },
|
|
377
|
+
]}
|
|
378
|
+
/>
|
|
379
|
+
|
|
380
|
+
{/* toolbar: ONE search over every row + filter chips (one active) + the target gate */}
|
|
381
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
|
382
|
+
<View style={{ flexGrow: 1, flexBasis: 220, maxWidth: 300 }}>
|
|
383
|
+
<SearchInput placeholder="Search by name or phone…" value={search} onChangeText={setSearch} accessibilityLabel="Search leads" />
|
|
384
|
+
</View>
|
|
385
|
+
<ChipGroup
|
|
386
|
+
accessibilityLabel="Filter leads"
|
|
387
|
+
options={FILTER_KEYS.map((key) => ({ label: `${labelFor(key)} · ${countFor(key)}`, value: key }))}
|
|
388
|
+
value={filter}
|
|
389
|
+
onValueChange={setFilter}
|
|
390
|
+
/>
|
|
391
|
+
<View style={{ flex: 1 }} />
|
|
392
|
+
<ChiTieuGate chiTieu={chiTieu} onSave={setChiTieu} />
|
|
393
|
+
</View>
|
|
394
|
+
|
|
395
|
+
{/* the pile, FIRST — same banded register as the rep cards below.
|
|
396
|
+
Each row is two press targets: the checkbox selects, the body opens. */}
|
|
397
|
+
<Card style={{ padding: 0 }}>
|
|
398
|
+
<CardHeader>
|
|
399
|
+
{/* select-all on the LEFT, aligned with the row checkboxes */}
|
|
400
|
+
<CheckboxInput accessibilityLabel="Select all unassigned leads" checked={allVisibleSelected} indeterminate={!allVisibleSelected && someVisibleSelected} onChange={(on) => toggleMany(visiblePile.map((l) => l.id), on)} disabled={visiblePile.length === 0} />
|
|
401
|
+
<CardHeaderTitle info="New leads with no rep yet — tick rows (or the header box), then assign in the bar below">Unassigned</CardHeaderTitle>
|
|
402
|
+
<CardHeaderMeta>{`${pile.length} leads`}</CardHeaderMeta>
|
|
403
|
+
</CardHeader>
|
|
404
|
+
{pile.length === 0 ? (
|
|
405
|
+
<View style={{ paddingVertical: 20 }}>
|
|
406
|
+
<EmptyState message="Every lead is assigned" hint="New leads arrive via “Add lead” or the signup form." />
|
|
407
|
+
</View>
|
|
408
|
+
) : visiblePile.length === 0 ? (
|
|
409
|
+
<View style={{ paddingVertical: 20 }}>
|
|
410
|
+
<EmptyState message="No unassigned leads match the filters" hint="Try another keyword or pick the All chip." />
|
|
411
|
+
</View>
|
|
412
|
+
) : (
|
|
413
|
+
<View>
|
|
414
|
+
{visiblePile.map((l, i) => (
|
|
415
|
+
<View key={l.id}>
|
|
416
|
+
{i > 0 ? <Divider /> : null}
|
|
417
|
+
<PressableRow
|
|
418
|
+
onPress={() => setOpenId(l.id)}
|
|
419
|
+
selected={l.id === openId}
|
|
420
|
+
marked={selected.has(l.id)}
|
|
421
|
+
style={{ gap: 8 }}
|
|
422
|
+
>
|
|
423
|
+
<CheckboxInput accessibilityLabel={`Select ${l.ten}`} checked={selected.has(l.id)} onChange={(on) => toggleOne(l.id, on)} />
|
|
424
|
+
<Pressable
|
|
425
|
+
accessibilityLabel={`Open record for ${l.ten}`}
|
|
426
|
+
accessibilityRole="button"
|
|
427
|
+
onPress={() => setOpenId(l.id)}
|
|
428
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 56 }}
|
|
429
|
+
>
|
|
430
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
431
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 10 }}>
|
|
432
|
+
<Text size="sm" weight="medium" numberOfLines={1} userSelect="none">{l.ten}</Text>
|
|
433
|
+
<Text size="sm" color="muted" tabular userSelect="none">{l.dienThoai}</Text>
|
|
434
|
+
</View>
|
|
435
|
+
<Text size="sm" color="muted" numberOfLines={1} userSelect="none">{l.ghiChuTiepNhan ? `${l.nguon} · ${l.ghiChuTiepNhan}` : l.nguon}</Text>
|
|
436
|
+
</View>
|
|
437
|
+
<Text size="xs" color={l.ngayTao === HOM_NAY ? "default" : "muted"} tabular userSelect="none">{l.ngayTao}</Text>
|
|
438
|
+
</Pressable>
|
|
439
|
+
</PressableRow>
|
|
440
|
+
</View>
|
|
441
|
+
))}
|
|
442
|
+
</View>
|
|
443
|
+
)}
|
|
444
|
+
<CardFooter>
|
|
445
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>
|
|
446
|
+
{selected.size > 0 ? `${selected.size} of ${pile.length} leads selected — assign in the bar below` : "Press a row to open the record — tick to bulk-assign"}
|
|
447
|
+
</Text>
|
|
448
|
+
</CardFooter>
|
|
449
|
+
</Card>
|
|
450
|
+
|
|
451
|
+
{/* one banded card per rep — header carries the verdict, body the leads */}
|
|
452
|
+
{showReps ? REPS.map((r) => {
|
|
453
|
+
const goi = goiHomNayCuaRep(leads, r.id);
|
|
454
|
+
const dat = goi >= chiTieu;
|
|
455
|
+
const rows = rowsOf(r.id);
|
|
456
|
+
const allRepSelected = rows.length > 0 && rows.every((l) => selected.has(l.id));
|
|
457
|
+
const someRepSelected = rows.some((l) => selected.has(l.id));
|
|
458
|
+
return (
|
|
459
|
+
<Card key={r.id} style={{ padding: 0 }}>
|
|
460
|
+
{/* header = identity + the verdict only; the select-all (aligned
|
|
461
|
+
with the row boxes) transfers this rep's whole book at once */}
|
|
462
|
+
<CardHeader>
|
|
463
|
+
<CheckboxInput accessibilityLabel={`Select all of ${r.ten}'s leads`} checked={allRepSelected} indeterminate={!allRepSelected && someRepSelected} onChange={(on) => toggleMany(rows.map((l) => l.id), on)} disabled={rows.length === 0} />
|
|
464
|
+
<Avatar name={r.ten} size={28} />
|
|
465
|
+
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
466
|
+
<Text size="sm" weight="semibold" numberOfLines={1} style={{ flexShrink: 1 }}>{r.ten}</Text>
|
|
467
|
+
<Text size="sm" weight="medium" color={dat ? "success" : goi === 0 ? "danger" : "warning"}>{dat ? "On target" : "Behind"}</Text>
|
|
468
|
+
</View>
|
|
469
|
+
</CardHeader>
|
|
470
|
+
{rows.length === 0 ? (
|
|
471
|
+
<EmptyState message="No leads match the filters" hint="Change the keyword or pick the All chip to see everything" />
|
|
472
|
+
) : (
|
|
473
|
+
<View>
|
|
474
|
+
{rows.map((l, i) => (
|
|
475
|
+
<View key={l.id}>
|
|
476
|
+
{i > 0 ? <Divider /> : null}
|
|
477
|
+
<PressableRow onPress={() => setOpenId(l.id)} selected={l.id === openId} marked={selected.has(l.id)} style={{ minHeight: 64, gap: 8 }}>
|
|
478
|
+
<CheckboxInput accessibilityLabel={`Select ${l.ten}`} checked={selected.has(l.id)} onChange={(on) => toggleOne(l.id, on)} />
|
|
479
|
+
<Pressable
|
|
480
|
+
accessibilityRole="button"
|
|
481
|
+
accessibilityLabel={`Open record for ${l.ten}`}
|
|
482
|
+
onPress={() => setOpenId(l.id)}
|
|
483
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
484
|
+
>
|
|
485
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
486
|
+
<Text weight="medium" numberOfLines={1}>{l.ten}</Text>
|
|
487
|
+
<Text size="sm" color="muted" numberOfLines={1}>{l.calls[0] ? (l.calls[0].ghiChu ?? KET_QUA[l.calls[0].ketQua].label) : "Never contacted"}</Text>
|
|
488
|
+
</View>
|
|
489
|
+
<Text size="xs" color="muted" tabular>{l.calls[0]?.time ?? "—"}</Text>
|
|
490
|
+
<Badge variant="dot" label={TRANG_THAI[l.trangThai].label} color={TRANG_THAI[l.trangThai].color} />
|
|
491
|
+
</Pressable>
|
|
492
|
+
</PressableRow>
|
|
493
|
+
</View>
|
|
494
|
+
))}
|
|
495
|
+
</View>
|
|
496
|
+
)}
|
|
497
|
+
{/* the rep's numbers — calls-vs-target + book size — moved out of
|
|
498
|
+
the busy header into a quiet summary footer */}
|
|
499
|
+
<CardFooter>
|
|
500
|
+
<Text size="xs" color="muted" tabular style={{ flex: 1 }}>{`${goi}/${chiTieu} calls today · ${giuBoi(r.id).length} leads held`}</Text>
|
|
501
|
+
</CardFooter>
|
|
502
|
+
</Card>
|
|
503
|
+
);
|
|
504
|
+
}) : null}
|
|
505
|
+
</View>
|
|
506
|
+
</ScrollView>
|
|
507
|
+
|
|
508
|
+
{/* assign / reassign every selected lead (pile OR rep) in one move */}
|
|
509
|
+
<FloatingActionBar count={selected.size} label="leads selected" onClear={() => setSelected(new Set())} clearLabel="Clear selection">
|
|
510
|
+
<Popover open={assignOpen} onOpenChange={setAssignOpen} side="top" align="end">
|
|
511
|
+
<PopoverTrigger>
|
|
512
|
+
<Button title="Assign" color="primary" />
|
|
513
|
+
</PopoverTrigger>
|
|
514
|
+
<PopoverContent style={{ width: 320 }}>
|
|
515
|
+
<View style={{ paddingHorizontal: 12, paddingTop: 10, paddingBottom: 6 }}>
|
|
516
|
+
<Text size="xs" color="muted" transform="uppercase" numberOfLines={2}>{`Assign ${selected.size} leads to`}</Text>
|
|
517
|
+
</View>
|
|
518
|
+
<View style={{ paddingHorizontal: 6, paddingBottom: 8, gap: 2 }}>
|
|
519
|
+
{REPS.map((r) => (
|
|
520
|
+
<MenuListItem key={r.id} icon={<Avatar name={r.ten} size={28} />} title={r.ten} description={`${giuBoi(r.id).length} leads · ${goiHomNayCuaRep(leads, r.id)} calls today`} onPress={() => giaoCho(r.id)} />
|
|
521
|
+
))}
|
|
522
|
+
</View>
|
|
523
|
+
</PopoverContent>
|
|
524
|
+
</Popover>
|
|
525
|
+
</FloatingActionBar>
|
|
526
|
+
|
|
527
|
+
{/* the shared lead workspace — body keyed by lead id so per-lead state
|
|
528
|
+
resets when ◀ ▶ (or ←/→) steps through the visible list */}
|
|
529
|
+
{openLead ? (
|
|
530
|
+
<Drawer
|
|
531
|
+
open onOpenChange={(o) => !o && setOpenId(null)} title={openLead.ten} width={600}
|
|
532
|
+
onPrev={seqIndex > 0 ? () => setOpenId(sequence[seqIndex - 1].id) : undefined}
|
|
533
|
+
onNext={seqIndex >= 0 && seqIndex < sequence.length - 1 ? () => setOpenId(sequence[seqIndex + 1].id) : undefined}
|
|
534
|
+
position={seqIndex >= 0 ? `${seqIndex + 1}/${sequence.length}` : undefined}
|
|
535
|
+
>
|
|
536
|
+
<LeadWorkspace key={openLead.id} lead={openLead} rep={rep} dangGiu={rep ? giuBoi(rep.id).length : 0} onLog={(kq, gc, cb) => ghiCuocGoi(openLead.id, kq, gc, cb)} />
|
|
537
|
+
</Drawer>
|
|
538
|
+
) : null}
|
|
539
|
+
</View>
|
|
540
|
+
);
|
|
541
|
+
}
|