feat(258): add Convert to Client button on leads
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { UserPlus, RefreshCw } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { UserPlus, RefreshCw, ArrowRightCircle } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
|
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
@@ -22,11 +23,14 @@ const statusOptions = ["NEW", "CONTACTED", "INTERESTED", "CONVERTED", "LOST"];
|
|||||||
|
|
||||||
export default function LeadsPage() {
|
export default function LeadsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selected, setSelected] = useState<Lead | null>(null);
|
const [selected, setSelected] = useState<Lead | null>(null);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [showConvert, setShowConvert] = useState(false);
|
||||||
const [form, setForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", notes: "", source: "" });
|
const [form, setForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", notes: "", source: "" });
|
||||||
const [statusUpdate, setStatusUpdate] = useState("");
|
const [statusUpdate, setStatusUpdate] = useState("");
|
||||||
|
const [convertForm, setConvertForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", areaId: "", planId: "", billingType: "POSTPAID" });
|
||||||
|
|
||||||
const { data = [], isLoading, refetch } = useQuery<Lead[]>({
|
const { data = [], isLoading, refetch } = useQuery<Lead[]>({
|
||||||
queryKey: ["leads", search],
|
queryKey: ["leads", search],
|
||||||
@@ -79,6 +83,41 @@ export default function LeadsPage() {
|
|||||||
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete"),
|
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const convertToClient = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await api.post("/api/v1/clients", {
|
||||||
|
firstName: convertForm.firstName, lastName: convertForm.lastName,
|
||||||
|
phone: convertForm.phone, email: convertForm.email || undefined,
|
||||||
|
address: convertForm.address || undefined,
|
||||||
|
areaId: convertForm.areaId || undefined,
|
||||||
|
planId: convertForm.planId || undefined,
|
||||||
|
});
|
||||||
|
// Mark lead as converted
|
||||||
|
if (selected) await api.patch(`/api/v1/leads/${selected.id}`, { status: "CONVERTED" });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
toast.success("Lead converted to client!");
|
||||||
|
setShowConvert(false);
|
||||||
|
setSelected(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ["leads"] });
|
||||||
|
if (data?.id) router.push(`/clients/${data.id}`);
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to convert lead"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: areas = [] } = useQuery<{ id: string; name: string }[]>({
|
||||||
|
queryKey: ["areas"],
|
||||||
|
queryFn: async () => { const r = await api.get("/api/v1/areas"); return Array.isArray(r.data) ? r.data : (r.data as any).data ?? []; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: activePlans = [] } = useQuery<{ id: string; name: string; monthlyPrice: number; type: string }[]>({
|
||||||
|
queryKey: ["plans", "active"],
|
||||||
|
queryFn: async () => { const r = await api.get("/api/v1/plans?isActive=true"); return Array.isArray(r.data) ? r.data : (r.data as any).data ?? []; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredConvertPlans = activePlans.filter(p => !convertForm.billingType || p.type === convertForm.billingType);
|
||||||
|
|
||||||
const counts = statusOptions.reduce((acc, s) => ({ ...acc, [s]: data.filter(l => l.status === s).length }), {} as Record<string, number>);
|
const counts = statusOptions.reduce((acc, s) => ({ ...acc, [s]: data.filter(l => l.status === s).length }), {} as Record<string, number>);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,14 +215,72 @@ export default function LeadsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between pt-1">
|
<div className="flex justify-between pt-1 flex-wrap gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button variant="danger" size="sm" onClick={() => { if (confirm("Delete this lead?")) deleteLead.mutate(selected.id); }} isLoading={deleteLead.isPending}>Delete</Button>
|
<Button variant="danger" size="sm" onClick={() => { if (confirm("Delete this lead?")) deleteLead.mutate(selected.id); }} isLoading={deleteLead.isPending}>Delete</Button>
|
||||||
|
{selected.status !== "CONVERTED" && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => {
|
||||||
|
setConvertForm({
|
||||||
|
firstName: selected.firstName, lastName: selected.lastName,
|
||||||
|
phone: selected.phone, email: selected.email ?? "",
|
||||||
|
address: selected.address ?? "", areaId: "", planId: "", billingType: "POSTPAID",
|
||||||
|
});
|
||||||
|
setShowConvert(true);
|
||||||
|
}}>
|
||||||
|
<ArrowRightCircle size={14} className="mr-1" /> Convert to Client
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setSelected(null)}>Close</Button>
|
<Button variant="outline" size="sm" onClick={() => setSelected(null)}>Close</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Convert to Client Modal */}
|
||||||
|
<Modal isOpen={showConvert} onClose={() => setShowConvert(false)} title="Convert Lead to Client" className="max-w-xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500">Pre-filled from lead data. Complete missing info to create the client.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="First Name *" value={convertForm.firstName} onChange={e => setConvertForm(f => ({ ...f, firstName: e.target.value }))} />
|
||||||
|
<Input label="Last Name *" value={convertForm.lastName} onChange={e => setConvertForm(f => ({ ...f, lastName: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<Input label="Phone *" value={convertForm.phone} onChange={e => setConvertForm(f => ({ ...f, phone: e.target.value }))} />
|
||||||
|
<Input label="Email" type="email" value={convertForm.email} onChange={e => setConvertForm(f => ({ ...f, email: e.target.value }))} />
|
||||||
|
<Input label="Address" value={convertForm.address} onChange={e => setConvertForm(f => ({ ...f, address: e.target.value }))} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Area</label>
|
||||||
|
<select className="border rounded-lg px-3 py-2 text-sm" value={convertForm.areaId} onChange={e => setConvertForm(f => ({ ...f, areaId: e.target.value }))}>
|
||||||
|
<option value="">— Select area —</option>
|
||||||
|
{areas.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Billing Type</label>
|
||||||
|
<select className="border rounded-lg px-3 py-2 text-sm" value={convertForm.billingType} onChange={e => setConvertForm(f => ({ ...f, billingType: e.target.value, planId: "" }))}>
|
||||||
|
<option value="POSTPAID">Postpaid</option>
|
||||||
|
<option value="PREPAID">Prepaid</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Plan *</label>
|
||||||
|
<select className="border rounded-lg px-3 py-2 text-sm" value={convertForm.planId} onChange={e => setConvertForm(f => ({ ...f, planId: e.target.value }))}>
|
||||||
|
<option value="">— Select plan —</option>
|
||||||
|
{filteredConvertPlans.map(p => <option key={p.id} value={p.id}>{p.name} — ₱{Number(p.monthlyPrice).toLocaleString()}/mo</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowConvert(false)}>Cancel</Button>
|
||||||
|
<Button onClick={() => convertToClient.mutate()} isLoading={convertToClient.isPending}
|
||||||
|
disabled={!convertForm.firstName || !convertForm.lastName || !convertForm.phone || !convertForm.planId}>
|
||||||
|
<ArrowRightCircle size={14} className="mr-1" /> Create Client
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Add Lead Modal */}
|
{/* Add Lead Modal */}
|
||||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add New Lead">
|
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add New Lead">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user