- 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
440 lines
19 KiB
TypeScript
440 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle, Button, cn } from '@horux/shared-ui';
|
|
import { PeriodSelector } from '@horux/shared-ui';
|
|
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
|
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { Header } from '@/components/layouts/header';
|
|
import {
|
|
ClipboardList,
|
|
CheckCircle2,
|
|
|
|
Clock,
|
|
AlertTriangle,
|
|
Building2,
|
|
} from 'lucide-react';
|
|
|
|
interface DeclaracionLink {
|
|
id: number;
|
|
año: number;
|
|
mes: number;
|
|
tipo: 'normal' | 'complementaria';
|
|
pdfFilename: string | null;
|
|
}
|
|
|
|
interface ObligacionPeriodo {
|
|
id: string;
|
|
nombre: string;
|
|
frecuencia: string | null;
|
|
fechaLimite: string | null;
|
|
categoria: string | null;
|
|
activa: boolean;
|
|
esRecomendada: boolean;
|
|
completada: boolean;
|
|
completadaAt: string | null;
|
|
completadaPor: string | null;
|
|
periodoCompletado: string | null;
|
|
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
|
periodoAplica: string;
|
|
/** Cuando la obligación fue completada al subir una declaración, apunta a ella. */
|
|
declaracion: DeclaracionLink | null;
|
|
}
|
|
|
|
interface ContribuyenteResumen {
|
|
id: string;
|
|
rfc: string;
|
|
nombre: string;
|
|
total: number;
|
|
completadas: number;
|
|
atrasadas: number;
|
|
pendientes: number;
|
|
obligaciones: ObligacionPeriodo[];
|
|
}
|
|
|
|
export default function PendientesPage() {
|
|
const { selectedContribuyenteId, setSelectedContribuyente } = useContribuyenteStore();
|
|
const { data: contribuyentes } = useContribuyentes();
|
|
const user = useAuthStore((s) => s.user);
|
|
|
|
const now = new Date();
|
|
const [periodo, setPeriodo] = useState(() => {
|
|
const y = now.getFullYear();
|
|
const m = now.getMonth() + 1;
|
|
return `${y}-${String(m).padStart(2, '0')}`;
|
|
});
|
|
|
|
// Derive fechaInicio/fechaFin for PeriodSelector
|
|
const fechaInicio = `${periodo}-01`;
|
|
const lastDay = new Date(parseInt(periodo.split('-')[0]), parseInt(periodo.split('-')[1]), 0).getDate();
|
|
const fechaFin = `${periodo}-${String(lastDay).padStart(2, '0')}`;
|
|
|
|
const [resumenes, setResumenes] = useState<ContribuyenteResumen[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
|
|
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
|
|
|
|
|
|
// Single contribuyente view — fetch period-aware data
|
|
useEffect(() => {
|
|
if (!selectedContribuyenteId) return;
|
|
setLoading(true);
|
|
apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`)
|
|
.then(({ data }) => setSingleObligaciones(data.data || []))
|
|
.catch(() => setSingleObligaciones([]))
|
|
.finally(() => setLoading(false));
|
|
}, [selectedContribuyenteId, periodo]);
|
|
|
|
// Portfolio view — fetch period-aware data for all contribuyentes
|
|
useEffect(() => {
|
|
if (selectedContribuyenteId) return;
|
|
if (!contribuyentes || contribuyentes.length === 0) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
Promise.all(
|
|
contribuyentes.map(async (c) => {
|
|
try {
|
|
const { data } = await apiClient.get(`/contribuyentes/${c.id}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
|
|
const items: ObligacionPeriodo[] = data.data || [];
|
|
return {
|
|
id: c.id, rfc: c.rfc, nombre: c.nombre,
|
|
total: items.length,
|
|
completadas: items.filter((o) => o.periodStatus === 'completada').length,
|
|
atrasadas: items.filter((o) => o.periodStatus === 'atrasada').length,
|
|
pendientes: items.filter((o) => o.periodStatus === 'pendiente').length,
|
|
obligaciones: items,
|
|
};
|
|
} catch {
|
|
return { id: c.id, rfc: c.rfc, nombre: c.nombre, total: 0, completadas: 0, atrasadas: 0, pendientes: 0, obligaciones: [] };
|
|
}
|
|
})
|
|
)
|
|
.then(setResumenes)
|
|
.finally(() => setLoading(false));
|
|
}, [selectedContribuyenteId, contribuyentes, periodo]);
|
|
|
|
// Filter portfolio: "Mis asignados" shows only the contribuyentes visible to the current user.
|
|
// For supervisors: their cartera contribuyentes (already filtered by useContribuyentes).
|
|
// For owners: all contribuyentes (no filter needed).
|
|
// Since useContribuyentes already filters by role, "Mis asignados" for non-owner
|
|
// is effectively the same as "Todos" (they only see their assigned ones).
|
|
const filteredResumenes = filter === 'mis' && user && contribuyentes
|
|
? resumenes.filter((r) => contribuyentes.some((c) => c.id === r.id))
|
|
: resumenes;
|
|
|
|
// Derived counts for single view
|
|
const completadasCount = singleObligaciones.filter((o) => o.periodStatus === 'completada').length;
|
|
const atrasadasCount = singleObligaciones.filter((o) => o.periodStatus === 'atrasada').length;
|
|
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
|
|
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
|
|
|
|
|
|
|
|
// Status badge
|
|
const statusBadge = (status: string) => {
|
|
if (status === 'completada') return <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">Completada</span>;
|
|
if (status === 'atrasada') return <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Atrasada</span>;
|
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300">Pendiente</span>;
|
|
};
|
|
|
|
// Frecuencia badge
|
|
const frecBadge = (f: string | null) => {
|
|
const colors: Record<string, string> = {
|
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
|
};
|
|
return f ? (
|
|
<span className={cn('text-xs px-2 py-0.5 rounded-full', colors[f] || 'bg-gray-100 text-gray-700')}>{f}</span>
|
|
) : null;
|
|
};
|
|
|
|
// Progress bar for a resumen row
|
|
const ProgressBar = ({ r }: { r: ContribuyenteResumen }) => {
|
|
const pct = r.total > 0 ? Math.round((r.completadas / r.total) * 100) : 0;
|
|
if (r.total === 0) return null;
|
|
return (
|
|
<div className="w-36">
|
|
<div className="flex justify-between text-xs mb-1 text-muted-foreground">
|
|
<span>{r.completadas}/{r.total}</span>
|
|
<span>{pct}%</span>
|
|
</div>
|
|
<div className="w-full bg-muted rounded-full h-2">
|
|
<div
|
|
className={cn(
|
|
'rounded-full h-2 transition-all',
|
|
pct === 100 ? 'bg-green-500' : pct > 50 ? 'bg-blue-500' : 'bg-amber-500'
|
|
)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Header title="Pendientes" />
|
|
<main className="p-6 space-y-6">
|
|
{loading ? (
|
|
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
|
|
) : !contribuyentes || contribuyentes.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
|
|
<p className="text-sm text-muted-foreground">Agrega contribuyentes para ver sus pendientes.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : selectedContribuyenteId ? (
|
|
/* =============== SINGLE CONTRIBUYENTE VIEW =============== */
|
|
<>
|
|
{/* Period selector */}
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-muted-foreground">Periodo:</span>
|
|
<PeriodSelector
|
|
fechaInicio={fechaInicio}
|
|
fechaFin={fechaFin}
|
|
onChange={(fi) => setPeriodo(fi.substring(0, 7))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="bg-blue-100 dark:bg-blue-900 rounded-full p-2">
|
|
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{singleObligaciones.length}</p>
|
|
<p className="text-xs text-muted-foreground">Total periodo</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="bg-red-100 dark:bg-red-900 rounded-full p-2">
|
|
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{atrasadasCount}</p>
|
|
<p className="text-xs text-muted-foreground">Atrasadas</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<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">{pendientesCount}</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">{completadasCount}</p>
|
|
<p className="text-xs text-muted-foreground">Completadas</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Obligations by category */}
|
|
{singleObligaciones.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-amber-500" />
|
|
<p className="font-medium">Sin obligaciones para este periodo</p>
|
|
<p className="text-sm mt-1">Ve a Configuración → Obligaciones Fiscales para generar recomendaciones.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
categorias.map((cat) => (
|
|
<Card key={cat}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm text-muted-foreground uppercase tracking-wide">{cat}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{singleObligaciones.filter((o) => (o.categoria || 'Sin categoría') === cat).map((ob) => {
|
|
const toggleKey = `${ob.id}:${ob.periodoAplica}`;
|
|
return (
|
|
<div
|
|
key={toggleKey}
|
|
className={cn(
|
|
'flex items-center justify-between py-2 border-b last:border-0',
|
|
ob.periodStatus === 'completada' && 'opacity-60'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<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" />
|
|
) : (
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</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">
|
|
<p className="text-xs text-muted-foreground">{ob.fechaLimite}</p>
|
|
{ob.periodoAplica !== periodo && (
|
|
<span className="text-xs text-red-500 font-medium">({ob.periodoAplica})</span>
|
|
)}
|
|
{ob.declaracion && (
|
|
<a
|
|
href={`${process.env.NEXT_PUBLIC_API_URL}/documentos/declaraciones/${ob.declaracion.id}/pdf/declaracion`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-600 hover:text-blue-700 hover:underline inline-flex items-center gap-1"
|
|
title={`Declaración ${ob.declaracion.tipo} ${String(ob.declaracion.mes).padStart(2, '0')}/${ob.declaracion.año} — ${ob.declaracion.pdfFilename ?? 'PDF'}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
↗ Declaración {String(ob.declaracion.mes).padStart(2, '0')}/{ob.declaracion.año}
|
|
{ob.declaracion.tipo === 'complementaria' && <span className="text-[10px] uppercase font-semibold">Compl.</span>}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{frecBadge(ob.frecuencia)}
|
|
{statusBadge(ob.periodStatus)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</>
|
|
) : (
|
|
/* =============== ALL CONTRIBUYENTES VIEW =============== */
|
|
<>
|
|
{/* Period selector + filter bar */}
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<span className="text-sm text-muted-foreground">Periodo:</span>
|
|
<PeriodSelector
|
|
fechaInicio={fechaInicio}
|
|
fechaFin={fechaFin}
|
|
onChange={(fi) => setPeriodo(fi.substring(0, 7))}
|
|
/>
|
|
<div className="flex rounded-lg border overflow-hidden text-sm ml-2">
|
|
<button
|
|
onClick={() => setFilter('todos')}
|
|
className={cn(
|
|
'px-3 py-1.5 transition-colors',
|
|
filter === 'todos'
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-muted'
|
|
)}
|
|
>
|
|
Todos
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter('mis')}
|
|
className={cn(
|
|
'px-3 py-1.5 border-l transition-colors',
|
|
filter === 'mis'
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-muted'
|
|
)}
|
|
>
|
|
Mis asignados
|
|
</button>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">
|
|
{filteredResumenes.length} contribuyente{filteredResumenes.length !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
Resumen de obligaciones por contribuyente para el periodo seleccionado. Selecciona uno para ver el detalle.
|
|
</p>
|
|
|
|
{filteredResumenes.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
<Building2 className="h-8 w-8 mx-auto mb-2" />
|
|
<p className="font-medium">
|
|
{filter === 'mis' ? 'No tienes contribuyentes asignados' : 'Sin contribuyentes'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{filteredResumenes.map((r) => (
|
|
<Card
|
|
key={r.id}
|
|
className="cursor-pointer hover:border-primary/50 transition-colors"
|
|
onClick={() => setSelectedContribuyente(r.id, r.rfc, r.nombre)}
|
|
>
|
|
<CardContent className="flex items-center justify-between py-4 px-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center text-sm font-bold">
|
|
{r.nombre.substring(0, 2).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold">{r.nombre}</p>
|
|
<p className="text-sm text-muted-foreground font-mono">{r.rfc}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-6">
|
|
{r.total > 0 ? (
|
|
<>
|
|
<ProgressBar r={r} />
|
|
<div className="flex items-center gap-3 text-right">
|
|
{r.atrasadas > 0 && (
|
|
<div className="text-right">
|
|
<p className="text-lg font-bold text-red-600">{r.atrasadas}</p>
|
|
<p className="text-xs text-muted-foreground">atrasadas</p>
|
|
</div>
|
|
)}
|
|
<div className="text-right">
|
|
<p className="text-lg font-bold">{r.pendientes}</p>
|
|
<p className="text-xs text-muted-foreground">pendientes</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center gap-1 text-amber-500">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<span className="text-xs">Sin configurar</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|