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 {
|
import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Circle,
|
|
||||||
Clock,
|
Clock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -75,7 +75,7 @@ export default function PendientesPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
|
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
|
||||||
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
|
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
|
||||||
const [toggling, setToggling] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Single contribuyente view — fetch period-aware data
|
// Single contribuyente view — fetch period-aware data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,31 +132,7 @@ export default function PendientesPage() {
|
|||||||
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
|
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
|
||||||
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
|
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
|
// Status badge
|
||||||
const statusBadge = (status: string) => {
|
const statusBadge = (status: string) => {
|
||||||
@@ -311,18 +287,15 @@ export default function PendientesPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<span className="shrink-0">
|
||||||
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'}
|
|
||||||
>
|
|
||||||
{ob.periodStatus === 'completada' ? (
|
{ob.periodStatus === 'completada' ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<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>
|
<div>
|
||||||
<p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p>
|
<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">
|
<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