feat: full CRUD on all pages — clients (add+navigate), client detail (payments tab+pay), invoices (detail+pay+void), payments (detail view), remittances (submit flow), tickets (detail+comments+create), leads (add+status update)

This commit is contained in:
Forge
2026-03-25 21:40:30 +08:00
parent 05ed524e47
commit 8ae1e14aee
7 changed files with 1301 additions and 870 deletions

View File

@@ -1,162 +1,166 @@
"use client"; "use client";
import { useState } from "react"; 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 { 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; 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 { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { formatDate, formatCurrency } from "@/lib/utils"; import { formatDate, formatCurrency } from "@/lib/utils";
import api from "@/lib/api"; 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<string, "success" | "warning" | "danger" | "muted"> = { const statusColor: Record<string, "success" | "warning" | "danger" | "muted"> = {
active: "success", ACTIVE: "success", active: "success",
ACTIVE: "success", SUSPENDED: "warning", suspended: "warning",
suspended: "warning", CANCELLED: "danger", cancelled: "danger",
SUSPENDED: "warning", DISCONNECTED: "danger",
cancelled: "danger", PENDING: "muted", pending: "muted",
CANCELLED: "danger", };
disconnected: "danger",
pending: "muted", const invColor: Record<string, "success" | "warning" | "danger" | "muted"> = {
PENDING: "muted", PAID: "success", paid: "success",
PARTIAL: "warning", partial: "warning",
OVERDUE: "danger", overdue: "danger",
SENT: "muted", DRAFT: "muted", VOID: "muted",
};
const channelColors: Record<string, string> = {
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() { export default function ClientDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const router = useRouter(); const router = useRouter();
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<Tab>("profile"); const [activeTab, setActiveTab] = useState<Tab>("profile");
const [payInvoice, setPayInvoice] = useState<Invoice | null>(null);
const [payForm, setPayForm] = useState({ amount: "", channel: "CASH", referenceNumber: "", notes: "" });
const { data: client, isLoading, refetch: refetchClient } = useQuery<Client>({ const { data: client, isLoading, refetch: refetchClient } = useQuery<Client>({
queryKey: ["client", id], queryKey: ["client", id],
queryFn: async () => { queryFn: async () => { const r = await api.get<Client>(`/api/v1/clients/${id}`); return r.data; },
const res = await api.get<Client>(`/api/v1/clients/${id}`);
return res.data;
},
}); });
const { data: subscriptions, isError: subsError } = useQuery<Subscription[]>({ const { data: subscriptions, isError: subsError } = useQuery<Subscription[]>({
queryKey: ["client-subscriptions", id], queryKey: ["client-subscriptions", id],
queryFn: async () => { queryFn: async () => { const r = await api.get<Subscription[]>(`/api/v1/clients/${id}/subscriptions`); return r.data; },
const res = await api.get<Subscription[]>(`/api/v1/clients/${id}/subscriptions`);
return Array.isArray(res.data) ? res.data : [];
},
enabled: activeTab === "subscriptions", enabled: activeTab === "subscriptions",
retry: false,
}); });
const { data: invoicesData } = useQuery<LegacyPaginatedResponse<Invoice>>({ const { data: invoicesData, refetch: refetchInvoices } = useQuery<LegacyPaginatedResponse<Invoice>>({
queryKey: ["client-invoices", id], queryKey: ["client-invoices", id],
queryFn: async () => { queryFn: async () => { const r = await api.get<LegacyPaginatedResponse<Invoice>>(`/api/v1/invoices?clientId=${id}&page=1&limit=30`); return r.data; },
const res = await api.get<LegacyPaginatedResponse<Invoice>>(`/api/v1/invoices?clientId=${id}&page=1&limit=20`);
return res.data;
},
enabled: activeTab === "invoices", enabled: activeTab === "invoices",
}); });
const { data: paymentsData } = useQuery<LegacyPaginatedResponse<Payment>>({
queryKey: ["client-payments", id],
queryFn: async () => {
const r = await api.get<LegacyPaginatedResponse<Payment>>(`/api/v1/payments?clientId=${id}&page=1&limit=30`);
return r.data;
},
enabled: activeTab === "payments",
});
const { data: ticketsData } = useQuery<PaginatedResponse<TicketType>>({ const { data: ticketsData } = useQuery<PaginatedResponse<TicketType>>({
queryKey: ["client-tickets", id], queryKey: ["client-tickets", id],
queryFn: async () => { queryFn: async () => { const r = await api.get<PaginatedResponse<TicketType>>(`/api/v1/tickets?clientId=${id}&page=1&limit=20`); return r.data; },
const res = await api.get<PaginatedResponse<TicketType>>(`/api/v1/tickets?clientId=${id}&page=1&limit=20`);
return res.data;
},
enabled: activeTab === "tickets", enabled: activeTab === "tickets",
}); });
const tabs: { key: Tab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ const recordPayment = useMutation({
{ key: "profile", label: "Profile", icon: ArrowLeft }, mutationFn: async () => {
{ key: "subscriptions", label: "Subscriptions", icon: Wifi }, await api.post("/api/v1/payments", {
{ key: "invoices", label: "Invoices", icon: FileText }, clientId: id, invoiceId: payInvoice?.id,
{ key: "tickets", label: "Tickets", icon: Ticket }, 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) { if (isLoading) return (
return ( <div className="space-y-4">
<div className="space-y-4"> <div className="h-8 w-48 animate-pulse bg-gray-200 rounded" />
<div className="h-8 w-48 animate-pulse bg-gray-200 rounded" /> <Card><CardContent className="py-10"><div className="h-32 animate-pulse bg-gray-100 rounded-lg" /></CardContent></Card>
<Card> </div>
<CardContent className="py-10"> );
<div className="h-32 animate-pulse bg-gray-100 rounded-lg" />
</CardContent>
</Card>
</div>
);
}
if (!client) { if (!client) return (
return ( <div className="text-center py-20 text-gray-400">
<div className="text-center py-20 text-gray-400"> <p>Client not found</p>
<p>Client not found</p> <Button variant="secondary" className="mt-4" onClick={() => router.push("/clients")}>Back to Clients</Button>
<Button variant="secondary" className="mt-4" onClick={() => router.push("/clients")}> </div>
Back to Clients );
</Button>
</div> const sub = client.subscriptions?.[0];
); const clientStatus = sub?.status ?? (client.isActive ? "ACTIVE" : "INACTIVE");
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push("/clients")}> <Button variant="ghost" size="sm" onClick={() => router.push("/clients")}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 mr-1" /> Back
</Button> </Button>
<div> <div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900"> <h1 className="text-2xl font-bold text-gray-900">{client.firstName} {client.lastName}</h1>
{client.firstName} {client.lastName} <p className="text-sm text-gray-500">{client.accountNumber} · {client.area?.name ?? "No area"}</p>
</h1>
<p className="text-sm text-gray-500">
{client.accountNumber} {client.area?.name ?? ""}
</p>
</div>
<div className="ml-auto flex items-center gap-2">
<Badge variant={client.isActive ? "success" : "muted"}>
{client.isActive ? "Active" : "Inactive"}
</Badge>
<Button size="sm" variant="outline" onClick={() => refetchClient()}>
<RefreshCw className="h-4 w-4" />
</Button>
</div> </div>
<Badge variant={statusColor[clientStatus] ?? "muted"}>{clientStatus}</Badge>
<Button size="sm" variant="outline" onClick={() => refetchClient()}><RefreshCw className="h-4 w-4" /></Button>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-gray-200"> <div className="flex border-b border-gray-200 overflow-x-auto">
{[ {tabs.map(tab => (
{ key: "profile" as Tab, label: "Profile" }, <button key={tab.key} onClick={() => setActiveTab(tab.key)}
{ key: "subscriptions" as Tab, label: "Subscriptions" }, className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap transition-colors ${
{ key: "invoices" as Tab, label: "Invoices" }, activeTab === tab.key ? "border-blue-600 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700"
{ key: "tickets" as Tab, label: "Tickets" }, }`}>{tab.label}</button>
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
</button>
))} ))}
</div> </div>
{/* Profile Tab */} {/* Profile */}
{activeTab === "profile" && ( {activeTab === "profile" && (
<Card> <Card>
<CardHeader> <CardHeader><CardTitle>Client Profile</CardTitle></CardHeader>
<CardTitle>Client Profile</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[ {[
{ label: "Account Number", value: client.accountNumber }, { label: "Account Number", value: client.accountNumber },
{ label: "Full Name", value: `${client.firstName} ${client.lastName}` }, { label: "Full Name", value: `${client.firstName} ${client.lastName}` },
{ label: "Email", value: client.email }, { label: "Email", value: client.email || "—" },
{ label: "Phone", value: client.phone }, { label: "Phone", value: client.phone },
{ label: "Address", value: client.address || "—" }, { label: "Address", value: client.address || "—" },
{ label: "Area", value: client.area?.name || "—" }, { label: "Area", value: client.area?.name || "—" },
@@ -173,126 +177,141 @@ export default function ClientDetailPage() {
</Card> </Card>
)} )}
{/* Subscriptions Tab */} {/* Subscriptions */}
{activeTab === "subscriptions" && ( {activeTab === "subscriptions" && (
<Card> <Card>
<CardHeader><CardTitle>Subscriptions</CardTitle></CardHeader> <CardHeader><CardTitle>Subscriptions</CardTitle></CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHead> <TableHead><TableRow><Th>Plan</Th><Th>Type</Th><Th>Status</Th><Th>Start Date</Th><Th>Monthly Rate</Th></TableRow></TableHead>
<TableRow>
<Th>Plan</Th>
<Th>Status</Th>
<Th>Start Date</Th>
<Th>Monthly Rate</Th>
</TableRow>
</TableHead>
<TableBody> <TableBody>
{subsError ? ( {!subscriptions || subscriptions.length === 0 ? <EmptyState message="No subscriptions" /> :
<tr><td colSpan={4} className="py-8 text-center text-gray-400 text-sm">No data yet</td></tr> subscriptions.map(sub => (
) : !subscriptions || subscriptions.length === 0 ? ( <TableRow key={sub.id}>
<EmptyState message="No subscriptions yet" /> <Td className="font-medium">{sub.plan?.name ?? "—"}</Td>
) : ( <Td><Badge variant="muted">{sub.type ?? sub.plan?.type ?? "—"}</Badge></Td>
subscriptions.map((sub) => ( <Td><Badge variant={statusColor[sub.status] ?? "muted"}>{sub.status}</Badge></Td>
<TableRow key={sub.id} className="hover:bg-gray-50 transition-colors">
<Td className="font-medium">{sub.plan?.name ?? sub.planId}</Td>
<Td>
<Badge variant={statusColor[sub.status] ?? "muted"}>
{sub.status}
</Badge>
</Td>
<Td>{formatDate(sub.startDate)}</Td> <Td>{formatDate(sub.startDate)}</Td>
<Td>{formatCurrency(sub.plan?.monthlyPrice ?? sub.monthlyRate ?? sub.mrc ?? 0)}</Td> <Td>{formatCurrency(sub.plan?.monthlyPrice ?? sub.monthlyRate ?? sub.mrc ?? 0)}</Td>
</TableRow> </TableRow>
)) ))
)} }
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Invoices Tab */} {/* Invoices */}
{activeTab === "invoices" && ( {activeTab === "invoices" && (
<Card> <Card>
<CardHeader><CardTitle>Invoices</CardTitle></CardHeader> <CardHeader><CardTitle>Invoices</CardTitle></CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHead> <TableHead><TableRow><Th>Invoice #</Th><Th>Total</Th><Th>Balance</Th><Th>Due Date</Th><Th>Status</Th><Th></Th></TableRow></TableHead>
<TableRow>
<Th>Invoice #</Th>
<Th>Amount</Th>
<Th>Due Date</Th>
<Th>Status</Th>
</TableRow>
</TableHead>
<TableBody> <TableBody>
{!invoicesData?.data || invoicesData.data.length === 0 ? ( {!invoicesData?.data || invoicesData.data.length === 0 ? <EmptyState message="No invoices" /> :
<EmptyState message="No invoices yet" /> invoicesData.data.map((inv: any) => (
) : (
invoicesData.data.map((inv) => (
<TableRow key={inv.id}> <TableRow key={inv.id}>
<Td className="font-mono text-xs">{inv.invoiceNumber ?? inv.id.slice(0, 8)}</Td> <Td className="font-mono text-xs">{inv.invoiceNumber ?? inv.id.slice(0, 8)}</Td>
<Td>{formatCurrency(inv.amount ?? inv.totalAmount ?? 0)}</Td> <Td>{formatCurrency(Number(inv.total ?? inv.amount ?? 0))}</Td>
<Td>{formatDate(inv.dueDate)}</Td> <Td className={Number(inv.balance) > 0 ? "text-red-600 font-medium" : "text-gray-500"}>
{formatCurrency(Number(inv.balance ?? 0))}
</Td>
<Td>{inv.dueDate ? formatDate(inv.dueDate) : "—"}</Td>
<Td><Badge variant={invColor[inv.status] ?? "muted"}>{inv.status}</Badge></Td>
<Td> <Td>
<Badge {["SENT", "PARTIAL", "OVERDUE"].includes(inv.status) && (
variant={ <Button size="sm" onClick={() => {
inv.status === "paid" || inv.status === "PAID" ? "success" : setPayInvoice(inv);
inv.status === "overdue" || inv.status === "OVERDUE" ? "danger" : "warning" setPayForm(f => ({ ...f, amount: String(Number(inv.balance ?? inv.total ?? 0)) }));
} }}>Pay</Button>
> )}
{inv.status}
</Badge>
</Td> </Td>
</TableRow> </TableRow>
)) ))
)} }
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Tickets Tab */} {/* Payments */}
{activeTab === "tickets" && ( {activeTab === "payments" && (
<Card> <Card>
<CardHeader><CardTitle>Tickets</CardTitle></CardHeader> <CardHeader><CardTitle>Payment History</CardTitle></CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHead> <TableHead><TableRow><Th>Date</Th><Th>Amount</Th><Th>Channel</Th><Th>Reference</Th><Th>Notes</Th></TableRow></TableHead>
<TableRow>
<Th>Subject</Th>
<Th>Type</Th>
<Th>Priority</Th>
<Th>Status</Th>
<Th>Created</Th>
</TableRow>
</TableHead>
<TableBody> <TableBody>
{!ticketsData?.data || ticketsData.data.length === 0 ? ( {!paymentsData?.data || paymentsData.data.length === 0 ? <EmptyState message="No payments yet" /> :
<EmptyState message="No tickets yet" /> paymentsData.data.map((p: any) => (
) : ( <TableRow key={p.id}>
ticketsData.data.map((ticket) => ( <Td className="text-sm">{p.paymentDate ? formatDate(p.paymentDate) : formatDate(p.createdAt)}</Td>
<TableRow key={ticket.id}> <Td className="font-medium text-green-700">{formatCurrency(Number(p.amount))}</Td>
<Td className="font-medium">{ticket.subject}</Td> <Td><span className={`px-2 py-0.5 rounded-full text-xs font-medium ${channelColors[p.channel] ?? "bg-gray-100 text-gray-600"}`}>{p.channel}</span></Td>
<Td><Badge variant="muted">{ticket.type}</Badge></Td> <Td className="text-xs text-gray-500">{p.referenceNumber ?? p.orNumber ?? "—"}</Td>
<Td> <Td className="text-xs text-gray-500">{p.notes ?? "—"}</Td>
<Badge variant={
ticket.priority === "urgent" ? "danger" :
ticket.priority === "high" ? "warning" : "muted"
}>{ticket.priority}</Badge>
</Td>
<Td><Badge variant="default">{ticket.status}</Badge></Td>
<Td className="text-xs text-gray-400">{formatDate(ticket.createdAt)}</Td>
</TableRow> </TableRow>
)) ))
)} }
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Tickets */}
{activeTab === "tickets" && (
<Card>
<CardHeader><CardTitle>Tickets</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHead><TableRow><Th>Subject</Th><Th>Type</Th><Th>Priority</Th><Th>Status</Th><Th>Created</Th></TableRow></TableHead>
<TableBody>
{!ticketsData?.data || ticketsData.data.length === 0 ? <EmptyState message="No tickets" /> :
ticketsData.data.map(t => (
<TableRow key={t.id}>
<Td className="font-medium">{t.subject}</Td>
<Td><Badge variant="muted">{t.type}</Badge></Td>
<Td><Badge variant={t.priority === "HIGH" ? "warning" : "muted"}>{t.priority}</Badge></Td>
<Td><Badge variant={t.status === "RESOLVED" ? "success" : t.status === "OPEN" ? "warning" : "muted"}>{t.status}</Badge></Td>
<Td className="text-xs text-gray-400">{formatDate(t.createdAt)}</Td>
</TableRow>
))
}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Pay Invoice Modal */}
<Modal isOpen={!!payInvoice} onClose={() => setPayInvoice(null)} title={`Record Payment — ${payInvoice?.invoiceNumber ?? ""}`}>
{payInvoice && (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-3 text-sm space-y-1">
<div className="flex justify-between"><span className="text-gray-500">Invoice Total</span><span className="font-medium">{formatCurrency(Number((payInvoice as any).total ?? payInvoice.amount))}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Amount Paid</span><span>{formatCurrency(Number((payInvoice as any).amountPaid ?? 0))}</span></div>
<div className="flex justify-between font-medium text-red-600"><span>Balance Due</span><span>{formatCurrency(Number((payInvoice as any).balance ?? payInvoice.amount))}</span></div>
</div>
<Input label="Amount" type="number" value={payForm.amount} onChange={e => setPayForm(f => ({ ...f, amount: e.target.value }))} />
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Payment Method</label>
<select className="border rounded-lg px-3 py-2 text-sm" value={payForm.channel} onChange={e => setPayForm(f => ({ ...f, channel: e.target.value }))}>
{["CASH","GCASH","MAYA","BANK_TRANSFER","CHECK"].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<Input label="Reference # (optional)" value={payForm.referenceNumber} onChange={e => setPayForm(f => ({ ...f, referenceNumber: e.target.value }))} />
<Input label="Notes (optional)" value={payForm.notes} onChange={e => setPayForm(f => ({ ...f, notes: e.target.value }))} />
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" onClick={() => setPayInvoice(null)}>Cancel</Button>
<Button onClick={() => recordPayment.mutate()} isLoading={recordPayment.isPending} disabled={!payForm.amount}>Record Payment</Button>
</div>
</div>
)}
</Modal>
</div> </div>
); );
} }

View File

@@ -1,192 +1,213 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from '@/lib/api'; import { useRouter } from "next/navigation";
import { Card, CardContent } from '@/components/ui/card'; import { UserPlus, Search, ChevronRight, RefreshCw } from "lucide-react";
import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from '@/components/ui/badge'; import { Button } from "@/components/ui/Button";
import { Skeleton } from '@/components/ui/skeleton'; import { Input } from "@/components/ui/Input";
import { Search, UserPlus, ChevronRight } from 'lucide-react'; 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 { interface Client {
id: string; id: string; accountNumber: string; firstName: string; lastName: string;
accountNumber: string; phone: string; email: string; address?: string; isActive: boolean;
firstName: string; area: { id: string; name: string } | null;
lastName: string; subscriptions: Array<{ status: string; type: string; monthlyPrice: string; plan?: { name: string }; }>;
phone: string;
isActive: boolean;
area: { 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 { const statusVariant: Record<string, "success" | "warning" | "danger" | "muted"> = {
data: Client[]; ACTIVE: "success", PENDING: "warning", SUSPENDED: "danger", DISCONNECTED: "muted", CANCELLED: "muted",
total: number;
page: number;
limit: number;
}
const statusColors: Record<string, string> = {
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',
}; };
export default function ClientsPage() { export default function ClientsPage() {
const [search, setSearch] = useState(''); const router = useRouter();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1); 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<ClientsResponse>({ const { data, isLoading, refetch } = useQuery<ClientsResponse>({
queryKey: ['clients', search, page], queryKey: ["clients", search, page],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: '20' }); const params = new URLSearchParams({ page: String(page), limit: "20" });
if (search) params.set('search', search); if (search) params.set("search", search);
const res = await api.get(`/api/v1/clients?${params}`); const res = await api.get<ClientsResponse>(`/api/v1/clients?${params}`);
return res.data; return res.data;
}, },
staleTime: 30_000, });
const { data: areas = [] } = useQuery<Area[]>({
queryKey: ["areas"],
queryFn: async () => { const r = await api.get<Area[]>("/api/v1/areas"); return r.data; },
});
const { data: plans = [] } = useQuery<Plan[]>({
queryKey: ["plans"],
queryFn: async () => { const r = await api.get<Plan[]>("/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 clients = data?.data ?? [];
const total = data?.total ?? 0; const total = data?.total ?? 0;
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-800">Clients</h1> <h1 className="text-2xl font-bold text-gray-900">Clients</h1>
<p className="text-slate-500 text-sm mt-1">{total} total clients</p> <p className="text-sm text-gray-500 mt-1">{total} total clients</p>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
<Button onClick={() => setShowAdd(true)} size="sm"><UserPlus size={14} className="mr-1" />Add Client</Button>
</div> </div>
<button
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ backgroundColor: '#0891B2' }}
>
<UserPlus size={16} />
Add Client
</button>
</div> </div>
{/* Search */} <Card>
<div className="relative mb-4"> <CardHeader>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <div className="relative">
<Input <Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
placeholder="Search by name or account number..." <input
className="pl-9" className="w-full pl-9 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={search} placeholder="Search by name or account number..."
onChange={(e) => { setSearch(e.target.value); setPage(1); }} value={search}
/> onChange={(e) => { setSearch(e.target.value); setPage(1); }}
</div> />
{/* Table */}
<Card className="border shadow-sm">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50">
<th className="text-left px-4 py-3 font-medium text-slate-500">Account #</th>
<th className="text-left px-4 py-3 font-medium text-slate-500">Name</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden md:table-cell">Area</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden lg:table-cell">Plan</th>
<th className="text-left px-4 py-3 font-medium text-slate-500">Status</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden xl:table-cell">Monthly</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b">
<td className="px-4 py-3"><Skeleton className="h-4 w-24" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-32" /></td>
<td className="px-4 py-3 hidden md:table-cell"><Skeleton className="h-4 w-20" /></td>
<td className="px-4 py-3 hidden lg:table-cell"><Skeleton className="h-4 w-24" /></td>
<td className="px-4 py-3"><Skeleton className="h-5 w-16 rounded-full" /></td>
<td className="px-4 py-3 hidden xl:table-cell"><Skeleton className="h-4 w-16" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-4" /></td>
</tr>
))
: clients.map((client) => {
const sub = client.subscriptions?.[0];
const status = sub?.status ?? (client.isActive ? 'ACTIVE' : 'INACTIVE');
return (
<tr
key={client.id}
className="border-b hover:bg-slate-50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 font-mono text-xs text-slate-600">
{client.accountNumber}
</td>
<td className="px-4 py-3 font-medium text-slate-800">
{client.firstName} {client.lastName}
<div className="text-xs text-slate-400">{client.phone}</div>
</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">
{client.area?.name ?? '—'}
</td>
<td className="px-4 py-3 text-slate-600 hidden lg:table-cell">
{sub?.plan?.name ?? (sub ? `${sub.type}` : '—')}
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[status] ?? 'bg-gray-100 text-gray-500'}`}
>
{status}
</span>
</td>
<td className="px-4 py-3 text-slate-600 hidden xl:table-cell">
{sub ? `${Number(sub.monthlyPrice).toLocaleString()}` : '—'}
</td>
<td className="px-4 py-3 text-slate-400">
<ChevronRight size={16} />
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
</CardHeader>
{/* Pagination */} <CardContent className="p-0">
{total > 20 && ( <table className="w-full text-sm">
<div className="flex items-center justify-between px-4 py-3 border-t"> <thead>
<p className="text-sm text-slate-500"> <tr className="border-b bg-gray-50">
Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total} <th className="text-left px-4 py-3 font-medium text-gray-500">Account #</th>
</p> <th className="text-left px-4 py-3 font-medium text-gray-500">Name</th>
<div className="flex gap-2"> <th className="text-left px-4 py-3 font-medium text-gray-500 hidden md:table-cell">Area</th>
<button <th className="text-left px-4 py-3 font-medium text-gray-500 hidden lg:table-cell">Plan</th>
disabled={page === 1} <th className="text-left px-4 py-3 font-medium text-gray-500">Status</th>
onClick={() => setPage(p => p - 1)} <th className="text-left px-4 py-3 font-medium text-gray-500 hidden xl:table-cell">Monthly</th>
className="px-3 py-1 text-sm border rounded-md disabled:opacity-40" <th className="px-4 py-3"></th>
> </tr>
Prev </thead>
</button> <tbody>
<button {isLoading ? (
disabled={page * 20 >= total} Array.from({ length: 8 }).map((_, i) => (
onClick={() => setPage(p => p + 1)} <tr key={i} className="border-b"><td colSpan={7} className="px-4 py-3"><div className="h-4 bg-gray-100 rounded animate-pulse" /></td></tr>
className="px-3 py-1 text-sm border rounded-md disabled:opacity-40" ))
> ) : clients.map((client) => {
Next const sub = client.subscriptions?.[0];
</button> const status = sub?.status ?? (client.isActive ? "ACTIVE" : "INACTIVE");
</div> return (
</div> <tr key={client.id} onClick={() => router.push(`/clients/${client.id}`)}
)} className="border-b hover:bg-blue-50 cursor-pointer transition-colors">
<td className="px-4 py-3 font-mono text-xs text-gray-600">{client.accountNumber}</td>
<td className="px-4 py-3 font-medium text-gray-800">
{client.firstName} {client.lastName}
<div className="text-xs text-gray-400">{client.phone}</div>
</td>
<td className="px-4 py-3 text-gray-600 hidden md:table-cell">{client.area?.name ?? "—"}</td>
<td className="px-4 py-3 text-gray-600 hidden lg:table-cell">{sub?.plan?.name ?? (sub ? sub.type : "—")}</td>
<td className="px-4 py-3">
<Badge variant={statusVariant[status] ?? "muted"}>{status}</Badge>
</td>
<td className="px-4 py-3 text-gray-600 hidden xl:table-cell">
{sub ? `${Number(sub.monthlyPrice).toLocaleString()}` : "—"}
</td>
<td className="px-4 py-3 text-gray-400"><ChevronRight size={16} /></td>
</tr>
);
})}
</tbody>
</table>
{!isLoading && clients.length === 0 && ( {!isLoading && clients.length === 0 && (
<div className="text-center py-12 text-slate-400"> <div className="text-center py-12 text-gray-400">No clients found{search ? ` for "${search}"` : ""}</div>
No clients found{search ? ` for "${search}"` : ''} )}
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-gray-500">
<span>Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}</span>
<div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Prev</button>
<button disabled={page * 20 >= total} onClick={() => setPage(p => p + 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Next</button>
</div>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Add Client Modal */}
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add New Client" className="max-w-xl">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" value={form.firstName} onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))} />
<Input label="Last Name" value={form.lastName} onChange={e => setForm(f => ({ ...f, lastName: e.target.value }))} />
</div>
<Input label="Email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
<Input label="Phone" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
<Input label="Address" value={form.address} onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Area</label>
<select className="border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.areaId} onChange={e => setForm(f => ({ ...f, areaId: e.target.value }))}>
<option value=""> Select area </option>
{areas.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Billing Type</label>
<select className="border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.billingType} onChange={e => setForm(f => ({ ...f, billingType: e.target.value }))}>
<option value="POSTPAID">Postpaid</option>
<option value="PREPAID">Prepaid</option>
</select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Plan <span className="text-red-500">*</span></label>
<select className="border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.planId} onChange={e => setForm(f => ({ ...f, planId: e.target.value }))}>
<option value=""> Select plan </option>
{plans.map(p => <option key={p.id} value={p.id}>{p.name} {Number(p.monthlyPrice).toLocaleString()}/mo</option>)}
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowAdd(false)}>Cancel</Button>
<Button onClick={() => createClient.mutate()} isLoading={createClient.isPending}
disabled={!form.firstName || !form.lastName || !form.phone || !form.planId}>
Create Client
</Button>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,170 +1,209 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from '@/lib/api'; import { RefreshCw, FileText } from "lucide-react";
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Input } from '@/components/ui/input'; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from "@/components/ui/Badge";
import { Search, ChevronRight } from 'lucide-react'; import { Button } from "@/components/ui/Button";
import { format } from 'date-fns'; 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 { interface Invoice {
id: string; id: string; invoiceNumber: string; clientId: string;
invoiceNumber: string;
dueDate: string;
subtotal: string;
lateFee: string;
total: string;
amountPaid: string;
balance: string;
status: string;
client?: { firstName: string; lastName: string; accountNumber: 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 { const statusVariant: Record<string, "success" | "warning" | "danger" | "muted"> = {
data: Invoice[]; PAID: "success", PARTIAL: "warning", OVERDUE: "danger",
total: number; SENT: "muted", DRAFT: "muted", VOID: "muted",
}
const statusColors: Record<string, string> = {
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 peso = (v: string | number) => const statusFilters = ["", "SENT", "PARTIAL", "OVERDUE", "PAID", "VOID"];
'₱' + Number(v ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 });
export default function InvoicesPage() { export default function InvoicesPage() {
const [search, setSearch] = useState(''); const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState(''); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Invoice | null>(null);
const [payForm, setPayForm] = useState({ amount: "", channel: "CASH", referenceNumber: "", notes: "" });
const { data, isLoading } = useQuery<InvoicesResponse>({ const { data, isLoading, refetch } = useQuery<InvoicesResponse>({
queryKey: ['invoices', search, statusFilter, page], queryKey: ["invoices", search, statusFilter, page],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: '20' }); const params = new URLSearchParams({ page: String(page), limit: "20" });
if (search) params.set('search', search); if (search) params.set("search", search);
if (statusFilter) params.set('status', statusFilter); if (statusFilter) params.set("status", statusFilter);
const res = await api.get(`/api/v1/invoices?${params}`); const res = await api.get<InvoicesResponse>(`/api/v1/invoices?${params}`);
return res.data; 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 invoices = data?.data ?? [];
const total = data?.total ?? 0; const total = data?.total ?? 0;
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-800">Invoices</h1> <h1 className="text-2xl font-bold text-gray-900">Invoices</h1>
<p className="text-slate-500 text-sm mt-1">{total} invoices</p> <p className="text-sm text-gray-500 mt-1">{total} total invoices</p>
</div> </div>
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
</div> </div>
<div className="flex gap-3 mb-4"> <Card>
<div className="relative flex-1"> <CardHeader>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <div className="flex flex-wrap gap-3 items-center">
<Input <input className="flex-1 min-w-[200px] border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search by invoice # or client..." placeholder="Search by client or invoice #..."
className="pl-9" value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
value={search} <div className="flex gap-2 flex-wrap">
onChange={(e) => { setSearch(e.target.value); setPage(1); }} {statusFilters.map(s => (
/> <button key={s} onClick={() => { setStatusFilter(s); setPage(1); }}
</div> className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${statusFilter === s ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
<select {s || "All"}
className="border rounded-md px-3 py-2 text-sm text-slate-700 bg-white" </button>
value={statusFilter} ))}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }} </div>
>
<option value="">All Statuses</option>
{['SENT', 'PARTIAL', 'PAID', 'OVERDUE', 'DRAFT', 'VOID'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<Card className="border shadow-sm">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50">
<th className="text-left px-4 py-3 font-medium text-slate-500">Invoice #</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden md:table-cell">Client</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden lg:table-cell">Due Date</th>
<th className="text-right px-4 py-3 font-medium text-slate-500">Total</th>
<th className="text-right px-4 py-3 font-medium text-slate-500 hidden md:table-cell">Balance</th>
<th className="text-left px-4 py-3 font-medium text-slate-500">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b">
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
))}
</tr>
))
: invoices.map((inv) => (
<tr key={inv.id} className="border-b hover:bg-slate-50 cursor-pointer transition-colors">
<td className="px-4 py-3 font-mono text-xs text-slate-600">{inv.invoiceNumber}</td>
<td className="px-4 py-3 text-slate-700 hidden md:table-cell">
{inv.client
? `${inv.client.firstName} ${inv.client.lastName}`
: '—'}
<div className="text-xs text-slate-400">{inv.client?.accountNumber}</div>
</td>
<td className="px-4 py-3 text-slate-600 hidden lg:table-cell">
{inv.dueDate ? format(new Date(inv.dueDate), 'MMM d, yyyy') : '—'}
</td>
<td className="px-4 py-3 text-right font-medium text-slate-800">{peso(inv.total)}</td>
<td className="px-4 py-3 text-right text-slate-600 hidden md:table-cell">
{Number(inv.balance) > 0 ? (
<span className="text-red-600 font-medium">{peso(inv.balance)}</span>
) : (
<span className="text-green-600">Paid</span>
)}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[inv.status] ?? 'bg-gray-100 text-gray-500'}`}>
{inv.status}
</span>
</td>
<td className="px-4 py-3 text-slate-400"><ChevronRight size={16} /></td>
</tr>
))}
</tbody>
</table>
</div> </div>
</CardHeader>
{!isLoading && invoices.length === 0 && ( <CardContent className="p-0">
<div className="text-center py-12 text-slate-400">No invoices found</div> <Table>
)} <TableHead>
<TableRow>
<Th>Invoice #</Th><Th>Client</Th><Th>Total</Th><Th>Paid</Th><Th>Balance</Th><Th>Due Date</Th><Th>Status</Th><Th></Th>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i}><Td colSpan={8}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td></TableRow>
))
) : invoices.length === 0 ? (
<EmptyState colSpan={8} message="No invoices found" icon={<FileText size={24} />} />
) : invoices.map(inv => (
<TableRow key={inv.id} onClick={() => setSelected(inv)} className="cursor-pointer hover:bg-blue-50 transition-colors">
<Td className="font-mono text-xs">{inv.invoiceNumber}</Td>
<Td className="font-medium">
{inv.client ? `${inv.client.firstName} ${inv.client.lastName}` : "—"}
<div className="text-xs text-gray-400">{inv.client?.accountNumber}</div>
</Td>
<Td>{formatCurrency(Number(inv.total))}</Td>
<Td className="text-green-700">{formatCurrency(Number(inv.amountPaid))}</Td>
<Td className={Number(inv.balance) > 0 ? "text-red-600 font-medium" : "text-gray-400"}>
{formatCurrency(Number(inv.balance))}
</Td>
<Td className={new Date(inv.dueDate) < new Date() && inv.status !== "PAID" ? "text-red-500" : ""}>
{formatDate(inv.dueDate)}
</Td>
<Td><Badge variant={statusVariant[inv.status] ?? "muted"}>{inv.status}</Badge></Td>
<Td className="text-gray-400 text-xs">View </Td>
</TableRow>
))}
</TableBody>
</Table>
{total > 20 && ( {total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t"> <div className="flex items-center justify-between px-4 py-3 border-t text-sm text-gray-500">
<p className="text-sm text-slate-500"> <span>Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}</span>
Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)} <button disabled={page === 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Prev</button>
className="px-3 py-1 text-sm border rounded-md disabled:opacity-40">Prev</button> <button disabled={page * 20 >= total} onClick={() => setPage(p => p + 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Next</button>
<button disabled={page * 20 >= total} onClick={() => setPage(p => p + 1)}
className="px-3 py-1 text-sm border rounded-md disabled:opacity-40">Next</button>
</div> </div>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Invoice Detail Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={`Invoice ${selected?.invoiceNumber ?? ""}`} className="max-w-lg">
{selected && (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
<div className="flex justify-between"><span className="text-gray-500">Client</span>
<span className="font-medium">{selected.client ? `${selected.client.firstName} ${selected.client.lastName}` : "—"}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Period</span>
<span>{selected.periodStart ? `${formatDate(selected.periodStart)} ${formatDate(selected.periodEnd!)}` : "—"}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Due Date</span>
<span className={new Date(selected.dueDate) < new Date() && selected.status !== "PAID" ? "text-red-500 font-medium" : ""}>
{formatDate(selected.dueDate)}</span></div>
<hr />
<div className="flex justify-between"><span className="text-gray-500">Subtotal</span><span>{formatCurrency(Number(selected.subtotal))}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Late Fee</span><span>{formatCurrency(Number(selected.lateFee))}</span></div>
<div className="flex justify-between font-semibold text-base"><span>Total</span><span>{formatCurrency(Number(selected.total))}</span></div>
<div className="flex justify-between text-green-700"><span className="text-gray-500">Amount Paid</span><span>{formatCurrency(Number(selected.amountPaid))}</span></div>
<div className={`flex justify-between font-bold ${Number(selected.balance) > 0 ? "text-red-600" : "text-green-600"}`}>
<span>Balance</span><span>{formatCurrency(Number(selected.balance))}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Status</span>
<Badge variant={statusVariant[selected.status] ?? "muted"}>{selected.status}</Badge></div>
</div>
{/* Pay form (only for unpaid) */}
{["SENT", "PARTIAL", "OVERDUE"].includes(selected.status) && (
<div className="border rounded-lg p-4 space-y-3 bg-blue-50">
<p className="text-sm font-semibold text-blue-800">Record Payment</p>
<Input label="Amount" type="number" value={payForm.amount}
onChange={e => setPayForm(f => ({ ...f, amount: e.target.value }))}
hint={`Balance due: ${formatCurrency(Number(selected.balance))}`} />
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Payment Method</label>
<select className="border rounded-lg px-3 py-2 text-sm" value={payForm.channel}
onChange={e => setPayForm(f => ({ ...f, channel: e.target.value }))}>
{["CASH","GCASH","MAYA","BANK_TRANSFER","CHECK"].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<Input label="Reference # (optional)" value={payForm.referenceNumber}
onChange={e => setPayForm(f => ({ ...f, referenceNumber: e.target.value }))} />
<Button className="w-full" onClick={() => recordPayment.mutate()} isLoading={recordPayment.isPending}
disabled={!payForm.amount}>Record Payment</Button>
</div>
)}
<div className="flex justify-between pt-1">
{selected.status !== "VOID" && selected.status !== "PAID" && (
<Button variant="danger" size="sm" onClick={() => voidInvoice.mutate(selected.id)} isLoading={voidInvoice.isPending}>Void Invoice</Button>
)}
<Button variant="outline" size="sm" onClick={() => setSelected(null)} className="ml-auto">Close</Button>
</div>
</div>
)}
</Modal>
</div> </div>
); );
} }

View File

@@ -1,32 +1,37 @@
"use client"; "use client";
import { useState } from "react"; 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 { UserPlus, RefreshCw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table"; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import api from "@/lib/api"; import api from "@/lib/api";
import { toast } from "sonner";
import type { Lead } from "@/types"; import type { Lead } from "@/types";
const statusVariant: Record<string, "success" | "warning" | "danger" | "muted" | "default"> = { const statusVariant: Record<string, "success" | "warning" | "danger" | "muted" | "default"> = {
NEW: "muted", NEW: "muted", CONTACTED: "default" as any, INTERESTED: "warning", CONVERTED: "success", LOST: "danger",
CONTACTED: "default",
INTERESTED: "warning",
CONVERTED: "success",
LOST: "danger",
}; };
export default function LeadsPage() { const statusOptions = ["NEW", "CONTACTED", "INTERESTED", "CONVERTED", "LOST"];
const [search, setSearch] = useState("");
const { data, isLoading, refetch } = useQuery<Lead[]>({ export default function LeadsPage() {
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Lead | null>(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<Lead[]>({
queryKey: ["leads", search], queryKey: ["leads", search],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ limit: "50" }); const params = new URLSearchParams({ limit: "100" });
if (search) params.set("search", search); if (search) params.set("search", search);
const res = await api.get<Lead[] | { data: Lead[] }>(`/api/v1/leads?${params}`); const res = await api.get<Lead[] | { data: Lead[] }>(`/api/v1/leads?${params}`);
const d = res.data; 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<string, number>);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -43,79 +88,124 @@ export default function LeadsPage() {
<h1 className="text-2xl font-bold text-gray-900">Leads</h1> <h1 className="text-2xl font-bold text-gray-900">Leads</h1>
<p className="text-sm text-gray-500 mt-1">Prospective customers pipeline</p> <p className="text-sm text-gray-500 mt-1">Prospective customers pipeline</p>
</div> </div>
<Button onClick={() => refetch()} variant="outline" size="sm"> <div className="flex gap-2">
<RefreshCw size={14} className="mr-1.5" /> Refresh <Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
</Button> <Button onClick={() => setShowAdd(true)} size="sm"><UserPlus size={14} className="mr-1" />Add Lead</Button>
</div>
</div> </div>
{/* Status summary */} {/* Pipeline summary */}
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
{Object.entries(statusVariant).map(([status]) => { {statusOptions.map(status => (
const count = leads.filter(l => l.status === status).length; <div key={status} className="bg-white border rounded-xl px-4 py-3 text-center min-w-[90px] shadow-sm">
return count > 0 ? ( <p className="text-2xl font-bold text-gray-800">{counts[status] ?? 0}</p>
<div key={status} className="bg-white border rounded-lg px-3 py-2 text-center min-w-[80px]"> <Badge variant={statusVariant[status] ?? "muted"} className="mt-1">{status}</Badge>
<p className="text-lg font-bold text-gray-800">{count}</p> </div>
<p className="text-xs text-gray-500">{status}</p> ))}
</div> <div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-center min-w-[90px]">
) : null; <p className="text-2xl font-bold text-blue-700">{data.length}</p>
})} <p className="text-xs text-blue-600 font-medium mt-1">TOTAL</p>
</div>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<CardTitle>All Leads ({leads.length})</CardTitle> <CardTitle>All Leads ({data.length})</CardTitle>
<Input <input className="border rounded-lg px-3 py-2 text-sm max-w-xs w-full focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search by name or phone..." placeholder="Search by name or phone..."
value={search} value={search} onChange={e => setSearch(e.target.value)} />
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow><Th>Name</Th><Th>Phone</Th><Th>Email</Th><Th>Address</Th><Th>Status</Th><Th>Source</Th><Th>Added</Th></TableRow>
<Th>Name</Th>
<Th>Phone</Th>
<Th>Email</Th>
<Th>Address</Th>
<Th>Status</Th>
<Th>Assigned To</Th>
<Th>Added</Th>
</TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
<TableRow><Td colSpan={7} className="text-center py-8 text-gray-400">Loading...</Td></TableRow> Array.from({ length: 6 }).map((_, i) => (
) : leads.length === 0 ? ( <TableRow key={i}><Td colSpan={7}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td></TableRow>
<EmptyState colSpan={7} message="No leads yet" icon={<UserPlus size={24} />} />
) : (
leads.map((lead) => (
<TableRow key={lead.id}>
<Td className="font-medium">{lead.firstName} {lead.lastName}</Td>
<Td>{lead.phone}</Td>
<Td className="text-gray-500">{lead.email ?? "—"}</Td>
<Td className="text-gray-500 max-w-[150px] truncate">{lead.address ?? "—"}</Td>
<Td>
<Badge variant={statusVariant[lead.status] ?? "muted"}>
{lead.status}
</Badge>
</Td>
<Td className="text-gray-500">
{lead.assignedTo
? `${lead.assignedTo.firstName} ${lead.assignedTo.lastName}`
: "—"}
</Td>
<Td className="text-gray-400 text-sm">{formatDate(lead.createdAt)}</Td>
</TableRow>
)) ))
)} ) : data.length === 0 ? (
<EmptyState colSpan={7} message="No leads yet" icon={<UserPlus size={24} />} />
) : data.map(lead => (
<TableRow key={lead.id} onClick={() => { setSelected(lead); setStatusUpdate(lead.status); }}
className="cursor-pointer hover:bg-blue-50 transition-colors">
<Td className="font-medium">{lead.firstName} {lead.lastName}</Td>
<Td>{lead.phone}</Td>
<Td className="text-gray-500">{lead.email ?? "—"}</Td>
<Td className="text-gray-500 max-w-[150px] truncate">{lead.address ?? "—"}</Td>
<Td><Badge variant={statusVariant[lead.status] ?? "muted"}>{lead.status}</Badge></Td>
<Td className="text-gray-400 text-sm">{lead.source ?? "—"}</Td>
<Td className="text-gray-400 text-xs">{formatDate(lead.createdAt)}</Td>
</TableRow>
))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
{/* Lead Detail Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={`${selected?.firstName ?? ""} ${selected?.lastName ?? ""}`}>
{selected && (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
<div className="flex justify-between"><span className="text-gray-500">Phone</span><span className="font-medium">{selected.phone}</span></div>
{selected.email && <div className="flex justify-between"><span className="text-gray-500">Email</span><span>{selected.email}</span></div>}
{selected.address && <div className="flex justify-between"><span className="text-gray-500">Address</span><span className="text-right max-w-[200px]">{selected.address}</span></div>}
{selected.source && <div className="flex justify-between"><span className="text-gray-500">Source</span><span>{selected.source}</span></div>}
{selected.notes && <div className="flex justify-between items-start"><span className="text-gray-500">Notes</span><span className="text-right max-w-[200px]">{selected.notes}</span></div>}
<div className="flex justify-between"><span className="text-gray-500">Added</span><span>{formatDate(selected.createdAt)}</span></div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Update Status</label>
<div className="flex gap-2 flex-wrap">
{statusOptions.map(s => (
<button key={s} onClick={() => setStatusUpdate(s)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border ${statusUpdate === s ? "border-blue-600 bg-blue-600 text-white" : "border-gray-200 text-gray-600 hover:border-blue-400"}`}>
{s}
</button>
))}
</div>
{statusUpdate !== selected.status && (
<Button size="sm" className="mt-1" onClick={() => updateStatus.mutate({ id: selected.id, status: statusUpdate })} isLoading={updateStatus.isPending}>
Update to {statusUpdate}
</Button>
)}
</div>
<div className="flex justify-between pt-1">
<Button variant="danger" size="sm" onClick={() => { if (confirm("Delete this lead?")) deleteLead.mutate(selected.id); }} isLoading={deleteLead.isPending}>Delete</Button>
<Button variant="outline" size="sm" onClick={() => setSelected(null)}>Close</Button>
</div>
</div>
)}
</Modal>
{/* Add Lead Modal */}
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add New Lead">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Input label="First Name *" value={form.firstName} onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))} />
<Input label="Last Name *" value={form.lastName} onChange={e => setForm(f => ({ ...f, lastName: e.target.value }))} />
</div>
<Input label="Phone *" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
<Input label="Email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
<Input label="Address" value={form.address} onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
<Input label="Source (e.g. Facebook, Referral)" value={form.source} onChange={e => setForm(f => ({ ...f, source: e.target.value }))} />
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Notes</label>
<textarea className="border rounded-lg px-3 py-2 text-sm resize-none h-16 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowAdd(false)}>Cancel</Button>
<Button onClick={() => addLead.mutate()} isLoading={addLead.isPending} disabled={!form.firstName || !form.lastName || !form.phone}>Add Lead</Button>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,136 +1,175 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { api } from '@/lib/api'; import { RefreshCw, CreditCard } from "lucide-react";
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from "@/components/ui/Card";
import { Input } from '@/components/ui/input'; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from "@/components/ui/Badge";
import { Search } from 'lucide-react'; import { Button } from "@/components/ui/Button";
import { format } from 'date-fns'; import { Modal } from "@/components/ui/Modal";
import { formatDate, formatCurrency } from "@/lib/utils";
import api from "@/lib/api";
interface Payment { interface Payment {
id: string; id: string; clientId: string;
amount: string;
channel: string;
paymentDate: string;
notes: string | null;
client?: { firstName: string; lastName: string; accountNumber: string }; client?: { firstName: string; lastName: string; accountNumber: string };
invoice?: { invoiceNumber: string; total: string };
invoiceId?: string;
amount: string; channel: string;
referenceNumber?: string; orNumber?: string; notes?: string;
paymentDate?: string; createdAt: string;
recordedBy?: { firstName: string; lastName: string }; recordedBy?: { firstName: string; lastName: string };
invoice?: { invoiceNumber: string };
} }
interface PaymentsResponse { data: Payment[]; total: number; page: number; limit: number; }
const channelColors: Record<string, string> = { const channelVariant: Record<string, "success" | "warning" | "muted" | "default"> = {
CASH: 'bg-green-100 text-green-700', CASH: "success", GCASH: "default", MAYA: "default", BANK_TRANSFER: "warning", CHECK: "muted",
GCASH: 'bg-blue-100 text-blue-700',
MAYA: 'bg-purple-100 text-purple-700',
BANK_TRANSFER: 'bg-orange-100 text-orange-700',
CHECK: 'bg-gray-100 text-gray-700',
}; };
const peso = (v: string | number) => const channelFilters = ["", "CASH", "GCASH", "MAYA", "BANK_TRANSFER", "CHECK"];
'₱' + Number(v ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 });
export default function PaymentsPage() { export default function PaymentsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState("");
const [channelFilter, setChannelFilter] = useState(''); const [channelFilter, setChannelFilter] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Payment | null>(null);
const { data, isLoading } = useQuery<{ data: Payment[]; total: number }>({ const { data, isLoading, refetch } = useQuery<PaymentsResponse>({
queryKey: ['payments', search, channelFilter, page], queryKey: ["payments", search, channelFilter, page],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: '20' }); const params = new URLSearchParams({ page: String(page), limit: "20" });
if (channelFilter) params.set('channel', channelFilter); if (search) params.set("search", search);
const res = await api.get(`/api/v1/payments?${params}`); if (channelFilter) params.set("channel", channelFilter);
const res = await api.get<PaymentsResponse>(`/api/v1/payments?${params}`);
return res.data; return res.data;
}, },
staleTime: 30_000,
}); });
const payments = data?.data ?? []; const payments = data?.data ?? [];
const total = data?.total ?? 0; const total = data?.total ?? 0;
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-800">Payments</h1> <h1 className="text-2xl font-bold text-gray-900">Payments</h1>
<p className="text-slate-500 text-sm mt-1">{total} payments</p> <p className="text-sm text-gray-500 mt-1">{total} total payments</p>
</div> </div>
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
</div> </div>
<div className="flex gap-3 mb-4"> <Card>
<div className="relative flex-1"> <CardHeader>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <div className="flex flex-wrap gap-3 items-center">
<Input placeholder="Search..." className="pl-9" value={search} <input className="flex-1 min-w-[200px] border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => { setSearch(e.target.value); setPage(1); }} /> placeholder="Search by client..."
</div> value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
<select <div className="flex gap-2 flex-wrap">
className="border rounded-md px-3 py-2 text-sm text-slate-700 bg-white" {channelFilters.map(c => (
value={channelFilter} <button key={c} onClick={() => { setChannelFilter(c); setPage(1); }}
onChange={(e) => { setChannelFilter(e.target.value); setPage(1); }} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${channelFilter === c ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
> {c || "All"}
<option value="">All Channels</option> </button>
{['CASH', 'GCASH', 'MAYA', 'BANK_TRANSFER', 'CHECK'].map((c) => ( ))}
<option key={c} value={c}>{c.replace('_', ' ')}</option> </div>
))}
</select>
</div>
<Card className="border shadow-sm">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50">
<th className="text-left px-4 py-3 font-medium text-slate-500">Date</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden md:table-cell">Client</th>
<th className="text-right px-4 py-3 font-medium text-slate-500">Amount</th>
<th className="text-left px-4 py-3 font-medium text-slate-500">Channel</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden lg:table-cell">Recorded By</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden xl:table-cell">Invoice</th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b">
{Array.from({ length: 6 }).map((_, j) => (
<td key={j} className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
))}
</tr>
))
: payments.map((p) => (
<tr key={p.id} className="border-b hover:bg-slate-50 transition-colors">
<td className="px-4 py-3 text-slate-600">
{p.paymentDate ? format(new Date(p.paymentDate), 'MMM d, yyyy') : '—'}
</td>
<td className="px-4 py-3 text-slate-700 hidden md:table-cell">
{p.client ? `${p.client.firstName} ${p.client.lastName}` : '—'}
<div className="text-xs text-slate-400">{p.client?.accountNumber}</div>
</td>
<td className="px-4 py-3 text-right font-semibold text-slate-800">{peso(p.amount)}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${channelColors[p.channel] ?? 'bg-gray-100 text-gray-500'}`}>
{p.channel?.replace('_', ' ')}
</span>
</td>
<td className="px-4 py-3 text-slate-600 hidden lg:table-cell">
{p.recordedBy ? `${p.recordedBy.firstName} ${p.recordedBy.lastName}` : '—'}
</td>
<td className="px-4 py-3 text-slate-500 font-mono text-xs hidden xl:table-cell">
{p.invoice?.invoiceNumber ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
{!isLoading && payments.length === 0 && ( </CardHeader>
<div className="text-center py-12 text-slate-400">No payments found</div> <CardContent className="p-0">
<Table>
<TableHead>
<TableRow><Th>Date</Th><Th>Client</Th><Th>Amount</Th><Th>Method</Th><Th>Reference</Th><Th>Invoice</Th><Th></Th></TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i}><Td colSpan={7}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td></TableRow>
))
) : payments.length === 0 ? (
<EmptyState colSpan={7} message="No payments found" icon={<CreditCard size={24} />} />
) : payments.map(p => (
<TableRow key={p.id} onClick={() => setSelected(p)} className="cursor-pointer hover:bg-blue-50 transition-colors">
<Td className="text-sm">{p.paymentDate ? formatDate(p.paymentDate) : formatDate(p.createdAt)}</Td>
<Td className="font-medium">
{p.client ? `${p.client.firstName} ${p.client.lastName}` : "—"}
<div className="text-xs text-gray-400">{p.client?.accountNumber}</div>
</Td>
<Td className="font-semibold text-green-700">{formatCurrency(Number(p.amount))}</Td>
<Td><Badge variant={channelVariant[p.channel] ?? "muted"}>{p.channel}</Badge></Td>
<Td className="text-xs text-gray-500">{p.referenceNumber ?? p.orNumber ?? "—"}</Td>
<Td className="text-xs font-mono text-gray-500">{p.invoice?.invoiceNumber ?? (p.invoiceId ? p.invoiceId.slice(0, 8) : "—")}</Td>
<Td className="text-gray-400 text-xs">View </Td>
</TableRow>
))}
</TableBody>
</Table>
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-gray-500">
<span>Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}</span>
<div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Prev</button>
<button disabled={page * 20 >= total} onClick={() => setPage(p => p + 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Next</button>
</div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Payment Detail Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Payment Details">
{selected && (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-4 space-y-2.5 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-500">Client</span>
<span className="font-medium">{selected.client ? `${selected.client.firstName} ${selected.client.lastName}` : "—"}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500">Account #</span>
<span className="font-mono text-xs">{selected.client?.accountNumber ?? "—"}</span>
</div>
<hr />
<div className="flex justify-between items-center">
<span className="text-gray-500">Amount</span>
<span className="text-xl font-bold text-green-700">{formatCurrency(Number(selected.amount))}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500">Method</span>
<Badge variant={channelVariant[selected.channel] ?? "muted"}>{selected.channel}</Badge>
</div>
{(selected.referenceNumber || selected.orNumber) && (
<div className="flex justify-between items-center">
<span className="text-gray-500">Reference #</span>
<span className="font-mono text-xs">{selected.referenceNumber ?? selected.orNumber}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-gray-500">Invoice</span>
<span className="font-mono text-xs">{selected.invoice?.invoiceNumber ?? (selected.invoiceId ? selected.invoiceId.slice(0, 8) : "—")}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500">Payment Date</span>
<span>{selected.paymentDate ? formatDate(selected.paymentDate) : formatDate(selected.createdAt)}</span>
</div>
{selected.notes && (
<div className="flex justify-between items-start">
<span className="text-gray-500">Notes</span>
<span className="text-right max-w-[200px]">{selected.notes}</span>
</div>
)}
{selected.recordedBy && (
<div className="flex justify-between items-center">
<span className="text-gray-500">Recorded By</span>
<span>{selected.recordedBy.firstName} {selected.recordedBy.lastName}</span>
</div>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={() => setSelected(null)}>Close</Button>
</div>
</div>
)}
</Modal>
</div> </div>
); );
} }

View File

@@ -1,126 +1,223 @@
'use client'; "use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useState } from "react";
import { api } from '@/lib/api'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from '@/components/ui/card'; import { RefreshCw, ArrowLeftRight, CheckCircle, Plus } from "lucide-react";
import { Skeleton } from '@/components/ui/skeleton'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { ChevronRight, CheckCircle } from 'lucide-react'; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { format } from 'date-fns'; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { formatDate, formatCurrency } from "@/lib/utils";
import api from "@/lib/api";
import { toast } from "sonner";
interface Payment {
id: string; clientId: string; amount: string; channel: string;
paymentDate?: string; createdAt: string;
client?: { firstName: string; lastName: string; accountNumber: string };
}
interface Remittance { interface Remittance {
id: string; id: string; collectorId?: string;
totalAmount: string; collector?: { firstName: string; lastName: string };
notes: string | null; amount: number | string; notes?: string; status: string;
status: string; payments?: Payment[]; createdAt: string;
createdAt: string;
collectedBy?: { firstName: string; lastName: string };
confirmedBy?: { firstName: string; lastName: string };
payments?: Array<{ id: string; amount: string }>;
} }
const peso = (v: string | number) => const statusVariant: Record<string, "success" | "warning" | "muted"> = {
'₱' + Number(v ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 }); CONFIRMED: "success", PENDING: "warning", DISPUTED: "muted",
};
export default function RemittancesPage() { export default function RemittancesPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [showSubmit, setShowSubmit] = useState(false);
const [notes, setNotes] = useState("");
const [selectedPaymentIds, setSelectedPaymentIds] = useState<string[]>([]);
const [selected, setSelected] = useState<Remittance | null>(null);
const { data, isLoading } = useQuery<{ data: Remittance[]; total: number }>({ const { data: remittances = [], isLoading, refetch } = useQuery<Remittance[]>({
queryKey: ['remittances'], queryKey: ["remittances"],
queryFn: async () => { queryFn: async () => {
const res = await api.get('/api/v1/remittances?limit=30'); const res = await api.get("/api/v1/remittances?limit=50");
return res.data; const d = res.data;
return Array.isArray(d) ? d : d.data ?? [];
}, },
staleTime: 30_000, });
const { data: unremitted = [] } = useQuery<Payment[]>({
queryKey: ["unremitted-payments"],
queryFn: async () => {
const res = await api.get<Payment[]>("/api/v1/payments/unremitted");
return Array.isArray(res.data) ? res.data : (res.data as any).data ?? [];
},
enabled: showSubmit,
}); });
const confirm = useMutation({ const confirm = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => { await api.patch(`/api/v1/remittances/${id}/confirm`); },
await api.patch(`/api/v1/remittances/${id}/confirm`); onSuccess: () => { toast.success("Remittance confirmed!"); qc.invalidateQueries({ queryKey: ["remittances"] }); },
}, onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to confirm"),
onSuccess: () => qc.invalidateQueries({ queryKey: ['remittances'] }),
}); });
const remittances = data?.data ?? []; const submitRemittance = useMutation({
mutationFn: async () => {
await api.post("/api/v1/remittances", { paymentIds: selectedPaymentIds, notes: notes || undefined });
},
onSuccess: () => {
toast.success("Remittance submitted!");
setShowSubmit(false);
setSelectedPaymentIds([]);
setNotes("");
qc.invalidateQueries({ queryKey: ["remittances"] });
qc.invalidateQueries({ queryKey: ["unremitted-payments"] });
},
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to submit remittance"),
});
const togglePayment = (id: string) => {
setSelectedPaymentIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const selectedTotal = unremitted
.filter(p => selectedPaymentIds.includes(p.id))
.reduce((sum, p) => sum + Number(p.amount), 0);
return ( return (
<div> <div className="space-y-6">
<div className="mb-6"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-800">Remittances</h1> <div>
<p className="text-slate-500 text-sm mt-1">Cash collections submitted by collectors</p> <h1 className="text-2xl font-bold text-gray-900">Remittances</h1>
<p className="text-sm text-gray-500 mt-1">{remittances.length} remittances</p>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
<Button onClick={() => setShowSubmit(true)} size="sm"><Plus size={14} className="mr-1" />Submit Remittance</Button>
</div>
</div> </div>
<Card className="border shadow-sm"> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <Table>
<table className="w-full text-sm"> <TableHead>
<thead> <TableRow><Th>Date</Th><Th>Collector</Th><Th>Amount</Th><Th>Status</Th><Th>Notes</Th><Th></Th></TableRow>
<tr className="border-b bg-slate-50"> </TableHead>
<th className="text-left px-4 py-3 font-medium text-slate-500">Date</th> <TableBody>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden md:table-cell">Collector</th> {isLoading ? (
<th className="text-right px-4 py-3 font-medium text-slate-500">Amount</th> Array.from({ length: 5 }).map((_, i) => (
<th className="text-right px-4 py-3 font-medium text-slate-500 hidden lg:table-cell">Payments</th> <TableRow key={i}><Td colSpan={6}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td></TableRow>
<th className="text-left px-4 py-3 font-medium text-slate-500">Status</th> ))
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden xl:table-cell">Confirmed By</th> ) : remittances.length === 0 ? (
<th className="px-4 py-3"></th> <EmptyState colSpan={6} message="No remittances yet" icon={<ArrowLeftRight size={24} />} />
</tr> ) : remittances.map(r => (
</thead> <TableRow key={r.id} onClick={() => setSelected(r)} className="cursor-pointer hover:bg-blue-50 transition-colors">
<tbody> <Td>{formatDate(r.createdAt)}</Td>
{isLoading <Td className="font-medium">{r.collector ? `${r.collector.firstName} ${r.collector.lastName}` : "—"}</Td>
? Array.from({ length: 6 }).map((_, i) => ( <Td className="font-semibold text-blue-700">{formatCurrency(Number(r.amount))}</Td>
<tr key={i} className="border-b"> <Td><Badge variant={statusVariant[r.status] ?? "muted"}>{r.status}</Badge></Td>
{[...Array(7)].map((_, j) => ( <Td className="text-gray-500 text-sm">{r.notes ?? "—"}</Td>
<td key={j} className="px-4 py-3"><Skeleton className="h-4 w-20" /></td> <Td>
))} {r.status === "PENDING" && (
</tr> <Button size="sm" variant="secondary" onClick={e => { e.stopPropagation(); confirm.mutate(r.id); }}>
)) <CheckCircle size={13} className="mr-1" />Confirm
: remittances.map((r) => ( </Button>
<tr key={r.id} className="border-b hover:bg-slate-50 transition-colors"> )}
<td className="px-4 py-3 text-slate-600"> </Td>
{r.createdAt ? format(new Date(r.createdAt), 'MMM d, yyyy') : '—'} </TableRow>
</td> ))}
<td className="px-4 py-3 text-slate-700 hidden md:table-cell"> </TableBody>
{r.collectedBy ? `${r.collectedBy.firstName} ${r.collectedBy.lastName}` : '—'} </Table>
</td>
<td className="px-4 py-3 text-right font-semibold text-slate-800">
{peso(r.totalAmount)}
</td>
<td className="px-4 py-3 text-right text-slate-600 hidden lg:table-cell">
{r.payments?.length ?? '—'}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
r.status === 'CONFIRMED'
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{r.status}
</span>
</td>
<td className="px-4 py-3 text-slate-600 hidden xl:table-cell">
{r.confirmedBy ? `${r.confirmedBy.firstName} ${r.confirmedBy.lastName}` : '—'}
</td>
<td className="px-4 py-3">
{r.status !== 'CONFIRMED' && (
<button
onClick={() => confirm.mutate(r.id)}
disabled={confirm.isPending}
className="flex items-center gap-1 text-xs font-medium text-green-700 hover:text-green-800"
>
<CheckCircle size={14} />
Confirm
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!isLoading && remittances.length === 0 && (
<div className="text-center py-12 text-slate-400">No remittances yet</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Remittance Detail Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Remittance Details" className="max-w-lg">
{selected && (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
<div className="flex justify-between"><span className="text-gray-500">Collector</span>
<span className="font-medium">{selected.collector ? `${selected.collector.firstName} ${selected.collector.lastName}` : "—"}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Total Amount</span>
<span className="text-lg font-bold text-blue-700">{formatCurrency(Number(selected.amount))}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Status</span>
<Badge variant={statusVariant[selected.status] ?? "muted"}>{selected.status}</Badge></div>
<div className="flex justify-between"><span className="text-gray-500">Date</span>
<span>{formatDate(selected.createdAt)}</span></div>
{selected.notes && <div className="flex justify-between"><span className="text-gray-500">Notes</span><span>{selected.notes}</span></div>}
</div>
{selected.payments && selected.payments.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Included Payments ({selected.payments.length})</p>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{selected.payments.map(p => (
<div key={p.id} className="flex justify-between text-sm bg-white border rounded-lg px-3 py-2">
<span className="text-gray-600">{p.client ? `${p.client.firstName} ${p.client.lastName}` : p.clientId.slice(0, 8)}</span>
<span className="font-medium text-green-700">{formatCurrency(Number(p.amount))}</span>
</div>
))}
</div>
</div>
)}
<div className="flex justify-between pt-1">
{selected.status === "PENDING" && (
<Button size="sm" onClick={() => { confirm.mutate(selected.id); setSelected(null); }} isLoading={confirm.isPending}>
<CheckCircle size={14} className="mr-1" />Confirm
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setSelected(null)} className="ml-auto">Close</Button>
</div>
</div>
)}
</Modal>
{/* Submit Remittance Modal */}
<Modal isOpen={showSubmit} onClose={() => setShowSubmit(false)} title="Submit Remittance" className="max-w-xl">
<div className="space-y-4">
<p className="text-sm text-gray-500">Select payments to include in this remittance.</p>
{unremitted.length === 0 ? (
<div className="text-center py-6 text-gray-400">No unremitted payments available.</div>
) : (
<>
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">{unremitted.length} unremitted payments</span>
<button onClick={() => setSelectedPaymentIds(unremitted.map(p => p.id))}
className="text-xs text-blue-600 hover:underline">Select All</button>
</div>
<div className="space-y-1.5 max-h-64 overflow-y-auto border rounded-lg p-2">
{unremitted.map(p => (
<label key={p.id} className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${selectedPaymentIds.includes(p.id) ? "bg-blue-50 border border-blue-200" : "hover:bg-gray-50 border border-transparent"}`}>
<div className="flex items-center gap-2">
<input type="checkbox" checked={selectedPaymentIds.includes(p.id)} onChange={() => togglePayment(p.id)} className="rounded" />
<div>
<p className="text-sm font-medium">{p.client ? `${p.client.firstName} ${p.client.lastName}` : "—"}</p>
<p className="text-xs text-gray-400">{p.channel} · {p.paymentDate ? formatDate(p.paymentDate) : formatDate(p.createdAt)}</p>
</div>
</div>
<span className="font-semibold text-green-700 text-sm">{formatCurrency(Number(p.amount))}</span>
</label>
))}
</div>
<div className="bg-blue-50 rounded-lg px-4 py-3 flex justify-between text-sm font-semibold text-blue-800">
<span>{selectedPaymentIds.length} payments selected</span>
<span>{formatCurrency(selectedTotal)}</span>
</div>
</>
)}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Notes (optional)</label>
<textarea className="border rounded-lg px-3 py-2 text-sm resize-none h-20 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes} onChange={e => setNotes(e.target.value)} placeholder="Add any notes..." />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowSubmit(false)}>Cancel</Button>
<Button onClick={() => submitRemittance.mutate()} isLoading={submitRemittance.isPending}
disabled={selectedPaymentIds.length === 0}>
Submit {selectedPaymentIds.length > 0 ? `(${formatCurrency(selectedTotal)})` : ""}
</Button>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,168 +1,294 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from '@/lib/api'; import { RefreshCw, Ticket, Plus, Send } from "lucide-react";
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from "@/components/ui/Card";
import { Input } from '@/components/ui/input'; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from "@/components/ui/Badge";
import { Search, Plus, ChevronRight } from 'lucide-react'; import { Button } from "@/components/ui/Button";
import { format } from 'date-fns'; 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";
interface Ticket { interface TicketComment { id: string; body: string; createdAt: string; author?: { firstName: string; lastName: string }; }
id: string; interface TicketItem {
subject: string; id: string; ticketNumber?: string; subject: string; description?: string;
type: string; type: string; priority: string; status: string; clientId?: string;
status: string; client?: { id: string; firstName: string; lastName: string; accountNumber: string };
priority: string; assignedTo?: { id: string; firstName: string; lastName: string } | string;
createdAt: string; createdAt: string; updatedAt?: string;
client?: { firstName: string; lastName: string; accountNumber: string }; comments?: TicketComment[];
assignedTo?: { firstName: string; lastName: string };
} }
interface PaginatedResponse<T> { data: T[]; meta: { total: number; page: number; limit: number; }; }
const statusColors: Record<string, string> = { const statusVariant: Record<string, "success" | "warning" | "danger" | "muted"> = {
OPEN: 'bg-blue-100 text-blue-700', OPEN: "warning", IN_PROGRESS: "default" as any, RESOLVED: "success", CLOSED: "muted",
IN_PROGRESS: 'bg-yellow-100 text-yellow-700', };
RESOLVED: 'bg-green-100 text-green-700', const priorityVariant: Record<string, "danger" | "warning" | "muted"> = {
CLOSED: 'bg-gray-100 text-gray-500', HIGH: "danger", NORMAL: "muted", LOW: "muted",
}; };
const priorityColors: Record<string, string> = { const statusFilters = ["", "OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"];
HIGH: 'bg-red-100 text-red-700', const typeFilters = ["", "SUPPORT", "BILLING", "INSTALLATION"];
NORMAL: 'bg-slate-100 text-slate-600',
};
const typeColors: Record<string, string> = {
INSTALLATION: 'bg-cyan-100 text-cyan-700',
SUPPORT: 'bg-purple-100 text-purple-700',
BILLING: 'bg-orange-100 text-orange-700',
};
export default function TicketsPage() { export default function TicketsPage() {
const [search, setSearch] = useState(''); const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState(''); const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState(''); const [statusFilter, setStatusFilter] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selected, setSelected] = useState<TicketItem | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [comment, setComment] = useState("");
const [newForm, setNewForm] = useState({ subject: "", description: "", type: "SUPPORT", priority: "NORMAL", clientSearch: "", clientId: "" });
const { data, isLoading } = useQuery<{ data: Ticket[]; total: number }>({ const { data, isLoading, refetch } = useQuery<PaginatedResponse<TicketItem>>({
queryKey: ['tickets', search, statusFilter, typeFilter, page], queryKey: ["tickets", search, statusFilter, typeFilter, page],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: '20' }); const params = new URLSearchParams({ page: String(page), limit: "20" });
if (statusFilter) params.set('status', statusFilter); if (search) params.set("search", search);
if (typeFilter) params.set('type', typeFilter); if (statusFilter) params.set("status", statusFilter);
const res = await api.get(`/api/v1/tickets?${params}`); if (typeFilter) params.set("type", typeFilter);
const res = await api.get<PaginatedResponse<TicketItem>>(`/api/v1/tickets?${params}`);
return res.data; return res.data;
}, },
staleTime: 30_000, });
const { data: ticketDetail, refetch: refetchDetail } = useQuery<TicketItem>({
queryKey: ["ticket", selected?.id],
queryFn: async () => {
const res = await api.get<TicketItem>(`/api/v1/tickets/${selected!.id}`);
return res.data;
},
enabled: !!selected?.id,
});
const { data: clientSearch = [], isFetching: searchingClients } = useQuery({
queryKey: ["client-search", newForm.clientSearch],
queryFn: async () => {
if (!newForm.clientSearch || newForm.clientSearch.length < 2) return [];
const res = await api.get(`/api/v1/clients?search=${encodeURIComponent(newForm.clientSearch)}&limit=10`);
return res.data?.data ?? [];
},
enabled: newForm.clientSearch.length >= 2,
});
const updateStatus = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) => {
await api.patch(`/api/v1/tickets/${id}`, { status });
},
onSuccess: () => { toast.success("Status updated"); refetch(); refetchDetail(); qc.invalidateQueries({ queryKey: ["ticket"] }); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"),
});
const addComment = useMutation({
mutationFn: async () => {
await api.post(`/api/v1/tickets/${selected!.id}/comments`, { body: comment });
},
onSuccess: () => { toast.success("Comment added"); setComment(""); refetchDetail(); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"),
});
const createTicket = useMutation({
mutationFn: async () => {
await api.post("/api/v1/tickets", {
subject: newForm.subject, description: newForm.description,
type: newForm.type, priority: newForm.priority,
clientId: newForm.clientId || undefined,
});
},
onSuccess: () => {
toast.success("Ticket created!");
setShowCreate(false);
setNewForm({ subject: "", description: "", type: "SUPPORT", priority: "NORMAL", clientSearch: "", clientId: "" });
qc.invalidateQueries({ queryKey: ["tickets"] });
},
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create ticket"),
}); });
const tickets = data?.data ?? []; const tickets = data?.data ?? [];
const total = data?.total ?? 0; const total = data?.meta?.total ?? 0;
const detail = ticketDetail ?? selected;
const comments = (ticketDetail as any)?.comments ?? [];
const nextStatus: Record<string, string> = { OPEN: "IN_PROGRESS", IN_PROGRESS: "RESOLVED", RESOLVED: "CLOSED" };
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-800">Tickets</h1> <h1 className="text-2xl font-bold text-gray-900">Tickets</h1>
<p className="text-slate-500 text-sm mt-1">{total} tickets</p> <p className="text-sm text-gray-500 mt-1">{total} total tickets</p>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
<Button onClick={() => setShowCreate(true)} size="sm"><Plus size={14} className="mr-1" />New Ticket</Button>
</div> </div>
<button
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ backgroundColor: '#0891B2' }}
>
<Plus size={16} />
New Ticket
</button>
</div> </div>
<div className="flex gap-3 mb-4 flex-wrap"> <Card>
<div className="relative flex-1 min-w-[200px]"> <CardHeader>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <div className="flex flex-wrap gap-3">
<Input placeholder="Search tickets..." className="pl-9" value={search} <input className="flex-1 min-w-[200px] border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => { setSearch(e.target.value); setPage(1); }} /> placeholder="Search tickets..." value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
</div> <div className="flex gap-2 flex-wrap">
<select className="border rounded-md px-3 py-2 text-sm bg-white" {statusFilters.map(s => (
value={typeFilter} onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}> <button key={s} onClick={() => { setStatusFilter(s); setPage(1); }}
<option value="">All Types</option> className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${statusFilter === s ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
{['INSTALLATION', 'SUPPORT', 'BILLING'].map(t => ( {s || "All Status"}
<option key={t} value={t}>{t}</option> </button>
))} ))}
</select> </div>
<select className="border rounded-md px-3 py-2 text-sm bg-white" <div className="flex gap-2 flex-wrap">
value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}> {typeFilters.map(t => (
<option value="">All Statuses</option> <button key={t} onClick={() => { setTypeFilter(t); setPage(1); }}
{['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'].map(s => ( className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${typeFilter === t ? "bg-purple-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
<option key={s} value={s}>{s.replace('_', ' ')}</option> {t || "All Types"}
))} </button>
</select> ))}
</div> </div>
<Card className="border shadow-sm">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50">
<th className="text-left px-4 py-3 font-medium text-slate-500">Subject</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden md:table-cell">Client</th>
<th className="text-left px-4 py-3 font-medium text-slate-500">Type</th>
<th className="text-left px-4 py-3 font-medium text-slate-500">Priority</th>
<th className="text-left px-4 py-3 font-medium text-slate-500">Status</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden lg:table-cell">Assigned</th>
<th className="text-left px-4 py-3 font-medium text-slate-500 hidden xl:table-cell">Created</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b">
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-4 py-3"><Skeleton className="h-4 w-16" /></td>
))}
</tr>
))
: tickets.map((t) => (
<tr key={t.id} className="border-b hover:bg-slate-50 cursor-pointer transition-colors">
<td className="px-4 py-3 text-slate-800 font-medium max-w-[200px] truncate">
{t.subject}
</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">
{t.client ? `${t.client.firstName} ${t.client.lastName}` : '—'}
<div className="text-xs text-slate-400">{t.client?.accountNumber}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${typeColors[t.type] ?? 'bg-gray-100 text-gray-500'}`}>
{t.type}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[t.priority] ?? 'bg-gray-100 text-gray-500'}`}>
{t.priority}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status] ?? 'bg-gray-100 text-gray-500'}`}>
{t.status?.replace('_', ' ')}
</span>
</td>
<td className="px-4 py-3 text-slate-600 hidden lg:table-cell">
{t.assignedTo ? `${t.assignedTo.firstName} ${t.assignedTo.lastName}` : 'Unassigned'}
</td>
<td className="px-4 py-3 text-slate-500 hidden xl:table-cell">
{t.createdAt ? format(new Date(t.createdAt), 'MMM d') : '—'}
</td>
<td className="px-4 py-3 text-slate-400"><ChevronRight size={16} /></td>
</tr>
))}
</tbody>
</table>
</div> </div>
{!isLoading && tickets.length === 0 && ( </CardHeader>
<div className="text-center py-12 text-slate-400">No tickets found</div> <CardContent className="p-0">
<Table>
<TableHead>
<TableRow><Th>Subject</Th><Th>Client</Th><Th>Type</Th><Th>Priority</Th><Th>Status</Th><Th>Created</Th><Th></Th></TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i}><Td colSpan={7}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td></TableRow>
))
) : tickets.length === 0 ? (
<EmptyState colSpan={7} message="No tickets found" icon={<Ticket size={24} />} />
) : tickets.map(t => (
<TableRow key={t.id} onClick={() => setSelected(t)} className="cursor-pointer hover:bg-blue-50 transition-colors">
<Td className="font-medium max-w-[200px] truncate">{t.subject}</Td>
<Td className="text-sm text-gray-600">{t.client ? `${t.client.firstName} ${t.client.lastName}` : "—"}</Td>
<Td><Badge variant="muted">{t.type}</Badge></Td>
<Td><Badge variant={priorityVariant[t.priority] ?? "muted"}>{t.priority}</Badge></Td>
<Td><Badge variant={statusVariant[t.status] ?? "muted"}>{t.status}</Badge></Td>
<Td className="text-xs text-gray-400">{formatDate(t.createdAt)}</Td>
<Td className="text-gray-400 text-xs">View </Td>
</TableRow>
))}
</TableBody>
</Table>
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-gray-500">
<span>Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}</span>
<div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Prev</button>
<button disabled={page * 20 >= total} onClick={() => setPage(p => p + 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Next</button>
</div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Ticket Detail Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={`Ticket — ${detail?.subject ?? ""}`} className="max-w-2xl">
{detail && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm bg-gray-50 rounded-lg p-4">
<div><span className="text-gray-500">Client</span><p className="font-medium">{detail.client ? `${detail.client.firstName} ${detail.client.lastName}` : "—"}</p></div>
<div><span className="text-gray-500">Type</span><p><Badge variant="muted">{detail.type}</Badge></p></div>
<div><span className="text-gray-500">Priority</span><p><Badge variant={priorityVariant[detail.priority] ?? "muted"}>{detail.priority}</Badge></p></div>
<div><span className="text-gray-500">Status</span><p><Badge variant={statusVariant[detail.status] ?? "muted"}>{detail.status}</Badge></p></div>
<div className="col-span-2"><span className="text-gray-500">Created</span><p>{formatDate(detail.createdAt)}</p></div>
{detail.description && <div className="col-span-2"><span className="text-gray-500">Description</span><p className="mt-1 text-gray-800 whitespace-pre-wrap">{detail.description}</p></div>}
</div>
{/* Status actions */}
{nextStatus[detail.status] && (
<Button size="sm" onClick={() => updateStatus.mutate({ id: detail.id, status: nextStatus[detail.status] })} isLoading={updateStatus.isPending}>
Move to {nextStatus[detail.status].replace("_", " ")}
</Button>
)}
{/* Comments */}
<div>
<p className="text-sm font-semibold text-gray-700 mb-2">Comments ({comments.length})</p>
<div className="space-y-2 max-h-48 overflow-y-auto mb-3">
{comments.length === 0 ? <p className="text-sm text-gray-400">No comments yet.</p> :
comments.map((c: TicketComment) => (
<div key={c.id} className="bg-white border rounded-lg px-3 py-2 text-sm">
<p className="font-medium text-gray-700 text-xs">{c.author ? `${c.author.firstName} ${c.author.lastName}` : "Staff"} · {formatDate(c.createdAt)}</p>
<p className="text-gray-800 mt-0.5">{c.body}</p>
</div>
))
}
</div>
<div className="flex gap-2">
<input className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add a comment..." value={comment} onChange={e => setComment(e.target.value)}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && comment.trim() && addComment.mutate()} />
<Button size="sm" onClick={() => addComment.mutate()} isLoading={addComment.isPending} disabled={!comment.trim()}>
<Send size={14} />
</Button>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setSelected(null)}>Close</Button>
</div>
</div>
)}
</Modal>
{/* Create Ticket Modal */}
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="New Ticket">
<div className="space-y-4">
<Input label="Subject *" value={newForm.subject} onChange={e => setNewForm(f => ({ ...f, subject: e.target.value }))} />
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea className="border rounded-lg px-3 py-2 text-sm resize-none h-24 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={newForm.description} onChange={e => setNewForm(f => ({ ...f, description: e.target.value }))} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Type</label>
<select className="border rounded-lg px-3 py-2 text-sm" value={newForm.type} onChange={e => setNewForm(f => ({ ...f, type: e.target.value }))}>
<option value="SUPPORT">Support</option>
<option value="BILLING">Billing</option>
<option value="INSTALLATION">Installation</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Priority</label>
<select className="border rounded-lg px-3 py-2 text-sm" value={newForm.priority} onChange={e => setNewForm(f => ({ ...f, priority: e.target.value }))}>
<option value="NORMAL">Normal</option>
<option value="HIGH">High</option>
</select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Link to Client (optional)</label>
<input className="border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search client by name..." value={newForm.clientSearch}
onChange={e => setNewForm(f => ({ ...f, clientSearch: e.target.value, clientId: "" }))} />
{(clientSearch as any[]).length > 0 && !newForm.clientId && (
<div className="border rounded-lg divide-y max-h-40 overflow-y-auto shadow-sm">
{(clientSearch as any[]).map((c: any) => (
<button key={c.id} onClick={() => setNewForm(f => ({ ...f, clientId: c.id, clientSearch: `${c.firstName} ${c.lastName}` }))}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 transition-colors">
<span className="font-medium">{c.firstName} {c.lastName}</span>
<span className="text-gray-400 ml-2 text-xs">{c.accountNumber}</span>
</button>
))}
</div>
)}
{newForm.clientId && <p className="text-xs text-green-600"> Client linked</p>}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button onClick={() => createTicket.mutate()} isLoading={createTicket.isPending} disabled={!newForm.subject}>Create Ticket</Button>
</div>
</div>
</Modal>
</div> </div>
); );
} }