Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx
Horux Dev 66d68c652c Revert "feat(ui): make dashboard responsive for iPhone and mobile devices"
This reverts commit d3b326e.

The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
2026-06-13 20:16:04 +00:00

345 lines
14 KiB
TypeScript

'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, toCfdiDate } 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: toCfdiDate(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 => toCfdiDate(c.fechaEmision).toISOString() >= fechaDesde);
}
if (fechaHasta) {
filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() <= 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) => toCfdiDate(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">{toCfdiDate(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>
);
}