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

171 lines
7.0 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 { api } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Search, ChevronRight } from 'lucide-react';
import { format } from 'date-fns';
interface Invoice {
id: string;
invoiceNumber: string;
dueDate: string;
subtotal: string;
lateFee: string;
total: string;
amountPaid: string;
balance: string;
status: string;
client?: { firstName: string; lastName: string; accountNumber: string };
}
interface InvoicesResponse {
data: Invoice[];
total: number;
}
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) =>
'₱' + Number(v ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 });
export default function InvoicesPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const { data, isLoading } = 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(`/api/v1/invoices?${params}`);
return res.data;
},
staleTime: 30_000,
});
const invoices = data?.data ?? [];
const total = data?.total ?? 0;
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-800">Invoices</h1>
<p className="text-slate-500 text-sm mt-1">{total} invoices</p>
</div>
</div>
<div className="flex gap-3 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Search by invoice # or client..."
className="pl-9"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="border rounded-md px-3 py-2 text-sm text-slate-700 bg-white"
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
>
<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>
{!isLoading && invoices.length === 0 && (
<div className="text-center py-12 text-slate-400">No invoices found</div>
)}
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-slate-500">
Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}
</p>
<div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)}
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 text-sm border rounded-md disabled:opacity-40">Next</button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}