Files
fiberops-web/app/(app)/clients/page.tsx

215 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { UserPlus, Search, ChevronRight, RefreshCw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Badge } from "@/components/ui/Badge";
import api from "@/lib/api";
import { toast } from "sonner";
interface Area { id: string; name: string; }
interface Plan { id: string; name: string; monthlyPrice: number; }
interface Client {
id: string; accountNumber: string; firstName: string; lastName: string;
phone: string; email: string; address?: string; isActive: boolean;
area: { id: string; name: string } | null;
subscriptions: Array<{ status: string; type: string; monthlyPrice: string; plan?: { name: string }; }>;
}
interface ClientsMeta { total: number; page: number; limit: number; totalPages: number; }
interface ClientsResponse { data: Client[]; meta: ClientsMeta; }
const statusVariant: Record<string, "success" | "warning" | "danger" | "muted"> = {
ACTIVE: "success", PENDING: "warning", SUSPENDED: "danger", DISCONNECTED: "muted", CANCELLED: "muted",
};
export default function ClientsPage() {
const router = useRouter();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({
firstName: "", lastName: "", email: "", phone: "", address: "",
areaId: "", planId: "", billingType: "POSTPAID",
});
const { data, isLoading, refetch } = useQuery<ClientsResponse>({
queryKey: ["clients", search, page],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (search) params.set("search", search);
const res = await api.get<ClientsResponse>(`/api/v1/clients?${params}`);
return res.data;
},
});
const { data: areas = [] } = useQuery<Area[]>({
queryKey: ["areas"],
queryFn: async () => { const r = await api.get<Area[]>("/api/v1/areas"); return r.data; },
});
const { data: plans = [] } = useQuery<Plan[]>({
queryKey: ["plans"],
queryFn: async () => { const r = await api.get<Plan[]>("/api/v1/plans"); return Array.isArray(r.data) ? r.data : (r.data as any).data ?? []; },
});
const createClient = useMutation({
mutationFn: async () => {
const res = await api.post("/api/v1/clients/onboard", {
firstName: form.firstName, lastName: form.lastName,
email: form.email, phone: form.phone, address: form.address,
areaId: form.areaId || undefined, planId: form.planId,
billingType: form.billingType,
});
return res.data;
},
onSuccess: (data: any) => {
toast.success("Client created successfully!");
qc.invalidateQueries({ queryKey: ["clients"] });
setShowAdd(false);
setForm({ firstName: "", lastName: "", email: "", phone: "", address: "", areaId: "", planId: "", billingType: "POSTPAID" });
if (data?.client?.id) router.push(`/clients/${data.client.id}`);
},
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create client"),
});
const clients = data?.data ?? [];
const total = data?.meta?.total ?? 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clients</h1>
<p className="text-sm text-gray-500 mt-1">{total} total clients</p>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
<Button onClick={() => setShowAdd(true)} size="sm"><UserPlus size={14} className="mr-1" />Add Client</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="w-full pl-9 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search by name or account number..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
</CardHeader>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-500">Account #</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 hidden md:table-cell">Area</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 hidden lg:table-cell">Plan</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 hidden xl:table-cell">Monthly</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b"><td colSpan={7} className="px-4 py-3"><div className="h-4 bg-gray-100 rounded animate-pulse" /></td></tr>
))
) : clients.map((client) => {
const sub = client.subscriptions?.[0];
const status = sub?.status ?? (client.isActive ? "ACTIVE" : "INACTIVE");
return (
<tr key={client.id} onClick={() => router.push(`/clients/${client.id}`)}
className="border-b hover:bg-blue-50 cursor-pointer transition-colors">
<td className="px-4 py-3 font-mono text-xs text-gray-600">{client.accountNumber}</td>
<td className="px-4 py-3 font-medium text-gray-800">
{client.firstName} {client.lastName}
<div className="text-xs text-gray-400">{client.phone}</div>
</td>
<td className="px-4 py-3 text-gray-600 hidden md:table-cell">{client.area?.name ?? "—"}</td>
<td className="px-4 py-3 text-gray-600 hidden lg:table-cell">{sub?.plan?.name ?? (sub ? sub.type : "—")}</td>
<td className="px-4 py-3">
<Badge variant={statusVariant[status] ?? "muted"}>{status}</Badge>
</td>
<td className="px-4 py-3 text-gray-600 hidden xl:table-cell">
{sub ? `${Number(sub.monthlyPrice).toLocaleString()}` : "—"}
</td>
<td className="px-4 py-3 text-gray-400"><ChevronRight size={16} /></td>
</tr>
);
})}
</tbody>
</table>
{!isLoading && clients.length === 0 && (
<div className="text-center py-12 text-gray-400">No clients found{search ? ` for "${search}"` : ""}</div>
)}
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-gray-500">
<span>Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}</span>
<div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1 border rounded-md disabled:opacity-40 cursor-pointer">Prev</button>
<button disabled={page >= (data?.meta?.totalPages ?? 1)} onClick={() => setPage(p => p + 1)} className="px-3 py-1 border rounded-md disabled:opacity-40 cursor-pointer">Next</button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Add Client Modal */}
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add New Client" className="max-w-xl">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" value={form.firstName} onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))} />
<Input label="Last Name" value={form.lastName} onChange={e => setForm(f => ({ ...f, lastName: e.target.value }))} />
</div>
<Input label="Email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
<Input label="Phone" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
<Input label="Address" value={form.address} onChange={e => setForm(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 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.areaId} onChange={e => setForm(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 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.billingType} onChange={e => setForm(f => ({ ...f, billingType: e.target.value }))}>
<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 <span className="text-red-500">*</span></label>
<select className="border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.planId} onChange={e => setForm(f => ({ ...f, planId: e.target.value }))}>
<option value=""> Select plan </option>
{plans.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 pt-2">
<Button variant="outline" onClick={() => setShowAdd(false)}>Cancel</Button>
<Button onClick={() => createClient.mutate()} isLoading={createClient.isPending}
disabled={!form.firstName || !form.lastName || !form.phone || !form.planId}>
Create Client
</Button>
</div>
</div>
</Modal>
</div>
);
}