Files
Horux Dev 7df27ce66d chore: catálogo obligaciones, cierre automático, fixes SAT y facturación
- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago.
- Soporte de frecuencia cuatrimestral en obligaciones y declaraciones.
- Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones.
- Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones.
- Nuevo servicio obligacion-evidencias.service.ts y endpoints REST.
- Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias.
- Notificaciones por email para evidencias de obligaciones.
- Adjuntar PDFs en correo de declaración subida.
- Fix drill-down de CFDIs: carga completa al visualizar.
- Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId.
- Fix suscripciones pending en /configuracion/planes-despacho.
- Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete.
- Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas.
- Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv).
- Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
2026-06-22 04:53:59 +00:00

441 lines
20 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',
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-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>
</>
);
}