diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index bc9f68d..9278622 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -1,193 +1,262 @@ -'use client'; +"use client"; -import { useState } from 'react'; -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 { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'; +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 { 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 api from "@/lib/api"; -const peso = (v: number) => 'โ‚ฑ' + (v ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 }); +const COLORS = ["#0891B2", "#059669", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899"]; + +function KpiCard({ title, value, sub, icon: Icon, color }: { title: string; value: string; sub?: string; icon: any; color: string }) { + return ( + + +
+
+

{title}

+

{value}

+ {sub &&

{sub}

} +
+
+ +
+
+
+
+ ); +} export default function ReportsPage() { - const [from, setFrom] = useState(() => { - const d = new Date(); - d.setDate(1); - return d.toISOString().split('T')[0]; - }); - const [to, setTo] = useState(() => new Date().toISOString().split('T')[0]); + const today = new Date(); + const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().split("T")[0]; + const [from, setFrom] = useState(firstOfMonth); + const [to, setTo] = useState(today.toISOString().split("T")[0]); - const { data: collection, isLoading: collLoading } = useQuery({ - queryKey: ['reports', 'collection', from, to], + const { data: collection = [], isLoading: collLoading, refetch: refetchAll } = useQuery({ + queryKey: ["reports-collection", from, to], queryFn: async () => { const res = await api.get(`/api/v1/reports/collection?from=${from}&to=${to}`); return res.data as Array<{ collector: string; totalAmount: number; paymentCount: number }>; }, }); - const { data: aging, isLoading: agingLoading } = useQuery({ - queryKey: ['reports', 'aging'], + const { data: aging = [] } = useQuery({ + queryKey: ["reports-aging"], queryFn: async () => { - const res = await api.get('/api/v1/reports/aging'); + const res = await api.get("/api/v1/reports/aging"); return res.data as Array<{ bucket: string; invoiceCount: number; totalAmount: number }>; }, }); - const { data: subscribers, isLoading: subLoading } = useQuery({ - queryKey: ['reports', 'subscribers'], + const { data: subscribers = [] } = useQuery({ + queryKey: ["reports-subscribers"], queryFn: async () => { - const res = await api.get('/api/v1/reports/subscribers'); - return res.data as Array<{ plan: string; count: number; revenue: number }>; + const res = await api.get("/api/v1/reports/subscribers"); + return res.data as Array<{ status: string; count: number; area?: string; plan?: string }>; }, }); - const { data: revenue, isLoading: revLoading } = useQuery({ - queryKey: ['reports', 'revenue'], + const { data: revenue = [] } = useQuery({ + queryKey: ["reports-revenue"], queryFn: async () => { - const res = await api.get('/api/v1/reports/revenue'); - return res.data as Array<{ month: string; total: number }>; + 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); }, }); + // 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" }; + return ( -
-
-

Reports

-

Financial and operational analytics

+
+
+
+

Reports

+

Financial and operational analytics

+
+
+
+ + setFrom(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> + + setTo(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> +
+ +
+
+ + {/* KPI Summary */} +
+ + + +
{/* Collection Report */} - - - Collection Report -
- setFrom(e.target.value)} - className="border rounded-md px-2 py-1 text-sm text-slate-700" /> - to - setTo(e.target.value)} - className="border rounded-md px-2 py-1 text-sm text-slate-700" /> -
-
- - {collLoading ? ( - - ) : !collection?.length ? ( -

No collections in this period

- ) : ( - - - - - - - - - - {collection?.map((row, i) => ( - - - - - - ))} - - - - - - -
CollectorPaymentsTotal
{row.collector}{row.paymentCount}{peso(row.totalAmount)}
Total - {collection?.reduce((s, r) => s + r.paymentCount, 0)} - - {peso(collection?.reduce((s, r) => s + r.totalAmount, 0) ?? 0)} -
- )} -
-
- -
- {/* Revenue Trend */} - - - Revenue Trend (12 months) - +
+ + Collection by Collector - {revLoading ? : ( - - - - `โ‚ฑ${(v/1000).toFixed(0)}k`} /> - peso(v)} /> - - - - )} + {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}%

+
+
+ ))} +
+ Total + {formatCurrency(totalCollected)} +
+
+ + + + `โ‚ฑ${(v/1000).toFixed(0)}k`} /> + formatCurrency(Number(v))} /> + + + + + ) + } - {/* Aging */} - - - Accounts Receivable Aging - + {/* Aging Report */} + + Accounts Receivable Aging - {agingLoading ? : ( -
- {aging?.map((bucket) => ( -
- {bucket.bucket}d -
-
b.totalAmount) ?? [1])) || 1)) * 100)}%`, - backgroundColor: bucket.bucket === '90+' ? '#DC2626' : bucket.bucket === '61-90' ? '#D97706' : '#0891B2', - }} - /> -
- {peso(bucket.totalAmount)} - {bucket.invoiceCount} inv +
+ {aging.map((a) => ( +
+
+

{a.bucket} days overdue

+

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

- ))} - {!aging?.some(b => b.totalAmount > 0) && ( -

No overdue invoices ๐ŸŽ‰

- )} +
+

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} +
+ ))} +
+ Total{totalSubs} +
+
)} -
- {/* Subscribers by Plan */} - - - Subscribers by Plan - - - {subLoading ? : ( - - - - - - - - - - {(subscribers as any[])?.map((row: any, i: number) => ( - - - - - + {subByArea.length > 0 && ( + + Active Subscribers by Area + +
+ {subByArea.map((a) => ( +
+ {a.area} +
+
+
+
+ {a.count} +
+
))} -
-
PlanSubscribersMonthly Revenue
{row.plan ?? row.name ?? 'โ€”'}{row.count ?? row.subscribers ?? 0} - {peso(row.revenue ?? row.monthlyRevenue ?? 0)} -
- )} -
-
+
+ + + )} +
); } diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 8816337..b637ddb 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Building2, CreditCard, Map, Wifi, Users, ChevronRight, @@ -128,27 +128,35 @@ function TenantSettings() { // โ”€โ”€โ”€ Sub-page: Billing Settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +interface FullBillingSettings { + billingDay?: number; + gracePeriodDays?: number; + lateFeeAmount?: string | number; + lateFeePercent?: string | number; + lateFeeGraceDays?: number; + currency?: string; +} + function BillingSettings() { - const [billingDay, setBillingDay] = useState("1"); - const [lateFeeAmount, setLateFeeAmount] = useState("0"); - const [graceDays, setGraceDays] = useState("0"); + const [fields, setFields] = useState({ billingDay: "1", gracePeriodDays: "5", lateFeeAmount: "0", lateFeePercent: "0", lateFeeGraceDays: "0", currency: "PHP" }); const [loaded, setLoaded] = useState(false); - const { isLoading } = useQuery({ + const { isLoading } = useQuery({ queryKey: ["tenant-billing-settings"], queryFn: async () => { - try { - const res = await api.get("/api/v1/tenants/me/settings"); - return res.data ?? {}; - } catch { - return {}; - } + const res = await api.get("/api/v1/tenants/me/settings"); + return res.data ?? {}; }, select: (data) => { if (!loaded && data) { - setBillingDay(String(data.billingDay ?? 1)); - setLateFeeAmount(String(data.lateFeeAmount ?? 0)); - setGraceDays(String(data.lateFeeGraceDays ?? 0)); + setFields({ + billingDay: String(data.billingDay ?? 1), + gracePeriodDays: String(data.gracePeriodDays ?? 5), + lateFeeAmount: String(data.lateFeeAmount ?? 0), + lateFeePercent: String(data.lateFeePercent ?? 0), + lateFeeGraceDays: String(data.lateFeeGraceDays ?? 0), + currency: data.currency ?? "PHP", + }); setLoaded(true); } return data; @@ -158,60 +166,52 @@ function BillingSettings() { const saveMutation = useMutation({ mutationFn: async () => { await api.patch("/api/v1/tenants/me/settings", { - billingDay: parseInt(billingDay), - lateFeeAmount: parseFloat(lateFeeAmount), - lateFeeGraceDays: parseInt(graceDays), + billingDay: parseInt(fields.billingDay), + gracePeriodDays: parseInt(fields.gracePeriodDays), + lateFeeAmount: parseFloat(fields.lateFeeAmount), + lateFeePercent: parseFloat(fields.lateFeePercent), + lateFeeGraceDays: parseInt(fields.lateFeeGraceDays), + currency: fields.currency, }); }, onSuccess: () => toast.success("Billing settings saved"), - onError: () => toast.error("Failed to save. Endpoint may not be available yet."), + onError: () => toast.error("Failed to save billing settings"), }); - if (isLoading) { - return
; - } + const set = (key: keyof typeof fields) => (e: React.ChangeEvent) => + setFields(f => ({ ...f, [key]: e.target.value })); + + if (isLoading) return
; return ( Billing Configuration -
{ e.preventDefault(); saveMutation.mutate(); }} - className="space-y-4 max-w-lg" - > - setBillingDay(e.target.value)} - hint="Day of month invoices are generated" - /> - setLateFeeAmount(e.target.value)} - /> - setGraceDays(e.target.value)} - hint="Days after due date before late fee applies" - /> - {saveMutation.isError && ( -

- Save endpoint not available yet โ€” changes not persisted. -

- )} - + { e.preventDefault(); saveMutation.mutate(); }} className="space-y-4 max-w-lg"> + + +
+ + +
+ +
+ + +
+
@@ -559,15 +559,19 @@ function PlansSettings() { // โ”€โ”€โ”€ Sub-page: Users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ interface UserItem { - id: string; - firstName: string; - lastName: string; - email: string; - isActive: boolean; + id: string; firstName: string; lastName: string; email: string; + phone?: string; isActive: boolean; + roleAssignments?: { role: string }[]; } +const ROLES = ["ADMIN", "STAFF", "COLLECTOR", "TECHNICIAN"]; + function UsersSettings() { - const { data, isLoading } = useQuery({ + const qc = useQueryClient(); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ firstName: "", lastName: "", email: "", password: "", phone: "", role: "STAFF" }); + + const { data, isLoading, refetch } = useQuery({ queryKey: ["users-settings"], queryFn: async () => { const res = await api.get("/api/v1/users"); @@ -575,46 +579,101 @@ function UsersSettings() { }, }); + const createUser = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/users", { + firstName: form.firstName, lastName: form.lastName, + email: form.email, password: form.password, + phone: form.phone || undefined, role: form.role, + }); + }, + onSuccess: () => { + toast.success("User created!"); + setShowAdd(false); + setForm({ firstName: "", lastName: "", email: "", password: "", phone: "", role: "STAFF" }); + refetch(); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create user"), + }); + + const toggleActive = useMutation({ + mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => { + await api.patch(`/api/v1/users/${id}`, { isActive: !isActive }); + }, + onSuccess: () => { toast.success("User updated"); refetch(); }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"), + }); + const users = data ?? []; return ( - - Users - - - - - - - - - - - {isLoading ? ( - Array.from({ length: 3 }).map((_, i) => ( - - {[1,2,3].map((j) => )} - - )) - ) : users.length === 0 ? ( - - ) : ( - users.map((u) => ( - - - - - - )) - )} - -
NameEmailStatus
{u.firstName} {u.lastName}{u.email} - - {u.isActive ? "Active" : "Inactive"} - -
-
-
+
+ + +
+ Users ({users.length}) + +
+
+ + + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + {[1,2,3,4,5].map(j => )} + )) + ) : users.length === 0 ? ( + + ) : users.map(u => { + const role = u.roleAssignments?.[0]?.role ?? "โ€”"; + return ( + + + + + + + + ); + })} + +
NameEmailRoleStatusActions
{u.firstName} {u.lastName}
{u.phone ?? ""}
{u.email}{role}{u.isActive ? "Active" : "Inactive"} + +
+
+
+ + setShowAdd(false)} title="Add New User"> +
+
+ setForm(f => ({ ...f, firstName: e.target.value }))} /> + setForm(f => ({ ...f, lastName: e.target.value }))} /> +
+ setForm(f => ({ ...f, email: e.target.value }))} /> + setForm(f => ({ ...f, password: e.target.value }))} hint="Minimum 8 characters" /> + setForm(f => ({ ...f, phone: e.target.value }))} /> +
+ + +
+
+ + +
+
+
+
); } diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 0da83a7..11d3fc6 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -5,7 +5,6 @@ import { usePathname } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { LayoutDashboard, Users, UserPlus, FileText, CreditCard, ArrowLeftRight, - Ticket, BarChart3, Settings, Wifi, ClipboardList, ScrollText, } from 'lucide-react'; const navItems = [ @@ -17,10 +16,9 @@ const navItems = [ { label: 'Payments', href: '/payments', icon: CreditCard, roles: [] }, { label: 'Remittances', href: '/remittances', icon: ArrowLeftRight, roles: [] }, { label: 'Tickets', href: '/tickets', icon: Ticket, roles: [] }, - { label: 'Tasks', href: '/tasks', icon: ClipboardList, roles: ['admin','staff'] }, { label: 'Reports', href: '/reports', icon: BarChart3, roles: ['admin','staff'] }, { label: 'Audit Log', href: '/audit-log', icon: ScrollText, roles: ['admin'] }, - { label: 'Settings', href: '/settings/tenant', icon: Settings, roles: ['admin'] }, + { label: 'Settings', href: '/settings', icon: Settings, roles: ['admin'] }, ]; export default function Sidebar() {