chore: catálogo obligaciones, cierre automático, fixes SAT y facturación

- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago.
- Soporte de frecuencia cuatrimestral en obligaciones y declaraciones.
- Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones.
- Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones.
- Nuevo servicio obligacion-evidencias.service.ts y endpoints REST.
- Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias.
- Notificaciones por email para evidencias de obligaciones.
- Adjuntar PDFs en correo de declaración subida.
- Fix drill-down de CFDIs: carga completa al visualizar.
- Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId.
- Fix suscripciones pending en /configuracion/planes-despacho.
- Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete.
- Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas.
- Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv).
- Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
This commit is contained in:
Horux Dev
2026-06-22 04:53:59 +00:00
parent b217342a96
commit 7df27ce66d
39 changed files with 2791 additions and 191 deletions

View File

@@ -23,9 +23,11 @@ import {
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as docsApi from '@/lib/api/documentos';
import { getObligacionesPorPeriodo, type ObligacionPeriodo } from '@/lib/api/obligaciones';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
const OBLIGACIONES_ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
{ value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' },
@@ -504,7 +506,7 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
const [obligacionesIds, setObligacionesIds] = useState<string[]>([]);
const [montoPago, setMontoPago] = useState('');
const [file, setFile] = useState<File | null>(null);
const [ligaFile, setLigaFile] = useState<File | null>(null);
@@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
const [err, setErr] = useState<string | null>(null);
const periodOptions = getPeriodOptions(periodicidad);
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
const obligacionesQ = useQuery({
queryKey: ['obligaciones-periodo-declaracion', selectedContribuyenteId, periodo],
queryFn: () => selectedContribuyenteId
? getObligacionesPorPeriodo(selectedContribuyenteId, periodo, false)
: Promise.resolve({ data: [], periodo }),
enabled: !!selectedContribuyenteId,
});
const handlePeriodicidadChange = (p: Periodicidad) => {
setPeriodicidad(p);
@@ -522,21 +533,21 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
}
};
const toggleImpuesto = (i: Impuesto) => {
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
const toggleObligacion = (id: string) => {
setObligacionesIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
if (!file) return setErr('Selecciona el PDF de la declaración');
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
if (obligacionesIds.length === 0) return setErr('Selecciona al menos una obligación fiscal');
try {
const pdfBase64 = await fileToBase64(file);
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
await create.mutateAsync({
año, mes, tipo, periodicidad, impuestos,
año, mes, tipo, periodicidad, obligacionesIds,
montoPago: montoNum,
pdfBase64, pdfFilename: file.name,
ligaPagoBase64,
@@ -606,16 +617,51 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
</div>
<div>
<Label>Impuestos cubiertos</Label>
<div className="grid grid-cols-3 gap-2 mt-1">
{IMPUESTOS.map(i => (
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
{i}
</label>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración definen qué recordatorios se desactivan.</p>
<Label>Obligaciones fiscales cubiertas</Label>
{!selectedContribuyenteId ? (
<p className="text-sm text-muted-foreground mt-1">Selecciona un contribuyente para ver sus obligaciones.</p>
) : obligacionesQ.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-2">
<Loader2 className="h-4 w-4 animate-spin" /> Cargando obligaciones...
</div>
) : obligacionesQ.error ? (
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
) : obligacionesQ.data?.data.length === 0 ? (
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
) : (
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
<div key={categoria}>
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
<div className="grid grid-cols-1 gap-2">
{(obligacionesQ.data?.data || [])
.filter(o => (o.categoria || 'Sin categoría') === categoria)
.map((o) => (
<label
key={o.id}
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
>
<input
type="checkbox"
checked={obligacionesIds.includes(o.id)}
onChange={() => toggleObligacion(o.id)}
className="accent-primary mt-0.5"
/>
<div className="flex-1">
<span className="font-medium">{o.nombre}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
{o.requierePago && (
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
)}
</div>
</label>
))}
</div>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-1">Selecciona las obligaciones fiscales que cubre esta declaración. Al guardar se marcarán como presentadas y, si aplica, quedarán a la espera de su comprobante de pago.</p>
</div>
<div>