Initial commit: Horux Despachos project
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user