Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

View File

@@ -0,0 +1,996 @@
'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', 'SUELDOS', 'DIOT', 'OTRO'];
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'];
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 = user?.role !== 'cliente';
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'];
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>
);
}