264 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|