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

210 lines
11 KiB
TypeScript
Raw 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, useMutation, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, FileText } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { formatDate, formatCurrency } from "@/lib/utils";
import api from "@/lib/api";
import { toast } from "sonner";
interface Invoice {
id: string; invoiceNumber: string; clientId: string;
client?: { firstName: string; lastName: string; accountNumber: string };
subtotal: string; lateFee: string; total: string;
amountPaid: string; balance: string;
status: string; dueDate: string; periodStart?: string; periodEnd?: string; notes?: string;
createdAt: string;
}
interface InvoicesResponse { data: Invoice[]; total: number; page: number; limit: number; }
const statusVariant: Record<string, "success" | "warning" | "danger" | "muted"> = {
PAID: "success", PARTIAL: "warning", OVERDUE: "danger",
SENT: "muted", DRAFT: "muted", VOID: "muted",
};
const statusFilters = ["", "SENT", "PARTIAL", "OVERDUE", "PAID", "VOID"];
export default function InvoicesPage() {
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Invoice | null>(null);
const [payForm, setPayForm] = useState({ amount: "", channel: "CASH", referenceNumber: "", notes: "" });
const { data, isLoading, refetch } = useQuery<InvoicesResponse>({
queryKey: ["invoices", search, statusFilter, page],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (search) params.set("search", search);
if (statusFilter) params.set("status", statusFilter);
const res = await api.get<InvoicesResponse>(`/api/v1/invoices?${params}`);
return res.data;
},
});
const recordPayment = useMutation({
mutationFn: async () => {
await api.post("/api/v1/payments", {
clientId: selected!.clientId, invoiceId: selected!.id,
amount: Number(payForm.amount), channel: payForm.channel,
referenceNumber: payForm.referenceNumber || undefined,
notes: payForm.notes || undefined,
paymentDate: new Date().toISOString(),
});
},
onSuccess: () => {
toast.success("Payment recorded!");
setSelected(null);
setPayForm({ amount: "", channel: "CASH", referenceNumber: "", notes: "" });
qc.invalidateQueries({ queryKey: ["invoices"] });
refetch();
},
onError: (e: any) => toast.error(e.response?.data?.message ?? "Payment failed"),
});
const voidInvoice = useMutation({
mutationFn: async (id: string) => { await api.patch(`/api/v1/invoices/${id}/void`); },
onSuccess: () => { toast.success("Invoice voided"); setSelected(null); qc.invalidateQueries({ queryKey: ["invoices"] }); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to void"),
});
const invoices = data?.data ?? [];
const total = data?.total ?? 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Invoices</h1>
<p className="text-sm text-gray-500 mt-1">{total} total invoices</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 or invoice #..."
value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
<div className="flex gap-2 flex-wrap">
{statusFilters.map(s => (
<button key={s} onClick={() => { setStatusFilter(s); setPage(1); }}
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"}`}>
{s || "All"}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<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 && (
<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>
{/* 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>
);
}