Initial commit - Horux Despachos NL
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Eye, Download } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
|
||||
const EXCEL_COLUMNS = [
|
||||
{ header: 'UUID', key: 'uuid', width: 40 },
|
||||
{ header: 'Fecha Emision', key: '_fechaEmision', width: 15 },
|
||||
{ header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 },
|
||||
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
|
||||
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
|
||||
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
|
||||
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
|
||||
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
|
||||
];
|
||||
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function CancelacionesPeriodoAnteriorPage() {
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['drilldown-cancelaciones-periodo-anterior', selectedContribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/cancelaciones-periodo-anterior?${params}`);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
|
||||
data,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'cancelacion',
|
||||
);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!sortedData || sortedData.length === 0) return;
|
||||
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cancelaciones-periodo-anterior');
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell title="Cancelaciones de Periodo Anterior">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay CFDIs cancelados de periodos anteriores</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.length} CFDIs emitidos en meses anteriores y cancelados este mes
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<SortableHeader label="Fecha Emision" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
|
||||
<SortableHeader label="Fecha Cancelacion" active={getSortIndicator('cancelacion')} onClick={() => toggleSort('cancelacion')} />
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sortedData || []).map((cfdi: any) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CfdiViewerModal
|
||||
cfdi={selectedCfdi}
|
||||
open={!!selectedCfdi}
|
||||
onClose={() => setSelectedCfdi(null)}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
120
apps/web/app/(dashboard)/alertas/cancelaciones/page.tsx
Normal file
120
apps/web/app/(dashboard)/alertas/cancelaciones/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Eye, Download } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
const EXCEL_COLUMNS = [
|
||||
{ header: 'UUID', key: 'uuid', width: 40 },
|
||||
{ header: 'Fecha Emision', key: '_fechaEmision', width: 15 },
|
||||
{ header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 },
|
||||
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
|
||||
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
|
||||
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
|
||||
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
|
||||
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
|
||||
];
|
||||
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function CancelacionesPage() {
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['drilldown-cancelaciones'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<Cfdi[]>('/alertas/drilldown/cancelaciones');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
|
||||
data,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'cancelacion',
|
||||
);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!sortedData || sortedData.length === 0) return;
|
||||
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-cancelados');
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell title="CFDIs Cancelados (Ultimos 5 años)">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay CFDIs cancelados</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-muted-foreground">{data.length} CFDIs cancelados</p>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<SortableHeader label="Fecha Emision" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
|
||||
<SortableHeader label="Fecha Cancelacion" active={getSortIndicator('cancelacion')} onClick={() => toggleSort('cancelacion')} />
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sortedData || []).map((cfdi: any) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CfdiViewerModal
|
||||
cfdi={selectedCfdi}
|
||||
open={!!selectedCfdi}
|
||||
onClose={() => setSelectedCfdi(null)}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
|
||||
export default function ConcentracionClientesPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['drilldown-concentracion-clientes'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<any[]>('/alertas/drilldown/concentracion-clientes');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<any, 'cfdis' | 'total'>(
|
||||
data,
|
||||
{
|
||||
cfdis: (d) => Number(d.cantidad || 0),
|
||||
total: (d) => Number(d.total || 0),
|
||||
},
|
||||
'total',
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardShell title="Concentracion de Clientes">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Participacion por Cliente (Facturas Emitidas)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay datos</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">RFC</th>
|
||||
<th className="pb-3 font-medium">Nombre</th>
|
||||
<SortableHeader label="CFDIs" align="right" active={getSortIndicator('cfdis')} onClick={() => toggleSort('cfdis')} />
|
||||
<SortableHeader label="Total Facturado" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
|
||||
<th className="pb-3 font-medium text-right">Participacion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sortedData || []).map((d: any) => (
|
||||
<tr key={d.rfc} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{d.rfc}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
|
||||
<td className="py-3 text-right">{d.cantidad}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
|
||||
<td className="py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary rounded-full h-2"
|
||||
style={{ width: `${Math.min(d.participacion, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium w-14 text-right">{d.participacion}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
|
||||
export default function ConcentracionProveedoresPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['drilldown-concentracion-proveedores'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<any[]>('/alertas/drilldown/concentracion-proveedores');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<any, 'cfdis' | 'total'>(
|
||||
data,
|
||||
{
|
||||
cfdis: (d) => Number(d.cantidad || 0),
|
||||
total: (d) => Number(d.total || 0),
|
||||
},
|
||||
'total',
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardShell title="Concentracion de Proveedores">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Participacion por Proveedor (Facturas Recibidas)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay datos</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">RFC</th>
|
||||
<th className="pb-3 font-medium">Nombre</th>
|
||||
<SortableHeader label="CFDIs" align="right" active={getSortIndicator('cfdis')} onClick={() => toggleSort('cfdis')} />
|
||||
<SortableHeader label="Total Facturado" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
|
||||
<th className="pb-3 font-medium text-right">Participacion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sortedData || []).map((d: any) => (
|
||||
<tr key={d.rfc} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{d.rfc}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
|
||||
<td className="py-3 text-right">{d.cantidad}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
|
||||
<td className="py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary rounded-full h-2"
|
||||
style={{ width: `${Math.min(d.participacion, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium w-14 text-right">{d.participacion}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
344
apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx
Normal file
344
apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
|
||||
const TIPO_ALERTA = 'discrepancia-regimen';
|
||||
|
||||
const EXCEL_COLUMNS = [
|
||||
{ header: 'UUID', key: 'uuid', width: 40 },
|
||||
{ header: 'Fecha', key: '_fecha', width: 15 },
|
||||
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
|
||||
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
|
||||
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
|
||||
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
|
||||
{ header: 'Regimen Receptor', key: 'regimenReceptor', width: 18 },
|
||||
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
|
||||
];
|
||||
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
regimenReceptor: c.regimenReceptor || c.regimenFiscalReceptor || '',
|
||||
}));
|
||||
}
|
||||
|
||||
export default function DiscrepanciaRegimenPage() {
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [view, setView] = useState<'activos' | 'descartados'>('activos');
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Filters
|
||||
const [fechaDesde, setFechaDesde] = useState('');
|
||||
const [fechaHasta, setFechaHasta] = useState('');
|
||||
const [regimenFilter, setRegimenFilter] = useState<string>('');
|
||||
|
||||
// Activos (lo que aparece en la alerta)
|
||||
const activosQ = useQuery({
|
||||
queryKey: ['drilldown-discrepancia', selectedContribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/discrepancia-regimen?${params}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: view === 'activos',
|
||||
});
|
||||
|
||||
// Descartados (lo que ya se marcó para ignorar)
|
||||
const descartadosQ = useQuery({
|
||||
queryKey: ['descartados-discrepancia', selectedContribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA });
|
||||
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`);
|
||||
return res.data.data;
|
||||
},
|
||||
enabled: view === 'descartados',
|
||||
});
|
||||
|
||||
const data = view === 'activos' ? activosQ.data : descartadosQ.data;
|
||||
const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading;
|
||||
|
||||
// Extract unique regímenes for the filter dropdown
|
||||
const regimenesUnicos = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const set = new Set<string>();
|
||||
data.forEach((c: any) => {
|
||||
const reg = c.regimenReceptor || c.regimenFiscalReceptor;
|
||||
if (reg) set.add(reg);
|
||||
});
|
||||
return [...set].sort();
|
||||
}, [data]);
|
||||
|
||||
// Apply filters: fecha + regimen (descartados already excluded by backend)
|
||||
const visibleData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let filtered = data;
|
||||
|
||||
if (fechaDesde) {
|
||||
filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
|
||||
}
|
||||
if (fechaHasta) {
|
||||
filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
|
||||
}
|
||||
if (regimenFilter) {
|
||||
filtered = filtered.filter((c: any) => (c.regimenReceptor || c.regimenFiscalReceptor) === regimenFilter);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [data, fechaDesde, fechaHasta, regimenFilter]);
|
||||
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
|
||||
visibleData,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'fecha',
|
||||
);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!sortedData || sortedData.length === 0) return;
|
||||
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-discrepancia-regimen');
|
||||
};
|
||||
|
||||
const toggleCheck = (id: string) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!sortedData) return;
|
||||
if (checked.size === sortedData.length) {
|
||||
setChecked(new Set());
|
||||
} else {
|
||||
setChecked(new Set(sortedData.map(c => String(c.id))));
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['drilldown-discrepancia'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['descartados-discrepancia'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['alertas'] });
|
||||
};
|
||||
|
||||
const handleDescartar = async () => {
|
||||
const cfdiIds = [...checked].map(id => Number(id));
|
||||
try {
|
||||
await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA });
|
||||
setChecked(new Set());
|
||||
invalidateAll();
|
||||
} catch {
|
||||
alert('Error al descartar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestaurar = async () => {
|
||||
const cfdiIds = [...checked].map(id => Number(id));
|
||||
try {
|
||||
await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } });
|
||||
setChecked(new Set());
|
||||
invalidateAll();
|
||||
} catch {
|
||||
alert('Error al restaurar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeView = (next: 'activos' | 'descartados') => {
|
||||
setView(next);
|
||||
setChecked(new Set());
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFechaDesde('');
|
||||
setFechaHasta('');
|
||||
setRegimenFilter('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = fechaDesde || fechaHasta || regimenFilter;
|
||||
|
||||
const allChecked = sortedData && sortedData.length > 0 &&
|
||||
checked.size === sortedData.length;
|
||||
|
||||
return (
|
||||
<DashboardShell title="CFDIs con Discrepancia de Régimen">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{view === 'activos'
|
||||
? 'Facturas recibidas con régimen fiscal que no coincide con los regímenes activos'
|
||||
: 'CFDIs descartados manualmente — ignorados en la alerta'}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Toggle Activos / Descartados */}
|
||||
<div className="flex rounded-md border bg-background p-0.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeView('activos')}
|
||||
className={`px-3 py-1 rounded ${view === 'activos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Activos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeView('descartados')}
|
||||
className={`px-3 py-1 rounded ${view === 'descartados' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Descartados
|
||||
</button>
|
||||
</div>
|
||||
{checked.size > 0 && view === 'activos' && (
|
||||
<Button variant="outline" size="sm" onClick={handleDescartar}>
|
||||
<EyeOff className="h-4 w-4 mr-1" />
|
||||
Descartar ({checked.size})
|
||||
</Button>
|
||||
)}
|
||||
{checked.size > 0 && view === 'descartados' && (
|
||||
<Button variant="outline" size="sm" onClick={handleRestaurar}>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Restaurar ({checked.size})
|
||||
</Button>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Excel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-end gap-3 mt-3 pt-3 border-t">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtros:
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Desde</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fechaDesde}
|
||||
onChange={e => setFechaDesde(e.target.value)}
|
||||
className="h-8 w-[150px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Hasta</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fechaHasta}
|
||||
onChange={e => setFechaHasta(e.target.value)}
|
||||
className="h-8 w-[150px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Régimen</label>
|
||||
<select
|
||||
value={regimenFilter}
|
||||
onChange={e => setRegimenFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{regimenesUnicos.map(r => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-8 text-xs">
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !sortedData || sortedData.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? 'No hay resultados con los filtros seleccionados'
|
||||
: view === 'activos'
|
||||
? 'No hay discrepancias nuevas'
|
||||
: 'No hay CFDIs descartados'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 w-8">
|
||||
<button onClick={toggleSelectAll} className="hover:text-foreground transition-colors">
|
||||
{allChecked ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium">Régimen Receptor</th>
|
||||
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map((cfdi: any) => (
|
||||
<tr key={cfdi.id} className={`border-b hover:bg-muted/50 ${checked.has(cfdi.id) ? 'bg-primary/5' : ''}`}>
|
||||
<td className="py-3">
|
||||
<button onClick={() => toggleCheck(cfdi.id)} className="hover:text-primary transition-colors">
|
||||
{checked.has(cfdi.id) ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
|
||||
<td className="py-3 font-mono font-bold text-destructive">{cfdi.regimenReceptor}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
{sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'con discrepancia' : 'descartados'}
|
||||
{hasActiveFilters && data && ` (de ${data.length} total)`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CfdiViewerModal
|
||||
cfdi={selectedCfdi}
|
||||
open={!!selectedCfdi}
|
||||
onClose={() => setSelectedCfdi(null)}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
118
apps/web/app/(dashboard)/alertas/efectivo/page.tsx
Normal file
118
apps/web/app/(dashboard)/alertas/efectivo/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Eye, Download } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
const EXCEL_COLUMNS = [
|
||||
{ header: 'UUID', key: 'uuid', width: 40 },
|
||||
{ header: 'Fecha', key: '_fecha', width: 15 },
|
||||
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
|
||||
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
|
||||
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
|
||||
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
|
||||
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
|
||||
{ header: 'Forma Pago', key: 'formaPago', width: 12 },
|
||||
];
|
||||
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function EfectivoPage() {
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['drilldown-efectivo'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<Cfdi[]>('/alertas/drilldown/efectivo');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
|
||||
data,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'fecha',
|
||||
);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!sortedData || sortedData.length === 0) return;
|
||||
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-pago-efectivo');
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell title="CFDIs con Pago en Efectivo">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay CFDIs con pago en efectivo</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-muted-foreground">{data.length} CFDIs con pago en efectivo</p>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sortedData || []).map((cfdi: any) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CfdiViewerModal
|
||||
cfdi={selectedCfdi}
|
||||
open={!!selectedCfdi}
|
||||
onClose={() => setSelectedCfdi(null)}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export default function ListaNegraClientesPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['drilldown-lista-negra-clientes'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<any[]>('/alertas/drilldown/lista-negra-clientes');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardShell title="Clientes en Lista Negra del SAT">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Clientes a los que has facturado que aparecen en la lista del Art. 69-B</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Ningun cliente en lista negra</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">RFC</th>
|
||||
<th className="pb-3 font-medium">Nombre</th>
|
||||
<th className="pb-3 font-medium">Situacion SAT</th>
|
||||
<th className="pb-3 font-medium text-right">CFDIs</th>
|
||||
<th className="pb-3 font-medium text-right">Total Facturado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((d: any) => (
|
||||
<tr key={d.rfc} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{d.rfc}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
|
||||
<td className="py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
d.situacionSat === 'Definitivo' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning'
|
||||
}`}>{d.situacionSat}</span>
|
||||
</td>
|
||||
<td className="py-3 text-right">{d.cantidad}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export default function ListaNegraProveedoresPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['drilldown-lista-negra-proveedores'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<any[]>('/alertas/drilldown/lista-negra-proveedores');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardShell title="Proveedores en Lista Negra del SAT">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Proveedores de los que has recibido facturas que aparecen en la lista del Art. 69-B</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Ningun proveedor en lista negra</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">RFC</th>
|
||||
<th className="pb-3 font-medium">Nombre</th>
|
||||
<th className="pb-3 font-medium">Situacion SAT</th>
|
||||
<th className="pb-3 font-medium text-right">CFDIs</th>
|
||||
<th className="pb-3 font-medium text-right">Total Facturado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((d: any) => (
|
||||
<tr key={d.rfc} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{d.rfc}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
|
||||
<td className="py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
d.situacionSat === 'Definitivo' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning'
|
||||
}`}>{d.situacionSat}</span>
|
||||
</td>
|
||||
<td className="py-3 text-right">{d.cantidad}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
241
apps/web/app/(dashboard)/alertas/page.tsx
Normal file
241
apps/web/app/(dashboard)/alertas/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
|
||||
import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle, ShieldAlert, ChevronRight, Clock } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
|
||||
interface AlertaAuto {
|
||||
id: string;
|
||||
tipo: string;
|
||||
titulo: string;
|
||||
mensaje: string;
|
||||
prioridad: 'alta' | 'media' | 'baja';
|
||||
detalle?: string;
|
||||
valor?: number;
|
||||
}
|
||||
|
||||
const prioridadStyles = {
|
||||
alta: 'border-l-4 border-l-destructive bg-destructive/5',
|
||||
media: 'border-l-4 border-l-warning bg-warning/5',
|
||||
baja: 'border-l-4 border-l-muted bg-muted/5',
|
||||
};
|
||||
|
||||
const prioridadIcons = {
|
||||
alta: AlertCircle,
|
||||
media: AlertTriangle,
|
||||
baja: Info,
|
||||
};
|
||||
|
||||
export default function AlertasPage() {
|
||||
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes');
|
||||
const { data: alertas, isLoading } = useAlertas({
|
||||
resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined,
|
||||
});
|
||||
const { data: stats } = useAlertasStats();
|
||||
const updateAlerta = useUpdateAlerta();
|
||||
const deleteAlerta = useDeleteAlerta();
|
||||
const markAllAsRead = useMarkAllAsRead();
|
||||
const router = useRouter();
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: alertasAuto } = useQuery({
|
||||
queryKey: ['alertas-automaticas', selectedContribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<AlertaAuto[]>(`/alertas/automaticas?${params}`);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: alertasManuales } = useQuery({
|
||||
queryKey: ['alertas-manuales', selectedContribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<any[]>(`/alertas/manuales?${params}`);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const handleResolver = async (id: string) => {
|
||||
await apiClient.patch(`/alertas/manuales/${id}/resolver`);
|
||||
queryClient.invalidateQueries({ queryKey: ['alertas-manuales'] });
|
||||
};
|
||||
|
||||
const handleMarkAsRead = (id: number) => {
|
||||
updateAlerta.mutate({ id, data: { leida: true } });
|
||||
};
|
||||
|
||||
const handleResolve = (id: number) => {
|
||||
updateAlerta.mutate({ id, data: { resuelta: true } });
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm('¿Eliminar esta alerta?')) {
|
||||
deleteAlerta.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell title="Alertas">
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Alertas del Sistema</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{alertasAuto?.length || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Obligaciones Pendientes</CardTitle>
|
||||
<Clock className="h-4 w-4 text-warning" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-warning">{alertasManuales?.length || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Alertas</CardTitle>
|
||||
<Bell className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{(alertasAuto?.length || 0) + (alertasManuales?.length || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Alertas Automáticas */}
|
||||
{alertasAuto && alertasAuto.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Alertas del Sistema ({alertasAuto.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{alertasAuto.map((alerta) => {
|
||||
const Icon = alerta.prioridad === 'alta' ? AlertCircle : AlertTriangle;
|
||||
return (
|
||||
<div
|
||||
key={alerta.id}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
alerta.prioridad === 'alta' && 'border-l-4 border-l-destructive bg-destructive/5',
|
||||
alerta.prioridad === 'media' && 'border-l-4 border-l-warning bg-warning/5',
|
||||
alerta.prioridad === 'baja' && 'border-l-4 border-l-muted bg-muted/5',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={cn(
|
||||
'h-5 w-5 mt-0.5 flex-shrink-0',
|
||||
alerta.prioridad === 'alta' && 'text-destructive',
|
||||
alerta.prioridad === 'media' && 'text-warning',
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm">{alerta.titulo}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">{alerta.mensaje}</p>
|
||||
</div>
|
||||
</div>
|
||||
{alerta.detalle && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(alerta.detalle!)}
|
||||
>
|
||||
Ver detalle <ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Obligaciones Fiscales Pendientes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Clock className="h-4 w-4" />
|
||||
Obligaciones Fiscales Pendientes ({alertasManuales?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!alertasManuales || alertasManuales.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted-foreground">
|
||||
<CheckCircle className="h-10 w-10 mx-auto mb-3 text-success" />
|
||||
<p className="text-sm">Todas las obligaciones fiscales estan al dia</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alertasManuales.map((alerta: any) => {
|
||||
const esPago = alerta.tipo.startsWith('pago-');
|
||||
const Icon = prioridadIcons[alerta.prioridad as keyof typeof prioridadIcons] || AlertTriangle;
|
||||
return (
|
||||
<div
|
||||
key={alerta.id}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
alerta.prioridad === 'alta' && 'border-l-4 border-l-destructive bg-destructive/5',
|
||||
alerta.prioridad === 'media' && 'border-l-4 border-l-warning bg-warning/5',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={cn(
|
||||
'h-5 w-5 mt-0.5 flex-shrink-0',
|
||||
alerta.prioridad === 'alta' && 'text-destructive',
|
||||
alerta.prioridad === 'media' && 'text-warning',
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm">{alerta.titulo}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">{alerta.mensaje}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Vencio: {(() => {
|
||||
const d = new Date(alerta.fechaVencimiento);
|
||||
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
})()}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResolver(alerta.id)}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{esPago ? 'Marcar como pagado' : 'Marcar como presentada'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
|
||||
const TIPO_ALERTA = 'tipo-relacion-sospechosa';
|
||||
|
||||
const EXCEL_COLUMNS = [
|
||||
{ header: 'UUID', key: 'uuid', width: 40 },
|
||||
{ header: 'Fecha', key: '_fecha', width: 15 },
|
||||
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
|
||||
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
|
||||
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
|
||||
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
|
||||
{ header: 'TipoRelacion', key: 'cfdiTipoRelacion', width: 14 },
|
||||
{ header: 'CFDIs Relacionados', key: 'cfdisRelacionados', width: 50 },
|
||||
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
|
||||
];
|
||||
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function TipoRelacionSospechosaPage() {
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [view, setView] = useState<'activos' | 'descartados'>('activos');
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [fechaDesde, setFechaDesde] = useState('');
|
||||
const [fechaHasta, setFechaHasta] = useState('');
|
||||
const [tipoRelFilter, setTipoRelFilter] = useState<string>('');
|
||||
|
||||
const activosQ = useQuery({
|
||||
queryKey: ['drilldown-tipo-relacion-sospechosa', selectedContribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/${TIPO_ALERTA}?${params}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: view === 'activos',
|
||||
});
|
||||
|
||||
const descartadosQ = useQuery({
|
||||
queryKey: ['descartados-tipo-relacion-sospechosa', selectedContribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA });
|
||||
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`);
|
||||
return res.data.data;
|
||||
},
|
||||
enabled: view === 'descartados',
|
||||
});
|
||||
|
||||
const data = view === 'activos' ? activosQ.data : descartadosQ.data;
|
||||
const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading;
|
||||
|
||||
const tiposRelUnicos = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const set = new Set<string>();
|
||||
data.forEach((c: any) => {
|
||||
if (c.cfdiTipoRelacion) set.add(c.cfdiTipoRelacion);
|
||||
});
|
||||
return [...set].sort();
|
||||
}, [data]);
|
||||
|
||||
const visibleData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let filtered = data;
|
||||
if (fechaDesde) filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
|
||||
if (fechaHasta) filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
|
||||
if (tipoRelFilter) filtered = filtered.filter((c: any) => c.cfdiTipoRelacion === tipoRelFilter);
|
||||
return filtered;
|
||||
}, [data, fechaDesde, fechaHasta, tipoRelFilter]);
|
||||
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
|
||||
visibleData,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'fecha',
|
||||
);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!sortedData || sortedData.length === 0) return;
|
||||
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-tipo-relacion-sospechosa');
|
||||
};
|
||||
|
||||
const toggleCheck = (id: string) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!sortedData) return;
|
||||
if (checked.size === sortedData.length) {
|
||||
setChecked(new Set());
|
||||
} else {
|
||||
setChecked(new Set(sortedData.map(c => String(c.id))));
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['drilldown-tipo-relacion-sospechosa'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['descartados-tipo-relacion-sospechosa'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['alertas'] });
|
||||
};
|
||||
|
||||
const handleDescartar = async () => {
|
||||
const cfdiIds = [...checked].map(id => Number(id));
|
||||
try {
|
||||
await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA });
|
||||
setChecked(new Set());
|
||||
invalidateAll();
|
||||
} catch {
|
||||
alert('Error al descartar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestaurar = async () => {
|
||||
const cfdiIds = [...checked].map(id => Number(id));
|
||||
try {
|
||||
await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } });
|
||||
setChecked(new Set());
|
||||
invalidateAll();
|
||||
} catch {
|
||||
alert('Error al restaurar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeView = (next: 'activos' | 'descartados') => {
|
||||
setView(next);
|
||||
setChecked(new Set());
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFechaDesde('');
|
||||
setFechaHasta('');
|
||||
setTipoRelFilter('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = fechaDesde || fechaHasta || tipoRelFilter;
|
||||
const allChecked = sortedData && sortedData.length > 0 && checked.size === sortedData.length;
|
||||
|
||||
return (
|
||||
<DashboardShell title="CFDI con Tipo de Relación sospechoso">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{view === 'activos'
|
||||
? 'Notas de crédito (E) que referencian un CFDI tratado como anticipo por otra factura — posible error de emisor (debería ser TipoRelacion 07)'
|
||||
: 'CFDIs descartados manualmente — ignorados en la alerta'}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-md border bg-background p-0.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeView('activos')}
|
||||
className={`px-3 py-1 rounded ${view === 'activos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Activos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeView('descartados')}
|
||||
className={`px-3 py-1 rounded ${view === 'descartados' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Descartados
|
||||
</button>
|
||||
</div>
|
||||
{checked.size > 0 && view === 'activos' && (
|
||||
<Button variant="outline" size="sm" onClick={handleDescartar}>
|
||||
<EyeOff className="h-4 w-4 mr-1" />
|
||||
Descartar ({checked.size})
|
||||
</Button>
|
||||
)}
|
||||
{checked.size > 0 && view === 'descartados' && (
|
||||
<Button variant="outline" size="sm" onClick={handleRestaurar}>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Restaurar ({checked.size})
|
||||
</Button>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Excel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 mt-3 pt-3 border-t">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtros:
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Desde</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fechaDesde}
|
||||
onChange={e => setFechaDesde(e.target.value)}
|
||||
className="h-8 w-[150px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Hasta</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fechaHasta}
|
||||
onChange={e => setFechaHasta(e.target.value)}
|
||||
className="h-8 w-[150px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">TipoRelacion</label>
|
||||
<select
|
||||
value={tipoRelFilter}
|
||||
onChange={e => setTipoRelFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{tiposRelUnicos.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-8 text-xs">
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !sortedData || sortedData.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? 'No hay resultados con los filtros seleccionados'
|
||||
: view === 'activos'
|
||||
? 'No hay CFDIs sospechosos'
|
||||
: 'No hay CFDIs descartados'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 w-8">
|
||||
<button onClick={toggleSelectAll} className="hover:text-foreground transition-colors">
|
||||
{allChecked ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
|
||||
<th className="pb-3 font-medium">Emisor</th>
|
||||
<th className="pb-3 font-medium">Receptor</th>
|
||||
<th className="pb-3 font-medium">TipoRel</th>
|
||||
<th className="pb-3 font-medium">Referenciados</th>
|
||||
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map((cfdi: any) => {
|
||||
const refs = (cfdi.cfdisRelacionados || '').split('|').filter(Boolean);
|
||||
return (
|
||||
<tr key={cfdi.id} className={`border-b hover:bg-muted/50 ${checked.has(cfdi.id) ? 'bg-primary/5' : ''}`}>
|
||||
<td className="py-3">
|
||||
<button onClick={() => toggleCheck(cfdi.id)} className="hover:text-primary transition-colors">
|
||||
{checked.has(cfdi.id) ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3 truncate max-w-[180px]">
|
||||
<div className="font-mono text-xs">{cfdi.rfcEmisor}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreEmisor}</div>
|
||||
</td>
|
||||
<td className="py-3 truncate max-w-[180px]">
|
||||
<div className="font-mono text-xs">{cfdi.rfcReceptor}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreReceptor}</div>
|
||||
</td>
|
||||
<td className="py-3 font-mono font-bold text-destructive">{cfdi.cfdiTipoRelacion}</td>
|
||||
<td className="py-3 font-mono text-xs">
|
||||
{refs.map((u: string) => (
|
||||
<div key={u}>{u.substring(0, 8)}</div>
|
||||
))}
|
||||
</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
{sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'sospechosos' : 'descartados'}
|
||||
{hasActiveFilters && data && ` (de ${data.length} total)`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CfdiViewerModal
|
||||
cfdi={selectedCfdi}
|
||||
open={!!selectedCfdi}
|
||||
onClose={() => setSelectedCfdi(null)}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user