Initial commit - Horux Despachos NL
This commit is contained in:
325
apps/web/components/obligaciones/tareas-tab.tsx
Normal file
325
apps/web/components/obligaciones/tareas-tab.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'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 completarMutation = useMutation({
|
||||
mutationFn: async (periodoId: string) => apiClient.post(`/tareas/periodo/${periodoId}/completar`),
|
||||
onSuccess: invalidate,
|
||||
onError: (err: unknown) => {
|
||||
const e = err as { response?: { data?: { message?: string } } };
|
||||
alert(e.response?.data?.message || 'No se pudo marcar como completada');
|
||||
},
|
||||
});
|
||||
|
||||
const descompletarMutation = useMutation({
|
||||
mutationFn: async (periodoId: string) => apiClient.delete(`/tareas/periodo/${periodoId}/completar`),
|
||||
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">
|
||||
<button
|
||||
onClick={() => p && (p.completada ? descompletarMutation.mutate(p.id) : completarMutation.mutate(p.id))}
|
||||
disabled={!p || completarMutation.isPending}
|
||||
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{p?.completada
|
||||
? <CheckCircle2 className="h-5 w-5 text-success" />
|
||||
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user