diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index 7d2eb95..0b8f975 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -1,20 +1,21 @@ "use client"; export const dynamic = "force-dynamic"; - - import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid } from "recharts"; -import { RefreshCw, TrendingUp, Users, AlertTriangle, DollarSign } from "lucide-react"; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend } from "recharts"; +import { RefreshCw, TrendingUp, Users, AlertTriangle, DollarSign, Download } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Badge } from "@/components/ui/Badge"; -import { formatCurrency } from "@/lib/utils"; +import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table"; +import { formatCurrency, formatDate } from "@/lib/utils"; import api from "@/lib/api"; const COLORS = ["#0891B2", "#059669", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899"]; +type Tab = "overview" | "collections" | "tickets"; + function KpiCard({ title, value, sub, icon: Icon, color }: { title: string; value: string; sub?: string; icon: any; color: string }) { return ( @@ -34,12 +35,26 @@ function KpiCard({ title, value, sub, icon: Icon, color }: { title: string; valu ); } +function downloadCSV(data: any[], filename: string) { + if (!data.length) return; + const headers = Object.keys(data[0]); + const rows = data.map(row => headers.map(h => JSON.stringify(row[h] ?? "")).join(",")); + const csv = [headers.join(","), ...rows].join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); +} + export default function ReportsPage() { const today = new Date(); const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().split("T")[0]; + const [tab, setTab] = useState("overview"); const [from, setFrom] = useState(firstOfMonth); const [to, setTo] = useState(today.toISOString().split("T")[0]); + // Overview data const { data: collection = [], isLoading: collLoading, refetch: refetchAll } = useQuery({ queryKey: ["reports-collection", from, to], queryFn: async () => { @@ -69,29 +84,54 @@ export default function ReportsPage() { queryFn: async () => { const res = await api.get("/api/v1/reports/revenue"); return (res.data as Array<{ month: string; revenue: number; totalInvoiced: number }>) - .filter(r => r.revenue > 0 || r.totalInvoiced > 0) - .slice(-12); + .filter(r => r.revenue > 0 || r.totalInvoiced > 0).slice(-12); }, }); + // Collections tab data + const { data: paymentsData } = useQuery({ + queryKey: ["reports-payments", from, to], + queryFn: async () => { + const res = await api.get(`/api/v1/payments?page=1&limit=100`); + return (res.data as any)?.data ?? []; + }, + enabled: tab === "collections", + }); + + // Tickets tab data + const { data: ticketsData } = useQuery({ + queryKey: ["reports-tickets"], + queryFn: async () => { + const [open, resolved, all] = await Promise.all([ + api.get("/api/v1/tickets?status=OPEN&limit=100"), + api.get("/api/v1/tickets?status=RESOLVED&limit=100"), + api.get("/api/v1/tickets?limit=50"), + ]); + return { + open: (open.data as any)?.meta?.total ?? (open.data as any)?.data?.length ?? 0, + resolved: (resolved.data as any)?.meta?.total ?? (resolved.data as any)?.data?.length ?? 0, + list: (all.data as any)?.data ?? [], + total: (all.data as any)?.meta?.total ?? 0, + }; + }, + enabled: tab === "tickets", + }); + // Derived KPIs const totalCollected = collection.reduce((s, c) => s + Number(c.totalAmount), 0); const totalPayments = collection.reduce((s, c) => s + c.paymentCount, 0); const totalOutstanding = aging.reduce((s, a) => s + Number(a.totalAmount), 0); const overdueCount = aging.reduce((s, a) => s + a.invoiceCount, 0); - - // Subscriber summary (status-only rows, no area key) const subByStatus = subscribers.filter(s => !s.area && !s.plan); const activeCount = subByStatus.find(s => s.status === "ACTIVE")?.count ?? 0; const pendingCount = subByStatus.find(s => s.status === "PENDING")?.count ?? 0; const suspendedCount = subByStatus.find(s => s.status === "SUSPENDED")?.count ?? 0; const totalSubs = subByStatus.reduce((s, x) => s + x.count, 0); - - // Subscriber by area (rows with area key) const subByArea = subscribers.filter(s => !!s.area); - const agingRisk: Record = { "0-30": "text-yellow-600", "31-60": "text-orange-600", "61-90": "text-red-500", "90+": "text-red-700" }; + const payments: any[] = paymentsData ?? []; + return (
@@ -112,154 +152,198 @@ export default function ReportsPage() {
- {/* KPI Summary */} -
- - - - + {/* Tabs */} +
+ {(["overview", "collections", "tickets"] as Tab[]).map(t => ( + + ))}
- {/* Collection Report */} -
- - Collection by Collector - - {collLoading ?
: - collection.length === 0 ?

No collection data for this period

: ( - <> -
- {collection.map((c, i) => ( -
-
-

{c.collector}

-

{c.paymentCount} payments

-
-
-

{formatCurrency(c.totalAmount)}

-

{totalCollected > 0 ? ((c.totalAmount / totalCollected) * 100).toFixed(1) : 0}%

-
+ {/* Overview Tab */} + {tab === "overview" && ( +
+
+ + + + +
+ +
+ + Collection by Collector + + {collLoading ?
: + collection.length === 0 ?

No collection data for this period

: ( + <> +
+ {collection.map((c, i) => ( +
+

{c.collector}

{c.paymentCount} payments

+

{formatCurrency(c.totalAmount)}

+
+ ))} +
Total{formatCurrency(totalCollected)}
- ))} -
- Total - {formatCurrency(totalCollected)} -
-
- - - - `₱${(v/1000).toFixed(0)}k`} /> - formatCurrency(Number(v))} /> - - - - - ) - } -
-
- - {/* Aging Report */} - - Accounts Receivable Aging - -
- {aging.map((a) => ( -
-
-

{a.bucket} days overdue

-

{a.invoiceCount} invoice{a.invoiceCount !== 1 ? "s" : ""}

-
-
-

0 ? agingRisk[a.bucket] ?? "text-gray-800" : "text-gray-400"}`}> - {formatCurrency(a.totalAmount)} -

-
-
- ))} -
- Total Outstanding - {formatCurrency(totalOutstanding)} -
-
-
-
-
- - {/* Revenue Trend */} - {revenue.length > 0 && ( - - Revenue Trend (Monthly) - - - - - - `₱${(v/1000).toFixed(0)}k`} /> - formatCurrency(Number(v))} /> - - - - - - - - )} - - {/* Subscribers by Status + Area */} -
- - Subscribers by Status - - {subByStatus.length === 0 ?

No subscriber data

: ( -
- - - - {subByStatus.map((_, i) => )} - - - - -
- {subByStatus.map((s, i) => ( -
-
- {s.status} - {s.count} + + `₱${(v/1000).toFixed(0)}k`} /> formatCurrency(Number(v))} /> + + + ) + } + + + + Accounts Receivable Aging + +
+ {aging.map((a) => ( +
+

{a.bucket} days

{a.invoiceCount} invoices

+

0 ? agingRisk[a.bucket] ?? "text-gray-800" : "text-gray-400"}`}>{formatCurrency(a.totalAmount)}

))} -
- Total{totalSubs} -
+
Total Outstanding{formatCurrency(totalOutstanding)}
-
- )} - - + + +
- {subByArea.length > 0 && ( - - Active Subscribers by Area - -
- {subByArea.map((a) => ( -
- {a.area} -
-
-
-
- {a.count} + {revenue.length > 0 && ( + + Revenue Trend (Monthly) + + + + + `₱${(v/1000).toFixed(0)}k`} /> + formatCurrency(Number(v))} /> + + + + + + + )} + +
+ + Subscribers by Status + + {subByStatus.length === 0 ?

No subscriber data

: ( +
+ + {subByStatus.map((_, i) => )} + +
+ {subByStatus.map((s, i) => (
{s.status}{s.count}
))} +
Total{totalSubs}
- ))} -
+ )} +
+
+ {subByArea.length > 0 && ( + + Active Subscribers by Area + +
+ {subByArea.map(a => ( +
+ {a.area} +
{a.count}
+
+ ))} +
+ + + )} +
+
+ )} + + {/* Collections Tab */} + {tab === "collections" && ( +
+
+

Payment Collections

+ +
+ + + + + + + + {payments.length === 0 ? : + payments.map((p: any) => ( + + + + + + + + + )) + } + +
DateClientAmountChannelReferenceNotes{formatDate(p.paymentDate ?? p.createdAt)}{p.client ? `${p.client.firstName} ${p.client.lastName}` : "—"}{formatCurrency(Number(p.amount))}{p.channel}{p.referenceNumber ?? "—"}{p.notes ?? "—"}
- )} -
+
+ )} + + {/* Tickets Tab */} + {tab === "tickets" && ( +
+ {ticketsData && ( + <> +
+

{ticketsData.open}

Open

+

{ticketsData.resolved}

Resolved

+

{ticketsData.total}

Total

+
+ + Recent Tickets + + + + + + + {ticketsData.list.length === 0 ? : + ticketsData.list.map((t: any) => ( + + + + + + + + + )) + } + +
SubjectClientTypePriorityStatusCreated{t.subject}{t.client ? `${t.client.firstName} ${t.client.lastName}` : "—"}{t.type}{t.priority}{t.status}{formatDate(t.createdAt)}
+
+
+ + )} +
+ )}
); }