- 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.
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Button, Card, CardContent, Input, Label,
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from '@horux/shared-ui';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { Plus, Trash2, Edit, CheckCircle2, Circle, Sparkles } from 'lucide-react';
|
|
|
|
const RECURRENCIAS = [
|
|
{ value: 'semanal', label: 'Semanal' },
|
|
{ value: 'quincenal', label: 'Quincenal' },
|
|
{ value: 'mensual', label: 'Mensual' },
|
|
{ value: 'bimestral', label: 'Bimestral' },
|
|
{ value: 'trimestral', label: 'Trimestral' },
|
|
{ value: 'semestral', label: 'Semestral' },
|
|
{ value: 'anual', label: 'Anual' },
|
|
];
|
|
|
|
const DIAS_SEMANA = [
|
|
{ value: 1, label: 'Lunes' },
|
|
{ value: 2, label: 'Martes' },
|
|
{ value: 3, label: 'Miércoles' },
|
|
{ value: 4, label: 'Jueves' },
|
|
{ value: 5, label: 'Viernes' },
|
|
{ value: 6, label: 'Sábado' },
|
|
{ value: 7, label: 'Domingo' },
|
|
];
|
|
|
|
interface Tarea {
|
|
id: string;
|
|
contribuyenteId: string;
|
|
nombre: string;
|
|
descripcion: string | null;
|
|
recurrencia: string;
|
|
diaSemana: number | null;
|
|
diaMes: number | null;
|
|
soloSupervisorCompleta: boolean;
|
|
esDefault: boolean;
|
|
active: boolean;
|
|
orden: number;
|
|
periodoActual: {
|
|
id: string;
|
|
fechaLimite: string;
|
|
completada: boolean;
|
|
completadaAt: string | null;
|
|
} | null;
|
|
}
|
|
|
|
interface FormState {
|
|
nombre: string;
|
|
descripcion: string;
|
|
recurrencia: string;
|
|
diaSemana: number;
|
|
diaMes: number;
|
|
soloSupervisorCompleta: boolean;
|
|
}
|
|
|
|
const EMPTY_FORM: FormState = {
|
|
nombre: '',
|
|
descripcion: '',
|
|
recurrencia: 'mensual',
|
|
diaSemana: 5,
|
|
diaMes: 10,
|
|
soloSupervisorCompleta: false,
|
|
};
|
|
|
|
export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null }) {
|
|
const queryClient = useQueryClient();
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
|
|
|
const tareasQuery = useQuery<Tarea[]>({
|
|
queryKey: ['tareas', contribuyenteId],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
|
|
const { data } = await apiClient.get<Tarea[]>(`/tareas?${params}`);
|
|
return data;
|
|
},
|
|
enabled: !!contribuyenteId,
|
|
});
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['tareas', contribuyenteId] });
|
|
|
|
const seedMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
|
|
await apiClient.post(`/tareas/seed?${params}`);
|
|
},
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const payload = {
|
|
nombre: form.nombre,
|
|
descripcion: form.descripcion || null,
|
|
recurrencia: form.recurrencia,
|
|
diaSemana: form.recurrencia === 'semanal' || form.recurrencia === 'quincenal' ? form.diaSemana : null,
|
|
diaMes: form.recurrencia === 'semanal' || form.recurrencia === 'quincenal' ? null : form.diaMes,
|
|
soloSupervisorCompleta: form.soloSupervisorCompleta,
|
|
};
|
|
if (editingId) {
|
|
await apiClient.patch(`/tareas/${editingId}`, payload);
|
|
} else {
|
|
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
|
|
await apiClient.post(`/tareas?${params}`, payload);
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
setShowForm(false);
|
|
setEditingId(null);
|
|
setForm(EMPTY_FORM);
|
|
invalidate();
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (id: string) => apiClient.delete(`/tareas/${id}`),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const handleEdit = (t: Tarea) => {
|
|
setEditingId(t.id);
|
|
setForm({
|
|
nombre: t.nombre,
|
|
descripcion: t.descripcion ?? '',
|
|
recurrencia: t.recurrencia,
|
|
diaSemana: t.diaSemana ?? 5,
|
|
diaMes: t.diaMes ?? 10,
|
|
soloSupervisorCompleta: t.soloSupervisorCompleta,
|
|
});
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleNew = () => {
|
|
setEditingId(null);
|
|
setForm(EMPTY_FORM);
|
|
setShowForm(true);
|
|
};
|
|
|
|
if (!contribuyenteId) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
Selecciona un contribuyente para gestionar sus tareas.
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const tareas = tareasQuery.data ?? [];
|
|
const isWeekly = form.recurrencia === 'semanal' || form.recurrencia === 'quincenal';
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{tareas.length === 0 && (
|
|
<Button variant="outline" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
|
|
<Sparkles className="h-4 w-4 mr-2" />
|
|
Generar recomendaciones
|
|
</Button>
|
|
)}
|
|
<Button onClick={handleNew}>
|
|
<Plus className="h-4 w-4 mr-2" /> Agregar tarea
|
|
</Button>
|
|
</div>
|
|
|
|
{tareasQuery.isLoading ? (
|
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
|
) : tareas.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
No hay tareas configuradas. Usa "Generar recomendaciones" para crear las 4 tareas default
|
|
(estados de cuenta, conciliación, contabilización, revisión fiscal preliminar).
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{tareas.map(t => {
|
|
const p = t.periodoActual;
|
|
const fl = p ? new Date(p.fechaLimite) : null;
|
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
const atrasada = !!fl && !p?.completada && fl < today;
|
|
const recurrenciaLabel = RECURRENCIAS.find(r => r.value === t.recurrencia)?.label;
|
|
const cuandoLabel = (t.recurrencia === 'semanal' || t.recurrencia === 'quincenal')
|
|
? DIAS_SEMANA.find(d => d.value === t.diaSemana)?.label
|
|
: `día ${t.diaMes}`;
|
|
return (
|
|
<Card key={t.id}>
|
|
<CardContent className="py-3 flex items-center gap-3">
|
|
<div className="flex-shrink-0" title={p?.completada ? 'Completada' : atrasada ? 'Atrasada' : 'Pendiente'}>
|
|
{p?.completada
|
|
? <CheckCircle2 className="h-5 w-5 text-success" />
|
|
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
|
{t.nombre}
|
|
</span>
|
|
{t.soloSupervisorCompleta && (
|
|
<span className="text-[10px] uppercase bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
|
|
Supervisor
|
|
</span>
|
|
)}
|
|
{atrasada && (
|
|
<span className="text-[10px] uppercase bg-destructive/10 text-destructive rounded px-1.5 py-0.5">
|
|
Atrasada
|
|
</span>
|
|
)}
|
|
</div>
|
|
{t.descripcion && (
|
|
<p className="text-xs text-muted-foreground truncate">{t.descripcion}</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{recurrenciaLabel} · {cuandoLabel}
|
|
{fl && ` · vence ${fl.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })}`}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<Button variant="ghost" size="icon" onClick={() => handleEdit(t)} title="Editar">
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
onClick={() => confirm(`¿Eliminar tarea "${t.nombre}"?`) && deleteMutation.mutate(t.id)}
|
|
title="Eliminar"
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<Dialog open={showForm} onOpenChange={setShowForm}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{editingId ? 'Editar tarea' : 'Nueva tarea'}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label>Nombre</Label>
|
|
<Input value={form.nombre} onChange={e => setForm(f => ({ ...f, nombre: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label>Descripción (opcional)</Label>
|
|
<Input value={form.descripcion} onChange={e => setForm(f => ({ ...f, descripcion: e.target.value }))} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label>Recurrencia</Label>
|
|
<select
|
|
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
|
|
value={form.recurrencia}
|
|
onChange={e => setForm(f => ({ ...f, recurrencia: e.target.value }))}
|
|
>
|
|
{RECURRENCIAS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label>{isWeekly ? 'Día de la semana' : 'Día del mes'}</Label>
|
|
{isWeekly ? (
|
|
<select
|
|
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
|
|
value={form.diaSemana}
|
|
onChange={e => setForm(f => ({ ...f, diaSemana: parseInt(e.target.value, 10) }))}
|
|
>
|
|
{DIAS_SEMANA.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
|
|
</select>
|
|
) : (
|
|
<Input
|
|
type="number" min={1} max={31}
|
|
value={form.diaMes}
|
|
onChange={e => setForm(f => ({ ...f, diaMes: parseInt(e.target.value, 10) || 1 }))}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.soloSupervisorCompleta}
|
|
onChange={e => setForm(f => ({ ...f, soloSupervisorCompleta: e.target.checked }))}
|
|
/>
|
|
Solo supervisor/owner pueden marcarla como completada
|
|
</label>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
|
|
<Button onClick={() => saveMutation.mutate()} disabled={!form.nombre || saveMutation.isPending}>
|
|
{editingId ? 'Guardar' : 'Crear'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|