diff --git a/app/(app)/audit-log/page.tsx b/app/(app)/audit-log/page.tsx index 0316ce1..be8c265 100644 --- a/app/(app)/audit-log/page.tsx +++ b/app/(app)/audit-log/page.tsx @@ -11,13 +11,29 @@ import { formatDateTime } from "@/lib/utils"; import api from "@/lib/api"; import type { AuditLog, PaginatedResponse } from "@/types"; +const ENTITY_TYPES = ["", "CLIENT", "INVOICE", "PAYMENT", "TICKET", "PLAN", "AREA", "USER", "SUBSCRIPTION", "LEAD", "REMITTANCE", "JOURNAL_ENTRY"]; +const ACTION_TYPES = ["", "CREATE", "UPDATE", "DELETE", "LOGIN", "LOGOUT", "ACTIVATE", "DEACTIVATE", "VOID", "RESOLVE", "CLOSE"]; + export default function AuditLogPage() { const [page, setPage] = useState(1); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [entityType, setEntityType] = useState(""); + const [actionType, setActionType] = useState(""); const { data, isLoading, refetch } = useQuery>({ - queryKey: ["audit-logs", page], + queryKey: ["audit-logs", page, dateFrom, dateTo, entityType, actionType], queryFn: async () => { - const res = await api.get>(`/api/v1/audit-logs?page=${page}&limit=50`); + const params = new URLSearchParams({ page: String(page), limit: "50" }); + if (dateFrom) params.set("dateFrom", new Date(dateFrom).toISOString()); + if (dateTo) { + const end = new Date(dateTo); + end.setHours(23, 59, 59, 999); + params.set("dateTo", end.toISOString()); + } + if (entityType) params.set("entityType", entityType); + if (actionType) params.set("action", actionType); + const res = await api.get>(`/api/v1/audit-logs?${params}`); return res.data; }, }); @@ -25,6 +41,16 @@ export default function AuditLogPage() { const logs = data?.data ?? []; const meta = data?.meta; + const clearFilters = () => { + setDateFrom(""); + setDateTo(""); + setEntityType(""); + setActionType(""); + setPage(1); + }; + + const hasFilters = dateFrom || dateTo || entityType || actionType; + return (
@@ -33,13 +59,52 @@ export default function AuditLogPage() {

Track all system activity

+ {/* Filters */} - Activity Log + +
+
+ + { setDateFrom(e.target.value); setPage(1); }} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-40" /> +
+
+ + { setDateTo(e.target.value); setPage(1); }} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-40" /> +
+
+ + +
+
+ + +
+ {hasFilters && ( + + )} +
+
+
+ + + Activity Log {meta ? `(${meta.total} entries)` : ""} @@ -61,7 +126,7 @@ export default function AuditLogPage() { )) ) : logs.length === 0 ? ( - + ) : ( logs.map((log) => ( diff --git a/app/(app)/clients/[id]/page.tsx b/app/(app)/clients/[id]/page.tsx index 4a65e5b..2923f2b 100644 --- a/app/(app)/clients/[id]/page.tsx +++ b/app/(app)/clients/[id]/page.tsx @@ -528,7 +528,22 @@ export default function ClientDetailPage() { {/* Profile */} {activeTab === "profile" && ( - Client Profile + +
+ Client Profile +
+ + + +
+
+
{[ diff --git a/app/(app)/clients/page.tsx b/app/(app)/clients/page.tsx index 06e3494..2d26890 100644 --- a/app/(app)/clients/page.tsx +++ b/app/(app)/clients/page.tsx @@ -53,11 +53,21 @@ export default function ClientsPage() { queryFn: async () => { const r = await api.get("/api/v1/areas"); return r.data; }, }); - const { data: plans = [] } = useQuery({ - queryKey: ["plans"], - queryFn: async () => { const r = await api.get("/api/v1/plans"); return Array.isArray(r.data) ? r.data : (r.data as any).data ?? []; }, + // Fetch only active plans, filter client-side by billing type + const { data: allActivePlans = [] } = useQuery<(Plan & { type: string; isActive: boolean })[]>({ + queryKey: ["plans", "active"], + queryFn: async () => { + const r = await api.get("/api/v1/plans?isActive=true"); + const raw = Array.isArray(r.data) ? r.data : (r.data as any).data ?? []; + return raw; + }, }); + // Filter plans by selected billing type + const filteredPlans = allActivePlans.filter(p => + !form.billingType || p.type === form.billingType + ); + const createClient = useMutation({ mutationFn: async () => { const res = await api.post("/api/v1/clients", { @@ -186,10 +196,11 @@ export default function ClientsPage() {
+

Plan list filters to match this type

@@ -197,8 +208,11 @@ export default function ClientsPage() { + {filteredPlans.length === 0 && form.billingType && ( +

No active {form.billingType.toLowerCase()} plans available.

+ )}
diff --git a/app/(app)/leads/page.tsx b/app/(app)/leads/page.tsx index 2b75111..1a1f0fd 100644 --- a/app/(app)/leads/page.tsx +++ b/app/(app)/leads/page.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { UserPlus, RefreshCw } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { UserPlus, RefreshCw, ArrowRightCircle } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table"; import { Badge } from "@/components/ui/Badge"; @@ -22,11 +23,14 @@ const statusOptions = ["NEW", "CONTACTED", "INTERESTED", "CONVERTED", "LOST"]; export default function LeadsPage() { const qc = useQueryClient(); + const router = useRouter(); const [search, setSearch] = useState(""); const [selected, setSelected] = useState(null); const [showAdd, setShowAdd] = useState(false); + const [showConvert, setShowConvert] = useState(false); const [form, setForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", notes: "", source: "" }); const [statusUpdate, setStatusUpdate] = useState(""); + const [convertForm, setConvertForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", areaId: "", planId: "", billingType: "POSTPAID" }); const { data = [], isLoading, refetch } = useQuery({ queryKey: ["leads", search], @@ -79,6 +83,41 @@ export default function LeadsPage() { onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete"), }); + const convertToClient = useMutation({ + mutationFn: async () => { + const res = await api.post("/api/v1/clients", { + firstName: convertForm.firstName, lastName: convertForm.lastName, + phone: convertForm.phone, email: convertForm.email || undefined, + address: convertForm.address || undefined, + areaId: convertForm.areaId || undefined, + planId: convertForm.planId || undefined, + }); + // Mark lead as converted + if (selected) await api.patch(`/api/v1/leads/${selected.id}`, { status: "CONVERTED" }); + return res.data; + }, + onSuccess: (data: any) => { + toast.success("Lead converted to client!"); + setShowConvert(false); + setSelected(null); + qc.invalidateQueries({ queryKey: ["leads"] }); + if (data?.id) router.push(`/clients/${data.id}`); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to convert lead"), + }); + + const { data: areas = [] } = useQuery<{ id: string; name: string }[]>({ + queryKey: ["areas"], + queryFn: async () => { const r = await api.get("/api/v1/areas"); return Array.isArray(r.data) ? r.data : (r.data as any).data ?? []; }, + }); + + const { data: activePlans = [] } = useQuery<{ id: string; name: string; monthlyPrice: number; type: string }[]>({ + queryKey: ["plans", "active"], + queryFn: async () => { const r = await api.get("/api/v1/plans?isActive=true"); return Array.isArray(r.data) ? r.data : (r.data as any).data ?? []; }, + }); + + const filteredConvertPlans = activePlans.filter(p => !convertForm.billingType || p.type === convertForm.billingType); + const counts = statusOptions.reduce((acc, s) => ({ ...acc, [s]: data.filter(l => l.status === s).length }), {} as Record); return ( @@ -176,14 +215,72 @@ export default function LeadsPage() { )}
-
- +
+
+ + {selected.status !== "CONVERTED" && ( + + )} +
)} + {/* Convert to Client Modal */} + setShowConvert(false)} title="Convert Lead to Client" className="max-w-xl"> +
+

Pre-filled from lead data. Complete missing info to create the client.

+
+ setConvertForm(f => ({ ...f, firstName: e.target.value }))} /> + setConvertForm(f => ({ ...f, lastName: e.target.value }))} /> +
+ setConvertForm(f => ({ ...f, phone: e.target.value }))} /> + setConvertForm(f => ({ ...f, email: e.target.value }))} /> + setConvertForm(f => ({ ...f, address: e.target.value }))} /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ {/* Add Lead Modal */} setShowAdd(false)} title="Add New Lead">
diff --git a/app/(app)/plans/page.tsx b/app/(app)/plans/page.tsx index b27f16a..91eb923 100644 --- a/app/(app)/plans/page.tsx +++ b/app/(app)/plans/page.tsx @@ -41,6 +41,7 @@ const emptyForm = { export default function PlansPage() { const qc = useQueryClient(); const [search, setSearch] = useState(""); + const [activeTab, setActiveTab] = useState<"active" | "archived">("active"); // Modals const [showCreate, setShowCreate] = useState(false); @@ -51,11 +52,12 @@ export default function PlansPage() { const [createForm, setCreateForm] = useState({ ...emptyForm }); const [editForm, setEditForm] = useState({ ...emptyForm }); - // GET /plans returns a plain array (not paginated) + // GET /plans with isActive filter const { data: allPlans = [], isLoading, isError, refetch } = useQuery({ - queryKey: ["plans"], + queryKey: ["plans", activeTab], queryFn: async () => { - const res = await api.get("/api/v1/plans"); + const isActive = activeTab === "active"; + const res = await api.get(`/api/v1/plans?isActive=${isActive}`); return Array.isArray(res.data) ? res.data : (res.data as any).data ?? []; }, }); @@ -149,6 +151,24 @@ export default function PlansPage() {
+ {/* Active / Archived tabs */} +
+ + +
+ { + await api.patch("/api/v1/auth/me", { + firstName: infoForm.firstName, + lastName: infoForm.lastName, + email: infoForm.email, + }); + }, + onSuccess: () => toast.success("Profile updated!"), + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to update profile"), + }); + + const resetPassword = useMutation({ + mutationFn: async () => { + if (pwForm.newPassword !== pwForm.confirmPassword) { + throw new Error("Passwords do not match"); + } + await api.post("/api/v1/auth/change-password", { + currentPassword: pwForm.currentPassword, + newPassword: pwForm.newPassword, + }); + }, + onSuccess: () => { + toast.success("Password changed successfully!"); + setPwForm({ currentPassword: "", newPassword: "", confirmPassword: "" }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? e.message ?? "Failed to change password"), + }); + + return ( +
+
+

My Profile

+

Manage your account settings

+
+ + {/* Profile Info */} + + + + + Personal Information + + + +
+ setInfoForm(f => ({ ...f, firstName: e.target.value }))} + /> + setInfoForm(f => ({ ...f, lastName: e.target.value }))} + /> +
+ setInfoForm(f => ({ ...f, email: e.target.value }))} + /> +
+ +
+
+
+ + {/* Change Password */} + + + + + Change Password + + + + setPwForm(f => ({ ...f, currentPassword: e.target.value }))} + /> + setPwForm(f => ({ ...f, newPassword: e.target.value }))} + hint="Minimum 8 characters" + /> + setPwForm(f => ({ ...f, confirmPassword: e.target.value }))} + /> + {pwForm.newPassword && pwForm.confirmPassword && pwForm.newPassword !== pwForm.confirmPassword && ( +

Passwords do not match

+ )} +
+ +
+
+
+
+ ); +} 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)}
+
+
+ + )} +
+ )} ); } diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index d8fd9ce..901896b 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -238,6 +238,8 @@ function AreasSettings() { const [areaName, setAreaName] = useState(""); const [zoneName, setZoneName] = useState(""); const [zoneAreaId, setZoneAreaId] = useState(""); + const [editArea, setEditArea] = useState(null); + const [editAreaName, setEditAreaName] = useState(""); const { data: areas, isLoading, refetch } = useQuery({ queryKey: ["areas"], @@ -256,26 +258,31 @@ function AreasSettings() { mutationFn: async () => { await api.post("/api/v1/areas", { name: areaName }); }, - onSuccess: () => { - toast.success("Area added"); - setAreaName(""); - setShowAddArea(false); - refetch(); - }, + onSuccess: () => { toast.success("Area added"); setAreaName(""); setShowAddArea(false); refetch(); }, onError: () => toast.error("Failed to add area"), }); + const updateAreaMutation = useMutation({ + mutationFn: async () => { + await api.patch(`/api/v1/areas/${editArea!.id}`, { name: editAreaName }); + }, + onSuccess: () => { toast.success("Area updated"); setEditArea(null); refetch(); }, + onError: () => toast.error("Failed to update area"), + }); + + const deleteAreaMutation = useMutation({ + mutationFn: async (id: string) => { + await api.delete(`/api/v1/areas/${id}`); + }, + onSuccess: () => { toast.success("Area archived"); refetch(); }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to archive area"), + }); + const addZoneMutation = useMutation({ mutationFn: async () => { await api.post("/api/v1/zones", { name: zoneName, areaId: zoneAreaId }); }, - onSuccess: () => { - toast.success("Zone added"); - setZoneName(""); - setZoneAreaId(""); - setShowAddZone(false); - refetch(); - }, + onSuccess: () => { toast.success("Zone added"); setZoneName(""); setZoneAreaId(""); setShowAddZone(false); refetch(); }, onError: () => toast.error("Failed to add zone"), }); @@ -287,21 +294,14 @@ function AreasSettings() { Areas & Zones
- - + +
- - - - + {isLoading ? ( @@ -309,6 +309,7 @@ function AreasSettings() { + )) ) : areaList.length === 0 ? ( @@ -318,9 +319,13 @@ function AreasSettings() { + )) @@ -330,20 +335,24 @@ function AreasSettings() { + {/* Edit Area Modal */} + setEditArea(null)} title="Edit Area"> +
{ e.preventDefault(); updateAreaMutation.mutate(); }} className="space-y-4"> + setEditAreaName(e.target.value)} /> +
+ + +
+ +
+ {/* Add Area Modal */} setShowAddArea(false)} title="Add Area">
{ e.preventDefault(); addAreaMutation.mutate(); }} className="space-y-4"> - setAreaName(e.target.value)} - placeholder="e.g. North Sector" - /> + setAreaName(e.target.value)} placeholder="e.g. North Sector" />
- +
@@ -353,33 +362,16 @@ function AreasSettings() { { e.preventDefault(); addZoneMutation.mutate(); }} className="space-y-4">
- setZoneAreaId(e.target.value)}> - {areaList.map((a) => ( - - ))} + {areaList.map(a => )}
- setZoneName(e.target.value)} - placeholder="e.g. Zone 1" - /> + setZoneName(e.target.value)} placeholder="e.g. Zone 1" />
- +
@@ -636,6 +628,8 @@ const ROLES = ["ADMIN", "STAFF", "COLLECTOR", "TECHNICIAN"]; function UsersSettings() { const qc = useQueryClient(); const [showAdd, setShowAdd] = useState(false); + const [resetPwUser, setResetPwUser] = useState(null); + const [newPassword, setNewPassword] = useState(""); const [form, setForm] = useState({ firstName: "", lastName: "", email: "", password: "", phone: "", role: "STAFF" }); const { data, isLoading, refetch } = useQuery({ @@ -671,6 +665,14 @@ function UsersSettings() { onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"), }); + const resetPassword = useMutation({ + mutationFn: async () => { + await api.patch(`/api/v1/users/${resetPwUser!.id}/password`, { newPassword }); + }, + onSuccess: () => { toast.success("Password reset!"); setResetPwUser(null); setNewPassword(""); }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to reset password"), + }); + const users = data ?? []; return ( @@ -693,17 +695,29 @@ function UsersSettings() { ) : users.length === 0 ? ( ) : users.map(u => { - const role = u.roleAssignments?.[0]?.role ?? "—"; + const roles = u.roleAssignments?.map(r => r.role) ?? []; + const primaryRole = roles[0] ?? "—"; return ( - + ); @@ -713,6 +727,21 @@ function UsersSettings() { + {/* Reset Password Modal */} + setResetPwUser(null)} title={`Reset Password — ${resetPwUser ? `${resetPwUser.firstName} ${resetPwUser.lastName}` : ""}`}> +
+

Enter a new password for this user. They will need to use this to log in.

+ setNewPassword(e.target.value)} hint="Minimum 8 characters" /> +
+ + +
+
+
+ setShowAdd(false)} title="Add New User">
diff --git a/app/(app)/tickets/page.tsx b/app/(app)/tickets/page.tsx index 41cc7a6..7b4f25f 100644 --- a/app/(app)/tickets/page.tsx +++ b/app/(app)/tickets/page.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { RefreshCw, Ticket, Plus, Send } from "lucide-react"; +import { RefreshCw, Ticket, Plus, Send, RotateCcw, User, MapPin, Zap } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; import { Card, CardContent, CardHeader } from "@/components/ui/Card"; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table"; import { Badge } from "@/components/ui/Badge"; @@ -34,11 +35,20 @@ const priorityVariant: Record = { const statusFilters = ["", "OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"]; const typeFilters = ["", "SUPPORT", "BILLING", "INSTALLATION"]; +// Previous status mapping (revert flow) +const prevStatus: Record = { + CLOSED: "RESOLVED", + RESOLVED: "IN_PROGRESS", + IN_PROGRESS: "OPEN", +}; + export default function TicketsPage() { const qc = useQueryClient(); + const { user } = useAuth(); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [typeFilter, setTypeFilter] = useState(""); + const [assignedToMe, setAssignedToMe] = useState(false); const [page, setPage] = useState(1); const [selected, setSelected] = useState(null); const [showCreate, setShowCreate] = useState(false); @@ -46,12 +56,13 @@ export default function TicketsPage() { const [newForm, setNewForm] = useState({ subject: "", description: "", type: "SUPPORT", priority: "NORMAL", clientSearch: "", clientId: "" }); const { data, isLoading, refetch } = useQuery>({ - queryKey: ["tickets", search, statusFilter, typeFilter, page], + queryKey: ["tickets", search, statusFilter, typeFilter, assignedToMe, page], queryFn: async () => { const params = new URLSearchParams({ page: String(page), limit: "20" }); if (search) params.set("search", search); if (statusFilter) params.set("status", statusFilter); if (typeFilter) params.set("type", typeFilter); + if (assignedToMe && user?.id) params.set("assignedToId", user.id); const res = await api.get>(`/api/v1/tickets?${params}`); return res.data; }, @@ -109,6 +120,22 @@ export default function TicketsPage() { onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create ticket"), }); + const activateClient = useMutation({ + mutationFn: async (clientId: string) => { + await api.patch(`/api/v1/clients/${clientId}`, { isActive: true }); + }, + onSuccess: () => { toast.success("Client activated! 🎉"); refetch(); refetchDetail(); }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to activate client"), + }); + + const revertStatus = useMutation({ + mutationFn: async ({ id, status }: { id: string; status: string }) => { + await api.patch(`/api/v1/tickets/${id}`, { status }); + }, + onSuccess: () => { toast.success("Status reverted"); refetch(); refetchDetail(); qc.invalidateQueries({ queryKey: ["ticket"] }); }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"), + }); + const tickets = data?.data ?? []; const total = data?.meta?.total ?? 0; const detail = ticketDetail ?? selected; @@ -150,6 +177,13 @@ export default function TicketsPage() { ))}
+
@@ -200,14 +234,43 @@ export default function TicketsPage() {
Status

{detail.status}

Created

{formatDate(detail.createdAt)}

{detail.description &&
Description

{detail.description}

} + {/* Installation: show Google Maps link using client address */} + {detail.type === "INSTALLATION" && detail.client && ( + + )} {/* Status actions */} - {nextStatus[detail.status] && ( - - )} +
+ {nextStatus[detail.status] && ( + + )} + {prevStatus[detail.status] && ( + + )} + {/* Installation ticket RESOLVED → show Activate Client button */} + {detail.type === "INSTALLATION" && detail.status === "RESOLVED" && detail.clientId && ( + + )} +
{/* Comments */}
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index a522d7b..319c386 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { Menu, LogOut, User } from "lucide-react"; +import Link from "next/link"; interface TopBarProps { onMenuClick: () => void; @@ -26,11 +27,11 @@ export function TopBar({ onMenuClick }: TopBarProps) {
-
+ {user?.name || user?.email || "User"} {user?.role} -
+
Area NameZonesArea NameZonesActions
{a.name} - {a.zones?.length - ? a.zones.map((z) => z.name).join(", ") - : No zones} + {a.zones?.length ? a.zones.map(z => z.name).join(", ") : No zones} + +
+ + +
{u.firstName} {u.lastName}
{u.phone ?? ""}
{u.email}{role} +
+ {roles.length > 0 ? roles.map(r => ( + {r} + )) : } +
+
{u.isActive ? "Active" : "Inactive"} - +
+ + +