Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx
Horux Dev d3b326e78c feat(ui): make dashboard responsive for iPhone and mobile devices
- Add Sheet primitive component for mobile drawers
- Add MobileNav with hamburger menu for dashboard layout
- Hide desktop sidebars on mobile; show mobile header
- Make dashboard header responsive with stacked layout on small screens
- Hide selector text on mobile, show icons only
- Convert fixed-width filters to responsive widths (CFDI, Clientes, Admin, Documentos, Alertas)
- Cap dialog widths to 95vw on mobile (CFDI viewer, Documentos, Reportes, Contribuyentes, Facturación)
- Make calendar grid smaller and use single-letter weekdays on mobile
- Update viewport to include viewport-fit=cover for Samsung safe areas
2026-06-13 19:55:06 +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-full sm: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-full sm: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>
);
}