feat: conceptos tab, filters, backfill, facturapi live keys, fixes
- Add Conceptos tab in CFDI page with column filters, sorting, pagination - Add GET /cfdi/conceptos endpoint with filters and orderBy - Backfill cfdi_conceptos from legacy XMLs (824k concepts inserted) - Fix CFDI delete button (bypass subscription check, add alerts) - Fix export to Excel (fetch all filtered results, limit 10k) - Fix facturacion page concepto delete bug (immutable updates, unique ids) - Add Facturapi live key auto-generation and caching - Fix SAT fechaPagoP parsing - Add metrics cache support for current year - Increase DB pool max to 15
This commit is contained in:
@@ -4,12 +4,12 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useDebounce } from '@horux/shared-ui';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { cancelarFactura } from '@/lib/api/facturacion';
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
import { useCfdis, useCfdiConceptos, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { getCfdis, createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { cancelarFactura, downloadPdf, downloadXml } from '@/lib/api/facturacion';
|
||||
import type { CfdiFilters, CfdiConceptoFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -262,6 +262,30 @@ export default function CfdiPage() {
|
||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Tabs: CFDIs vs Conceptos
|
||||
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
|
||||
|
||||
// Conceptos filters & state
|
||||
const [conceptoFilters, setConceptoFilters] = useState<CfdiConceptoFilters>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
const [conceptoSearch, setConceptoSearch] = useState('');
|
||||
const [conceptoColumnFilters, setConceptoColumnFilters] = useState({
|
||||
fechaInicio: '',
|
||||
fechaFin: '',
|
||||
uuid: '',
|
||||
claveProdServ: '',
|
||||
descripcion: '',
|
||||
});
|
||||
const [conceptoOrder, setConceptoOrder] = useState<{ by: 'fecha' | 'importe'; dir: 'asc' | 'desc' }>({ by: 'fecha', dir: 'desc' });
|
||||
const [openConceptoFilter, setOpenConceptoFilter] = useState<'fecha' | 'uuid' | 'clave' | 'descripcion' | null>(null);
|
||||
const hasConceptoDateFilter = !!conceptoColumnFilters.fechaInicio || !!conceptoColumnFilters.fechaFin;
|
||||
const hasConceptoUuidFilter = !!conceptoColumnFilters.uuid;
|
||||
const hasConceptoClaveFilter = !!conceptoColumnFilters.claveProdServ;
|
||||
const hasConceptoDescFilter = !!conceptoColumnFilters.descripcion;
|
||||
const { data: conceptosData, isLoading: conceptosLoading } = useCfdiConceptos(conceptoFilters);
|
||||
|
||||
// Debounced values for autocomplete
|
||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
|
||||
@@ -322,6 +346,21 @@ export default function CfdiPage() {
|
||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||
|
||||
// Sync shared filters between CFDIs and Conceptos tabs
|
||||
useEffect(() => {
|
||||
setConceptoFilters(prev => ({
|
||||
...prev,
|
||||
fechaInicio: columnFilters.fechaInicio || undefined,
|
||||
fechaFin: columnFilters.fechaFin || undefined,
|
||||
tipo: filters.tipo,
|
||||
tipoComprobante: filters.tipoComprobante,
|
||||
estado: filters.estado,
|
||||
rfc: filters.rfc,
|
||||
search: searchTerm || undefined,
|
||||
page: 1,
|
||||
}));
|
||||
}, [filters.tipo, filters.tipoComprobante, filters.estado, filters.rfc, columnFilters.fechaInicio, columnFilters.fechaFin, searchTerm]);
|
||||
|
||||
// Cancelación Facturapi state
|
||||
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
|
||||
const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02');
|
||||
@@ -343,11 +382,58 @@ export default function CfdiPage() {
|
||||
};
|
||||
|
||||
const canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar';
|
||||
const canDelete = user?.role === 'owner' || user?.role === 'contador';
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
// Conceptos column filters & sorting
|
||||
const applyConceptoColumnFilters = () => {
|
||||
setConceptoFilters(prev => ({
|
||||
...prev,
|
||||
fechaInicio: conceptoColumnFilters.fechaInicio || undefined,
|
||||
fechaFin: conceptoColumnFilters.fechaFin || undefined,
|
||||
uuid: conceptoColumnFilters.uuid || undefined,
|
||||
claveProdServ: conceptoColumnFilters.claveProdServ || undefined,
|
||||
descripcion: conceptoColumnFilters.descripcion || undefined,
|
||||
orderBy: conceptoOrder.by,
|
||||
orderDir: conceptoOrder.dir,
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleConceptoOrder = (by: 'fecha' | 'importe') => {
|
||||
setConceptoOrder(prev => {
|
||||
const dir = prev.by === by && prev.dir === 'desc' ? 'asc' : 'desc';
|
||||
return { by, dir };
|
||||
});
|
||||
// Apply after state update using the new values
|
||||
setConceptoFilters(prev => ({
|
||||
...prev,
|
||||
orderBy: by,
|
||||
orderDir: by === conceptoOrder.by && conceptoOrder.dir === 'desc' ? 'asc' : 'desc',
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const clearConceptoDateFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: '', fechaFin: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, fechaInicio: undefined, fechaFin: undefined, page: 1 }));
|
||||
};
|
||||
const clearConceptoUuidFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, uuid: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, uuid: undefined, page: 1 }));
|
||||
};
|
||||
const clearConceptoClaveFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, claveProdServ: undefined, page: 1 }));
|
||||
};
|
||||
const clearConceptoDescFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, descripcion: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, descripcion: undefined, page: 1 }));
|
||||
};
|
||||
|
||||
// Export to Excel
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
@@ -356,7 +442,17 @@ export default function CfdiPage() {
|
||||
|
||||
setExporting(true);
|
||||
try {
|
||||
const exportData = data.data.map(cfdi => ({
|
||||
// Traer TODOS los CFDIs que coinciden con los filtros (sin paginación)
|
||||
const allFilters: CfdiFilters = { ...filters, page: 1, limit: 10000 };
|
||||
const allData = await getCfdis(allFilters);
|
||||
const rows = allData.data;
|
||||
|
||||
if (!rows.length) {
|
||||
alert('No hay datos para exportar');
|
||||
return;
|
||||
}
|
||||
|
||||
const exportData = rows.map(cfdi => ({
|
||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Serie': cfdi.serie || '',
|
||||
@@ -399,6 +495,40 @@ export default function CfdiPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async (facturapiId: string | null) => {
|
||||
if (!facturapiId) return;
|
||||
try {
|
||||
const blob = await downloadPdf(facturapiId);
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `factura-${facturapiId}.pdf`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
alert('Error al descargar PDF: ' + (error?.message || 'Desconocido'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadXml = async (facturapiId: string | null) => {
|
||||
if (!facturapiId) return;
|
||||
try {
|
||||
const blob = await downloadXml(facturapiId);
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `factura-${facturapiId}.xml`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
alert('Error al descargar XML: ' + (error?.message || 'Desconocido'));
|
||||
}
|
||||
};
|
||||
|
||||
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
|
||||
const row = {
|
||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||
@@ -741,11 +871,19 @@ export default function CfdiPage() {
|
||||
|
||||
const handleDelete = async (id: string | number) => {
|
||||
const idStr = String(id);
|
||||
console.log('[DeleteCFDI] Intentando eliminar ID:', idStr, 'tipo:', typeof id);
|
||||
if (!id || idStr === 'undefined' || idStr === 'null' || idStr === '0') {
|
||||
alert('ID de CFDI inválido: ' + idStr);
|
||||
return;
|
||||
}
|
||||
if (confirm('¿Eliminar este CFDI?')) {
|
||||
try {
|
||||
await deleteCfdi.mutateAsync(idStr);
|
||||
} catch (error) {
|
||||
alert('CFDI eliminado correctamente');
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting CFDI:', error);
|
||||
const msg = error?.response?.data?.message || error?.message || 'Error al eliminar el CFDI';
|
||||
alert('Error: ' + msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -809,6 +947,21 @@ export default function CfdiPage() {
|
||||
<>
|
||||
<Header title="Gestion de CFDI" />
|
||||
<main className="p-6 space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('cfdis')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'cfdis' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
|
||||
>
|
||||
CFDIs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('conceptos')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'conceptos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
|
||||
>
|
||||
Conceptos
|
||||
</button>
|
||||
</div>
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -899,6 +1052,8 @@ export default function CfdiPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{activeTab === 'cfdis' && (
|
||||
<>
|
||||
{/* Add CFDI Form */}
|
||||
{showForm && canEdit && (
|
||||
<Card>
|
||||
@@ -1586,6 +1741,7 @@ export default function CfdiPage() {
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">Uso CFDI</th>
|
||||
<th className="pb-3 font-medium text-right">Total</th>
|
||||
<th className="pb-3 font-medium">Estado</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
@@ -1625,6 +1781,9 @@ export default function CfdiPage() {
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span className="text-xs text-muted-foreground">{cfdi.usoCfdi || '-'}</span>
|
||||
</td>
|
||||
<td className="py-3 text-right font-medium">
|
||||
{formatCurrency(cfdi.total)}
|
||||
</td>
|
||||
@@ -1654,6 +1813,21 @@ export default function CfdiPage() {
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
{(cfdi as any).source === 'facturapi' && cfdi.facturapiId && (
|
||||
<>
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownloadPdf(cfdi.facturapiId)}
|
||||
title="Descargar PDF (Facturapi)"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
{/* XML download hidden */}
|
||||
</>
|
||||
)}
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1678,15 +1852,17 @@ export default function CfdiPage() {
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(cfdi.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="Eliminar registro (solo local)"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(cfdi.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="Eliminar registro (solo local)"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
@@ -1729,6 +1905,219 @@ export default function CfdiPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'conceptos' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
Conceptos ({conceptosData?.total || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{conceptosLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-20"></div>
|
||||
<div className="h-4 bg-muted rounded flex-1 max-w-[200px]"></div>
|
||||
<div className="h-4 bg-muted rounded w-16"></div>
|
||||
<div className="h-4 bg-muted rounded w-16"></div>
|
||||
<div className="h-4 bg-muted rounded w-24"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : conceptosData?.data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No se encontraron conceptos
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => toggleConceptoOrder('fecha')} className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
Fecha CFDI
|
||||
{conceptoOrder.by === 'fecha' ? (
|
||||
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
<Popover open={openConceptoFilter === 'fecha'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'fecha' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDateFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por fecha</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">Desde</Label>
|
||||
<Input type="date" className="h-8 text-sm" value={conceptoColumnFilters.fechaInicio} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Hasta</Label>
|
||||
<Input type="date" className="h-8 text-sm" value={conceptoColumnFilters.fechaFin} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaFin: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoDateFilter && <Button size="sm" variant="outline" onClick={clearConceptoDateFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
UUID
|
||||
<Popover open={openConceptoFilter === 'uuid'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'uuid' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoUuidFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por UUID</h4>
|
||||
<Input placeholder="UUID..." className="h-8 text-sm" value={conceptoColumnFilters.uuid} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, uuid: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoUuidFilter && <Button size="sm" variant="outline" onClick={clearConceptoUuidFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
Clave
|
||||
<Popover open={openConceptoFilter === 'clave'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'clave' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoClaveFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por clave</h4>
|
||||
<Input placeholder="Clave prod/serv..." className="h-8 text-sm" value={conceptoColumnFilters.claveProdServ} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoClaveFilter && <Button size="sm" variant="outline" onClick={clearConceptoClaveFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
Descripción
|
||||
<Popover open={openConceptoFilter === 'descripcion'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'descripcion' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDescFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por descripción</h4>
|
||||
<Input placeholder="Descripción..." className="h-8 text-sm" value={conceptoColumnFilters.descripcion} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, descripcion: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoDescFilter && <Button size="sm" variant="outline" onClick={clearConceptoDescFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<th className="pb-3 font-medium">Cantidad</th>
|
||||
<th className="pb-3 font-medium">Unidad</th>
|
||||
<th className="pb-3 font-medium text-right">V. Unitario</th>
|
||||
<th className="pb-3 font-medium text-right">
|
||||
<button onClick={() => toggleConceptoOrder('importe')} className="flex items-center gap-1 hover:text-foreground transition-colors ml-auto">
|
||||
Importe
|
||||
{conceptoOrder.by === 'importe' ? (
|
||||
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
{/* Descuento and IVA columns hidden */}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conceptosData?.data.map((c) => (
|
||||
<tr key={c.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 text-sm">
|
||||
{c.cfdiFechaEmision ? new Date(c.cfdiFechaEmision).toLocaleDateString('es-MX') : '-'}
|
||||
</td>
|
||||
<td className="py-3 text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={c.cfdiUuid || ''}>
|
||||
{c.cfdiUuid || '-'}
|
||||
</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.claveProdServ || '-'}</td>
|
||||
<td className="py-3 text-sm max-w-[250px] truncate" title={c.descripcion}>{c.descripcion}</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcEmisor || '-'}</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcReceptor || '-'}</td>
|
||||
<td className="py-3 text-sm">{c.cantidad}</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.claveUnidad || c.unidad || '-'}</td>
|
||||
<td className="py-3 text-sm text-right">${Number(c.valorUnitario).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="py-3 text-sm text-right">${Number(c.importe).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
{/* Descuento and IVA cells hidden */}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination for conceptos */}
|
||||
{conceptosData && conceptosData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pagina {conceptosData.page} de {conceptosData.totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={conceptosData.page <= 1}
|
||||
onClick={() =>
|
||||
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.page || 1) - 1 })
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={conceptosData.page >= conceptosData.totalPages}
|
||||
onClick={() =>
|
||||
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.page || 1) + 1 })
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<CfdiViewerModal
|
||||
|
||||
Reference in New Issue
Block a user