feat: bulk XML upload, period selector, and session persistence

- Add bulk XML CFDI upload support (up to 300MB)
- Add period selector component for month/year navigation
- Fix session persistence on page refresh (Zustand hydration)
- Fix income/expense classification based on tenant RFC
- Fix IVA calculation from XML (correct Impuestos element)
- Add error handling to reportes page
- Support multiple CORS origins
- Update reportes service with proper Decimal/BigInt handling
- Add RFC to tenant view store for proper CFDI classification
- Update README with changelog and new features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-22 06:51:53 +00:00
parent 0c10c887d2
commit c3ce7199af
37 changed files with 1680 additions and 216 deletions

View File

@@ -1,22 +1,226 @@
'use client';
import { useState } from 'react';
import { useState, useRef } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCfdis } from '@/lib/hooks/use-cfdi';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useCfdis, useCreateCfdi, useCreateManyCfdis, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
import { FileText, Search, ChevronLeft, ChevronRight } from 'lucide-react';
import type { CreateCfdiData } from '@/lib/api/cfdi';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store';
type CfdiTipo = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
const initialFormData: CreateCfdiData = {
uuidFiscal: '',
tipo: 'ingreso',
serie: '',
folio: '',
fechaEmision: new Date().toISOString().split('T')[0],
fechaTimbrado: new Date().toISOString().split('T')[0],
rfcEmisor: '',
nombreEmisor: '',
rfcReceptor: '',
nombreReceptor: '',
subtotal: 0,
descuento: 0,
iva: 0,
isrRetenido: 0,
ivaRetenido: 0,
total: 0,
moneda: 'MXN',
metodoPago: 'PUE',
formaPago: '03',
usoCfdi: 'G03',
};
// Helper function to find element regardless of namespace prefix
function findElement(doc: Document, localName: string): Element | null {
// Try common prefixes first (most reliable for CFDI)
const prefixes = ['cfdi', 'tfd', 'pago20', 'pago10', 'nomina12', ''];
for (const prefix of prefixes) {
const tagName = prefix ? `${prefix}:${localName}` : localName;
const el = doc.getElementsByTagName(tagName)[0] as Element;
if (el) return el;
}
// Try with wildcard - search all elements by localName
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === localName) {
return elements[i];
}
}
return null;
}
// Parse CFDI XML and extract data
function parseCfdiXml(xmlString: string, tenantRfc: string): CreateCfdiData | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
// Check for parse errors
const parseError = doc.querySelector('parsererror');
if (parseError) {
console.error('XML parse error:', parseError.textContent);
return null;
}
// Get the Comprobante element (root)
const comprobante = findElement(doc, 'Comprobante');
if (!comprobante) {
console.error('No se encontro elemento Comprobante');
return null;
}
// Get TimbreFiscalDigital for UUID
const timbre = findElement(doc, 'TimbreFiscalDigital');
const uuid = timbre?.getAttribute('UUID') || '';
const fechaTimbradoRaw = timbre?.getAttribute('FechaTimbrado') || '';
// Get Emisor
const emisor = findElement(doc, 'Emisor');
const rfcEmisor = emisor?.getAttribute('Rfc') || emisor?.getAttribute('rfc') || '';
const nombreEmisor = emisor?.getAttribute('Nombre') || emisor?.getAttribute('nombre') || '';
// Get Receptor
const receptor = findElement(doc, 'Receptor');
const rfcReceptor = receptor?.getAttribute('Rfc') || receptor?.getAttribute('rfc') || '';
const nombreReceptor = receptor?.getAttribute('Nombre') || receptor?.getAttribute('nombre') || '';
const usoCfdi = receptor?.getAttribute('UsoCFDI') || '';
// Determine type based on tenant RFC
// If tenant is emisor -> ingreso (we issued the invoice)
// If tenant is receptor -> egreso (we received the invoice)
const tenantRfcUpper = tenantRfc.toUpperCase();
let tipoFinal: CreateCfdiData['tipo'];
if (rfcEmisor.toUpperCase() === tenantRfcUpper) {
tipoFinal = 'ingreso';
} else if (rfcReceptor.toUpperCase() === tenantRfcUpper) {
tipoFinal = 'egreso';
} else {
// Fallback: use TipoDeComprobante
const tipoComprobante = comprobante.getAttribute('TipoDeComprobante') || 'I';
tipoFinal = tipoComprobante === 'E' ? 'egreso' : 'ingreso';
}
// Get impuestos - search for the Impuestos element that is direct child of Comprobante
// (not the ones inside Conceptos)
let totalImpuestosTrasladados = 0;
let totalImpuestosRetenidos = 0;
// Try to get TotalImpuestosTrasladados from Comprobante's direct Impuestos child
const allImpuestos = doc.getElementsByTagName('*');
for (let i = 0; i < allImpuestos.length; i++) {
const el = allImpuestos[i];
if (el.localName === 'Impuestos' && el.parentElement?.localName === 'Comprobante') {
totalImpuestosTrasladados = parseFloat(el.getAttribute('TotalImpuestosTrasladados') || '0');
totalImpuestosRetenidos = parseFloat(el.getAttribute('TotalImpuestosRetenidos') || '0');
break;
}
}
// Fallback: calculate IVA from total - subtotal if not found
const subtotal = parseFloat(comprobante.getAttribute('SubTotal') || '0');
const descuento = parseFloat(comprobante.getAttribute('Descuento') || '0');
const total = parseFloat(comprobante.getAttribute('Total') || '0');
if (totalImpuestosTrasladados === 0 && total > subtotal) {
totalImpuestosTrasladados = Math.max(0, total - subtotal + descuento + totalImpuestosRetenidos);
}
// Get retenciones breakdown
let isrRetenido = 0;
let ivaRetenido = 0;
const retenciones = doc.querySelectorAll('[localName="Retencion"], Retencion, cfdi\\:Retencion');
retenciones.forEach((ret: Element) => {
const impuesto = ret.getAttribute('Impuesto');
const importe = parseFloat(ret.getAttribute('Importe') || '0');
if (impuesto === '001') isrRetenido = importe; // ISR
if (impuesto === '002') ivaRetenido = importe; // IVA
});
// Parse dates - handle both ISO format and datetime format
const fechaEmisionRaw = comprobante.getAttribute('Fecha') || '';
const fechaEmision = fechaEmisionRaw.includes('T') ? fechaEmisionRaw.split('T')[0] : fechaEmisionRaw;
const fechaTimbrado = fechaTimbradoRaw.includes('T') ? fechaTimbradoRaw.split('T')[0] : fechaTimbradoRaw;
// Validate required fields
if (!uuid) {
console.error('UUID no encontrado en el XML');
return null;
}
if (!rfcEmisor || !rfcReceptor) {
console.error('RFC emisor o receptor no encontrado');
return null;
}
if (!fechaEmision) {
console.error('Fecha de emision no encontrada');
return null;
}
return {
uuidFiscal: uuid.toUpperCase(),
tipo: tipoFinal,
serie: comprobante.getAttribute('Serie') || '',
folio: comprobante.getAttribute('Folio') || '',
fechaEmision,
fechaTimbrado: fechaTimbrado || fechaEmision,
rfcEmisor,
nombreEmisor: nombreEmisor || 'Sin nombre',
rfcReceptor,
nombreReceptor: nombreReceptor || 'Sin nombre',
subtotal,
descuento,
iva: totalImpuestosTrasladados,
isrRetenido,
ivaRetenido,
total,
moneda: comprobante.getAttribute('Moneda') || 'MXN',
tipoCambio: parseFloat(comprobante.getAttribute('TipoCambio') || '1'),
metodoPago: comprobante.getAttribute('MetodoPago') || '',
formaPago: comprobante.getAttribute('FormaPago') || '',
usoCfdi,
};
} catch (error) {
console.error('Error parsing XML:', error);
return null;
}
}
export default function CfdiPage() {
const { user } = useAuthStore();
const { viewingTenantRfc } = useTenantViewStore();
const fileInputRef = useRef<HTMLInputElement>(null);
// Get the effective tenant RFC (viewing tenant or user's tenant)
const tenantRfc = viewingTenantRfc || user?.tenantRfc || '';
const [filters, setFilters] = useState<CfdiFilters>({
page: 1,
limit: 20,
});
const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false);
const [showBulkForm, setShowBulkForm] = useState(false);
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
const [bulkData, setBulkData] = useState('');
const [xmlFiles, setXmlFiles] = useState<File[]>([]);
const [parsedXmls, setParsedXmls] = useState<{ file: string; data: CreateCfdiData | null; error?: string }[]>([]);
const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml');
const { data, isLoading } = useCfdis(filters);
const createCfdi = useCreateCfdi();
const createManyCfdis = useCreateManyCfdis();
const deleteCfdi = useDeleteCfdi();
const canEdit = user?.role === 'admin' || user?.role === 'contador';
const handleSearch = () => {
setFilters({ ...filters, search: searchTerm, page: 1 });
@@ -26,6 +230,112 @@ export default function CfdiPage() {
setFilters({ ...filters, tipo, page: 1 });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createCfdi.mutateAsync(formData);
setFormData(initialFormData);
setShowForm(false);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al crear CFDI');
}
};
const handleBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const cfdis = JSON.parse(bulkData);
if (!Array.isArray(cfdis)) {
throw new Error('El formato debe ser un array de CFDIs');
}
const result = await createManyCfdis.mutateAsync(cfdis);
alert(`Se crearon ${result.count} CFDIs exitosamente`);
setBulkData('');
setShowBulkForm(false);
} catch (error: any) {
alert(error.message || 'Error al procesar CFDIs');
}
};
const handleXmlFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setXmlFiles(files);
// Parse each XML file
const parsed = await Promise.all(
files.map(async (file) => {
try {
const text = await file.text();
const data = parseCfdiXml(text, tenantRfc);
if (!data) {
return { file: file.name, data: null, error: 'No se pudo parsear el XML' };
}
if (!data.uuidFiscal) {
return { file: file.name, data: null, error: 'UUID no encontrado en el XML' };
}
return { file: file.name, data };
} catch (error) {
return { file: file.name, data: null, error: 'Error al leer el archivo' };
}
})
);
setParsedXmls(parsed);
};
const handleXmlBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validCfdis = parsedXmls
.filter((p) => p.data !== null)
.map((p) => p.data as CreateCfdiData);
if (validCfdis.length === 0) {
alert('No hay CFDIs validos para cargar');
return;
}
try {
const result = await createManyCfdis.mutateAsync(validCfdis);
alert(`Se crearon ${result.count} CFDIs exitosamente`);
setXmlFiles([]);
setParsedXmls([]);
setShowBulkForm(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error: any) {
alert(error.response?.data?.message || 'Error al cargar CFDIs');
}
};
const clearXmlFiles = () => {
setXmlFiles([]);
setParsedXmls([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDelete = async (id: string) => {
if (confirm('¿Eliminar este CFDI?')) {
try {
await deleteCfdi.mutateAsync(id);
} catch (error) {
console.error('Error deleting CFDI:', error);
}
}
};
const calculateTotal = () => {
const subtotal = formData.subtotal || 0;
const descuento = formData.descuento || 0;
const iva = formData.iva || 0;
const isrRetenido = formData.isrRetenido || 0;
const ivaRetenido = formData.ivaRetenido || 0;
return subtotal - descuento + iva - isrRetenido - ivaRetenido;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
@@ -39,6 +349,14 @@ export default function CfdiPage() {
year: 'numeric',
});
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}).toUpperCase();
};
return (
<>
<Header title="Gestion de CFDI" />
@@ -81,10 +399,378 @@ export default function CfdiPage() {
Egresos
</Button>
</div>
{canEdit && (
<div className="flex gap-2">
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
<Plus className="h-4 w-4 mr-1" />
Agregar
</Button>
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
<Upload className="h-4 w-4 mr-1" />
Carga Masiva
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Add CFDI Form */}
{showForm && canEdit && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Agregar CFDI</CardTitle>
<CardDescription>Ingresa los datos del comprobante fiscal</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>UUID Fiscal</Label>
<div className="flex gap-2">
<Input
value={formData.uuidFiscal}
onChange={(e) => setFormData({ ...formData, uuidFiscal: e.target.value.toUpperCase() })}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
required
/>
<Button type="button" variant="outline" onClick={() => setFormData({ ...formData, uuidFiscal: generateUUID() })}>
Gen
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Tipo</Label>
<Select
value={formData.tipo}
onValueChange={(v) => setFormData({ ...formData, tipo: v as CfdiTipo })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ingreso">Ingreso</SelectItem>
<SelectItem value="egreso">Egreso</SelectItem>
<SelectItem value="traslado">Traslado</SelectItem>
<SelectItem value="nomina">Nomina</SelectItem>
<SelectItem value="pago">Pago</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Serie</Label>
<Input
value={formData.serie}
onChange={(e) => setFormData({ ...formData, serie: e.target.value })}
placeholder="A"
/>
</div>
<div className="space-y-2">
<Label>Folio</Label>
<Input
value={formData.folio}
onChange={(e) => setFormData({ ...formData, folio: e.target.value })}
placeholder="001"
/>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Fecha Emision</Label>
<Input
type="date"
value={formData.fechaEmision}
onChange={(e) => setFormData({ ...formData, fechaEmision: e.target.value, fechaTimbrado: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label>Fecha Timbrado</Label>
<Input
type="date"
value={formData.fechaTimbrado}
onChange={(e) => setFormData({ ...formData, fechaTimbrado: e.target.value })}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="p-4 border rounded-lg space-y-3">
<h4 className="font-medium">Emisor</h4>
<div className="space-y-2">
<Label>RFC Emisor</Label>
<Input
value={formData.rfcEmisor}
onChange={(e) => setFormData({ ...formData, rfcEmisor: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label>Nombre Emisor</Label>
<Input
value={formData.nombreEmisor}
onChange={(e) => setFormData({ ...formData, nombreEmisor: e.target.value })}
placeholder="Empresa Emisora SA de CV"
required
/>
</div>
</div>
<div className="p-4 border rounded-lg space-y-3">
<h4 className="font-medium">Receptor</h4>
<div className="space-y-2">
<Label>RFC Receptor</Label>
<Input
value={formData.rfcReceptor}
onChange={(e) => setFormData({ ...formData, rfcReceptor: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label>Nombre Receptor</Label>
<Input
value={formData.nombreReceptor}
onChange={(e) => setFormData({ ...formData, nombreReceptor: e.target.value })}
placeholder="Empresa Receptora SA de CV"
required
/>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-6">
<div className="space-y-2">
<Label>Subtotal</Label>
<Input
type="number"
step="0.01"
value={formData.subtotal}
onChange={(e) => setFormData({ ...formData, subtotal: parseFloat(e.target.value) || 0 })}
required
/>
</div>
<div className="space-y-2">
<Label>Descuento</Label>
<Input
type="number"
step="0.01"
value={formData.descuento}
onChange={(e) => setFormData({ ...formData, descuento: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>IVA</Label>
<Input
type="number"
step="0.01"
value={formData.iva}
onChange={(e) => setFormData({ ...formData, iva: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>ISR Ret.</Label>
<Input
type="number"
step="0.01"
value={formData.isrRetenido}
onChange={(e) => setFormData({ ...formData, isrRetenido: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>IVA Ret.</Label>
<Input
type="number"
step="0.01"
value={formData.ivaRetenido}
onChange={(e) => setFormData({ ...formData, ivaRetenido: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>Total</Label>
<Input
type="number"
step="0.01"
value={formData.total || calculateTotal()}
onChange={(e) => setFormData({ ...formData, total: parseFloat(e.target.value) || 0 })}
required
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
Cancelar
</Button>
<Button type="submit" disabled={createCfdi.isPending}>
{createCfdi.isPending ? 'Guardando...' : 'Guardar CFDI'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Bulk Upload Form */}
{showBulkForm && canEdit && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Carga Masiva de CFDIs</CardTitle>
<CardDescription>Sube archivos XML o pega datos en formato JSON</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Mode selector */}
<div className="flex gap-2 mb-4">
<Button
type="button"
variant={uploadMode === 'xml' ? 'default' : 'outline'}
size="sm"
onClick={() => setUploadMode('xml')}
>
<FileUp className="h-4 w-4 mr-1" />
Subir XMLs
</Button>
<Button
type="button"
variant={uploadMode === 'json' ? 'default' : 'outline'}
size="sm"
onClick={() => setUploadMode('json')}
>
JSON
</Button>
</div>
{uploadMode === 'xml' ? (
<form onSubmit={handleXmlBulkSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Archivos XML de CFDI</Label>
<div className="border-2 border-dashed rounded-lg p-6 text-center">
<input
ref={fileInputRef}
type="file"
accept=".xml"
multiple
onChange={handleXmlFilesChange}
className="hidden"
id="xml-upload"
/>
<label htmlFor="xml-upload" className="cursor-pointer">
<FileUp className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
Haz clic para seleccionar archivos XML o arrastralos aqui
</p>
<p className="text-xs text-muted-foreground mt-1">
Puedes seleccionar multiples archivos
</p>
</label>
</div>
</div>
{/* Show parsed results */}
{parsedXmls.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Archivos procesados ({parsedXmls.filter(p => p.data).length} validos de {parsedXmls.length})</Label>
<Button type="button" variant="ghost" size="sm" onClick={clearXmlFiles}>
Limpiar
</Button>
</div>
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
{parsedXmls.map((parsed, idx) => (
<div key={idx} className="p-2 flex items-center gap-2 text-sm">
{parsed.data ? (
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
) : (
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{parsed.file}</p>
{parsed.data ? (
<p className="text-xs text-muted-foreground">
{parsed.data.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} - {parsed.data.nombreEmisor?.substring(0, 30)}... - ${parsed.data.total?.toLocaleString()}
</p>
) : (
<p className="text-xs text-destructive">{parsed.error}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
Cancelar
</Button>
<Button
type="submit"
disabled={createManyCfdis.isPending || parsedXmls.filter(p => p.data).length === 0}
>
{createManyCfdis.isPending ? 'Procesando...' : `Cargar ${parsedXmls.filter(p => p.data).length} CFDIs`}
</Button>
</div>
</form>
) : (
<form onSubmit={handleBulkSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Datos JSON</Label>
<textarea
className="w-full h-48 p-3 border rounded-lg font-mono text-sm bg-background"
value={bulkData}
onChange={(e) => setBulkData(e.target.value)}
placeholder={`[
{
"uuidFiscal": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"tipo": "ingreso",
"fechaEmision": "2025-01-15",
"fechaTimbrado": "2025-01-15",
"rfcEmisor": "XAXX010101000",
"nombreEmisor": "Empresa SA de CV",
"rfcReceptor": "XAXX010101001",
"nombreReceptor": "Cliente SA de CV",
"subtotal": 10000,
"iva": 1600,
"total": 11600
}
]`}
required
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
Cancelar
</Button>
<Button type="submit" disabled={createManyCfdis.isPending}>
{createManyCfdis.isPending ? 'Procesando...' : 'Cargar CFDIs'}
</Button>
</div>
</form>
)}
</CardContent>
</Card>
)}
{/* Table */}
<Card>
<CardHeader>
@@ -109,10 +795,12 @@ export default function CfdiPage() {
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">Tipo</th>
<th className="pb-3 font-medium">Serie/Folio</th>
<th className="pb-3 font-medium">Emisor/Receptor</th>
<th className="pb-3 font-medium">Folio</th>
<th className="pb-3 font-medium">Emisor</th>
<th className="pb-3 font-medium">Receptor</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Estado</th>
{canEdit && <th className="pb-3 font-medium"></th>}
</tr>
</thead>
<tbody className="text-sm">
@@ -131,19 +819,25 @@ export default function CfdiPage() {
</span>
</td>
<td className="py-3">
{cfdi.serie || '-'}-{cfdi.folio || '-'}
{cfdi.folio || '-'}
</td>
<td className="py-3">
<div>
<p className="font-medium">
{cfdi.tipo === 'ingreso'
? cfdi.nombreReceptor
: cfdi.nombreEmisor}
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreEmisor}>
{cfdi.nombreEmisor}
</p>
<p className="text-xs text-muted-foreground">
{cfdi.tipo === 'ingreso'
? cfdi.rfcReceptor
: cfdi.rfcEmisor}
{cfdi.rfcEmisor}
</p>
</div>
</td>
<td className="py-3">
<div>
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreReceptor}>
{cfdi.nombreReceptor}
</p>
<p className="text-xs text-muted-foreground">
{cfdi.rfcReceptor}
</p>
</div>
</td>
@@ -161,6 +855,18 @@ export default function CfdiPage() {
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
</span>
</td>
{canEdit && (
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(cfdi.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
)}
</tr>
))}
</tbody>