almeira-web/facturacion-dian-hub/frontend/src/components/InvoicesTable.tsx

264 lines
11 KiB
TypeScript

import React, { useState } from 'react';
import { useHubStore } from '../store/useHubStore.js';
import { Invoice } from '../types/index.js';
import { hubApi } from '../api/hubApi.js';
import {
Search,
Eye,
FileCode,
FileDown,
ExternalLink,
CheckCircle2,
AlertCircle,
Clock,
Sparkles,
} from 'lucide-react';
export const InvoicesTable: React.FC = () => {
const { invoices, setSelectedInvoice, tenants } = useHubStore();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACEPTADA' | 'RECHAZADA'>('ALL');
const getTenantName = (tenantId: string) => {
const t = tenants.find((tenant) => tenant.id === tenantId);
return t ? t.name : 'Innovación & Desarrollo';
};
const getTenantBadge = (tenantId: string) => {
const t = tenants.find((tenant) => tenant.id === tenantId);
if (t?.slug === 'dripzone') {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-purple-500/10 px-2 py-0.5 text-[11px] font-semibold text-purple-400 border border-purple-500/20">
👟 DripZone
</span>
);
}
if (t?.slug === 'pacifictrans') {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-blue-500/10 px-2 py-0.5 text-[11px] font-semibold text-blue-400 border border-blue-500/20">
🚛 PacificTrans
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-md bg-slate-500/10 px-2 py-0.5 text-[11px] font-semibold text-slate-400 border border-slate-500/20">
🏢 Emisor
</span>
);
};
const filteredInvoices = invoices.filter((inv) => {
const matchesSearch =
inv.fullNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.customer.legalOrFullName.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.customer.identificationNumber.includes(searchTerm) ||
(inv.cufe && inv.cufe.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesStatus = statusFilter === 'ALL' || inv.dianStatus === statusFilter;
return matchesSearch && matchesStatus;
});
const formatCop = (val: number) => {
return new Intl.NumberFormat('es-CO', {
style: 'currency',
currency: 'COP',
minimumFractionDigits: 0,
}).format(val);
};
const handleDownloadXml = async (inv: Invoice, e: React.MouseEvent) => {
e.stopPropagation();
try {
const xml = await hubApi.getXml(inv.id);
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `face_${inv.fullNumber}.xml`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert('Error al descargar el archivo XML.');
}
};
const handleDownloadPdf = (inv: Invoice, e: React.MouseEvent) => {
e.stopPropagation();
window.open(hubApi.getPdfUrl(inv.id), '_blank');
};
return (
<div className="glass-card rounded-2xl border border-slate-800 overflow-hidden">
{/* Header & Controls */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 p-5 border-b border-slate-800/80 bg-slate-900/40">
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
Comprobantes Electrónicos UBL 2.1
<span className="text-xs font-normal text-slate-400">({filteredInvoices.length} facturas)</span>
</h2>
<p className="text-xs text-slate-400">
Historial de facturas firmadas con XAdES-BES y transmitidas a la DIAN.
</p>
</div>
<div className="flex flex-wrap items-center gap-2.5 w-full sm:w-auto">
{/* Search Input */}
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-500" />
<input
type="text"
placeholder="Buscar por número, cliente, NIT o CUFE..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-lg border border-slate-800 bg-slate-950/80 py-2 pl-9 pr-3 text-xs text-slate-200 placeholder-slate-500 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-1 rounded-lg border border-slate-800 bg-slate-950 p-1">
<button
onClick={() => setStatusFilter('ALL')}
className={`rounded px-2.5 py-1 text-xs font-medium transition-all ${
statusFilter === 'ALL' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:text-white'
}`}
>
Todas
</button>
<button
onClick={() => setStatusFilter('ACEPTADA')}
className={`rounded px-2.5 py-1 text-xs font-medium transition-all ${
statusFilter === 'ACEPTADA' ? 'bg-emerald-500/20 text-emerald-400 font-semibold' : 'text-slate-400 hover:text-white'
}`}
>
Aceptadas
</button>
<button
onClick={() => setStatusFilter('RECHAZADA')}
className={`rounded px-2.5 py-1 text-xs font-medium transition-all ${
statusFilter === 'RECHAZADA' ? 'bg-red-500/20 text-red-400 font-semibold' : 'text-slate-400 hover:text-white'
}`}
>
Rechazadas
</button>
</div>
</div>
</div>
{/* Invoices Table */}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950/60 uppercase tracking-wider text-[11px] text-slate-400 border-b border-slate-800">
<tr>
<th className="px-5 py-3.5 font-semibold">Comprobante</th>
<th className="px-5 py-3.5 font-semibold">Tenant</th>
<th className="px-5 py-3.5 font-semibold">Cliente / Adquiriente</th>
<th className="px-5 py-3.5 font-semibold">Fecha Emisión</th>
<th className="px-5 py-3.5 font-semibold text-right">Total (COP)</th>
<th className="px-5 py-3.5 font-semibold text-center">Estado DIAN</th>
<th className="px-5 py-3.5 font-semibold text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-medium">
{filteredInvoices.length === 0 ? (
<tr>
<td colSpan={7} className="py-12 text-center text-slate-500">
No se encontraron comprobantes fiscales que coincidan con la búsqueda.
</td>
</tr>
) : (
filteredInvoices.map((inv) => (
<tr
key={inv.id}
onClick={() => setSelectedInvoice(inv)}
className="cursor-pointer transition-colors hover:bg-slate-800/40"
>
{/* Comprobante */}
<td className="px-5 py-4">
<div className="font-mono font-bold text-white flex items-center gap-1.5">
{inv.fullNumber}
</div>
<div className="text-[10px] text-slate-500 font-mono truncate max-w-[140px]">
CUFE: {inv.cufe?.slice(0, 16)}...
</div>
</td>
{/* Tenant */}
<td className="px-5 py-4">{getTenantBadge(inv.tenantId)}</td>
{/* Cliente */}
<td className="px-5 py-4">
<div className="font-semibold text-slate-200">{inv.customer.legalOrFullName}</div>
<div className="text-[11px] text-slate-400">
NIT/CC: {inv.customer.identificationNumber}
</div>
</td>
{/* Fecha */}
<td className="px-5 py-4">
<div className="text-slate-200">{inv.issueDate}</div>
<div className="text-[11px] text-slate-500 font-mono">{inv.issueTime}</div>
</td>
{/* Total */}
<td className="px-5 py-4 text-right">
<div className="font-bold text-white text-sm">{formatCop(inv.grandTotal)}</div>
<div className="text-[10px] text-emerald-400">IVA: {formatCop(inv.taxTotal)}</div>
</td>
{/* Estado DIAN */}
<td className="px-5 py-4 text-center">
{inv.dianStatus === 'ACEPTADA' ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="h-3 w-3" />
Aceptada
</span>
) : inv.dianStatus === 'RECHAZADA' ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-2.5 py-1 text-[11px] font-semibold text-red-400 border border-red-500/20">
<AlertCircle className="h-3 w-3" />
Rechazada
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-1 text-[11px] font-semibold text-amber-400 border border-amber-500/20">
<Clock className="h-3 w-3" />
{inv.dianStatus}
</span>
)}
</td>
{/* Acciones */}
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setSelectedInvoice(inv)}
title="Ver Detalles y CUFE"
className="rounded-lg border border-slate-800 bg-slate-900 p-1.5 text-slate-400 hover:text-white hover:border-slate-700"
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => handleDownloadXml(inv, e)}
title="Descargar XML UBL 2.1 Firmado"
className="rounded-lg border border-slate-800 bg-slate-900 p-1.5 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30"
>
<FileCode className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => handleDownloadPdf(inv, e)}
title="Ver PDF Gráfico"
className="rounded-lg border border-slate-800 bg-slate-900 p-1.5 text-slate-400 hover:text-blue-400 hover:border-blue-500/30"
>
<FileDown className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};