From 5ba7d1d13ff8615c91540ebc998ea4c776a5467d Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 25 Mar 2026 08:38:54 +0800 Subject: [PATCH] feat: real dashboard + clients table with API integration --- app/(app)/clients/page.tsx | 251 ++++++++++++++++++----------------- app/(app)/dashboard/page.tsx | 200 +++++++++++++++++++--------- 2 files changed, 268 insertions(+), 183 deletions(-) diff --git a/app/(app)/clients/page.tsx b/app/(app)/clients/page.tsx index 12fcf02..8acd62a 100644 --- a/app/(app)/clients/page.tsx +++ b/app/(app)/clients/page.tsx @@ -1,91 +1,84 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState } from 'react'; 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 { Badge } from '@/components/ui/badge'; -import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { - 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'; +import { Search, UserPlus, ChevronRight } from 'lucide-react'; interface Client { id: string; + accountNumber: string; firstName: string; lastName: string; - email?: string; phone: string; - address: string; - accountNumber: string; isActive: boolean; - subscriptions?: { status: string; plan?: { name: string; monthlyPrice: number } }[]; - createdAt: string; + area: { name: string } | null; + subscriptions: Array<{ + status: string; + type: string; + monthlyPrice: string; + plan?: { name: string }; + }>; } interface ClientsResponse { data: Client[]; - meta?: { total: number; page: number; limit: number }; + total: number; + page: number; + limit: number; } -function getSubStatus(client: Client): string { - return client.subscriptions?.[0]?.status ?? 'NO_SUB'; -} - -const STATUS_COLORS: Record = { - ACTIVE: 'bg-emerald-100 text-emerald-700', +const statusColors: Record = { + ACTIVE: 'bg-green-100 text-green-700', PENDING: 'bg-yellow-100 text-yellow-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', - NO_SUB: 'bg-gray-100 text-gray-400', }; export default function ClientsPage() { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); - const debouncedSearch = useDebounce(search, 400); - const limit = 20; const { data, isLoading } = useQuery({ - queryKey: ['clients', debouncedSearch, page], + queryKey: ['clients', search, page], queryFn: async () => { - const params = new URLSearchParams({ page: String(page), limit: String(limit) }); - if (debouncedSearch) params.set('search', debouncedSearch); + const params = new URLSearchParams({ page: String(page), limit: '20' }); + if (search) params.set('search', search); const res = await api.get(`/api/v1/clients?${params}`); return res.data; }, + staleTime: 30_000, }); - const clients: Client[] = Array.isArray(data) ? data : (data?.data ?? []); - const total = (data as any)?.meta?.total ?? clients.length; + const clients = data?.data ?? []; + const total = data?.total ?? 0; return (
- {/* Header */} -
+

Clients

-

- {isLoading ? '...' : `${total} subscriber${total !== 1 ? 's' : ''}`} -

+

{total} total clients

- +
{/* Search */} -
+
{ setSearch(e.target.value); setPage(1); }} @@ -93,87 +86,107 @@ export default function ClientsPage() {
{/* Table */} - + - - - - Account # - Name - Phone - Plan - Status - Address - - - - - {isLoading ? ( - Array.from({ length: 8 }).map((_, i) => ( - - {Array.from({ length: 7 }).map((_, j) => ( - - ))} - - )) - ) : clients.length === 0 ? ( - - - {search ? `No clients matching "${search}"` : 'No clients yet'} - - - ) : ( - clients.map((c) => { - const subStatus = getSubStatus(c); - const planName = c.subscriptions?.[0]?.plan?.name ?? '—'; - return ( - - {c.accountNumber} - {c.firstName} {c.lastName} - - - {c.phone} - - - {planName} - - - {subStatus.replace('_', ' ')} - - - - - {c.address} - - - - - - - - - ); - }) - )} - -
+
+ + + + + + + + + + + + + + {isLoading + ? Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + )) + : clients.map((client) => { + const sub = client.subscriptions?.[0]; + const status = sub?.status ?? (client.isActive ? 'ACTIVE' : 'INACTIVE'); + return ( + + + + + + + + + + ); + })} + +
Account #NameAreaPlanStatusMonthly
+ {client.accountNumber} + + {client.firstName} {client.lastName} +
{client.phone}
+
+ {client.area?.name ?? '—'} + + {sub?.plan?.name ?? (sub ? `${sub.type}` : '—')} + + + {status} + + + {sub ? `₱${Number(sub.monthlyPrice).toLocaleString()}` : '—'} + + +
+
+ + {/* Pagination */} + {total > 20 && ( +
+

+ Showing {(page - 1) * 20 + 1}–{Math.min(page * 20, total)} of {total} +

+
+ + +
+
+ )} + + {!isLoading && clients.length === 0 && ( +
+ No clients found{search ? ` for "${search}"` : ''} +
+ )}
- - {/* Pagination */} - {total > limit && ( -
- Page {page} of {Math.ceil(total / limit)} -
- - -
-
- )}
); } diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 7c959ac..3276152 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -4,45 +4,56 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { - totalClients: number; - activeSubscribers: number; - todayCollections: number; - overdueInvoices: number; + subscribers: { total: number; active: number; pending: number; suspended: number }; + billing: { unpaidInvoices: number; overdueInvoices: number }; + support: { openTickets: number; inProgressTickets: number }; + tasks: { pending: number }; + revenue: { thisMonth: number; lastMonth: number; trend: number }; + leads: { total: number; new: number }; } -const kpiCards = [ - { - key: 'totalClients' as const, - label: 'Total Clients', - icon: Users, - color: '#0891B2', - format: (v: number) => v.toLocaleString(), - }, - { - key: 'activeSubscribers' as const, - label: 'Active Subscribers', - icon: Wifi, - color: '#059669', - format: (v: number) => v.toLocaleString(), - }, - { - key: 'todayCollections' as const, - label: "Today's Collections", - icon: DollarSign, - color: '#7C3AED', - format: (v: number) => '₱' + v.toLocaleString('en-PH', { minimumFractionDigits: 2 }), - }, - { - key: 'overdueInvoices' as const, - label: 'Overdue Invoices', - icon: AlertTriangle, - color: '#DC2626', - format: (v: number) => v.toLocaleString(), - }, -]; +function KpiCard({ + label, + value, + sub, + icon: Icon, + color, + isLoading, +}: { + label: string; + value: string; + sub?: string; + icon: React.ElementType; + color: string; + isLoading: boolean; +}) { + return ( + + + {label} +
+ +
+
+ + {isLoading ? ( + + ) : ( + <> +

{value}

+ {sub &&

{sub}

} + + )} +
+
+ ); +} export default function DashboardPage() { const { data, isLoading, error } = useQuery({ @@ -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 (
@@ -62,39 +77,96 @@ export default function DashboardPage() { {error && (
- Failed to load dashboard data. The API endpoint may not be available yet. + Failed to load dashboard data.
)} -
- {kpiCards.map((card) => { - const Icon = card.icon; - return ( - - - - {card.label} - -
- -
-
- - {isLoading ? ( - - ) : ( -

- {data ? card.format(data[card.key]) : '—'} -

- )} -
-
- ); - })} + {/* KPI Row 1 */} +
+ + +
+ + {/* KPI Row 2 */} +
+ + + +
+ + {/* Quick stats */} + + + Subscriber Breakdown + + + {isLoading ? ( +
+ + +
+ ) : ( +
+ {[ + { 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) => ( +
+

+ {item.value} +

+

{item.label}

+
+ ))} +
+ )} +
+
); }