- 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
345 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|