diff --git a/app/(app)/clients/[id]/page.tsx b/app/(app)/clients/[id]/page.tsx index 75c002f..b122fe9 100644 --- a/app/(app)/clients/[id]/page.tsx +++ b/app/(app)/clients/[id]/page.tsx @@ -1,162 +1,166 @@ "use client"; import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; -import { ArrowLeft, Wifi, FileText, Ticket, RefreshCw } from "lucide-react"; +import { ArrowLeft, Wifi, FileText, Ticket, CreditCard, RefreshCw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { Input } from "@/components/ui/Input"; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table"; import { formatDate, formatCurrency } from "@/lib/utils"; import api from "@/lib/api"; -import type { Client, Subscription, Invoice, Ticket as TicketType, PaginatedResponse, LegacyPaginatedResponse } from "@/types"; +import { toast } from "sonner"; +import type { Client, Subscription, Invoice, Ticket as TicketType, Payment, PaginatedResponse, LegacyPaginatedResponse } from "@/types"; -type Tab = "profile" | "subscriptions" | "invoices" | "tickets"; +type Tab = "profile" | "subscriptions" | "invoices" | "payments" | "tickets"; const statusColor: Record = { - active: "success", - ACTIVE: "success", - suspended: "warning", - SUSPENDED: "warning", - cancelled: "danger", - CANCELLED: "danger", - disconnected: "danger", - pending: "muted", - PENDING: "muted", + ACTIVE: "success", active: "success", + SUSPENDED: "warning", suspended: "warning", + CANCELLED: "danger", cancelled: "danger", + DISCONNECTED: "danger", + PENDING: "muted", pending: "muted", +}; + +const invColor: Record = { + PAID: "success", paid: "success", + PARTIAL: "warning", partial: "warning", + OVERDUE: "danger", overdue: "danger", + SENT: "muted", DRAFT: "muted", VOID: "muted", +}; + +const channelColors: Record = { + CASH: "bg-green-100 text-green-700", + GCASH: "bg-blue-100 text-blue-700", + MAYA: "bg-purple-100 text-purple-700", + BANK_TRANSFER: "bg-yellow-100 text-yellow-700", + CHECK: "bg-gray-100 text-gray-700", }; export default function ClientDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); + const qc = useQueryClient(); const [activeTab, setActiveTab] = useState("profile"); + const [payInvoice, setPayInvoice] = useState(null); + const [payForm, setPayForm] = useState({ amount: "", channel: "CASH", referenceNumber: "", notes: "" }); const { data: client, isLoading, refetch: refetchClient } = useQuery({ queryKey: ["client", id], - queryFn: async () => { - const res = await api.get(`/api/v1/clients/${id}`); - return res.data; - }, + queryFn: async () => { const r = await api.get(`/api/v1/clients/${id}`); return r.data; }, }); const { data: subscriptions, isError: subsError } = useQuery({ queryKey: ["client-subscriptions", id], - queryFn: async () => { - const res = await api.get(`/api/v1/clients/${id}/subscriptions`); - return Array.isArray(res.data) ? res.data : []; - }, + queryFn: async () => { const r = await api.get(`/api/v1/clients/${id}/subscriptions`); return r.data; }, enabled: activeTab === "subscriptions", - retry: false, }); - const { data: invoicesData } = useQuery>({ + const { data: invoicesData, refetch: refetchInvoices } = useQuery>({ queryKey: ["client-invoices", id], - queryFn: async () => { - const res = await api.get>(`/api/v1/invoices?clientId=${id}&page=1&limit=20`); - return res.data; - }, + queryFn: async () => { const r = await api.get>(`/api/v1/invoices?clientId=${id}&page=1&limit=30`); return r.data; }, enabled: activeTab === "invoices", }); + const { data: paymentsData } = useQuery>({ + queryKey: ["client-payments", id], + queryFn: async () => { + const r = await api.get>(`/api/v1/payments?clientId=${id}&page=1&limit=30`); + return r.data; + }, + enabled: activeTab === "payments", + }); + const { data: ticketsData } = useQuery>({ queryKey: ["client-tickets", id], - queryFn: async () => { - const res = await api.get>(`/api/v1/tickets?clientId=${id}&page=1&limit=20`); - return res.data; - }, + queryFn: async () => { const r = await api.get>(`/api/v1/tickets?clientId=${id}&page=1&limit=20`); return r.data; }, enabled: activeTab === "tickets", }); - const tabs: { key: Tab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ - { key: "profile", label: "Profile", icon: ArrowLeft }, - { key: "subscriptions", label: "Subscriptions", icon: Wifi }, - { key: "invoices", label: "Invoices", icon: FileText }, - { key: "tickets", label: "Tickets", icon: Ticket }, + const recordPayment = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/payments", { + clientId: id, invoiceId: payInvoice?.id, + amount: Number(payForm.amount), channel: payForm.channel, + referenceNumber: payForm.referenceNumber || undefined, + notes: payForm.notes || undefined, + paymentDate: new Date().toISOString(), + }); + }, + onSuccess: () => { + toast.success("Payment recorded!"); + setPayInvoice(null); + setPayForm({ amount: "", channel: "CASH", referenceNumber: "", notes: "" }); + qc.invalidateQueries({ queryKey: ["client-invoices", id] }); + qc.invalidateQueries({ queryKey: ["client-payments", id] }); + refetchInvoices(); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Payment failed"), + }); + + const tabs = [ + { key: "profile" as Tab, label: "Profile" }, + { key: "subscriptions" as Tab, label: "Subscriptions" }, + { key: "invoices" as Tab, label: "Invoices" }, + { key: "payments" as Tab, label: "Payments" }, + { key: "tickets" as Tab, label: "Tickets" }, ]; - if (isLoading) { - return ( -
-
- - -
- - -
- ); - } + if (isLoading) return ( +
+
+
+
+ ); - if (!client) { - return ( -
-

Client not found

- -
- ); - } + if (!client) return ( +
+

Client not found

+ +
+ ); + + const sub = client.subscriptions?.[0]; + const clientStatus = sub?.status ?? (client.isActive ? "ACTIVE" : "INACTIVE"); return (
{/* Header */}
-
-

- {client.firstName} {client.lastName} -

-

- {client.accountNumber} • {client.area?.name ?? ""} -

-
-
- - {client.isActive ? "Active" : "Inactive"} - - +
+

{client.firstName} {client.lastName}

+

{client.accountNumber} · {client.area?.name ?? "No area"}

+ {clientStatus} +
{/* Tabs */} -
- {[ - { key: "profile" as Tab, label: "Profile" }, - { key: "subscriptions" as Tab, label: "Subscriptions" }, - { key: "invoices" as Tab, label: "Invoices" }, - { key: "tickets" as Tab, label: "Tickets" }, - ].map((tab) => ( - +
+ {tabs.map(tab => ( + ))}
- {/* Profile Tab */} + {/* Profile */} {activeTab === "profile" && ( - - Client Profile - + Client Profile
{[ { label: "Account Number", value: client.accountNumber }, { label: "Full Name", value: `${client.firstName} ${client.lastName}` }, - { label: "Email", value: client.email }, + { label: "Email", value: client.email || "—" }, { label: "Phone", value: client.phone }, { label: "Address", value: client.address || "—" }, { label: "Area", value: client.area?.name || "—" }, @@ -173,126 +177,141 @@ export default function ClientDetailPage() { )} - {/* Subscriptions Tab */} + {/* Subscriptions */} {activeTab === "subscriptions" && ( Subscriptions - - - - - - - - + - {subsError ? ( - - ) : !subscriptions || subscriptions.length === 0 ? ( - - ) : ( - subscriptions.map((sub) => ( - - - + {!subscriptions || subscriptions.length === 0 ? : + subscriptions.map(sub => ( + + + + )) - )} + }
PlanStatusStart DateMonthly RatePlanTypeStatusStart DateMonthly Rate
No data yet
{sub.plan?.name ?? sub.planId} - - {sub.status} - - {sub.plan?.name ?? "—"}{sub.type ?? sub.plan?.type ?? "—"}{sub.status} {formatDate(sub.startDate)} {formatCurrency(sub.plan?.monthlyPrice ?? sub.monthlyRate ?? sub.mrc ?? 0)}
)} - {/* Invoices Tab */} + {/* Invoices */} {activeTab === "invoices" && ( Invoices - - - - - - - - + - {!invoicesData?.data || invoicesData.data.length === 0 ? ( - - ) : ( - invoicesData.data.map((inv) => ( + {!invoicesData?.data || invoicesData.data.length === 0 ? : + invoicesData.data.map((inv: any) => ( - - + + + + )) - )} + }
Invoice #AmountDue DateStatusInvoice #TotalBalanceDue DateStatus {inv.invoiceNumber ?? inv.id.slice(0, 8)}{formatCurrency(inv.amount ?? inv.totalAmount ?? 0)}{formatDate(inv.dueDate)}{formatCurrency(Number(inv.total ?? inv.amount ?? 0))} 0 ? "text-red-600 font-medium" : "text-gray-500"}> + {formatCurrency(Number(inv.balance ?? 0))} + {inv.dueDate ? formatDate(inv.dueDate) : "—"}{inv.status} - - {inv.status} - + {["SENT", "PARTIAL", "OVERDUE"].includes(inv.status) && ( + + )}
)} - {/* Tickets Tab */} - {activeTab === "tickets" && ( + {/* Payments */} + {activeTab === "payments" && ( - Tickets + Payment History - - - - - - - - - + - {!ticketsData?.data || ticketsData.data.length === 0 ? ( - - ) : ( - ticketsData.data.map((ticket) => ( - - - - - - + {!paymentsData?.data || paymentsData.data.length === 0 ? : + paymentsData.data.map((p: any) => ( + + + + + + )) - )} + }
SubjectTypePriorityStatusCreatedDateAmountChannelReferenceNotes {ticket.subject}{ticket.type} - {ticket.priority} - {ticket.status}{formatDate(ticket.createdAt)}{p.paymentDate ? formatDate(p.paymentDate) : formatDate(p.createdAt)}{formatCurrency(Number(p.amount))}{p.channel}{p.referenceNumber ?? p.orNumber ?? "—"}{p.notes ?? "—"}
)} + + {/* Tickets */} + {activeTab === "tickets" && ( + + Tickets + + + + + {!ticketsData?.data || ticketsData.data.length === 0 ? : + ticketsData.data.map(t => ( + + + + + + + + )) + } + +
SubjectTypePriorityStatusCreated{t.subject}{t.type}{t.priority}{t.status}{formatDate(t.createdAt)}
+
+
+ )} + + {/* Pay Invoice Modal */} + setPayInvoice(null)} title={`Record Payment — ${payInvoice?.invoiceNumber ?? ""}`}> + {payInvoice && ( +
+
+
Invoice Total{formatCurrency(Number((payInvoice as any).total ?? payInvoice.amount))}
+
Amount Paid{formatCurrency(Number((payInvoice as any).amountPaid ?? 0))}
+
Balance Due{formatCurrency(Number((payInvoice as any).balance ?? payInvoice.amount))}
+
+ setPayForm(f => ({ ...f, amount: e.target.value }))} /> +
+ + +
+ setPayForm(f => ({ ...f, referenceNumber: e.target.value }))} /> + setPayForm(f => ({ ...f, notes: e.target.value }))} /> +
+ + +
+
+ )} +
); } diff --git a/app/(app)/clients/page.tsx b/app/(app)/clients/page.tsx index 8acd62a..5bb7d24 100644 --- a/app/(app)/clients/page.tsx +++ b/app/(app)/clients/page.tsx @@ -1,192 +1,213 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -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 { Skeleton } from '@/components/ui/skeleton'; -import { Search, UserPlus, ChevronRight } from 'lucide-react'; +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { UserPlus, Search, ChevronRight, RefreshCw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Modal } from "@/components/ui/Modal"; +import { Badge } from "@/components/ui/Badge"; +import api from "@/lib/api"; +import { toast } from "sonner"; +interface Area { id: string; name: string; } +interface Plan { id: string; name: string; monthlyPrice: number; } interface Client { - id: string; - accountNumber: string; - firstName: string; - lastName: string; - phone: string; - isActive: boolean; - area: { name: string } | null; - subscriptions: Array<{ - status: string; - type: string; - monthlyPrice: string; - plan?: { name: string }; - }>; + id: string; accountNumber: string; firstName: string; lastName: string; + phone: string; email: string; address?: string; isActive: boolean; + area: { id: string; name: string } | null; + subscriptions: Array<{ status: string; type: string; monthlyPrice: string; plan?: { name: string }; }>; } +interface ClientsResponse { data: Client[]; total: number; page: number; limit: number; } -interface ClientsResponse { - data: Client[]; - total: number; - page: number; - limit: number; -} - -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-700', - CANCELLED: 'bg-gray-100 text-gray-500', +const statusVariant: Record = { + ACTIVE: "success", PENDING: "warning", SUSPENDED: "danger", DISCONNECTED: "muted", CANCELLED: "muted", }; export default function ClientsPage() { - const [search, setSearch] = useState(''); + const router = useRouter(); + const qc = useQueryClient(); + const [search, setSearch] = useState(""); const [page, setPage] = useState(1); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ + firstName: "", lastName: "", email: "", phone: "", address: "", + areaId: "", planId: "", billingType: "POSTPAID", + }); - const { data, isLoading } = useQuery({ - queryKey: ['clients', search, page], + const { data, isLoading, refetch } = useQuery({ + queryKey: ["clients", search, page], queryFn: async () => { - const params = new URLSearchParams({ page: String(page), limit: '20' }); - if (search) params.set('search', search); - const res = await api.get(`/api/v1/clients?${params}`); + 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 { data: areas = [] } = useQuery({ + queryKey: ["areas"], + 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 ?? []; }, + }); + + const createClient = useMutation({ + mutationFn: async () => { + const res = await api.post("/api/v1/clients/onboard", { + firstName: form.firstName, lastName: form.lastName, + email: form.email, phone: form.phone, address: form.address, + areaId: form.areaId || undefined, planId: form.planId, + billingType: form.billingType, + }); + return res.data; + }, + onSuccess: (data: any) => { + toast.success("Client created successfully!"); + qc.invalidateQueries({ queryKey: ["clients"] }); + setShowAdd(false); + setForm({ firstName: "", lastName: "", email: "", phone: "", address: "", areaId: "", planId: "", billingType: "POSTPAID" }); + if (data?.client?.id) router.push(`/clients/${data.client.id}`); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create client"), }); const clients = data?.data ?? []; const total = data?.total ?? 0; return ( -
-
+
+
-

Clients

-

{total} total clients

+

Clients

+

{total} total clients

+
+
+ +
-
- {/* Search */} -
- - { setSearch(e.target.value); setPage(1); }} - /> -
- - {/* Table */} - - -
- - - - - - - - - - - - - - {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()}` : '—'} - - -
+ + +
+ + { setSearch(e.target.value); setPage(1); }} + />
- - {/* Pagination */} - {total > 20 && ( -
-

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

-
- - -
-
- )} - +
+ + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + )) + ) : clients.map((client) => { + const sub = client.subscriptions?.[0]; + const status = sub?.status ?? (client.isActive ? "ACTIVE" : "INACTIVE"); + return ( + router.push(`/clients/${client.id}`)} + className="border-b hover:bg-blue-50 cursor-pointer transition-colors"> + + + + + + + + + ); + })} + +
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()}` : "—"} +
{!isLoading && clients.length === 0 && ( -
- No clients found{search ? ` for "${search}"` : ''} +
No clients found{search ? ` for "${search}"` : ""}
+ )} + {total > 20 && ( +
+ Showing {(page - 1) * 20 + 1}–{Math.min(page * 20, total)} of {total} +
+ + +
)} + + {/* Add Client Modal */} + setShowAdd(false)} title="Add New Client" className="max-w-xl"> +
+
+ setForm(f => ({ ...f, firstName: e.target.value }))} /> + setForm(f => ({ ...f, lastName: e.target.value }))} /> +
+ setForm(f => ({ ...f, email: e.target.value }))} /> + setForm(f => ({ ...f, phone: e.target.value }))} /> + setForm(f => ({ ...f, address: e.target.value }))} /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
); } diff --git a/app/(app)/invoices/page.tsx b/app/(app)/invoices/page.tsx index 9e5af2e..713b809 100644 --- a/app/(app)/invoices/page.tsx +++ b/app/(app)/invoices/page.tsx @@ -1,170 +1,209 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { api } from '@/lib/api'; -import { Card, CardContent } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Search, ChevronRight } from 'lucide-react'; -import { format } from 'date-fns'; +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { RefreshCw, FileText } 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"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Modal } from "@/components/ui/Modal"; +import { formatDate, formatCurrency } from "@/lib/utils"; +import api from "@/lib/api"; +import { toast } from "sonner"; interface Invoice { - id: string; - invoiceNumber: string; - dueDate: string; - subtotal: string; - lateFee: string; - total: string; - amountPaid: string; - balance: string; - status: string; + id: string; invoiceNumber: string; clientId: string; client?: { firstName: string; lastName: string; accountNumber: string }; + subtotal: string; lateFee: string; total: string; + amountPaid: string; balance: string; + status: string; dueDate: string; periodStart?: string; periodEnd?: string; notes?: string; + createdAt: string; } +interface InvoicesResponse { data: Invoice[]; total: number; page: number; limit: number; } -interface InvoicesResponse { - data: Invoice[]; - total: number; -} - -const statusColors: Record = { - SENT: 'bg-blue-100 text-blue-700', - PARTIAL: 'bg-yellow-100 text-yellow-700', - PAID: 'bg-green-100 text-green-700', - OVERDUE: 'bg-red-100 text-red-700', - DRAFT: 'bg-gray-100 text-gray-500', - VOID: 'bg-gray-100 text-gray-400', +const statusVariant: Record = { + PAID: "success", PARTIAL: "warning", OVERDUE: "danger", + SENT: "muted", DRAFT: "muted", VOID: "muted", }; -const peso = (v: string | number) => - '₱' + Number(v ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 }); +const statusFilters = ["", "SENT", "PARTIAL", "OVERDUE", "PAID", "VOID"]; export default function InvoicesPage() { - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); + const qc = useQueryClient(); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); const [page, setPage] = useState(1); + const [selected, setSelected] = useState(null); + const [payForm, setPayForm] = useState({ amount: "", channel: "CASH", referenceNumber: "", notes: "" }); - const { data, isLoading } = useQuery({ - queryKey: ['invoices', search, statusFilter, page], + const { data, isLoading, refetch } = useQuery({ + queryKey: ["invoices", search, statusFilter, page], queryFn: async () => { - const params = new URLSearchParams({ page: String(page), limit: '20' }); - if (search) params.set('search', search); - if (statusFilter) params.set('status', statusFilter); - const res = await api.get(`/api/v1/invoices?${params}`); + const params = new URLSearchParams({ page: String(page), limit: "20" }); + if (search) params.set("search", search); + if (statusFilter) params.set("status", statusFilter); + const res = await api.get(`/api/v1/invoices?${params}`); return res.data; }, - staleTime: 30_000, + }); + + const recordPayment = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/payments", { + clientId: selected!.clientId, invoiceId: selected!.id, + amount: Number(payForm.amount), channel: payForm.channel, + referenceNumber: payForm.referenceNumber || undefined, + notes: payForm.notes || undefined, + paymentDate: new Date().toISOString(), + }); + }, + onSuccess: () => { + toast.success("Payment recorded!"); + setSelected(null); + setPayForm({ amount: "", channel: "CASH", referenceNumber: "", notes: "" }); + qc.invalidateQueries({ queryKey: ["invoices"] }); + refetch(); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Payment failed"), + }); + + const voidInvoice = useMutation({ + mutationFn: async (id: string) => { await api.patch(`/api/v1/invoices/${id}/void`); }, + onSuccess: () => { toast.success("Invoice voided"); setSelected(null); qc.invalidateQueries({ queryKey: ["invoices"] }); }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to void"), }); const invoices = data?.data ?? []; const total = data?.total ?? 0; return ( -
-
+
+
-

Invoices

-

{total} invoices

+

Invoices

+

{total} total invoices

+
-
-
- - { setSearch(e.target.value); setPage(1); }} - /> -
- -
- - - -
- - - - - - - - - - - - - - {isLoading - ? Array.from({ length: 8 }).map((_, i) => ( - - {Array.from({ length: 7 }).map((_, j) => ( - - ))} - - )) - : invoices.map((inv) => ( - - - - - - - - - - ))} - -
Invoice #ClientDue DateTotalBalanceStatus
{inv.invoiceNumber} - {inv.client - ? `${inv.client.firstName} ${inv.client.lastName}` - : '—'} -
{inv.client?.accountNumber}
-
- {inv.dueDate ? format(new Date(inv.dueDate), 'MMM d, yyyy') : '—'} - {peso(inv.total)} - {Number(inv.balance) > 0 ? ( - {peso(inv.balance)} - ) : ( - Paid - )} - - - {inv.status} - -
+ + +
+ { setSearch(e.target.value); setPage(1); }} /> +
+ {statusFilters.map(s => ( + + ))} +
- - {!isLoading && invoices.length === 0 && ( -
No invoices found
- )} - +
+ + + + + + + + + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + )) + ) : invoices.length === 0 ? ( + } /> + ) : invoices.map(inv => ( + setSelected(inv)} className="cursor-pointer hover:bg-blue-50 transition-colors"> + + + + + + + + + + ))} + +
Invoice #ClientTotalPaidBalanceDue DateStatus
{inv.invoiceNumber} + {inv.client ? `${inv.client.firstName} ${inv.client.lastName}` : "—"} +
{inv.client?.accountNumber}
+
{formatCurrency(Number(inv.total))}{formatCurrency(Number(inv.amountPaid))} 0 ? "text-red-600 font-medium" : "text-gray-400"}> + {formatCurrency(Number(inv.balance))} + + {formatDate(inv.dueDate)} + {inv.status}View →
{total > 20 && ( -
-

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

+
+ Showing {(page - 1) * 20 + 1}–{Math.min(page * 20, total)} of {total}
- - + +
)} + + {/* Invoice Detail Modal */} + setSelected(null)} title={`Invoice ${selected?.invoiceNumber ?? ""}`} className="max-w-lg"> + {selected && ( +
+
+
Client + {selected.client ? `${selected.client.firstName} ${selected.client.lastName}` : "—"}
+
Period + {selected.periodStart ? `${formatDate(selected.periodStart)} – ${formatDate(selected.periodEnd!)}` : "—"}
+
Due Date + + {formatDate(selected.dueDate)}
+
+
Subtotal{formatCurrency(Number(selected.subtotal))}
+
Late Fee{formatCurrency(Number(selected.lateFee))}
+
Total{formatCurrency(Number(selected.total))}
+
Amount Paid{formatCurrency(Number(selected.amountPaid))}
+
0 ? "text-red-600" : "text-green-600"}`}> + Balance{formatCurrency(Number(selected.balance))}
+
Status + {selected.status}
+
+ + {/* Pay form (only for unpaid) */} + {["SENT", "PARTIAL", "OVERDUE"].includes(selected.status) && ( +
+

Record Payment

+ setPayForm(f => ({ ...f, amount: e.target.value }))} + hint={`Balance due: ${formatCurrency(Number(selected.balance))}`} /> +
+ + +
+ setPayForm(f => ({ ...f, referenceNumber: e.target.value }))} /> + +
+ )} + +
+ {selected.status !== "VOID" && selected.status !== "PAID" && ( + + )} + +
+
+ )} +
); } diff --git a/app/(app)/leads/page.tsx b/app/(app)/leads/page.tsx index c3b244f..2b75111 100644 --- a/app/(app)/leads/page.tsx +++ b/app/(app)/leads/page.tsx @@ -1,32 +1,37 @@ "use client"; import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { UserPlus, RefreshCw } 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"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; +import { Modal } from "@/components/ui/Modal"; import { formatDate } from "@/lib/utils"; import api from "@/lib/api"; +import { toast } from "sonner"; import type { Lead } from "@/types"; const statusVariant: Record = { - NEW: "muted", - CONTACTED: "default", - INTERESTED: "warning", - CONVERTED: "success", - LOST: "danger", + NEW: "muted", CONTACTED: "default" as any, INTERESTED: "warning", CONVERTED: "success", LOST: "danger", }; -export default function LeadsPage() { - const [search, setSearch] = useState(""); +const statusOptions = ["NEW", "CONTACTED", "INTERESTED", "CONVERTED", "LOST"]; - const { data, isLoading, refetch } = useQuery({ +export default function LeadsPage() { + const qc = useQueryClient(); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ firstName: "", lastName: "", phone: "", email: "", address: "", notes: "", source: "" }); + const [statusUpdate, setStatusUpdate] = useState(""); + + const { data = [], isLoading, refetch } = useQuery({ queryKey: ["leads", search], queryFn: async () => { - const params = new URLSearchParams({ limit: "50" }); + const params = new URLSearchParams({ limit: "100" }); if (search) params.set("search", search); const res = await api.get(`/api/v1/leads?${params}`); const d = res.data; @@ -34,7 +39,47 @@ export default function LeadsPage() { }, }); - const leads = data ?? []; + const addLead = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/leads", { + firstName: form.firstName, lastName: form.lastName, + phone: form.phone, email: form.email || undefined, + address: form.address || undefined, notes: form.notes || undefined, + source: form.source || undefined, + }); + }, + onSuccess: () => { + toast.success("Lead added!"); + setShowAdd(false); + setForm({ firstName: "", lastName: "", phone: "", email: "", address: "", notes: "", source: "" }); + qc.invalidateQueries({ queryKey: ["leads"] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to add lead"), + }); + + const updateStatus = useMutation({ + mutationFn: async ({ id, status }: { id: string; status: string }) => { + await api.patch(`/api/v1/leads/${id}`, { status }); + }, + onSuccess: () => { + toast.success("Status updated!"); + qc.invalidateQueries({ queryKey: ["leads"] }); + if (selected) setSelected(prev => prev ? { ...prev, status: statusUpdate as Lead["status"] } : prev); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to update"), + }); + + const deleteLead = useMutation({ + mutationFn: async (id: string) => { await api.delete(`/api/v1/leads/${id}`); }, + onSuccess: () => { + toast.success("Lead deleted"); + setSelected(null); + qc.invalidateQueries({ queryKey: ["leads"] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete"), + }); + + const counts = statusOptions.reduce((acc, s) => ({ ...acc, [s]: data.filter(l => l.status === s).length }), {} as Record); return (
@@ -43,79 +88,124 @@ export default function LeadsPage() {

Leads

Prospective customers pipeline

- +
+ + +
- {/* Status summary */} + {/* Pipeline summary */}
- {Object.entries(statusVariant).map(([status]) => { - const count = leads.filter(l => l.status === status).length; - return count > 0 ? ( -
-

{count}

-

{status}

-
- ) : null; - })} + {statusOptions.map(status => ( +
+

{counts[status] ?? 0}

+ {status} +
+ ))} +
+

{data.length}

+

TOTAL

+
- All Leads ({leads.length}) - All Leads ({data.length}) + setSearch(e.target.value)} - className="max-w-xs" - /> + value={search} onChange={e => setSearch(e.target.value)} />
- - - - - - - - - + {isLoading ? ( - - ) : leads.length === 0 ? ( - } /> - ) : ( - leads.map((lead) => ( - - - - - - - - - + Array.from({ length: 6 }).map((_, i) => ( + )) - )} + ) : data.length === 0 ? ( + } /> + ) : data.map(lead => ( + { setSelected(lead); setStatusUpdate(lead.status); }} + className="cursor-pointer hover:bg-blue-50 transition-colors"> + + + + + + + + + ))}
NamePhoneEmailAddressStatusAssigned ToAddedNamePhoneEmailAddressStatusSourceAdded Loading...{lead.firstName} {lead.lastName}{lead.phone}{lead.email ?? "—"}{lead.address ?? "—"} - - {lead.status} - - - {lead.assignedTo - ? `${lead.assignedTo.firstName} ${lead.assignedTo.lastName}` - : "—"} - {formatDate(lead.createdAt)}
{lead.firstName} {lead.lastName}{lead.phone}{lead.email ?? "—"}{lead.address ?? "—"}{lead.status}{lead.source ?? "—"}{formatDate(lead.createdAt)}
+ + {/* Lead Detail Modal */} + setSelected(null)} title={`${selected?.firstName ?? ""} ${selected?.lastName ?? ""}`}> + {selected && ( +
+
+
Phone{selected.phone}
+ {selected.email &&
Email{selected.email}
} + {selected.address &&
Address{selected.address}
} + {selected.source &&
Source{selected.source}
} + {selected.notes &&
Notes{selected.notes}
} +
Added{formatDate(selected.createdAt)}
+
+ +
+ +
+ {statusOptions.map(s => ( + + ))} +
+ {statusUpdate !== selected.status && ( + + )} +
+ +
+ + +
+
+ )} +
+ + {/* Add Lead Modal */} + setShowAdd(false)} title="Add New Lead"> +
+
+ setForm(f => ({ ...f, firstName: e.target.value }))} /> + setForm(f => ({ ...f, lastName: e.target.value }))} /> +
+ setForm(f => ({ ...f, phone: e.target.value }))} /> + setForm(f => ({ ...f, email: e.target.value }))} /> + setForm(f => ({ ...f, address: e.target.value }))} /> + setForm(f => ({ ...f, source: e.target.value }))} /> +
+ +