feat: real dashboard + clients table with API integration

This commit is contained in:
Forge
2026-03-25 08:38:54 +08:00
parent 142c0d1497
commit 5ba7d1d13f
2 changed files with 268 additions and 183 deletions

View File

@@ -1,91 +1,84 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button'; import { api } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { import { Search, UserPlus, ChevronRight } from 'lucide-react';
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { Search, UserPlus, Phone, MapPin, ChevronRight } from 'lucide-react';
import { api } from '@/lib/api';
import Link from 'next/link';
import { useDebounce } from '@/lib/hooks';
interface Client { interface Client {
id: string; id: string;
accountNumber: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
email?: string;
phone: string; phone: string;
address: string;
accountNumber: string;
isActive: boolean; isActive: boolean;
subscriptions?: { status: string; plan?: { name: string; monthlyPrice: number } }[]; area: { name: string } | null;
createdAt: string; subscriptions: Array<{
status: string;
type: string;
monthlyPrice: string;
plan?: { name: string };
}>;
} }
interface ClientsResponse { interface ClientsResponse {
data: Client[]; data: Client[];
meta?: { total: number; page: number; limit: number }; total: number;
page: number;
limit: number;
} }
function getSubStatus(client: Client): string { const statusColors: Record<string, string> = {
return client.subscriptions?.[0]?.status ?? 'NO_SUB'; ACTIVE: 'bg-green-100 text-green-700',
}
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-emerald-100 text-emerald-700',
PENDING: 'bg-yellow-100 text-yellow-700', PENDING: 'bg-yellow-100 text-yellow-700',
SUSPENDED: 'bg-red-100 text-red-700', SUSPENDED: 'bg-red-100 text-red-700',
DISCONNECTED: 'bg-gray-100 text-gray-600', DISCONNECTED: 'bg-gray-100 text-gray-700',
CANCELLED: 'bg-gray-100 text-gray-500', CANCELLED: 'bg-gray-100 text-gray-500',
NO_SUB: 'bg-gray-100 text-gray-400',
}; };
export default function ClientsPage() { export default function ClientsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const debouncedSearch = useDebounce(search, 400);
const limit = 20;
const { data, isLoading } = useQuery<ClientsResponse>({ const { data, isLoading } = useQuery<ClientsResponse>({
queryKey: ['clients', debouncedSearch, page], queryKey: ['clients', search, page],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) }); const params = new URLSearchParams({ page: String(page), limit: '20' });
if (debouncedSearch) params.set('search', debouncedSearch); if (search) params.set('search', search);
const res = await api.get(`/api/v1/clients?${params}`); const res = await api.get(`/api/v1/clients?${params}`);
return res.data; return res.data;
}, },
staleTime: 30_000,
}); });
const clients: Client[] = Array.isArray(data) ? data : (data?.data ?? []); const clients = data?.data ?? [];
const total = (data as any)?.meta?.total ?? clients.length; const total = data?.total ?? 0;
return ( return (
<div> <div>
{/* Header */} <div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-800">Clients</h1> <h1 className="text-2xl font-bold text-slate-800">Clients</h1>
<p className="text-slate-500 text-sm mt-1"> <p className="text-slate-500 text-sm mt-1">{total} total clients</p>
{isLoading ? '...' : `${total} subscriber${total !== 1 ? 's' : ''}`}
</p>
</div> </div>
<Button style={{ backgroundColor: '#0891B2', color: 'white' }}> <button
<UserPlus size={16} className="mr-2" /> className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ backgroundColor: '#0891B2' }}
>
<UserPlus size={16} />
Add Client Add Client
</Button> </button>
</div> </div>
{/* Search */} {/* Search */}
<div className="relative mb-4 max-w-sm"> <div className="relative mb-4">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<Input <Input
placeholder="Search by name, account #, phone..." placeholder="Search by name or account number..."
className="pl-9" className="pl-9"
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
@@ -93,87 +86,107 @@ export default function ClientsPage() {
</div> </div>
{/* Table */} {/* Table */}
<Card> <Card className="border shadow-sm">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <div className="overflow-x-auto">
<TableHeader> <table className="w-full text-sm">
<TableRow> <thead>
<TableHead>Account #</TableHead> <tr className="border-b bg-slate-50">
<TableHead>Name</TableHead> <th className="text-left px-4 py-3 font-medium text-slate-500">Account #</th>
<TableHead>Phone</TableHead> <th className="text-left px-4 py-3 font-medium text-slate-500">Name</th>
<TableHead>Plan</TableHead> <th className="text-left px-4 py-3 font-medium text-slate-500 hidden md:table-cell">Area</th>
<TableHead>Status</TableHead> <th className="text-left px-4 py-3 font-medium text-slate-500 hidden lg:table-cell">Plan</th>
<TableHead>Address</TableHead> <th className="text-left px-4 py-3 font-medium text-slate-500">Status</th>
<TableHead className="w-10"></TableHead> <th className="text-left px-4 py-3 font-medium text-slate-500 hidden xl:table-cell">Monthly</th>
</TableRow> <th className="px-4 py-3"></th>
</TableHeader> </tr>
<TableBody> </thead>
{isLoading ? ( <tbody>
Array.from({ length: 8 }).map((_, i) => ( {isLoading
<TableRow key={i}> ? Array.from({ length: 8 }).map((_, i) => (
{Array.from({ length: 7 }).map((_, j) => ( <tr key={i} className="border-b">
<TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell> <td className="px-4 py-3"><Skeleton className="h-4 w-24" /></td>
))} <td className="px-4 py-3"><Skeleton className="h-4 w-32" /></td>
</TableRow> <td className="px-4 py-3 hidden md:table-cell"><Skeleton className="h-4 w-20" /></td>
)) <td className="px-4 py-3 hidden lg:table-cell"><Skeleton className="h-4 w-24" /></td>
) : clients.length === 0 ? ( <td className="px-4 py-3"><Skeleton className="h-5 w-16 rounded-full" /></td>
<TableRow> <td className="px-4 py-3 hidden xl:table-cell"><Skeleton className="h-4 w-16" /></td>
<TableCell colSpan={7} className="text-center py-12 text-slate-400"> <td className="px-4 py-3"><Skeleton className="h-4 w-4" /></td>
{search ? `No clients matching "${search}"` : 'No clients yet'} </tr>
</TableCell> ))
</TableRow> : clients.map((client) => {
) : ( const sub = client.subscriptions?.[0];
clients.map((c) => { const status = sub?.status ?? (client.isActive ? 'ACTIVE' : 'INACTIVE');
const subStatus = getSubStatus(c); return (
const planName = c.subscriptions?.[0]?.plan?.name ?? '—'; <tr
return ( key={client.id}
<TableRow key={c.id} className="hover:bg-slate-50 cursor-pointer"> className="border-b hover:bg-slate-50 cursor-pointer transition-colors"
<TableCell className="font-mono text-sm text-slate-600">{c.accountNumber}</TableCell> >
<TableCell className="font-medium">{c.firstName} {c.lastName}</TableCell> <td className="px-4 py-3 font-mono text-xs text-slate-600">
<TableCell> {client.accountNumber}
<span className="flex items-center gap-1 text-slate-500 text-sm"> </td>
<Phone size={12} />{c.phone} <td className="px-4 py-3 font-medium text-slate-800">
</span> {client.firstName} {client.lastName}
</TableCell> <div className="text-xs text-slate-400">{client.phone}</div>
<TableCell className="text-sm text-slate-600">{planName}</TableCell> </td>
<TableCell> <td className="px-4 py-3 text-slate-600 hidden md:table-cell">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[subStatus] ?? STATUS_COLORS.NO_SUB}`}> {client.area?.name ?? '—'}
{subStatus.replace('_', ' ')} </td>
</span> <td className="px-4 py-3 text-slate-600 hidden lg:table-cell">
</TableCell> {sub?.plan?.name ?? (sub ? `${sub.type}` : '—')}
<TableCell> </td>
<span className="flex items-center gap-1 text-slate-400 text-xs max-w-[180px] truncate"> <td className="px-4 py-3">
<MapPin size={10} />{c.address} <span
</span> className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[status] ?? 'bg-gray-100 text-gray-500'}`}
</TableCell> >
<TableCell> {status}
<Link href={`/clients/${c.id}`} className="text-slate-400 hover:text-slate-700"> </span>
<ChevronRight size={16} /> </td>
</Link> <td className="px-4 py-3 text-slate-600 hidden xl:table-cell">
</TableCell> {sub ? `${Number(sub.monthlyPrice).toLocaleString()}` : '—'}
</TableRow> </td>
); <td className="px-4 py-3 text-slate-400">
}) <ChevronRight size={16} />
)} </td>
</TableBody> </tr>
</Table> );
})}
</tbody>
</table>
</div>
{/* Pagination */}
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-slate-500">
Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}
</p>
<div className="flex gap-2">
<button
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
className="px-3 py-1 text-sm border rounded-md disabled:opacity-40"
>
Prev
</button>
<button
disabled={page * 20 >= total}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1 text-sm border rounded-md disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
{!isLoading && clients.length === 0 && (
<div className="text-center py-12 text-slate-400">
No clients found{search ? ` for "${search}"` : ''}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Pagination */}
{total > limit && (
<div className="mt-4 flex items-center justify-between text-sm text-slate-500">
<span>Page {page} of {Math.ceil(total / limit)}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={page * limit >= total}>
Next
</Button>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -4,45 +4,56 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Users, Wifi, DollarSign, AlertTriangle } from 'lucide-react'; import { Users, Wifi, DollarSign, AlertTriangle, Ticket, ClipboardList } from 'lucide-react';
interface DashboardSummary { interface DashboardSummary {
totalClients: number; subscribers: { total: number; active: number; pending: number; suspended: number };
activeSubscribers: number; billing: { unpaidInvoices: number; overdueInvoices: number };
todayCollections: number; support: { openTickets: number; inProgressTickets: number };
overdueInvoices: number; tasks: { pending: number };
revenue: { thisMonth: number; lastMonth: number; trend: number };
leads: { total: number; new: number };
} }
const kpiCards = [ function KpiCard({
{ label,
key: 'totalClients' as const, value,
label: 'Total Clients', sub,
icon: Users, icon: Icon,
color: '#0891B2', color,
format: (v: number) => v.toLocaleString(), isLoading,
}, }: {
{ label: string;
key: 'activeSubscribers' as const, value: string;
label: 'Active Subscribers', sub?: string;
icon: Wifi, icon: React.ElementType;
color: '#059669', color: string;
format: (v: number) => v.toLocaleString(), isLoading: boolean;
}, }) {
{ return (
key: 'todayCollections' as const, <Card className="border shadow-sm">
label: "Today's Collections", <CardHeader className="flex flex-row items-center justify-between pb-2">
icon: DollarSign, <CardTitle className="text-sm font-medium text-slate-500">{label}</CardTitle>
color: '#7C3AED', <div
format: (v: number) => '₱' + v.toLocaleString('en-PH', { minimumFractionDigits: 2 }), className="w-9 h-9 rounded-lg flex items-center justify-center"
}, style={{ backgroundColor: color + '1A' }}
{ >
key: 'overdueInvoices' as const, <Icon size={18} style={{ color }} />
label: 'Overdue Invoices', </div>
icon: AlertTriangle, </CardHeader>
color: '#DC2626', <CardContent>
format: (v: number) => v.toLocaleString(), {isLoading ? (
}, <Skeleton className="h-8 w-24" />
]; ) : (
<>
<p className="text-2xl font-bold text-slate-800">{value}</p>
{sub && <p className="text-xs text-slate-500 mt-1">{sub}</p>}
</>
)}
</CardContent>
</Card>
);
}
export default function DashboardPage() { export default function DashboardPage() {
const { data, isLoading, error } = useQuery<DashboardSummary>({ const { data, isLoading, error } = useQuery<DashboardSummary>({
@@ -53,6 +64,10 @@ export default function DashboardPage() {
}, },
}); });
const fmt = (n: number) => n?.toLocaleString() ?? '—';
const peso = (n: number) =>
'₱' + (n ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 });
return ( return (
<div> <div>
<div className="mb-6"> <div className="mb-6">
@@ -62,39 +77,96 @@ export default function DashboardPage() {
{error && ( {error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm"> <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
Failed to load dashboard data. The API endpoint may not be available yet. Failed to load dashboard data.
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> {/* KPI Row 1 */}
{kpiCards.map((card) => { <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 mb-4">
const Icon = card.icon; <KpiCard
return ( label="Active Subscribers"
<Card key={card.key} className="border shadow-sm"> value={fmt(data?.subscribers?.active ?? 0)}
<CardHeader className="flex flex-row items-center justify-between pb-2"> sub={`${fmt(data?.subscribers?.total ?? 0)} total · ${fmt(data?.subscribers?.pending ?? 0)} pending`}
<CardTitle className="text-sm font-medium text-slate-500"> icon={Wifi}
{card.label} color="#059669"
</CardTitle> isLoading={isLoading}
<div />
className="w-9 h-9 rounded-lg flex items-center justify-center" <KpiCard
style={{ backgroundColor: card.color + '1A' }} label="Revenue This Month"
> value={peso(data?.revenue?.thisMonth ?? 0)}
<Icon size={18} style={{ color: card.color }} /> sub={`Last month: ${peso(data?.revenue?.lastMonth ?? 0)}`}
</div> icon={DollarSign}
</CardHeader> color="#0891B2"
<CardContent> isLoading={isLoading}
{isLoading ? ( />
<Skeleton className="h-8 w-24" /> <KpiCard
) : ( label="Overdue Invoices"
<p className="text-2xl font-bold text-slate-800"> value={fmt(data?.billing?.overdueInvoices ?? 0)}
{data ? card.format(data[card.key]) : '—'} sub={`${fmt(data?.billing?.unpaidInvoices ?? 0)} unpaid total`}
</p> icon={AlertTriangle}
)} color="#DC2626"
</CardContent> isLoading={isLoading}
</Card> />
);
})}
</div> </div>
{/* KPI Row 2 */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 mb-8">
<KpiCard
label="Open Tickets"
value={fmt(data?.support?.openTickets ?? 0)}
sub={`${fmt(data?.support?.inProgressTickets ?? 0)} in progress`}
icon={Ticket}
color="#7C3AED"
isLoading={isLoading}
/>
<KpiCard
label="Pending Tasks"
value={fmt(data?.tasks?.pending ?? 0)}
sub="Manual tasks awaiting action"
icon={ClipboardList}
color="#D97706"
isLoading={isLoading}
/>
<KpiCard
label="Total Clients"
value={fmt(data?.subscribers?.total ?? 0)}
sub={`${fmt(data?.leads?.new ?? 0)} new leads`}
icon={Users}
color="#0F172A"
isLoading={isLoading}
/>
</div>
{/* Quick stats */}
<Card className="border shadow-sm">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-700">Subscriber Breakdown</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: 'Active', value: data?.subscribers?.active ?? 0, color: '#059669' },
{ label: 'Pending', value: data?.subscribers?.pending ?? 0, color: '#D97706' },
{ label: 'Suspended', value: data?.subscribers?.suspended ?? 0, color: '#DC2626' },
{ label: 'Total', value: data?.subscribers?.total ?? 0, color: '#0891B2' },
].map((item) => (
<div key={item.label} className="text-center p-3 rounded-lg bg-slate-50">
<p className="text-2xl font-bold" style={{ color: item.color }}>
{item.value}
</p>
<p className="text-xs text-slate-500 mt-1">{item.label}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div> </div>
); );
} }