- 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
997 lines
47 KiB
TypeScript
997 lines
47 KiB
TypeScript
'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>
|
||
);
|
||
}
|