Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/documentos/page.tsx
Horux Dev 9b535354fb feat(papeleria): aprobación independiente por cliente
- Agrega migración 050 con columnas de aprobación de cliente
  (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.)
- Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos
- Backend: list/download permiten acceso a clientes filtrando por entidades visibles
- Backend: notificación por email a clientes cuando se les solicita aprobación
- Frontend: checkbox independiente para solicitar aprobación del cliente
- Frontend: badge de estado combinado (owner + cliente)
- Frontend: botones de aprobar/rechazar para clientes en su propio flujo
2026-05-29 00:36:33 +00:00

997 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { Fragment, useState } from 'react';
import { useOpiniones, useConsultarOpinion, useDescargarPdf } from '@/lib/hooks/use-documentos';
import {
useDeclaraciones,
useCreateDeclaracion,
useUploadComprobantePago,
useDeleteDeclaracion,
useDownloadDeclaracionPdf,
} from '@/lib/hooks/use-declaraciones';
import { fileToBase64, type Declaracion, type Impuesto, type Periodicidad } from '@/lib/api/declaraciones';
import { useConstancias, useConsultarConstancia, useDescargarConstanciaPdf } from '@/lib/hooks/use-constancias';
import type { Constancia } from '@/lib/api/constancias';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Tabs, TabsList, TabsTrigger, TabsContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Input, Label } from '@horux/shared-ui';
import {
FileCheck, Download, RefreshCw, Loader2, AlertTriangle, CheckCircle2, XCircle, Clock,
IdCard, FileText, Upload, Plus, Trash2, Receipt, FolderOpen, Tag, Briefcase,
} from 'lucide-react';
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as docsApi from '@/lib/api/documentos';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
{ value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' },
{ value: 'trimestral', label: 'Trimestral' },
{ value: 'semestral', label: 'Semestral' },
{ value: 'anual', label: 'Anual' },
];
const PERIODICIDAD_LABELS: Record<string, string> = {
mensual: 'Mensual', bimestral: 'Bimestral', trimestral: 'Trimestral',
semestral: 'Semestral', anual: 'Anual',
};
/** Returns period options based on periodicidad. Each option has a value (mes start) and a label. */
function getPeriodOptions(periodicidad: Periodicidad): { value: number; label: string }[] {
switch (periodicidad) {
case 'mensual':
return MESES.map((m, i) => ({ value: i + 1, label: m }));
case 'bimestral':
return [
{ value: 1, label: 'Enero Febrero' },
{ value: 3, label: 'Marzo Abril' },
{ value: 5, label: 'Mayo Junio' },
{ value: 7, label: 'Julio Agosto' },
{ value: 9, label: 'Septiembre Octubre' },
{ value: 11, label: 'Noviembre Diciembre' },
];
case 'trimestral':
return [
{ value: 1, label: 'Enero Marzo' },
{ value: 4, label: 'Abril Junio' },
{ value: 7, label: 'Julio Septiembre' },
{ value: 10, label: 'Octubre Diciembre' },
];
case 'semestral':
return [
{ value: 1, label: 'Enero Junio' },
{ value: 7, label: 'Julio Diciembre' },
];
case 'anual':
return [{ value: 1, label: 'Anual' }];
default:
return MESES.map((m, i) => ({ value: i + 1, label: m }));
}
}
/** Returns a display label for a period (mes) given its periodicidad */
function getPeriodLabel(periodicidad: string, mes: number): string {
const options = getPeriodOptions(periodicidad as Periodicidad);
return options.find(o => o.value === mes)?.label || MESES[mes - 1] || String(mes);
}
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
function EstatusBadge({ estatus }: { estatus: string }) {
if (estatus === 'Positiva') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> {estatus}</span>;
if (estatus === 'Negativa') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> {estatus}</span>;
return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><AlertTriangle className="h-3 w-3" /> {estatus}</span>;
}
export default function DocumentosPage() {
const user = useAuthStore((s) => s.user);
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
const canSeePapeleria = true; // Todos los roles pueden ver papelería (cliente con restricciones)
return (
<>
<Header title="Documentos" />
<main className="p-6">
<Tabs defaultValue="declaraciones" className="space-y-4">
<TabsList>
<TabsTrigger value="opinion"><FileCheck className="h-4 w-4 mr-1.5" /> Opinión de Cumplimiento</TabsTrigger>
<TabsTrigger value="constancia"><IdCard className="h-4 w-4 mr-1.5" /> Constancia de Situación Fiscal</TabsTrigger>
<TabsTrigger value="declaraciones"><FileText className="h-4 w-4 mr-1.5" /> Declaraciones</TabsTrigger>
<TabsTrigger value="extras"><FolderOpen className="h-4 w-4 mr-1.5" /> Extras</TabsTrigger>
{canSeePapeleria && (
<TabsTrigger value="papeleria"><Briefcase className="h-4 w-4 mr-1.5" /> Papelería de Trabajo</TabsTrigger>
)}
</TabsList>
<TabsContent value="opinion"><OpinionTab canConsultar={canConsultarOpinion} /></TabsContent>
<TabsContent value="constancia"><ConstanciaTab /></TabsContent>
<TabsContent value="declaraciones"><DeclaracionesTab /></TabsContent>
<TabsContent value="extras"><ExtrasTab /></TabsContent>
{canSeePapeleria && (
<TabsContent value="papeleria"><PapeleriaTab /></TabsContent>
)}
</Tabs>
</main>
</>
);
}
// ============================================================================
// Opinión de Cumplimiento
// ============================================================================
function OpinionTab({ canConsultar }: { canConsultar: boolean }) {
const { data: opiniones, isLoading, error } = useOpiniones();
const consultar = useConsultarOpinion();
const descargar = useDescargarPdf();
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2"><FileCheck className="h-5 w-5" /> Opinión de Cumplimiento</h2>
{canConsultar && (
<Button onClick={() => consultar.mutate()} disabled={consultar.isPending} size="sm">
{consultar.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Consultando...</> : <><RefreshCw className="h-4 w-4 mr-2" /> Consultar ahora</>}
</Button>
)}
</div>
{consultar.isError && <div className="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(consultar.error as Error).message}</div>}
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error al cargar opiniones: {(error as Error).message}</div>}
{!isLoading && !error && opiniones?.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FileCheck className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay opiniones registradas.</p>
<p className="text-sm mt-1">La consulta automática se ejecuta cada semana.</p>
</div>
)}
{!isLoading && opiniones && opiniones.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Fecha de consulta</th>
<th className="pb-2 font-medium">Estatus</th>
<th className="pb-2 font-medium">Folio</th>
<th className="pb-2 font-medium">RFC</th>
<th className="pb-2 font-medium text-right">PDF</th>
</tr>
</thead>
<tbody className="divide-y">
{opiniones.map((op) => (
<tr key={op.id} className="hover:bg-muted/50">
<td className="py-3"><div className="flex items-center gap-2"><Clock className="h-4 w-4 text-muted-foreground" />{new Date(op.fechaConsulta).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></td>
<td className="py-3"><EstatusBadge estatus={op.estatus} /></td>
<td className="py-3 font-mono text-xs">{op.folio}</td>
<td className="py-3 font-mono text-xs">{op.rfc}</td>
<td className="py-3 text-right"><Button variant="ghost" size="sm" onClick={() => descargar.mutate(op.id)} disabled={descargar.isPending}><Download className="h-3.5 w-3.5 mr-1" /> Descargar</Button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Constancia de Situación Fiscal
// ============================================================================
function ConstanciaTab() {
const user = useAuthStore((s) => s.user);
const canConsultar = user?.role === 'owner' || user?.role === 'cfo';
const { data: constancias, isLoading, error } = useConstancias();
const consultar = useConsultarConstancia();
const descargar = useDescargarConstanciaPdf();
const [expandedId, setExpandedId] = useState<number | null>(null);
const latest = constancias?.[0];
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><IdCard className="h-5 w-5" /> Constancia de Situación Fiscal</h2>
<p className="text-sm text-muted-foreground mt-0.5">Descarga mensual automática del SAT el día 1. También se actualizan el domicilio fiscal y los regímenes activos del tenant.</p>
</div>
{canConsultar && (
<Button onClick={() => consultar.mutate()} disabled={consultar.isPending} size="sm">
{consultar.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Consultando...</> : <><RefreshCw className="h-4 w-4 mr-2" /> Consultar ahora</>}
</Button>
)}
</div>
{consultar.isError && <div className="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(consultar.error as Error).message}</div>}
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(error as Error).message}</div>}
{!isLoading && !error && constancias?.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<IdCard className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay constancias registradas.</p>
<p className="text-sm mt-1">La consulta automática se ejecuta el 1° de cada mes.</p>
</div>
)}
{latest && (
<ConstanciaDetalle datos={latest.datos} />
)}
</CardContent>
</Card>
{constancias && constancias.length > 0 && (
<Card>
<CardContent className="pt-6">
<h3 className="text-md font-semibold mb-3">Historial (últimas {constancias.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-2 font-medium">Fecha de consulta</th>
<th className="pb-2 pr-2 font-medium">Estatus</th>
<th className="pb-2 pr-2 font-medium">RFC</th>
<th className="pb-2 pr-2 font-medium">Régimenes activos</th>
<th className="pb-2 pr-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{constancias.map((c) => {
const activos = c.datos.regimenes.filter(r => !r.fechaFin).length;
const expanded = expandedId === c.id;
return (
<Fragment key={c.id}>
<tr className="hover:bg-muted/50">
<td className="py-3 pr-2"><div className="flex items-center gap-2"><Clock className="h-4 w-4 text-muted-foreground" />{new Date(c.fechaConsulta).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })}</div></td>
<td className="py-3 pr-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${c.estatusPadron === 'ACTIVO' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
{c.estatusPadron || '—'}
</span>
</td>
<td className="py-3 pr-2 font-mono text-xs">{c.rfc}</td>
<td className="py-3 pr-2 text-xs">{activos}</td>
<td className="py-3 pr-2 text-right">
<Button variant="ghost" size="sm" onClick={() => setExpandedId(expanded ? null : c.id)}>
{expanded ? 'Ocultar' : 'Ver datos'}
</Button>
<Button variant="ghost" size="sm" onClick={() => descargar.mutate(c.id)} disabled={descargar.isPending}>
<Download className="h-3.5 w-3.5 mr-1" /> PDF
</Button>
</td>
</tr>
{expanded && (
<tr><td colSpan={5} className="p-0 bg-muted/30"><div className="p-4"><ConstanciaDetalle datos={c.datos} /></div></td></tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
function ConstanciaDetalle({ datos }: { datos: Constancia['datos'] }) {
const d = datos.domicilio;
const domicilio = [
[d.tipoVialidad, d.nombreVialidad].filter(Boolean).join(' '),
d.numeroExterior && `Ext. ${d.numeroExterior}`,
d.numeroInterior && d.numeroInterior.toUpperCase() !== 'SIN NUMERO' && `Int. ${d.numeroInterior}`,
d.colonia,
[d.codigoPostal, d.localidad].filter(Boolean).join(' '),
[d.municipio, d.entidadFederativa].filter(Boolean).join(', '),
].filter(Boolean).join(' · ');
const regimenesActivos = datos.regimenes.filter(r => !r.fechaFin);
return (
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Identificación</h4>
<dl className="space-y-1">
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">RFC</dt><dd className="font-mono">{datos.rfc}</dd></div>
{datos.curp && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">CURP</dt><dd className="font-mono">{datos.curp}</dd></div>}
{datos.razonSocial && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">Razón social</dt><dd className="text-right">{datos.razonSocial}</dd></div>}
{!datos.razonSocial && (datos.nombre || datos.primerApellido) && (
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Nombre</dt><dd className="text-right">{[datos.nombre, datos.primerApellido, datos.segundoApellido].filter(Boolean).join(' ')}</dd></div>
)}
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Estatus</dt><dd>{datos.estatusPadron}</dd></div>
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Inicio de operaciones</dt><dd>{datos.fechaInicioOperaciones}</dd></div>
</dl>
</div>
<div>
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Domicilio fiscal</h4>
<p>{domicilio || '—'}</p>
</div>
<div className="md:col-span-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Regímenes activos ({regimenesActivos.length})</h4>
{regimenesActivos.length === 0 ? (
<p className="text-muted-foreground">Sin regímenes activos</p>
) : (
<ul className="space-y-1">
{regimenesActivos.map((r, i) => (
<li key={i} className="flex justify-between gap-2">
<span>{r.nombre}</span>
<span className="text-xs text-muted-foreground">desde {r.fechaInicio}</span>
</li>
))}
</ul>
)}
</div>
{datos.obligaciones.length > 0 && (
<div className="md:col-span-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Obligaciones ({datos.obligaciones.filter(o => !o.fechaFin).length} activas)</h4>
<ul className="space-y-1">
{datos.obligaciones.filter(o => !o.fechaFin).map((o, i) => (
<li key={i} className="text-xs">
<span className="font-medium">{o.descripcion}</span>
<span className="text-muted-foreground"> {o.descripcionVencimiento}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
// ============================================================================
// Declaraciones
// ============================================================================
function DeclaracionesTab() {
const user = useAuthStore((s) => s.user);
const canUpload = !!user?.role && ROLES_UPLOAD.includes(user.role);
const { selectedContribuyenteId } = useContribuyenteStore();
// Default: current month range
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const [fechaDesde, setFechaDesde] = useState(`${y}-${String(m).padStart(2, '0')}-01`);
const [fechaHasta, setFechaHasta] = useState(`${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`);
const [uploadOpen, setUploadOpen] = useState(false);
const [pagoDeclaracion, setPagoDeclaracion] = useState<Declaracion | null>(null);
const { data: declaraciones, isLoading, error } = useDeclaraciones(fechaDesde, fechaHasta, selectedContribuyenteId);
const deleteDecl = useDeleteDeclaracion();
const downloadPdf = useDownloadDeclaracionPdf();
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><FileText className="h-5 w-5" /> Declaraciones</h2>
<p className="text-sm text-muted-foreground mt-0.5">Sube el PDF de cada declaración y su comprobante de pago. Al subirla, se desactivan los recordatorios correspondientes.</p>
</div>
{canUpload && <Button size="sm" onClick={() => setUploadOpen(true)}><Plus className="h-4 w-4 mr-1.5" /> Subir declaración</Button>}
</div>
<div className="flex flex-wrap items-end gap-3 mb-4 pb-3 border-b">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Desde</label>
<Input type="date" value={fechaDesde} onChange={(e) => setFechaDesde(e.target.value)} className="h-8 w-[150px] text-sm" />
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Hasta</label>
<Input type="date" value={fechaHasta} onChange={(e) => setFechaHasta(e.target.value)} className="h-8 w-[150px] text-sm" />
</div>
</div>
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(error as Error).message}</div>}
{!isLoading && !error && declaraciones && declaraciones.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay declaraciones en el rango seleccionado.</p>
{canUpload && <p className="text-sm mt-1">Usa el botón "Subir declaración" para cargar la primera.</p>}
</div>
)}
{!isLoading && declaraciones && declaraciones.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-2 font-medium">Periodo</th>
<th className="pb-2 pr-2 font-medium">Tipo</th>
<th className="pb-2 pr-2 font-medium">Impuestos</th>
<th className="pb-2 pr-2 font-medium text-right">Monto</th>
<th className="pb-2 pr-2 font-medium">Declaración</th>
<th className="pb-2 pr-2 font-medium">Pago</th>
<th className="pb-2 pr-2 font-medium">Fecha subida</th>
<th className="pb-2 pr-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{declaraciones.map((d) => {
const isPaidByAmount = d.montoPago === 0;
const isPaid = d.tienePagoPdf || isPaidByAmount;
return (
<tr key={d.id} className="hover:bg-muted/50">
<td className="py-3 pr-2 font-medium">{getPeriodLabel(d.periodicidad, d.mes)} {d.año}</td>
<td className="py-3 pr-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${d.tipo === 'normal' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'}`}>
{d.tipo === 'normal' ? 'Normal' : 'Complementaria'}
</span>
</td>
<td className="py-3 pr-2">
<div className="flex flex-wrap gap-1">
{d.impuestos.map(i => <span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-muted">{i}</span>)}
</div>
</td>
<td className="py-3 pr-2 text-right font-mono text-sm">
{d.montoPago != null ? `$${d.montoPago.toLocaleString('es-MX', { minimumFractionDigits: 2 })}` : '—'}
</td>
<td className="py-3 pr-2">
<div className="flex flex-col gap-1">
{d.pdfFilename && (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'declaracion', filename: d.pdfFilename! })} className="inline-flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="h-3 w-3" /> Declaración
</button>
)}
{d.tieneLigaPago && (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'liga', filename: d.ligaPagoFilename || `liga-pago-${d.id}.pdf` })} className="inline-flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="h-3 w-3" /> Liga de pago
</button>
)}
</div>
</td>
<td className="py-3 pr-2">
{d.tienePagoPdf ? (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'pago', filename: d.pdfPagoFilename || `pago-${d.id}.pdf` })} className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400 hover:underline">
<CheckCircle2 className="h-3 w-3" /> Pagado
</button>
) : isPaidByAmount ? (
<span className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3" /> $0 Sin pago
</span>
) : (
<span className="text-xs text-muted-foreground">Sin comprobante</span>
)}
</td>
<td className="py-3 pr-2 text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' })}
</td>
<td className="py-3 pr-2">
<div className="flex items-center justify-end gap-1">
{canUpload && !isPaid && (
<Button variant="ghost" size="sm" onClick={() => setPagoDeclaracion(d)} title="Subir comprobante de pago">
<Receipt className="h-3.5 w-3.5" />
</Button>
)}
{canUpload && (
<Button variant="ghost" size="sm" onClick={() => { if (confirm('¿Eliminar esta declaración?')) deleteDecl.mutate(d.id); }} title="Eliminar">
<Trash2 className="h-3.5 w-3.5 text-red-600" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
{uploadOpen && <UploadDialog onClose={() => setUploadOpen(false)} />}
{pagoDeclaracion && <ComprobantePagoDialog declaracion={pagoDeclaracion} onClose={() => setPagoDeclaracion(null)} />}
</Card>
);
}
// ============================================================================
// Upload Dialog
// ============================================================================
function UploadDialog({ onClose }: { onClose: () => void }) {
const create = useCreateDeclaracion();
const { selectedContribuyenteId } = useContribuyenteStore();
const currentYear = new Date().getFullYear();
const [año, setAño] = useState(currentYear);
const [mes, setMes] = useState(new Date().getMonth() + 1);
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
const [montoPago, setMontoPago] = useState('');
const [file, setFile] = useState<File | null>(null);
const [ligaFile, setLigaFile] = useState<File | null>(null);
const [notas, setNotas] = useState('');
const [err, setErr] = useState<string | null>(null);
const periodOptions = getPeriodOptions(periodicidad);
const handlePeriodicidadChange = (p: Periodicidad) => {
setPeriodicidad(p);
// Reset mes to first valid option for the new periodicidad
const opts = getPeriodOptions(p);
if (!opts.find(o => o.value === mes)) {
setMes(opts[0].value);
}
};
const toggleImpuesto = (i: Impuesto) => {
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
if (!file) return setErr('Selecciona el PDF de la declaración');
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
try {
const pdfBase64 = await fileToBase64(file);
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
await create.mutateAsync({
año, mes, tipo, periodicidad, impuestos,
montoPago: montoNum,
pdfBase64, pdfFilename: file.name,
ligaPagoBase64,
ligaPagoFilename: ligaFile?.name,
notas: notas.trim() || undefined,
contribuyenteId: selectedContribuyenteId || undefined,
});
onClose();
} catch (e: any) {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
}
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Subir declaración</DialogTitle>
<DialogDescription>
Al subir se marcarán como resueltos los recordatorios de declaración correspondientes. Si es complementaria, también los de pago.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className={`grid gap-3 ${periodicidad === 'anual' ? 'grid-cols-1' : 'grid-cols-2'}`}>
<div>
<Label>Periodicidad</Label>
<select value={periodicidad} onChange={(e) => handlePeriodicidadChange(e.target.value as Periodicidad)} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{PERIODICIDADES.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
{periodicidad !== 'anual' && (
<div>
<Label>Periodo</Label>
<select value={mes} onChange={(e) => setMes(Number(e.target.value))} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{periodOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Año</Label>
<select value={año} onChange={(e) => setAño(Number(e.target.value))} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{yearsOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<Label>Tipo</Label>
<select value={tipo} onChange={(e) => setTipo(e.target.value as 'normal' | 'complementaria')} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
<option value="normal">Normal</option>
<option value="complementaria">Complementaria</option>
</select>
</div>
<div>
<Label>Monto a pagar</Label>
<Input
type="number"
min="0"
step="0.01"
value={montoPago}
onChange={(e) => setMontoPago(e.target.value)}
placeholder="0.00"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">Si es $0.00, se marca como pagado automáticamente.</p>
</div>
</div>
<div>
<Label>Impuestos cubiertos</Label>
<div className="grid grid-cols-3 gap-2 mt-1">
{IMPUESTOS.map(i => (
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
{i}
</label>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración definen qué recordatorios se desactivan.</p>
</div>
<div>
<Label>PDF de la declaración</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="mt-1" />
</div>
<div>
<Label>PDF de la liga de pago (opcional)</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setLigaFile(e.target.files?.[0] || null)} className="mt-1" />
<p className="text-xs text-muted-foreground mt-1">Documento con la línea de captura/referencia para pagar la declaración.</p>
</div>
<div>
<Label>Notas (opcional)</Label>
<textarea value={notas} onChange={(e) => setNotas(e.target.value)} rows={2} maxLength={2000} className="w-full px-3 py-2 rounded-md border bg-background text-sm mt-1" />
</div>
{err && <div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</> : <><Upload className="h-4 w-4 mr-1.5" /> Subir</>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Comprobante de Pago Dialog
// ============================================================================
function ComprobantePagoDialog({ declaracion, onClose }: { declaracion: Declaracion; onClose: () => void }) {
const upload = useUploadComprobantePago();
const [file, setFile] = useState<File | null>(null);
const [err, setErr] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
if (!file) return setErr('Selecciona el PDF del comprobante de pago');
try {
const pdfBase64 = await fileToBase64(file);
await upload.mutateAsync({ id: declaracion.id, pdfBase64, pdfFilename: file.name });
onClose();
} catch (e: any) {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
}
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Subir comprobante de pago</DialogTitle>
<DialogDescription>
{MESES[declaracion.mes - 1]} {declaracion.año} {declaracion.tipo === 'normal' ? 'Normal' : 'Complementaria'}. Al subirlo se marcan como pagados los recordatorios de pago correspondientes.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div>
<Label>PDF del comprobante</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="mt-1" />
</div>
{err && <div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={upload.isPending}>
{upload.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</> : <><Upload className="h-4 w-4 mr-1.5" /> Subir</>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Extras — PDFs libres (acuses, contratos, poderes, estados de cuenta, etc.)
// ============================================================================
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
function ExtrasTab() {
const user = useAuthStore((s) => s.user);
const canUpload = !!user?.role && ROLES_UPLOAD_EXTRA.includes(user.role);
const { selectedContribuyenteId } = useContribuyenteStore();
const qc = useQueryClient();
const [uploadOpen, setUploadOpen] = useState(false);
const [categoriaFiltro, setCategoriaFiltro] = useState<string>('');
const extrasQ = useQuery({
queryKey: ['documentos-extras', selectedContribuyenteId, categoriaFiltro],
queryFn: () => docsApi.listarExtras(selectedContribuyenteId, categoriaFiltro || null),
});
const categoriasQ = useQuery({
queryKey: ['documentos-extras-categorias', selectedContribuyenteId],
queryFn: () => docsApi.listarCategoriasExtras(selectedContribuyenteId),
});
const deleteMut = useMutation({
mutationFn: (id: number) => docsApi.eliminarExtra(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documentos-extras'] });
qc.invalidateQueries({ queryKey: ['documentos-extras-categorias'] });
},
});
const handleDelete = (id: number, nombre: string) => {
if (!confirm(`¿Eliminar "${nombre}"? Esta accion no se puede deshacer.`)) return;
deleteMut.mutate(id);
};
const handleDownload = async (id: number, filename: string) => {
try {
const blob = await docsApi.descargarExtraPdf(id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
alert('Error al descargar el documento');
}
};
const extras = extrasQ.data || [];
const categorias = categoriasQ.data || [];
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-lg font-semibold">Documentos extras</h3>
{categorias.length > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<select
value={categoriaFiltro}
onChange={(e) => setCategoriaFiltro(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Todas las categorias</option>
{categorias.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
</div>
{canUpload && (
<Button onClick={() => setUploadOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-1.5" />
Subir PDF
</Button>
)}
</div>
{extrasQ.isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : extras.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{categoriaFiltro
? `No hay documentos en la categoria "${categoriaFiltro}"`
: 'Aun no hay documentos extras. Sube el primero con el boton de arriba.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium">Categoría</th>
<th className="pb-3 font-medium">Descripción</th>
<th className="pb-3 font-medium">Subido por</th>
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{extras.map((e) => (
<tr key={e.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{e.nombre}</td>
<td className="py-3">
{e.categoria ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-muted text-muted-foreground">
<Tag className="h-3 w-3" />
{e.categoria}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3 max-w-[300px] truncate text-muted-foreground" title={e.descripcion || ''}>
{e.descripcion || '—'}
</td>
<td className="py-3 text-xs text-muted-foreground">{e.subidoPor || '—'}</td>
<td className="py-3 text-xs text-muted-foreground">
{new Date(e.createdAt).toLocaleDateString('es-MX')}
</td>
<td className="py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(e.id, e.pdfFilename)}
title="Descargar PDF"
>
<Download className="h-4 w-4" />
</Button>
{canUpload && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(e.id, e.nombre)}
disabled={deleteMut.isPending}
title="Eliminar"
className="hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-4">
{extras.length} documento{extras.length !== 1 ? 's' : ''}
</p>
</div>
)}
</CardContent>
{uploadOpen && (
<UploadExtraDialog
open={uploadOpen}
onClose={() => setUploadOpen(false)}
contribuyenteId={selectedContribuyenteId}
categoriasExistentes={categorias}
/>
)}
</Card>
);
}
function UploadExtraDialog({
open, onClose, contribuyenteId, categoriasExistentes,
}: {
open: boolean;
onClose: () => void;
contribuyenteId: string | null;
categoriasExistentes: string[];
}) {
const qc = useQueryClient();
const [nombre, setNombre] = useState('');
const [descripcion, setDescripcion] = useState('');
const [categoria, setCategoria] = useState('');
const [file, setFile] = useState<File | null>(null);
const [err, setErr] = useState<string | null>(null);
const upload = useMutation({
mutationFn: async () => {
if (!file) throw new Error('Selecciona un archivo PDF');
if (!nombre.trim()) throw new Error('El nombre es requerido');
const pdfBase64 = await fileToBase64(file);
return docsApi.crearExtra(
{
nombre: nombre.trim(),
descripcion: descripcion.trim() || undefined,
categoria: categoria.trim() || undefined,
pdfBase64,
pdfFilename: file.name,
},
contribuyenteId,
);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documentos-extras'] });
qc.invalidateQueries({ queryKey: ['documentos-extras-categorias'] });
onClose();
},
onError: (e: any) => {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
upload.mutate();
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Subir documento extra</DialogTitle>
<DialogDescription>
PDFs libres como acuses del SAT, contratos, poderes notariales, estados de cuenta, etc.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div>
<Label>Nombre *</Label>
<Input
value={nombre}
onChange={(e) => setNombre(e.target.value)}
placeholder="Ej. Acuse declaracion anual 2024"
className="mt-1"
maxLength={255}
/>
</div>
<div>
<Label>Categoría (opcional)</Label>
<Input
value={categoria}
onChange={(e) => setCategoria(e.target.value)}
placeholder="Ej. Acuses SAT, Contratos, Poderes..."
className="mt-1"
list="categorias-extras-list"
maxLength={100}
/>
{categoriasExistentes.length > 0 && (
<datalist id="categorias-extras-list">
{categoriasExistentes.map((c) => <option key={c} value={c} />)}
</datalist>
)}
</div>
<div>
<Label>Descripción (opcional)</Label>
<textarea
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
placeholder="Notas internas sobre el documento"
rows={3}
maxLength={2000}
className="mt-1 flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<Label>PDF *</Label>
<Input
type="file"
accept="application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="mt-1"
/>
</div>
{err && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
{err}
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={upload.isPending}>
{upload.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</>
) : (
<><Upload className="h-4 w-4 mr-1.5" /> Subir</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}