feat(258): add Convert to Client button on leads
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
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 { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
@@ -22,11 +23,14 @@ const statusOptions = ["NEW", "CONTACTED", "INTERESTED", "CONVERTED", "LOST"];
|
||||
|
||||
export default function LeadsPage() {
|
||||
const qc = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState<Lead | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [showConvert, setShowConvert] = useState(false);
|
||||
const [form, setForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", notes: "", source: "" });
|
||||
const [statusUpdate, setStatusUpdate] = useState("");
|
||||
const [convertForm, setConvertForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", areaId: "", planId: "", billingType: "POSTPAID" });
|
||||
|
||||
const { data = [], isLoading, refetch } = useQuery<Lead[]>({
|
||||
queryKey: ["leads", search],
|
||||
@@ -79,6 +83,41 @@ export default function LeadsPage() {
|
||||
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>);
|
||||
|
||||
return (
|
||||
@@ -176,14 +215,72 @@ export default function LeadsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-1">
|
||||
<Button variant="danger" size="sm" onClick={() => { if (confirm("Delete this lead?")) deleteLead.mutate(selected.id); }} isLoading={deleteLead.isPending}>Delete</Button>
|
||||
<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>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 */}
|
||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add New Lead">
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user