From 142c0d1497df8eaaec0407443a895cf5054eb70c Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 25 Mar 2026 08:37:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20real=20data-fetching=20pages=20?= =?UTF-8?q?=E2=80=94=20clients,=20invoices,=20payments,=20tickets,=20repor?= =?UTF-8?q?ts=20with=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/clients/page.tsx | 172 ++++++++++++++++++++++++++++------- app/(app)/invoices/page.tsx | 143 +++++++++++++++++++++++------ app/(app)/payments/page.tsx | 144 ++++++++++++++++++++++++------ app/(app)/reports/page.tsx | 173 ++++++++++++++++++++++++++++++++---- app/(app)/tickets/page.tsx | 163 +++++++++++++++++++++++++++------ lib/hooks.ts | 10 +++ 6 files changed, 671 insertions(+), 134 deletions(-) create mode 100644 lib/hooks.ts diff --git a/app/(app)/clients/page.tsx b/app/(app)/clients/page.tsx index c99338f..12fcf02 100644 --- a/app/(app)/clients/page.tsx +++ b/app/(app)/clients/page.tsx @@ -1,71 +1,179 @@ 'use client'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; +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, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; -import { Search, UserPlus } from 'lucide-react'; +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 { + id: 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; +} + +interface ClientsResponse { + data: Client[]; + meta?: { 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', + PENDING: 'bg-yellow-100 text-yellow-700', + SUSPENDED: 'bg-red-100 text-red-700', + DISCONNECTED: 'bg-gray-100 text-gray-600', + 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], + queryFn: async () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (debouncedSearch) params.set('search', debouncedSearch); + const res = await api.get(`/api/v1/clients?${params}`); + return res.data; + }, + }); + + const clients: Client[] = Array.isArray(data) ? data : (data?.data ?? []); + const total = (data as any)?.meta?.total ?? clients.length; return (
+ {/* Header */}

Clients

-

Manage your ISP subscribers

+

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

-
- - -
-
- - setSearch(e.target.value)} - /> -
-
-
+ {/* Search */} +
+ + { setSearch(e.target.value); setPage(1); }} + /> +
+ + {/* Table */} + + Account # Name - Email Phone Plan Status - Actions + Address + - - - No clients yet. Add your first client to get started. - - + {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} + + + + + + + + + ); + }) + )}
+ + {/* Pagination */} + {total > limit && ( +
+ Page {page} of {Math.ceil(total / limit)} +
+ + +
+
+ )}
); } diff --git a/app/(app)/invoices/page.tsx b/app/(app)/invoices/page.tsx index 421f0a3..744bfa6 100644 --- a/app/(app)/invoices/page.tsx +++ b/app/(app)/invoices/page.tsx @@ -1,50 +1,143 @@ 'use client'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +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 } from 'lucide-react'; +import { api } from '@/lib/api'; +import { useDebounce } from '@/lib/hooks'; +import { format } from 'date-fns'; + +interface Invoice { + id: string; + invoiceNumber: string; + status: string; + subtotal: number; + lateFee: number; + total: number; + amountPaid: number; + balance: number; + dueDate: string; + createdAt: string; + client?: { firstName: string; lastName: string; accountNumber: string }; +} + +const STATUS_COLORS: Record = { + DRAFT: 'bg-gray-100 text-gray-600', + SENT: 'bg-blue-100 text-blue-700', + PARTIAL: 'bg-yellow-100 text-yellow-700', + PAID: 'bg-emerald-100 text-emerald-700', + OVERDUE: 'bg-red-100 text-red-700', + VOID: 'bg-gray-100 text-gray-400', +}; export default function InvoicesPage() { + const [search, setSearch] = useState(''); + const [status, setStatus] = useState(''); + const [page, setPage] = useState(1); + const debouncedSearch = useDebounce(search, 400); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['invoices', debouncedSearch, status, page], + queryFn: async () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (status) params.set('status', status); + if (debouncedSearch) params.set('search', debouncedSearch); + const res = await api.get(`/api/v1/invoices?${params}`); + return res.data; + }, + }); + + const invoices: Invoice[] = Array.isArray(data) ? data : (data?.data ?? data?.items ?? []); + const total = (data as any)?.meta?.total ?? (data as any)?.total ?? invoices.length; + + const STATUSES = ['', 'SENT', 'PARTIAL', 'PAID', 'OVERDUE', 'DRAFT', 'VOID']; + return (

Invoices

-

Track and manage billing invoices

+

Track billing and payment status

- - -
- - -
-
+ {/* Filters */} +
+
+ + { setSearch(e.target.value); setPage(1); }} /> +
+
+ {STATUSES.map(s => ( + + ))} +
+
+ + Invoice # Client - Amount - Due Date Status - Actions + Total + Balance + Due Date - - - No invoices found. - - + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + ) : invoices.length === 0 ? ( + + + No invoices found + + + ) : ( + invoices.map((inv) => ( + + {inv.invoiceNumber} + + {inv.client ? `${inv.client.firstName} ${inv.client.lastName}` : '—'} + {inv.client && #{inv.client.accountNumber}} + + + + {inv.status} + + + ₱{Number(inv.total).toLocaleString()} + 0 ? 'text-red-600 font-medium' : 'text-slate-400'}`}> + {Number(inv.balance) > 0 ? `₱${Number(inv.balance).toLocaleString()}` : '—'} + + + {format(new Date(inv.dueDate), 'MMM d, yyyy')} + + + )) + )}
diff --git a/app/(app)/payments/page.tsx b/app/(app)/payments/page.tsx index f70ae61..3b715f4 100644 --- a/app/(app)/payments/page.tsx +++ b/app/(app)/payments/page.tsx @@ -1,54 +1,140 @@ 'use client'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; import { Search } from 'lucide-react'; +import { api } from '@/lib/api'; +import { format } from 'date-fns'; + +interface Payment { + id: string; + amount: number; + channel: string; + paymentDate: string; + notes?: string; + client?: { firstName: string; lastName: string; accountNumber: string }; + invoice?: { invoiceNumber: string }; + recordedBy?: { firstName: string; lastName: string }; +} + +const CHANNEL_COLORS: Record = { + CASH: 'bg-emerald-100 text-emerald-700', + GCASH: 'bg-blue-100 text-blue-700', + MAYA: 'bg-green-100 text-green-700', + BANK_TRANSFER: 'bg-purple-100 text-purple-700', + CHECK: 'bg-orange-100 text-orange-700', +}; export default function PaymentsPage() { + const [page, setPage] = useState(1); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['payments', page], + queryFn: async () => { + const res = await api.get(`/api/v1/payments?page=${page}&limit=${limit}`); + return res.data; + }, + }); + + const payments: Payment[] = Array.isArray(data) ? data : (data?.data ?? data?.items ?? []); + const total = (data as any)?.meta?.total ?? (data as any)?.total ?? payments.length; + + const totalAmount = payments.reduce((sum, p) => sum + Number(p.amount), 0); + return (
-
-

Payments

-

Record and review payment transactions

+
+
+

Payments

+

Payment collection records

+
+ {payments.length > 0 && ( +
+

Total (this page)

+

₱{totalAmount.toLocaleString()}

+
+ )}
- - -
- - -
-
+ - Ref # - Client - Amount - Channel Date - Actions + Client + Invoice + Channel + Amount + Recorded By + Notes - - - No payments recorded yet. - - + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((_, j) => ( + + ))} + + )) + ) : payments.length === 0 ? ( + + No payments yet + + ) : ( + payments.map((p) => ( + + + {format(new Date(p.paymentDate), 'MMM d, yyyy')} + + + {p.client ? `${p.client.firstName} ${p.client.lastName}` : '—'} + + + {p.invoice?.invoiceNumber ?? '—'} + + + + {p.channel} + + + + ₱{Number(p.amount).toLocaleString()} + + + {p.recordedBy ? `${p.recordedBy.firstName} ${p.recordedBy.lastName}` : '—'} + + + {p.notes ?? '—'} + + + )) + )}
+ + {total > limit && ( +
+ Page {page} of {Math.ceil(total / limit)} +
+ + +
+
+ )}
); } diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index bdfeb71..abb8f18 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -1,32 +1,167 @@ 'use client'; +import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { BarChart3 } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { api } from '@/lib/api'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, Legend, +} from 'recharts'; + +const PIE_COLORS = ['#0891B2', '#059669', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']; export default function ReportsPage() { + const { data: collection, isLoading: loadingCollection } = useQuery({ + queryKey: ['reports-collection'], + queryFn: async () => (await api.get('/api/v1/reports/collection')).data, + }); + + const { data: aging, isLoading: loadingAging } = useQuery({ + queryKey: ['reports-aging'], + queryFn: async () => (await api.get('/api/v1/reports/aging')).data, + }); + + const { data: subscribers, isLoading: loadingSubs } = useQuery({ + queryKey: ['reports-subscribers'], + queryFn: async () => (await api.get('/api/v1/reports/subscribers')).data, + }); + + const { data: revenue, isLoading: loadingRevenue } = useQuery({ + queryKey: ['reports-revenue'], + queryFn: async () => (await api.get('/api/v1/reports/revenue')).data, + }); + + const { data: plans, isLoading: loadingPlans } = useQuery({ + queryKey: ['reports-plans'], + queryFn: async () => (await api.get('/api/v1/reports/plans')).data, + }); + + const collectionList = Array.isArray(collection) ? collection : []; + const agingList = Array.isArray(aging) ? aging : []; + const revList = Array.isArray(revenue) ? revenue : []; + const planList = Array.isArray(plans) ? plans : []; + + // Subscribers summary + const subSummary = subscribers ? [ + { name: 'Active', value: (subscribers as any).active ?? 0 }, + { name: 'Pending', value: (subscribers as any).pending ?? 0 }, + { name: 'Suspended', value: (subscribers as any).suspended ?? 0 }, + { name: 'Disconnected', value: (subscribers as any).disconnected ?? 0 }, + ].filter(s => s.value > 0) : []; + return (
-

Reports

-

Financial and operational reports

+

Reports & Analytics

+

Business performance overview

-
- {['Collections Report', 'Subscriber Growth', 'Invoice Aging', 'Technician Performance'].map( - (report) => ( - - - - - {report} - - - -

Coming soon — report generation will be available here.

-
-
- ) - )} +
+ + {/* Revenue Trend */} + + Monthly Revenue (₱) + + {loadingRevenue ? : ( + + + + + `₱${Number(v).toLocaleString()}`} /> + [`₱${Number(v).toLocaleString()}`, 'Revenue']} /> + + + + )} + + + + {/* Subscriber Status */} + + Subscriber Status + + {loadingSubs ? : subSummary.length === 0 ? ( +

No data

+ ) : ( + + + `${name}: ${value}`}> + {subSummary.map((_, i) => )} + + + + + )} +
+
+ + {/* Aging */} + + Invoice Aging (₱) + + {loadingAging ? : agingList.length === 0 ? ( +

No outstanding invoices

+ ) : ( + + + + + + [`₱${Number(v).toLocaleString()}`, 'Amount']} /> + + + + )} +
+
+ + {/* Collection by Collector */} + + Collection by Collector + + {loadingCollection ? : collectionList.length === 0 ? ( +

No collection data

+ ) : ( +
+ {collectionList.map((c: any, i: number) => ( +
+
+

{c.collector}

+

{c.paymentCount} payments

+
+

₱{Number(c.totalAmount).toLocaleString()}

+
+ ))} +
+ )} +
+
+ + {/* Plans Distribution */} + + Subscribers by Plan + + {loadingPlans ? : planList.length === 0 ? ( +

No data

+ ) : ( +
+ {planList.map((p: any, i: number) => ( +
+

{p.planName ?? p.name ?? 'Plan ' + (i+1)}

+
+
+
x.count || 1))) * 100)}%` }} /> +
+

{p.count}

+
+
+ ))} +
+ )} + + +
); diff --git a/app/(app)/tickets/page.tsx b/app/(app)/tickets/page.tsx index 9a8b444..7a478f7 100644 --- a/app/(app)/tickets/page.tsx +++ b/app/(app)/tickets/page.tsx @@ -1,58 +1,163 @@ 'use client'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; import { Search, Plus } from 'lucide-react'; +import { api } from '@/lib/api'; +import { useDebounce } from '@/lib/hooks'; +import { format } from 'date-fns'; + +interface Ticket { + id: string; + subject: string; + type: string; + status: string; + priority: string; + createdAt: string; + client?: { firstName: string; lastName: string; accountNumber: string }; + assignedTo?: { firstName: string; lastName: string }; +} + +const STATUS_COLORS: Record = { + OPEN: 'bg-blue-100 text-blue-700', + IN_PROGRESS: 'bg-yellow-100 text-yellow-700', + RESOLVED: 'bg-emerald-100 text-emerald-700', + CLOSED: 'bg-gray-100 text-gray-500', +}; +const TYPE_COLORS: Record = { + INSTALLATION: 'bg-purple-100 text-purple-700', + SUPPORT: 'bg-orange-100 text-orange-700', + BILLING: 'bg-cyan-100 text-cyan-700', +}; +const PRIORITY_COLORS: Record = { + NORMAL: 'bg-gray-100 text-gray-600', + HIGH: 'bg-red-100 text-red-700', +}; + +const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']; +const TYPES = ['', 'INSTALLATION', 'SUPPORT', 'BILLING']; export default function TicketsPage() { + const [search, setSearch] = useState(''); + const [status, setStatus] = useState(''); + const [type, setType] = useState(''); + const [page, setPage] = useState(1); + const debouncedSearch = useDebounce(search, 400); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['tickets', debouncedSearch, status, type, page], + queryFn: async () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (status) params.set('status', status); + if (type) params.set('type', type); + if (debouncedSearch) params.set('search', debouncedSearch); + const res = await api.get(`/api/v1/tickets?${params}`); + return res.data; + }, + }); + + const tickets: Ticket[] = Array.isArray(data) ? data : (data?.data ?? data?.items ?? []); + const total = (data as any)?.meta?.total ?? (data as any)?.total ?? tickets.length; + return (

Tickets

-

Support and installation tickets

+

Support, installation, and billing issues

-
- - -
- - -
-
+ {/* Filters */} +
+
+ + { setSearch(e.target.value); setPage(1); }} /> +
+
+ {STATUSES.map(s => ( + + ))} +
+
+ {TYPES.map(t => ( + + ))} +
+
+ + - Ticket # - Client + Subject Type - Priority Status + Priority + Client Assigned To - Actions + Created - - - No tickets found. - - + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + {Array.from({ length: 7 }).map((_, j) => ( + + ))} + )) + ) : tickets.length === 0 ? ( + No tickets found + ) : ( + tickets.map((t) => ( + + {t.subject} + + + {t.type} + + + + + {t.status.replace('_', ' ')} + + + + + {t.priority} + + + + {t.client ? `${t.client.firstName} ${t.client.lastName}` : '—'} + + + {t.assignedTo ? `${t.assignedTo.firstName} ${t.assignedTo.lastName}` : 'Unassigned'} + + + {format(new Date(t.createdAt), 'MMM d, yyyy')} + + + )) + )}
diff --git a/lib/hooks.ts b/lib/hooks.ts new file mode 100644 index 0000000..fe61f7b --- /dev/null +++ b/lib/hooks.ts @@ -0,0 +1,10 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(t); + }, [value, delay]); + return debounced; +}