ifixkart-admin/app/(admin)/invoices/page.tsx

457 lines
20 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import {
Search,
RefreshCw,
Download,
Eye,
FileText,
FileSpreadsheet,
Printer,
X,
} from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { apiFetch } from '@/services/api/client';
import { SlideOver } from '@/components/ui/SlideOver';
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
interface AdminInvoiceResponse {
invoice_id: string;
invoice_no: string;
order_id: string | null;
customer_id: string;
customer_email: string | null;
subtotal: number;
discount_amount: number;
cgst: number;
sgst: number;
igst: number;
total_amount: number;
status: string;
pdf_url: string | null;
created_at: string;
}
function statusBadgeClass(status?: string) {
const s = (status || '').toUpperCase();
if (s.includes('CANCEL') || s.includes('VOID') || s.includes('UNPAID')) return 'bg-destructive text-white';
if (s.includes('PENDING') || s.includes('DRAFT')) return 'bg-warning text-white';
return 'bg-success text-white';
}
export default function InvoiceIntelligencePage() {
const [invoices, setInvoices] = useState<AdminInvoiceResponse[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedInvoiceId, setSelectedInvoiceId] = useState<string | null>(null);
const [detailData, setDetailData] = useState<AdminInvoiceResponse | null>(null);
const [previewModal, setPreviewModal] = useState<{
open: boolean;
invoice: AdminInvoiceResponse | null;
format: 'standard' | 'thermal';
}>({ open: false, invoice: null, format: 'standard' });
const [previewPdfUrl, setPreviewPdfUrl] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const backendUrl = process.env.NEXT_PUBLIC_API_URL || '';
const fetchInvoices = async () => {
setLoading(true);
try {
const data = await apiFetch<AdminInvoiceResponse[]>('/api/v1/admin/invoices');
setInvoices(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch invoices');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchInvoices();
}, []);
useEffect(() => {
return () => {
if (previewPdfUrl) URL.revokeObjectURL(previewPdfUrl);
};
}, [previewPdfUrl]);
const handleInspectInvoice = (invoice: AdminInvoiceResponse) => {
setSelectedInvoiceId(invoice.invoice_id);
setDetailData(invoice);
};
const closeDetails = () => {
setSelectedInvoiceId(null);
setDetailData(null);
};
const handleExport = (type: 'excel' | 'zip') => {
window.open(`${backendUrl}/api/v1/admin/invoices/export/${type}`, '_blank');
toast.success(type === 'excel' ? 'Invoice Excel download started' : 'Invoice ZIP download started');
};
const closePreview = () => {
if (previewPdfUrl) {
URL.revokeObjectURL(previewPdfUrl);
setPreviewPdfUrl(null);
}
setPreviewModal({ open: false, invoice: null, format: 'standard' });
};
const handleOpenPreview = async (inv: AdminInvoiceResponse, formatType: 'standard' | 'thermal') => {
setPreviewModal({ open: true, invoice: inv, format: formatType });
setPreviewLoading(true);
if (previewPdfUrl) {
URL.revokeObjectURL(previewPdfUrl);
setPreviewPdfUrl(null);
}
try {
const ep = formatType === 'thermal' ? 'thermal-download' : 'download';
const res = await fetch(`${backendUrl}/api/v1/admin/invoices/${inv.invoice_id}/${ep}`);
if (!res.ok) throw new Error('PDF compilation failed');
const blob = await res.blob();
setPreviewPdfUrl(URL.createObjectURL(blob));
} catch {
toast.error('Could not load the invoice preview');
} finally {
setPreviewLoading(false);
}
};
const statusCounts = useMemo(() => {
const map = new Map<string, number>();
invoices.forEach((inv) => {
const key = inv.status || 'Unknown';
map.set(key, (map.get(key) || 0) + 1);
});
return Array.from(map.entries());
}, [invoices]);
const filteredInvoices = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return invoices.filter((inv) => {
const statusMatch = statusFilter === 'all' || (inv.status || 'Unknown') === statusFilter;
const searchMatch =
!q ||
inv.invoice_no.toLowerCase().includes(q) ||
(inv.customer_email || '').toLowerCase().includes(q) ||
(inv.order_id || '').toLowerCase().includes(q);
return statusMatch && searchMatch;
});
}, [invoices, searchQuery, statusFilter]);
const pager = useClientPagination(filteredInvoices);
const dataCardShell =
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
const primaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
const secondaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50';
const renderStatusBadge = (status: string) => (
<span
className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${statusBadgeClass(status)}`}
>
{status || 'Issued'}
</span>
);
return (
<div className="flex flex-col gap-5 min-w-0">
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-foreground">Sales Invoices</h1>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
{invoices.length}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
<button
type="button"
onClick={fetchInvoices}
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
title="Reload"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={() => handleExport('excel')} className={secondaryButton}>
<FileText className="w-3.5 h-3.5" />
Export Excel
</button>
<button type="button" onClick={() => handleExport('zip')} className={secondaryButton}>
<Download className="w-3.5 h-3.5" />
ZIP archive
</button>
</div>
</div>
<div className={dataCardShell}>
<div className="px-5 pt-5">
<div className="-mx-5 px-5 flex flex-wrap items-center justify-between gap-3 min-w-0 pb-4 border-b border-border">
<div className="relative flex-1 min-w-[180px] max-w-[480px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="text"
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
/>
</div>
{statusCounts.length > 0 && (
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1 shrink-0 overflow-x-auto max-w-full">
<button
type="button"
onClick={() => setStatusFilter('all')}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap ${
statusFilter === 'all' ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
All ({invoices.length})
</button>
{statusCounts.map(([status, count]) => (
<button
key={status}
type="button"
onClick={() => setStatusFilter(status)}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap ${
statusFilter === status ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
{status} ({count})
</button>
))}
</div>
)}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<span className="text-[13px] text-muted-foreground">Loading invoices...</span>
</div>
) : (
<div className="overflow-x-auto">
<table className="crm-data-table min-w-[860px]">
<thead>
<tr className="bg-gray-50 border-y border-gray-200">
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Invoice no.</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Customer</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Date</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Amount</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Status</th>
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">Action</th>
</tr>
</thead>
<tbody className="bg-card">
{filteredInvoices.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-[13px] text-muted-foreground">
{searchQuery || statusFilter !== 'all' ? 'No invoices match your search.' : 'No invoices found.'}
</td>
</tr>
) : (
pager.items.map((invoice) => (
<tr
key={invoice.invoice_id}
className={`border-t border-border hover:bg-muted/10 cursor-pointer ${
selectedInvoiceId === invoice.invoice_id ? 'bg-muted/20' : ''
}`}
onClick={() => handleInspectInvoice(invoice)}
>
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground font-mono">
{invoice.invoice_no}
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
{invoice.customer_email || 'Walk-in guest'}
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
{format(new Date(invoice.created_at), 'dd-MMM-yyyy')}
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{invoice.total_amount.toFixed(2)}</td>
<td className="px-5 py-3.5">{renderStatusBadge(invoice.status)}</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-center gap-1.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleOpenPreview(invoice, 'standard');
}}
className="inline-flex items-center h-8 px-3 crm-radius-control border border-border bg-card text-foreground text-[12px] font-medium hover:bg-muted cursor-pointer"
>
Preview
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleInspectInvoice(invoice);
}}
className="w-8 h-8 crm-radius-control border border-border text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer flex items-center justify-center"
aria-label="View invoice"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<TablePagination {...pager} />
</div>
<SlideOver
open={!!selectedInvoiceId && !!detailData}
onClose={closeDetails}
title={detailData ? `Invoice ${detailData.invoice_no}` : 'Invoice details'}
icon={<FileSpreadsheet className="w-4 h-4 text-primary shrink-0" />}
>
{detailData && (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[12px] text-muted-foreground">
{format(new Date(detailData.created_at), 'dd-MMM-yyyy HH:mm')}
</p>
<div className="mt-2">{renderStatusBadge(detailData.status)}</div>
</div>
</div>
<div className="space-y-3 text-[13px]">
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase mb-1">Related order</p>
<p className="font-mono text-foreground">{detailData.order_id || '—'}</p>
</div>
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase mb-1">Billed to</p>
<p className="text-foreground">{detailData.customer_email || 'Walk-in guest'}</p>
<p className="text-[12px] text-muted-foreground font-mono mt-0.5">ID: {detailData.customer_id}</p>
</div>
</div>
<div className="p-3 border border-border crm-radius-card bg-muted/10 space-y-1.5 text-[13px]">
<div className="flex justify-between text-muted-foreground">
<span>Taxable value</span>
<span>{detailData.subtotal.toFixed(2)}</span>
</div>
{detailData.discount_amount > 0 && (
<div className="flex justify-between text-success">
<span>Discount</span>
<span>-{detailData.discount_amount.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-muted-foreground">
<span>CGST</span>
<span>{detailData.cgst.toFixed(2)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>SGST</span>
<span>{detailData.sgst.toFixed(2)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>IGST</span>
<span>{detailData.igst.toFixed(2)}</span>
</div>
<div className="flex justify-between font-semibold text-foreground border-t border-border pt-1.5">
<span>Grand total</span>
<span>{detailData.total_amount.toFixed(2)}</span>
</div>
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex flex-wrap items-center justify-end gap-2 bg-card">
<button type="button" onClick={() => handleOpenPreview(detailData, 'standard')} className={secondaryButton}>
<Eye className="w-3.5 h-3.5" />
View invoice
</button>
<button type="button" onClick={() => handleOpenPreview(detailData, 'thermal')} className={primaryButton}>
<Printer className="w-3.5 h-3.5" />
View receipt
</button>
</div>
</div>
)}
</SlideOver>
{previewModal.open && previewModal.invoice && (
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4">
<button
type="button"
aria-label="Close preview overlay"
className="absolute inset-0 bg-black/50 cursor-default"
onClick={closePreview}
/>
<div className="relative z-[91] w-full max-w-4xl h-[88vh] flex flex-col bg-card border border-border crm-radius-section shadow-xl overflow-hidden">
<div className="shrink-0 h-12 px-5 flex items-center justify-between border-b border-border gap-3">
<div className="min-w-0">
<p className="text-[16px] font-semibold text-foreground truncate">
{previewModal.invoice.invoice_no}
<span className="text-[13px] font-medium text-muted-foreground ml-2">
{previewModal.format === 'thermal' ? 'Thermal receipt' : 'GST invoice'}
</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => {
const ep = previewModal.format === 'thermal' ? 'thermal-download' : 'download';
window.open(`${backendUrl}/api/v1/admin/invoices/${previewModal.invoice?.invoice_id}/${ep}`, '_blank');
}}
className={secondaryButton}
>
<Printer className="w-3.5 h-3.5" />
Open / print
</button>
<button
type="button"
onClick={closePreview}
className="w-8 h-8 crm-radius-control text-primary hover:bg-muted cursor-pointer flex items-center justify-center"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 bg-muted/30 p-2 flex items-center justify-center">
{previewLoading ? (
<div className="flex flex-col items-center justify-center gap-2.5 py-16">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<span className="text-[13px] text-muted-foreground">Loading preview...</span>
</div>
) : previewPdfUrl ? (
<iframe
src={previewPdfUrl}
className="w-full h-full bg-white border border-border crm-radius-card"
title="GST invoice preview"
/>
) : (
<p className="text-[13px] text-muted-foreground text-center px-6">
Unable to render preview. Use Open / print to view the document.
</p>
)}
</div>
</div>
</div>
)}
</div>
);
}