Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}