Files
HoruxDespachos/apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx
2026-04-27 22:09:36 -06:00

500 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',
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>
</>
);
}