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:
@@ -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">
|
||||
|
||||
223
apps/web/app/(dashboard)/tareas/page.tsx
Normal file
223
apps/web/app/(dashboard)/tareas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/web/lib/api/tareas-mis.ts
Normal file
31
apps/web/lib/api/tareas-mis.ts
Normal 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);
|
||||
37
apps/web/lib/hooks/use-tareas-mis.ts
Normal file
37
apps/web/lib/hooks/use-tareas-mis.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user