feat: pagina /tareas + quitar completar obligaciones fiscales

- Nueva pagina /tareas para ver y marcar tareas operativas
- Endpoint GET /tareas/mis-tareas con periodo actual
- Quitado boton de marcar completada de obligaciones fiscales en /pendientes
This commit is contained in:
Horux Dev
2026-05-23 23:41:28 +00:00
parent e8b0733304
commit bba000d308
4 changed files with 299 additions and 35 deletions

View File

@@ -11,7 +11,7 @@ import { Header } from '@/components/layouts/header';
import {
ClipboardList,
CheckCircle2,
Circle,
Clock,
AlertTriangle,
Building2,
@@ -75,7 +75,7 @@ export default function PendientesPage() {
const [loading, setLoading] = useState(true);
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
const [toggling, setToggling] = useState<string | null>(null);
// Single contribuyente view — fetch period-aware data
useEffect(() => {
@@ -132,31 +132,7 @@ export default function PendientesPage() {
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
const toggleComplete = async (obligacionId: string, currentStatus: string, periodoAplica: string) => {
if (!selectedContribuyenteId) return;
const key = `${obligacionId}:${periodoAplica}`;
setToggling(key);
try {
if (currentStatus === 'completada') {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/uncomplete-periodo`,
{ periodo: periodoAplica }
);
} else {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/complete-periodo`,
{ periodo: periodoAplica }
);
}
// Refetch
const { data } = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
setSingleObligaciones(data.data || []);
} catch {
// silent — state stays as-is
} finally {
setToggling(null);
}
};
// Status badge
const statusBadge = (status: string) => {
@@ -311,18 +287,15 @@ export default function PendientesPage() {
)}
>
<div className="flex items-center gap-3">
<button
onClick={() => toggleComplete(ob.id, ob.periodStatus, ob.periodoAplica)}
disabled={toggling === toggleKey}
className="shrink-0 focus:outline-none"
title={ob.periodStatus === 'completada' ? 'Marcar como pendiente' : 'Marcar como completada'}
>
<span className="shrink-0">
{ob.periodStatus === 'completada' ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : ob.periodStatus === 'atrasada' ? (
<AlertTriangle className="h-4 w-4 text-red-400" />
) : (
<Circle className={cn('h-4 w-4', ob.periodStatus === 'atrasada' ? 'text-red-400' : 'text-muted-foreground')} />
<Clock className="h-4 w-4 text-muted-foreground" />
)}
</button>
</span>
<div>
<p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">

View File

@@ -0,0 +1,223 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button, cn } from '@horux/shared-ui';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { useMisTareas, useCompletarTareaPeriodo, useDescompletarTareaPeriodo } from '@/lib/hooks/use-tareas-mis';
import { CheckCircle2, Circle, AlertTriangle, Clock, Building2 } from 'lucide-react';
const RECURRENCIAS: Record<string, string> = {
semanal: 'Semanal',
quincenal: 'Quincenal',
mensual: 'Mensual',
bimestral: 'Bimestral',
trimestral: 'Trimestral',
semestral: 'Semestral',
anual: 'Anual',
};
const DIAS_SEMANA = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
interface TareaItem {
id: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
nombre: string;
descripcion: string | null;
recurrencia: string;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
periodoActual: {
id: string;
fechaLimite: string;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
notas: string | null;
} | null;
}
export default function TareasPage() {
const { data: tareas, isLoading } = useMisTareas();
const completarMut = useCompletarTareaPeriodo();
const descompletarMut = useDescompletarTareaPeriodo();
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'completadas'>('todas');
if (isLoading) {
return (
<DashboardShell title="Mis Tareas">
<p className="text-muted-foreground">Cargando tareas...</p>
</DashboardShell>
);
}
const all = tareas ?? [];
const filtered = all.filter((t: TareaItem) => {
if (filter === 'pendientes') return !t.periodoActual?.completada;
if (filter === 'completadas') return t.periodoActual?.completada;
return true;
});
// Agrupar por contribuyente
const grouped = filtered.reduce((acc: Record<string, TareaItem[]>, t: TareaItem) => {
const key = t.contribuyenteId;
if (!acc[key]) acc[key] = [];
acc[key].push(t);
return acc;
}, {});
const contribuyenteMap = all.reduce((acc: Record<string, { rfc: string; razonSocial: string }>, t: TareaItem) => {
if (!acc[t.contribuyenteId]) {
acc[t.contribuyenteId] = { rfc: t.contribuyenteRfc, razonSocial: t.contribuyenteRazonSocial };
}
return acc;
}, {});
const pendingCount = all.filter((t: TareaItem) => !t.periodoActual?.completada).length;
const completedCount = all.filter((t: TareaItem) => t.periodoActual?.completada).length;
return (
<DashboardShell title="Mis Tareas">
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-amber-100 dark:bg-amber-900 rounded-full p-2">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pendientes</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-green-100 dark:bg-green-900 rounded-full p-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-2xl font-bold">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completadas</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex gap-2">
{(['todas', 'pendientes', 'completadas'] as const).map((f) => (
<Button
key={f}
variant={filter === f ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter(f)}
>
{f === 'todas' ? 'Todas' : f === 'pendientes' ? 'Pendientes' : 'Completadas'}
</Button>
))}
</div>
{/* Tareas por contribuyente */}
{Object.keys(grouped).length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay tareas {filter === 'pendientes' ? 'pendientes' : filter === 'completadas' ? 'completadas' : ''}.
</CardContent>
</Card>
) : (
Object.keys(grouped).map((contribuyenteId) => {
const info = contribuyenteMap[contribuyenteId];
const items = grouped[contribuyenteId];
return (
<Card key={contribuyenteId}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{info.razonSocial}</span>
<span className="text-muted-foreground font-normal">({info.rfc})</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{items.map((t: TareaItem) => {
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 recLabel = RECURRENCIAS[t.recurrencia] || t.recurrencia;
const cuando = (t.recurrencia === 'semanal' || t.recurrencia === 'quincenal')
? DIAS_SEMANA[t.diaSemana ?? 1]
: `día ${t.diaMes}`;
return (
<div
key={t.id}
className={cn(
'flex items-center gap-3 py-2 border-b last:border-0',
p?.completada && 'opacity-60'
)}
>
<button
onClick={() => {
if (!p) return;
if (p.completada) {
descompletarMut.mutate(p.id);
} else {
completarMut.mutate(p.id);
}
}}
disabled={!p || completarMut.isPending || descompletarMut.isPending}
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
className="flex-shrink-0 focus:outline-none"
>
{p?.completada ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : atrasada ? (
<AlertTriangle className="h-5 w-5 text-red-400" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn('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-red-100 text-red-700 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">
{recLabel} · {cuando}
{fl && ` · vence ${fl.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })}`}
</p>
</div>
</div>
);
})}
</CardContent>
</Card>
);
})
)}
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,31 @@
import { apiClient } from './client';
export interface TareaConContribuyente {
id: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
nombre: string;
descripcion: string | null;
recurrencia: string;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
esDefault: boolean;
active: boolean;
orden: number;
createdAt: string;
auxiliarAsignadoId?: string | null;
periodoActual: {
id: string;
periodo: string;
fechaLimite: string;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
notas: string | null;
} | null;
}
export const getMisTareas = () =>
apiClient.get<TareaConContribuyente[]>('/tareas/mis-tareas').then(r => r.data);

View File

@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getMisTareas } from '../api/tareas-mis';
import { apiClient } from '../api/client';
export function useMisTareas() {
return useQuery({
queryKey: ['tareas-mis-tareas'],
queryFn: getMisTareas,
});
}
export function useCompletarTareaPeriodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (periodoId: string) =>
apiClient.post(`/tareas/periodo/${periodoId}/completar`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tareas-mis-tareas'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
onError: (err: any) => {
alert(err.response?.data?.message || 'No se pudo marcar como completada');
},
});
}
export function useDescompletarTareaPeriodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (periodoId: string) =>
apiClient.delete(`/tareas/periodo/${periodoId}/completar`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tareas-mis-tareas'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}