Initial commit - Horux Despachos NL
This commit is contained in:
466
apps/web/app/(dashboard)/pendientes/page.tsx
Normal file
466
apps/web/app/(dashboard)/pendientes/page.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
'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,
|
||||
Circle,
|
||||
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');
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
// 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'))];
|
||||
|
||||
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) => {
|
||||
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">
|
||||
<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'}
|
||||
>
|
||||
{ob.periodStatus === 'completada' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className={cn('h-4 w-4', ob.periodStatus === 'atrasada' ? 'text-red-400' : 'text-muted-foreground')} />
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user