Files
Horux Dev 7df27ce66d 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.
2026-06-22 04:53:59 +00:00

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>
);
}