- 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.
501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
Input,
|
|
Label,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
cn,
|
|
} from '@horux/shared-ui';
|
|
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { Header } from '@/components/layouts/header';
|
|
import { TareasTab } from '@/components/obligaciones/tareas-tab';
|
|
import { Plus, Trash2, RotateCcw, Sparkles, ChevronDown, Building2 } from 'lucide-react';
|
|
|
|
interface Obligacion {
|
|
id: string;
|
|
catalogoId: string | null;
|
|
nombre: string;
|
|
fundamento: string | null;
|
|
frecuencia: string | null;
|
|
fechaLimite: string | null;
|
|
categoria: string | null;
|
|
activa: boolean;
|
|
esRecomendada: boolean;
|
|
esCustom: boolean;
|
|
}
|
|
|
|
interface CatalogoItem {
|
|
id: string;
|
|
nombre: string;
|
|
fundamento: string;
|
|
frecuencia: string;
|
|
fechaLimite: string;
|
|
categoria: string;
|
|
aplica: string;
|
|
}
|
|
|
|
export default function ObligacionesPage() {
|
|
const queryClient = useQueryClient();
|
|
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } =
|
|
useContribuyenteStore();
|
|
const [obligaciones, setObligaciones] = useState<Obligacion[]>([]);
|
|
const [catalogo, setCatalogo] = useState<CatalogoItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showAdd, setShowAdd] = useState(false);
|
|
const [showRemoved, setShowRemoved] = useState(false);
|
|
const [addMode, setAddMode] = useState<'catalogo' | 'custom'>('catalogo');
|
|
const [customForm, setCustomForm] = useState({
|
|
nombre: '',
|
|
fundamento: '',
|
|
frecuencia: '',
|
|
fechaLimite: '',
|
|
categoria: '',
|
|
});
|
|
const [selectedCatalogoId, setSelectedCatalogoId] = useState('');
|
|
const [activeTab, setActiveTab] = useState<'obligaciones' | 'tareas'>('obligaciones');
|
|
|
|
const fetchObligaciones = useCallback(async () => {
|
|
if (!selectedContribuyenteId) return;
|
|
setLoading(true);
|
|
try {
|
|
const { data } = await apiClient.get(
|
|
`/contribuyentes/${selectedContribuyenteId}/obligaciones`
|
|
);
|
|
setObligaciones(data.data);
|
|
} catch {
|
|
setObligaciones([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [selectedContribuyenteId]);
|
|
|
|
useEffect(() => {
|
|
fetchObligaciones();
|
|
}, [fetchObligaciones]);
|
|
|
|
useEffect(() => {
|
|
apiClient
|
|
.get('/contribuyentes/catalogo-obligaciones')
|
|
.then(({ data }) => setCatalogo(data.data))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const handleInit = async () => {
|
|
if (!selectedContribuyenteId || !selectedContribuyenteRfc) return;
|
|
try {
|
|
await apiClient.post(
|
|
`/contribuyentes/${selectedContribuyenteId}/obligaciones/init`,
|
|
{
|
|
rfc: selectedContribuyenteRfc,
|
|
regimenes: [],
|
|
tieneNomina: false,
|
|
}
|
|
);
|
|
await fetchObligaciones();
|
|
invalidateRelated();
|
|
} catch (err: unknown) {
|
|
const e = err as { response?: { data?: { message?: string } } };
|
|
alert(e.response?.data?.message || 'Error al generar recomendaciones');
|
|
}
|
|
};
|
|
|
|
const handleAdd = async () => {
|
|
if (!selectedContribuyenteId) return;
|
|
try {
|
|
if (addMode === 'catalogo' && selectedCatalogoId) {
|
|
const item = catalogo.find((c) => c.id === selectedCatalogoId);
|
|
if (!item) return;
|
|
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/obligaciones`, {
|
|
catalogoId: item.id,
|
|
nombre: item.nombre,
|
|
fundamento: item.fundamento,
|
|
frecuencia: item.frecuencia,
|
|
fechaLimite: item.fechaLimite,
|
|
categoria: item.categoria,
|
|
});
|
|
} else if (addMode === 'custom' && customForm.nombre) {
|
|
await apiClient.post(
|
|
`/contribuyentes/${selectedContribuyenteId}/obligaciones`,
|
|
customForm
|
|
);
|
|
}
|
|
setShowAdd(false);
|
|
setSelectedCatalogoId('');
|
|
setCustomForm({ nombre: '', fundamento: '', frecuencia: '', fechaLimite: '', categoria: '' });
|
|
await fetchObligaciones();
|
|
} catch (err: unknown) {
|
|
const e = err as { response?: { data?: { message?: string } } };
|
|
alert(e.response?.data?.message || 'Error al agregar obligación');
|
|
}
|
|
};
|
|
|
|
const invalidateRelated = () => {
|
|
queryClient.invalidateQueries({ queryKey: ['alertas-manuales'] });
|
|
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
|
|
queryClient.invalidateQueries({ queryKey: ['alertas'] });
|
|
queryClient.invalidateQueries({ queryKey: ['eventos'] });
|
|
};
|
|
|
|
const handleRemove = async (id: string) => {
|
|
await apiClient.delete(
|
|
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${id}`
|
|
);
|
|
await fetchObligaciones();
|
|
invalidateRelated();
|
|
};
|
|
|
|
const handleRestore = async (id: string) => {
|
|
await apiClient.post(
|
|
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${id}/restore`
|
|
);
|
|
await fetchObligaciones();
|
|
invalidateRelated();
|
|
};
|
|
|
|
if (!selectedContribuyenteId) {
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto">
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold">Selecciona un contribuyente</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Usa el selector de RFCs en el header para elegir un contribuyente.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activas = obligaciones.filter((o) => o.activa);
|
|
const removidas = obligaciones.filter((o) => !o.activa);
|
|
const categorias = [...new Set(activas.map((o) => o.categoria || 'Sin categoría'))];
|
|
|
|
const frecuenciaBadge = (f: string | null) => {
|
|
const colors: Record<string, string> = {
|
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
|
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
|
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
};
|
|
return f ? (
|
|
<span className={cn('text-xs px-2 py-0.5 rounded-full', colors[f] || colors['eventual'])}>
|
|
{f}
|
|
</span>
|
|
) : null;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Header title="Obligaciones Fiscales" />
|
|
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
|
{/* Subtítulo */}
|
|
<p className="text-sm text-muted-foreground">
|
|
{selectedContribuyenteNombre} — {selectedContribuyenteRfc}
|
|
</p>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b">
|
|
<button
|
|
onClick={() => setActiveTab('obligaciones')}
|
|
className={cn(
|
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
|
activeTab === 'obligaciones'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
Obligaciones
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('tareas')}
|
|
className={cn(
|
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
|
activeTab === 'tareas'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
Tareas
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'tareas' ? (
|
|
<TareasTab contribuyenteId={selectedContribuyenteId ?? null} />
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-end gap-2">
|
|
{activas.length === 0 && (
|
|
<Button
|
|
onClick={handleInit}
|
|
variant="outline"
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Sparkles className="h-4 w-4" /> Generar recomendaciones
|
|
</Button>
|
|
)}
|
|
<Button onClick={() => setShowAdd(true)} className="flex items-center gap-2">
|
|
<Plus className="h-4 w-4" /> Agregar
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Active obligations */}
|
|
{loading ? (
|
|
<p className="text-muted-foreground">Cargando...</p>
|
|
) : activas.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold">Sin obligaciones configuradas</h3>
|
|
<p className="text-sm text-muted-foreground mt-1 mb-4">
|
|
Importa las obligaciones desde la Constancia de Situación Fiscal (CSF) o agrega manualmente.
|
|
</p>
|
|
<Button onClick={handleInit} className="flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4" /> Generar recomendaciones
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{categorias.map((cat) => (
|
|
<div key={cat}>
|
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
{cat}
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{activas
|
|
.filter((o) => (o.categoria || 'Sin categoría') === cat)
|
|
.map((ob) => (
|
|
<Card key={ob.id}>
|
|
<CardContent className="flex items-center justify-between py-3 px-5">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<p className="font-medium text-sm">{ob.nombre}</p>
|
|
{frecuenciaBadge(ob.frecuencia)}
|
|
{ob.esRecomendada && (
|
|
<span className="text-xs text-amber-600 dark:text-amber-400">
|
|
★ Recomendada
|
|
</span>
|
|
)}
|
|
{ob.esCustom && (
|
|
<span className="text-xs text-purple-600 dark:text-purple-400">
|
|
✎ Custom
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-4 mt-1">
|
|
{ob.fundamento && (
|
|
<p className="text-xs text-muted-foreground">{ob.fundamento}</p>
|
|
)}
|
|
{ob.fechaLimite && (
|
|
<p className="text-xs text-muted-foreground">
|
|
📅 {ob.fechaLimite}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(ob.id)}
|
|
className="text-destructive hover:text-destructive ml-2 shrink-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Removed obligations */}
|
|
{removidas.length > 0 && (
|
|
<div>
|
|
<button
|
|
onClick={() => setShowRemoved(!showRemoved)}
|
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ChevronDown
|
|
className={cn('h-4 w-4 transition-transform', showRemoved && 'rotate-180')}
|
|
/>
|
|
{removidas.length} obligaciones desactivadas
|
|
</button>
|
|
{showRemoved && (
|
|
<div className="mt-2 space-y-2">
|
|
{removidas.map((ob) => (
|
|
<Card key={ob.id} className="opacity-50">
|
|
<CardContent className="flex items-center justify-between py-3 px-5">
|
|
<div>
|
|
<p className="font-medium text-sm line-through">{ob.nombre}</p>
|
|
<p className="text-xs text-muted-foreground">{ob.categoria}</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRestore(ob.id)}
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add dialog */}
|
|
<Dialog open={showAdd} onOpenChange={setShowAdd}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Agregar obligación fiscal</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant={addMode === 'catalogo' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setAddMode('catalogo')}
|
|
>
|
|
Del catálogo
|
|
</Button>
|
|
<Button
|
|
variant={addMode === 'custom' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setAddMode('custom')}
|
|
>
|
|
Personalizada
|
|
</Button>
|
|
</div>
|
|
|
|
{addMode === 'catalogo' ? (
|
|
<div className="max-h-64 overflow-y-auto space-y-1 border rounded-md p-1">
|
|
{catalogo.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
Cargando catálogo...
|
|
</p>
|
|
) : (
|
|
catalogo.map((item) => {
|
|
const yaAgregada = obligaciones.some(
|
|
(o) => o.catalogoId === item.id && o.activa
|
|
);
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
disabled={yaAgregada}
|
|
onClick={() => setSelectedCatalogoId(item.id)}
|
|
className={cn(
|
|
'w-full text-left p-3 rounded-md text-sm transition-colors',
|
|
yaAgregada
|
|
? 'opacity-30 cursor-not-allowed'
|
|
: selectedCatalogoId === item.id
|
|
? 'bg-primary/10 border border-primary'
|
|
: 'hover:bg-accent'
|
|
)}
|
|
>
|
|
<p className="font-medium">{item.nombre}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{item.categoria} · {item.frecuencia} · {item.fechaLimite}
|
|
</p>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label>Nombre *</Label>
|
|
<Input
|
|
value={customForm.nombre}
|
|
onChange={(e) =>
|
|
setCustomForm((p) => ({ ...p, nombre: e.target.value }))
|
|
}
|
|
placeholder="Nombre de la obligación"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Fundamento legal</Label>
|
|
<Input
|
|
value={customForm.fundamento}
|
|
onChange={(e) =>
|
|
setCustomForm((p) => ({ ...p, fundamento: e.target.value }))
|
|
}
|
|
placeholder="Art. X LISR"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label>Frecuencia</Label>
|
|
<Input
|
|
value={customForm.frecuencia}
|
|
onChange={(e) =>
|
|
setCustomForm((p) => ({ ...p, frecuencia: e.target.value }))
|
|
}
|
|
placeholder="mensual, anual..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Fecha límite</Label>
|
|
<Input
|
|
value={customForm.fechaLimite}
|
|
onChange={(e) =>
|
|
setCustomForm((p) => ({ ...p, fechaLimite: e.target.value }))
|
|
}
|
|
placeholder="Día 17 del mes..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>Categoría</Label>
|
|
<Input
|
|
value={customForm.categoria}
|
|
onChange={(e) =>
|
|
setCustomForm((p) => ({ ...p, categoria: e.target.value }))
|
|
}
|
|
placeholder="Federal mensual, Anual..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowAdd(false)}>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
onClick={handleAdd}
|
|
disabled={
|
|
addMode === 'catalogo' ? !selectedCatalogoId : !customForm.nombre
|
|
}
|
|
>
|
|
Agregar
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|