Files
fiberops-web/app/(app)/payments/page.tsx

178 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, CreditCard, AlertCircle } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/Card";
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { formatDate, formatCurrency } from "@/lib/utils";
import api from "@/lib/api";
interface Payment {
id: string; clientId: 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 };
}
interface PaymentsResponse { data: Payment[]; total: number; page: number; limit: number; }
const channelVariant: Record<string, "success" | "warning" | "muted" | "default"> = {
CASH: "success", GCASH: "default", MAYA: "default", BANK_TRANSFER: "warning", CHECK: "muted",
};
const channelFilters = ["", "CASH", "GCASH", "MAYA", "BANK_TRANSFER", "CHECK"];
export default function PaymentsPage() {
const [search, setSearch] = useState("");
const [channelFilter, setChannelFilter] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Payment | null>(null);
const { data, isLoading, isError, refetch } = useQuery<PaymentsResponse>({
queryKey: ["payments", search, channelFilter, page],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (search) params.set("search", search);
if (channelFilter) params.set("channel", channelFilter);
const res = await api.get<PaymentsResponse>(`/api/v1/payments?${params}`);
return res.data;
},
});
const payments = data?.data ?? [];
const total = data?.total ?? 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Payments</h1>
<p className="text-sm text-gray-500 mt-1">{total} total payments</p>
</div>
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3 items-center">
<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 client..."
value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
<div className="flex gap-2 flex-wrap">
{channelFilters.map(c => (
<button key={c} onClick={() => { setChannelFilter(c); 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"}
</button>
))}
</div>
</div>
</CardHeader>
<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>
))
) : isError ? (
<TableRow><Td colSpan={7}><div className="flex items-center gap-2 py-6 justify-center text-red-400 text-sm"><AlertCircle size={16} />Failed to load payments. <button onClick={() => refetch()} className="underline">Retry</button></div></Td></TableRow>
) : payments.length === 0 ? (
<EmptyState colSpan={7} message="No payments found" icon={<CreditCard size={24} />} />
) : payments.map(p => (
<TableRow key={p.id} data-testid="payment-row" 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>
</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>
);
}