feat: web sprint 250-262 — plans/clients/tickets/audit/profile/settings/leads/reports #26

Merged
kibin merged 11 commits from feat/web-sprint-250-262 into main 2026-04-01 08:09:45 +00:00
Showing only changes of commit 272a543425 - Show all commits

View File

@@ -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">