457 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|