Update: nueva version Horux Despachos
This commit is contained in:
324
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
324
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
claveUnidad?: string;
|
||||
claveProdServ?: string;
|
||||
}
|
||||
|
||||
interface CfdiInvoiceProps {
|
||||
cfdi: Cfdi;
|
||||
conceptos?: CfdiConcepto[];
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const formatDateTime = (dateString: string) =>
|
||||
new Date(dateString).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
EMITIDO: 'Emitido',
|
||||
RECIBIDO: 'Recibido',
|
||||
};
|
||||
|
||||
const tipoCompLabels: Record<string, string> = {
|
||||
I: 'Ingreso',
|
||||
E: 'Egreso',
|
||||
T: 'Traslado',
|
||||
P: 'Pago',
|
||||
N: 'Nómina',
|
||||
};
|
||||
|
||||
const formaPagoLabels: Record<string, string> = {
|
||||
'01': 'Efectivo',
|
||||
'02': 'Cheque nominativo',
|
||||
'03': 'Transferencia electrónica',
|
||||
'04': 'Tarjeta de crédito',
|
||||
'28': 'Tarjeta de débito',
|
||||
'99': 'Por definir',
|
||||
};
|
||||
|
||||
const metodoPagoLabels: Record<string, string> = {
|
||||
PUE: 'Pago en una sola exhibición',
|
||||
PPD: 'Pago en parcialidades o diferido',
|
||||
};
|
||||
|
||||
const usoCfdiLabels: Record<string, string> = {
|
||||
G01: 'Adquisición de mercancías',
|
||||
G02: 'Devoluciones, descuentos o bonificaciones',
|
||||
G03: 'Gastos en general',
|
||||
I01: 'Construcciones',
|
||||
I02: 'Mobilario y equipo de oficina',
|
||||
I03: 'Equipo de transporte',
|
||||
I04: 'Equipo de cómputo',
|
||||
I05: 'Dados, troqueles, moldes',
|
||||
I06: 'Comunicaciones telefónicas',
|
||||
I07: 'Comunicaciones satelitales',
|
||||
I08: 'Otra maquinaria y equipo',
|
||||
D01: 'Honorarios médicos',
|
||||
D02: 'Gastos médicos por incapacidad',
|
||||
D03: 'Gastos funerales',
|
||||
D04: 'Donativos',
|
||||
D05: 'Intereses por créditos hipotecarios',
|
||||
D06: 'Aportaciones voluntarias SAR',
|
||||
D07: 'Primas por seguros de gastos médicos',
|
||||
D08: 'Gastos de transportación escolar',
|
||||
D09: 'Depósitos en cuentas para el ahorro',
|
||||
D10: 'Pagos por servicios educativos',
|
||||
P01: 'Por definir',
|
||||
S01: 'Sin efectos fiscales',
|
||||
CP01: 'Pagos',
|
||||
CN01: 'Nómina',
|
||||
};
|
||||
|
||||
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
|
||||
({ cfdi, conceptos }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white text-gray-800 max-w-[850px] mx-auto text-sm shadow-lg"
|
||||
style={{ fontFamily: 'Segoe UI, Roboto, Arial, sans-serif' }}
|
||||
>
|
||||
{/* Header con gradiente */}
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-900 text-white p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold opacity-90">Emisor</h2>
|
||||
<p className="text-xl font-bold mt-1">{cfdi.nombreEmisor}</p>
|
||||
<p className="text-blue-200 text-sm mt-1">RFC: {cfdi.rfcEmisor}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-3 mb-2">
|
||||
<span
|
||||
className={`px-3 py-1 text-xs font-bold rounded-full ${
|
||||
cfdi.status === 'Vigente' || cfdi.status === '1'
|
||||
? 'bg-green-400 text-green-900'
|
||||
: 'bg-red-400 text-red-900'
|
||||
}`}
|
||||
>
|
||||
{cfdi.status === 'Vigente' || cfdi.status === '1' ? 'VIGENTE' : 'CANCELADO'}
|
||||
</span>
|
||||
<span className="px-3 py-1 text-xs font-bold rounded-full bg-white/20">
|
||||
{typeLabels[cfdi.type] || cfdi.type} {cfdi.tipoComprobante ? `(${tipoCompLabels[cfdi.tipoComprobante] || cfdi.tipoComprobante})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold tracking-tight">
|
||||
{cfdi.serie && <span className="text-blue-300">{cfdi.serie}-</span>}
|
||||
{cfdi.folio || 'S/N'}
|
||||
</div>
|
||||
<p className="text-blue-200 text-sm mt-1">{formatDate(cfdi.fechaEmision)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Receptor */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-5 border-l-4 border-blue-600">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Receptor</p>
|
||||
<p className="text-lg font-semibold text-gray-800 mt-1">{cfdi.nombreReceptor}</p>
|
||||
<p className="text-gray-600 text-sm">RFC: {cfdi.rfcReceptor}</p>
|
||||
</div>
|
||||
{cfdi.usoCfdi && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Uso CFDI</p>
|
||||
<p className="text-sm font-medium text-gray-700 mt-1">
|
||||
{cfdi.usoCfdi} - {usoCfdiLabels[cfdi.usoCfdi] || ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datos del Comprobante */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Método Pago</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">
|
||||
{cfdi.metodoPago || '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || '' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Forma Pago</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">
|
||||
{cfdi.formaPago || '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || '' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
|
||||
{cfdi.typeCambio && cfdi.typeCambio !== 1 && (
|
||||
<p className="text-xs text-gray-500">TC: {cfdi.typeCambio}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Versión</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">CFDI 4.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conceptos */}
|
||||
{conceptos && conceptos.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2 flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-blue-600 rounded-full"></span>
|
||||
Conceptos
|
||||
</h3>
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Descripción</th>
|
||||
<th className="text-center py-3 px-3 font-semibold text-gray-700 w-20">Cant.</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">P. Unitario</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conceptos.map((concepto, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={`border-t border-gray-100 ${idx % 2 === 1 ? 'bg-gray-50/50' : ''}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<p className="text-gray-800">{concepto.descripcion}</p>
|
||||
{concepto.claveProdServ && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Clave: {concepto.claveProdServ}
|
||||
{concepto.claveUnidad && ` | Unidad: ${concepto.claveUnidad}`}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center py-3 px-3 text-gray-700">{concepto.cantidad}</td>
|
||||
<td className="text-right py-3 px-4 text-gray-700">
|
||||
{formatCurrency(concepto.valorUnitario)}
|
||||
</td>
|
||||
<td className="text-right py-3 px-4 font-medium text-gray-800">
|
||||
{formatCurrency(concepto.importe)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totales */}
|
||||
<div className="flex justify-end mb-5">
|
||||
<div className="w-80 bg-gray-50 rounded-lg overflow-hidden">
|
||||
<div className="divide-y divide-gray-200">
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium">{formatCurrency(cfdi.subtotal)}</span>
|
||||
</div>
|
||||
{cfdi.descuento > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">Descuento</span>
|
||||
<span className="font-medium text-red-600">-{formatCurrency(cfdi.descuento)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.ivaTraslado > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">IVA (16%)</span>
|
||||
<span className="font-medium">{formatCurrency(cfdi.ivaTraslado)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.ivaRetencion > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">IVA Retenido</span>
|
||||
<span className="font-medium text-red-600">-{formatCurrency(cfdi.ivaRetencion)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.isrRetencion > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">ISR Retenido</span>
|
||||
<span className="font-medium text-red-600">-{formatCurrency(cfdi.isrRetencion)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-700 text-white py-3 px-4 flex justify-between items-center">
|
||||
<span className="font-semibold">TOTAL</span>
|
||||
<span className="text-xl font-bold">{formatCurrency(cfdi.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timbre Fiscal Digital */}
|
||||
<div className="bg-gradient-to-r from-gray-100 to-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="flex gap-4">
|
||||
{/* QR Placeholder */}
|
||||
<div className="w-24 h-24 bg-white border-2 border-gray-300 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-gray-400 mt-1 block">QR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info del Timbre */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-semibold mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Timbre Fiscal Digital
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500">UUID: </span>
|
||||
<span className="text-xs font-mono text-blue-700 font-medium">{cfdi.uuid}</span>
|
||||
</div>
|
||||
<div>
|
||||
{cfdi.fechaCertSat && (<>
|
||||
<span className="text-xs text-gray-500">Fecha Certificación SAT: </span>
|
||||
<span className="text-xs font-medium text-gray-700">{formatDateTime(cfdi.fechaCertSat)}</span>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leyenda */}
|
||||
<p className="text-[10px] text-gray-400 mt-3 text-center border-t border-gray-200 pt-2">
|
||||
Este documento es una representación impresa de un CFDI • Verificable en: https://verificacfdi.facturaelectronica.sat.gob.mx
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CfdiInvoice.displayName = 'CfdiInvoice';
|
||||
244
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal file
244
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@horux/shared-ui';
|
||||
import { CfdiInvoice } from './cfdi-invoice';
|
||||
import { getCfdiXml, getCfdiConceptos } from '@/lib/api/cfdi';
|
||||
import { Download, FileText, Loader2, Printer } from 'lucide-react';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
claveProdServ?: string;
|
||||
claveUnidad?: string;
|
||||
}
|
||||
|
||||
interface CfdiViewerModalProps {
|
||||
cfdi: Cfdi | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||
const conceptos: CfdiConcepto[] = [];
|
||||
|
||||
const elements = doc.getElementsByTagName('*');
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (elements[i].localName === 'Concepto') {
|
||||
const el = elements[i];
|
||||
conceptos.push({
|
||||
descripcion: el.getAttribute('Descripcion') || '',
|
||||
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
|
||||
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
|
||||
importe: parseFloat(el.getAttribute('Importe') || '0'),
|
||||
claveProdServ: el.getAttribute('ClaveProdServ') || undefined,
|
||||
claveUnidad: el.getAttribute('ClaveUnidad') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conceptos;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
|
||||
const invoiceRef = useRef<HTMLDivElement>(null);
|
||||
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
|
||||
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
|
||||
const [xmlContent, setXmlContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cfdi) {
|
||||
setXmlContent(null);
|
||||
setConceptos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cfdi.xmlOriginal) setXmlContent(cfdi.xmlOriginal);
|
||||
|
||||
// Fetch conceptos from DB, fallback to XML parsing
|
||||
getCfdiConceptos(cfdi.id)
|
||||
.then((dbConceptos) => {
|
||||
if (dbConceptos.length > 0) {
|
||||
setConceptos(dbConceptos.map((c: any) => ({
|
||||
descripcion: c.descripcion,
|
||||
cantidad: Number(c.cantidad),
|
||||
valorUnitario: Number(c.valorUnitario),
|
||||
importe: Number(c.importe),
|
||||
claveProdServ: c.claveProdServ || undefined,
|
||||
claveUnidad: c.claveUnidad || undefined,
|
||||
})));
|
||||
} else if (cfdi.xmlOriginal) {
|
||||
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
|
||||
} else {
|
||||
setConceptos([]);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cfdi.xmlOriginal) {
|
||||
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
|
||||
}
|
||||
});
|
||||
}, [cfdi]);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!invoiceRef.current || !cfdi) return;
|
||||
|
||||
setDownloading('pdf');
|
||||
try {
|
||||
const html2pdf = (await import('html2pdf.js')).default;
|
||||
|
||||
const opt = {
|
||||
margin: 10,
|
||||
filename: `factura-${cfdi.uuid.substring(0, 8)}.pdf`,
|
||||
image: { type: 'jpeg' as const, quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm' as const, format: 'a4' as const, orientation: 'portrait' as const },
|
||||
};
|
||||
|
||||
await html2pdf().set(opt).from(invoiceRef.current).save();
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
alert('Error al generar el PDF');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadXml = async () => {
|
||||
if (!cfdi) return;
|
||||
|
||||
setDownloading('xml');
|
||||
try {
|
||||
let xml = xmlContent;
|
||||
|
||||
if (!xml) {
|
||||
xml = await getCfdiXml(cfdi.id);
|
||||
}
|
||||
|
||||
if (!xml) {
|
||||
alert('No hay XML disponible para este CFDI');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cfdi-${cfdi.uuid.substring(0, 8)}.xml`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading XML:', error);
|
||||
alert('Error al descargar el XML');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
if (!invoiceRef.current) return;
|
||||
|
||||
// Create a print-specific stylesheet
|
||||
const printStyles = document.createElement('style');
|
||||
printStyles.innerHTML = `
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
#cfdi-print-area, #cfdi-print-area * {
|
||||
visibility: visible;
|
||||
}
|
||||
#cfdi-print-area {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(printStyles);
|
||||
|
||||
// Add ID to the invoice container for print targeting
|
||||
invoiceRef.current.id = 'cfdi-print-area';
|
||||
|
||||
// Trigger print
|
||||
window.print();
|
||||
|
||||
// Clean up
|
||||
document.head.removeChild(printStyles);
|
||||
invoiceRef.current.removeAttribute('id');
|
||||
};
|
||||
|
||||
if (!cfdi) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Vista de Factura</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
{downloading === 'pdf' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadXml}
|
||||
disabled={downloading !== null || !xmlContent}
|
||||
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
|
||||
>
|
||||
{downloading === 'xml' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
XML
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrint}
|
||||
disabled={downloading !== null}
|
||||
title="Imprimir factura"
|
||||
>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
|
||||
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
87
apps/web/components/charts/bar-chart.tsx
Normal file
87
apps/web/components/charts/bar-chart.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
|
||||
interface BarChartProps {
|
||||
title: string;
|
||||
data: { mes: string; ingresos: number; egresos: number }[];
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `$${(value / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `$${(value / 1000).toFixed(0)}K`;
|
||||
}
|
||||
return `$${value}`;
|
||||
};
|
||||
|
||||
export function BarChart({ title, data }: BarChartProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="mes"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(value)
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="ingresos"
|
||||
name="Ingresos"
|
||||
fill="hsl(var(--success))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="egresos"
|
||||
name="Egresos"
|
||||
fill="hsl(var(--destructive))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1
apps/web/components/charts/index.ts
Normal file
1
apps/web/components/charts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BarChart } from './bar-chart';
|
||||
108
apps/web/components/contribuyente-selector.tsx
Normal file
108
apps/web/components/contribuyente-selector.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { Building2, ChevronDown, Check, Users } from 'lucide-react';
|
||||
|
||||
// Rutas donde el selector NO aplica (vistas cross-contribuyente del despacho).
|
||||
const HIDDEN_PATHS = ['/despachos'];
|
||||
|
||||
export function ContribuyenteSelector() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: contribuyentes, isLoading } = useContribuyentes();
|
||||
const { selectedContribuyenteId, setSelectedContribuyente, clearSelectedContribuyente } =
|
||||
useContribuyenteStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.contribuyente-selector')) setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Auto-select if user has exactly 1 contribuyente (common for clients)
|
||||
useEffect(() => {
|
||||
if (contribuyentes && contribuyentes.length === 1 && !selectedContribuyenteId) {
|
||||
setSelectedContribuyente(contribuyentes[0].id, contribuyentes[0].rfc, contribuyentes[0].nombre);
|
||||
}
|
||||
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]);
|
||||
|
||||
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
|
||||
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;
|
||||
|
||||
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
|
||||
|
||||
return (
|
||||
<div className="contribuyente-selector relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent transition-colors"
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-[180px] truncate">
|
||||
{selected ? selected.nombre : 'Todos los RFCs'}
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-2 w-80 rounded-lg border bg-card shadow-lg z-50">
|
||||
<div className="p-2 border-b">
|
||||
<p className="text-xs text-muted-foreground px-2">Contribuyentes</p>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-1">
|
||||
{/* Todos los RFCs — only show if more than 1 contribuyente */}
|
||||
{contribuyentes.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { clearSelectedContribuyente(); setOpen(false); }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left',
|
||||
!selectedContribuyenteId && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">Todos los RFCs</p>
|
||||
<p className="text-xs text-muted-foreground">{contribuyentes.length} contribuyentes</p>
|
||||
</div>
|
||||
{!selectedContribuyenteId && <Check className="h-4 w-4 text-primary flex-shrink-0" />}
|
||||
</button>
|
||||
<div className="border-t my-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lista de contribuyentes */}
|
||||
{contribuyentes.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left',
|
||||
selectedContribuyenteId === c.id && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center text-xs font-medium">
|
||||
{c.nombre.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{c.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{c.rfc}</p>
|
||||
</div>
|
||||
{selectedContribuyenteId === c.id && <Check className="h-4 w-4 text-primary flex-shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/web/components/despachos/despacho-subnav.tsx
Normal file
57
apps/web/components/despachos/despacho-subnav.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { Building2, UserCheck, Users } from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
|
||||
{ href: '/despachos/equipo', label: 'Equipo', icon: Users, roles: ['owner', 'cfo', 'supervisor'] },
|
||||
];
|
||||
|
||||
export function DespachoSubnav() {
|
||||
const pathname = usePathname();
|
||||
const role = useAuthStore(s => s.user?.role);
|
||||
if (!role) return null;
|
||||
const visibles = ITEMS.filter(i => i.roles.includes(role));
|
||||
return (
|
||||
<div className="flex border-b mb-6">
|
||||
{visibles.map(item => {
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
active
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Resuelve la página default según rol al entrar a /despachos. */
|
||||
export function defaultDespachoPathForRole(role: string): string {
|
||||
if (role === 'owner' || role === 'cfo') return '/despachos/contribuyentes';
|
||||
if (role === 'supervisor' || role === 'auxiliar') return '/despachos/mis-asignados';
|
||||
return '/despachos/mis-asignados';
|
||||
}
|
||||
431
apps/web/components/documentos/papeleria-tab.tsx
Normal file
431
apps/web/components/documentos/papeleria-tab.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card, CardContent, Button, Input, Label,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
cn,
|
||||
} from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare } from 'lucide-react';
|
||||
|
||||
const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||
const ALLOWED_MIMES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
const ALLOWED_EXT = '.pdf,.doc,.docx,.xls,.xlsx';
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
const ROLES_APROBADOR = new Set(['owner', 'cfo', 'supervisor']);
|
||||
|
||||
interface Papeleria {
|
||||
id: number;
|
||||
contribuyenteId: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
archivoFilename: string;
|
||||
archivoMime: string;
|
||||
archivoSize: number;
|
||||
anio: number;
|
||||
mes: number;
|
||||
requiereAprobacion: boolean;
|
||||
estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
||||
comentarioRechazo: string | null;
|
||||
subidoPor: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const result = String(r.result);
|
||||
const i = result.indexOf(',');
|
||||
resolve(i >= 0 ? result.substring(i + 1) : result);
|
||||
};
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) {
|
||||
if (!requiereAprobacion) {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
|
||||
}
|
||||
if (estado === 'aprobado') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
|
||||
}
|
||||
if (estado === 'rechazado') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> Pendiente</span>;
|
||||
}
|
||||
|
||||
export function PapeleriaTab() {
|
||||
const user = useAuthStore(s => s.user);
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
const queryClient = useQueryClient();
|
||||
const canApprove = user?.role ? ROLES_APROBADOR.has(user.role) : false;
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
|
||||
const [comentarioRechazo, setComentarioRechazo] = useState('');
|
||||
|
||||
// Filtros
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [filterAnio, setFilterAnio] = useState<number | ''>('');
|
||||
const [filterMes, setFilterMes] = useState<number | ''>('');
|
||||
const [filterEstado, setFilterEstado] = useState<string>('');
|
||||
|
||||
const query = useQuery<Papeleria[]>({
|
||||
queryKey: ['papeleria', selectedContribuyenteId, filterAnio, filterMes, filterEstado],
|
||||
queryFn: async () => {
|
||||
const p = new URLSearchParams({ contribuyenteId: selectedContribuyenteId! });
|
||||
if (filterAnio) p.set('anio', String(filterAnio));
|
||||
if (filterMes) p.set('mes', String(filterMes));
|
||||
if (filterEstado) p.set('estado', filterEstado);
|
||||
const { data } = await apiClient.get<Papeleria[]>(`/papeleria?${p}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!selectedContribuyenteId,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['papeleria'] });
|
||||
|
||||
// Upload form state
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [nombre, setNombre] = useState('');
|
||||
const [descripcion, setDescripcion] = useState('');
|
||||
const [anio, setAnio] = useState(currentYear);
|
||||
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||
const [requiereAprobacion, setRequiereAprobacion] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const resetUpload = () => {
|
||||
setFile(null);
|
||||
setNombre('');
|
||||
setDescripcion('');
|
||||
setAnio(currentYear);
|
||||
setMes(new Date().getMonth() + 1);
|
||||
setRequiereAprobacion(false);
|
||||
setUploadError(null);
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!file) throw new Error('Selecciona un archivo');
|
||||
if (!ALLOWED_MIMES.includes(file.type)) throw new Error('Formato no permitido. Usa PDF, Word o Excel.');
|
||||
if (file.size > MAX_SIZE) throw new Error('El archivo excede 5 MB.');
|
||||
const base64 = await fileToBase64(file);
|
||||
await apiClient.post('/papeleria', {
|
||||
contribuyenteId: selectedContribuyenteId,
|
||||
nombre: nombre || file.name,
|
||||
descripcion: descripcion || null,
|
||||
anio,
|
||||
mes,
|
||||
requiereAprobacion,
|
||||
archivoBase64: base64,
|
||||
archivoFilename: file.name,
|
||||
archivoMime: file.type,
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setUploadError(err?.response?.data?.message || err.message || 'Error al subir');
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowUpload(false);
|
||||
resetUpload();
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async (item: Papeleria) => {
|
||||
const res = await apiClient.get(`/papeleria/${item.id}/download`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = item.archivoFilename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
});
|
||||
|
||||
const aprobarMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rechazarMutation = useMutation({
|
||||
mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) =>
|
||||
apiClient.post(`/papeleria/${id}/rechazar`, { comentario }),
|
||||
onSuccess: () => {
|
||||
setRechazoFor(null);
|
||||
setComentarioRechazo('');
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const eliminarMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
if (!selectedContribuyenteId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Selecciona un contribuyente para ver su papelería.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const items = query.data ?? [];
|
||||
const años = useMemo(() => {
|
||||
const set = new Set<number>([currentYear]);
|
||||
items.forEach(i => set.add(i.anio));
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [items, currentYear]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filtros + upload */}
|
||||
<div className="flex items-end justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Año</Label>
|
||||
<select
|
||||
value={filterAnio}
|
||||
onChange={e => setFilterAnio(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
className="h-9 rounded-md border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{años.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Mes</Label>
|
||||
<select
|
||||
value={filterMes}
|
||||
onChange={e => setFilterMes(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
className="h-9 rounded-md border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{MESES.map((m, i) => <option key={i + 1} value={i + 1}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Estado</Label>
|
||||
<select
|
||||
value={filterEstado}
|
||||
onChange={e => setFilterEstado(e.target.value)}
|
||||
className="h-9 rounded-md border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="pendiente">Pendiente</option>
|
||||
<option value="aprobado">Aprobado</option>
|
||||
<option value="rechazado">Rechazado</option>
|
||||
<option value="sin_aprobacion">Sin aprobación</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowUpload(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Listado */}
|
||||
{query.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No hay documentos en papelería con los filtros seleccionados.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map(it => (
|
||||
<Card key={it.id}>
|
||||
<CardContent className="py-3 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{it.nombre}</span>
|
||||
<EstadoBadge estado={it.estado} requiereAprobacion={it.requiereAprobacion} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{MESES[it.mes - 1]} {it.anio}
|
||||
</span>
|
||||
</div>
|
||||
{it.descripcion && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{it.descripcion}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
|
||||
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
{it.estado === 'rechazado' && it.comentarioRechazo && (
|
||||
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{it.comentarioRechazo}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => aprobarMutation.mutate(it.id)}
|
||||
title="Aprobar"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => setRechazoFor(it)}
|
||||
title="Rechazar"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Upload */}
|
||||
<Dialog open={showUpload} onOpenChange={(o) => { setShowUpload(o); if (!o) resetUpload(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Subir documento de papelería</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Archivo (PDF, Word o Excel · máx 5 MB)</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept={ALLOWED_EXT}
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
setFile(f);
|
||||
if (f && !nombre) setNombre(f.name.replace(/\.[^.]+$/, ''));
|
||||
setUploadError(null);
|
||||
}}
|
||||
className="block w-full text-sm border rounded-md px-3 py-2"
|
||||
/>
|
||||
{file && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{file.name} · {(file.size / 1024).toFixed(0)} KB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Nombre</Label>
|
||||
<Input value={nombre} onChange={e => setNombre(e.target.value)} placeholder="Ej. Reporte de cuentas" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Descripción (opcional)</Label>
|
||||
<Input value={descripcion} onChange={e => setDescripcion(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Mes</Label>
|
||||
<select
|
||||
value={mes}
|
||||
onChange={e => setMes(parseInt(e.target.value, 10))}
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
{MESES.map((m, i) => <option key={i + 1} value={i + 1}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Año</Label>
|
||||
<Input
|
||||
type="number" min={2020} max={2100}
|
||||
value={anio}
|
||||
onChange={e => setAnio(parseInt(e.target.value, 10) || currentYear)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requiereAprobacion}
|
||||
onChange={e => setRequiereAprobacion(e.target.checked)}
|
||||
/>
|
||||
Este documento requiere aprobación de owner/supervisor
|
||||
</label>
|
||||
{uploadError && (
|
||||
<p className="text-xs text-destructive flex items-start gap-1">
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{uploadError}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowUpload(false)}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
>
|
||||
{uploadMutation.isPending ? 'Subiendo...' : 'Subir'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Rechazo */}
|
||||
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rechazar documento</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
Vas a rechazar <strong>{rechazoFor?.nombre}</strong>. El comentario es opcional.
|
||||
</p>
|
||||
<div>
|
||||
<Label>Comentario (opcional)</Label>
|
||||
<Input
|
||||
value={comentarioRechazo}
|
||||
onChange={e => setComentarioRechazo(e.target.value)}
|
||||
placeholder="Motivo del rechazo..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setRechazoFor(null); setComentarioRechazo(''); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => rechazoFor && rechazarMutation.mutate({ id: rechazoFor.id, comentario: comentarioRechazo || null })}
|
||||
className={cn('bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
>
|
||||
Rechazar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/web/components/fiscal-disclaimer.tsx
Normal file
18
apps/web/components/fiscal-disclaimer.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Disclaimer legal/fiscal mostrado al pie de páginas con cálculos estimados
|
||||
* (dashboard, impuestos, reportes). Acota la responsabilidad legal de Horux 360
|
||||
* frente al usuario y el SAT.
|
||||
*/
|
||||
export function FiscalDisclaimer() {
|
||||
return (
|
||||
<div className="mt-8 border-t pt-4 flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p className="leading-relaxed">
|
||||
Cálculos estimados generados automáticamente con base en reglas fiscales vigentes.
|
||||
Validar con un contador. Horux 360 no se responsabiliza por discrepancias ante el SAT.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
apps/web/components/impuestos/activos-fijos-tab.tsx
Normal file
369
apps/web/components/impuestos/activos-fijos-tab.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, Button, Input, Label,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
KpiCard, cn,
|
||||
} from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import {
|
||||
Wallet, Calendar, AlertTriangle, CheckCircle2, Trash2, RotateCcw,
|
||||
Building2, TrendingUp, Clock, CircleSlash, Filter,
|
||||
} from 'lucide-react';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
interface ActivoFijoItem {
|
||||
cfdiId: number;
|
||||
uuid: string;
|
||||
fechaEmision: string;
|
||||
rfcEmisor: string;
|
||||
nombreEmisor: string;
|
||||
usoCfdi: string;
|
||||
concepto: string;
|
||||
porcentajeAnual: number;
|
||||
porcentajeMensual: number;
|
||||
total: number;
|
||||
iva: number;
|
||||
moi: number;
|
||||
acumuladoHastaMesAnterior: number;
|
||||
acreditableEsteMes: number;
|
||||
saldoPendiente: number;
|
||||
estado: 'activo' | 'agotado' | 'baja_venta' | 'baja_desecho' | 'baja_otro';
|
||||
baja: { fechaBaja: string; motivo: string; comentario: string | null } | null;
|
||||
}
|
||||
|
||||
interface Totales {
|
||||
cantidad: number;
|
||||
totalMoi: number;
|
||||
totalAcumuladoPrevio: number;
|
||||
totalEsteMes: number;
|
||||
totalSaldoPendiente: number;
|
||||
cantidadActivos: number;
|
||||
cantidadAgotados: number;
|
||||
cantidadDeBaja: number;
|
||||
}
|
||||
|
||||
interface Response {
|
||||
items: ActivoFijoItem[];
|
||||
totales: Totales;
|
||||
usosExcluidos: string[];
|
||||
}
|
||||
|
||||
const USOS_DISPONIBLES: { clave: string; concepto: string }[] = [
|
||||
{ clave: 'I01', concepto: 'Construcciones' },
|
||||
{ clave: 'I02', concepto: 'Mobiliario y equipo de oficina' },
|
||||
{ clave: 'I03', concepto: 'Equipo de transporte' },
|
||||
{ clave: 'I04', concepto: 'Equipo de cómputo y accesorios' },
|
||||
{ clave: 'I05', concepto: 'Dados, troqueles, moldes, matrices' },
|
||||
{ clave: 'I06', concepto: 'Comunicaciones telefónicas' },
|
||||
{ clave: 'I07', concepto: 'Comunicaciones satelitales' },
|
||||
{ clave: 'I08', concepto: 'Otra maquinaria y equipo' },
|
||||
];
|
||||
|
||||
const ESTADO_LABEL: Record<string, { label: string; color: string }> = {
|
||||
activo: { label: 'Activo', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
|
||||
agotado: { label: 'Agotado', color: 'bg-muted text-muted-foreground' },
|
||||
baja_venta: { label: 'Vendido', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
baja_desecho: { label: 'Desechado', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
|
||||
baja_otro: { label: 'Baja', color: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-900/30 dark:text-zinc-400' },
|
||||
};
|
||||
|
||||
export function ActivosFijosTab({ año, mes }: { año: number; mes: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
const [filtroEstado, setFiltroEstado] = useState<'todos' | 'activos' | 'baja' | 'agotados'>('todos');
|
||||
const [bajaModal, setBajaModal] = useState<ActivoFijoItem | null>(null);
|
||||
const [bajaForm, setBajaForm] = useState({
|
||||
fechaBaja: new Date().toISOString().slice(0, 10),
|
||||
motivo: 'venta' as 'venta' | 'desecho' | 'otro',
|
||||
comentario: '',
|
||||
});
|
||||
const [conceptosModal, setConceptosModal] = useState(false);
|
||||
const [conceptosDraft, setConceptosDraft] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data, isLoading } = useQuery<Response>({
|
||||
queryKey: ['activos-fijos', año, mes, selectedContribuyenteId, filtroEstado],
|
||||
queryFn: async () => {
|
||||
const p = new URLSearchParams({ año: String(año), mes: String(mes), estado: filtroEstado });
|
||||
if (selectedContribuyenteId) p.set('contribuyenteId', selectedContribuyenteId);
|
||||
const res = await apiClient.get<Response>(`/impuestos/activos-fijos?${p}`);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['activos-fijos'] });
|
||||
|
||||
const bajaMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!bajaModal) return;
|
||||
await apiClient.post(`/impuestos/activos-fijos/${bajaModal.cfdiId}/baja`, {
|
||||
fechaBaja: bajaForm.fechaBaja,
|
||||
motivo: bajaForm.motivo,
|
||||
comentario: bajaForm.comentario || null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setBajaModal(null);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const revertirMutation = useMutation({
|
||||
mutationFn: async (cfdiId: number) => apiClient.delete(`/impuestos/activos-fijos/${cfdiId}/baja`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const conceptosMutation = useMutation({
|
||||
mutationFn: async (excluidos: string[]) => {
|
||||
if (!selectedContribuyenteId) return;
|
||||
await apiClient.put('/impuestos/activos-fijos/usos-excluidos', {
|
||||
contribuyenteId: selectedContribuyenteId,
|
||||
usos: excluidos,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setConceptosModal(false);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const openConceptos = () => {
|
||||
setConceptosDraft(new Set(data?.usosExcluidos ?? []));
|
||||
setConceptosModal(true);
|
||||
};
|
||||
const toggleConcepto = (clave: string) => {
|
||||
setConceptosDraft(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(clave)) next.delete(clave);
|
||||
else next.add(clave);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const t = data?.totales;
|
||||
|
||||
const openBaja = (a: ActivoFijoItem) => {
|
||||
setBajaForm({
|
||||
fechaBaja: a.baja?.fechaBaja ?? new Date().toISOString().slice(0, 10),
|
||||
motivo: (a.baja?.motivo as 'venta' | 'desecho' | 'otro') ?? 'venta',
|
||||
comentario: a.baja?.comentario ?? '',
|
||||
});
|
||||
setBajaModal(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Disclaimer */}
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="py-3 text-xs text-amber-900 dark:text-amber-100 flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong>Vista informativa.</strong> El sistema considera estos CFDIs como gasto del periodo
|
||||
(igual que el SAT), por lo que ya están en tu Dashboard y en tu cálculo de ISR.
|
||||
Esta vista te sirve para llevar el seguimiento de la deducción mensual proporcional
|
||||
(% anual ÷ 12) y decidir manualmente si la aplicas en tu declaración anual.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard title="Monto Original (MOI)" value={t?.totalMoi ?? 0} icon={<Wallet className="h-4 w-4" />} subtitle={`${t?.cantidad ?? 0} CFDIs`} />
|
||||
<KpiCard title="Acumulado al mes anterior" value={t?.totalAcumuladoPrevio ?? 0} icon={<Calendar className="h-4 w-4" />} subtitle="Ya deducible" />
|
||||
<KpiCard title="Acreditable este mes" value={t?.totalEsteMes ?? 0} icon={<TrendingUp className="h-4 w-4" />} subtitle="A aplicar este mes" />
|
||||
<KpiCard title="Saldo pendiente" value={t?.totalSaldoPendiente ?? 0} icon={<Clock className="h-4 w-4" />} subtitle="Por deducir en futuro" />
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-xs text-muted-foreground">Mostrar:</Label>
|
||||
<Select value={filtroEstado} onValueChange={(v) => setFiltroEstado(v as typeof filtroEstado)}>
|
||||
<SelectTrigger className="w-40 h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
<SelectItem value="activos">Activos</SelectItem>
|
||||
<SelectItem value="agotados">Agotados</SelectItem>
|
||||
<SelectItem value="baja">Dados de baja</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={openConceptos} disabled={!selectedContribuyenteId}>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
Conceptos
|
||||
{data && data.usosExcluidos.length > 0 && (
|
||||
<span className="ml-1 text-[10px] bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
|
||||
{data.usosExcluidos.length} excluidos
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{t ? `${t.cantidadActivos} activos · ${t.cantidadAgotados} agotados · ${t.cantidadDeBaja} bajas` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<p className="p-6 text-sm text-muted-foreground">Cargando...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="p-6 text-sm text-muted-foreground text-center">
|
||||
No hay activos fijos en el periodo seleccionado.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-medium">Fecha</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Emisor</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Concepto</th>
|
||||
<th className="text-right px-3 py-2 font-medium">MOI</th>
|
||||
<th className="text-right px-3 py-2 font-medium">% anual</th>
|
||||
<th className="text-right px-3 py-2 font-medium">Acum. previo</th>
|
||||
<th className="text-right px-3 py-2 font-medium">Este mes</th>
|
||||
<th className="text-right px-3 py-2 font-medium">Saldo</th>
|
||||
<th className="text-center px-3 py-2 font-medium">Estado</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(a => {
|
||||
const estadoMeta = ESTADO_LABEL[a.estado] ?? ESTADO_LABEL.activo;
|
||||
const esBaja = a.estado.startsWith('baja_');
|
||||
return (
|
||||
<tr key={a.cfdiId} className="border-b hover:bg-muted/30">
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{new Date(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-mono text-xs">{a.rfcEmisor}</div>
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[180px]">{a.nombreEmisor}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="font-mono">{a.usoCfdi}</div>
|
||||
<div className="text-muted-foreground truncate max-w-[180px]">{a.concepto}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-medium tabular-nums">{formatCurrency(a.moi)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{(a.porcentajeAnual * 100).toFixed(0)}%</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{formatCurrency(a.acumuladoHastaMesAnterior)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium text-success">{formatCurrency(a.acreditableEsteMes)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatCurrency(a.saldoPendiente)}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={cn('inline-block px-2 py-0.5 rounded-full text-[10px] font-medium', estadoMeta.color)}>
|
||||
{estadoMeta.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{esBaja ? (
|
||||
<Button
|
||||
variant="ghost" size="icon" title="Revertir baja"
|
||||
onClick={() => revertirMutation.mutate(a.cfdiId)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
) : a.estado === 'activo' ? (
|
||||
<Button
|
||||
variant="ghost" size="icon" title="Dar de baja"
|
||||
onClick={() => openBaja(a)}
|
||||
>
|
||||
<CircleSlash className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal conceptos: excluir usos CFDI que el contador no quiere ver */}
|
||||
<Dialog open={conceptosModal} onOpenChange={(o) => { if (!o) setConceptosModal(false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Conceptos a considerar como activos fijos</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Desmarca los conceptos cuyos CFDIs en este contribuyente NO sean adquisiciones de activos fijos
|
||||
(ej. servicio telefónico mensual con uso I06). Por default todos están considerados.
|
||||
</p>
|
||||
{USOS_DISPONIBLES.map(u => {
|
||||
const excluido = conceptosDraft.has(u.clave);
|
||||
return (
|
||||
<label key={u.clave} className="flex items-start gap-2 cursor-pointer text-sm py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!excluido}
|
||||
onChange={() => toggleConcepto(u.clave)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-mono text-xs mr-2">{u.clave}</span>
|
||||
<span>{u.concepto}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConceptosModal(false)}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={() => conceptosMutation.mutate([...conceptosDraft])}
|
||||
disabled={conceptosMutation.isPending}
|
||||
>
|
||||
{conceptosMutation.isPending ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal baja */}
|
||||
<Dialog open={!!bajaModal} onOpenChange={(o) => { if (!o) setBajaModal(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dar de baja activo fijo</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>{bajaModal?.concepto}</strong> — {bajaModal?.nombreEmisor}
|
||||
</p>
|
||||
<div>
|
||||
<Label>Fecha de baja</Label>
|
||||
<Input type="date" value={bajaForm.fechaBaja} onChange={e => setBajaForm(f => ({ ...f, fechaBaja: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Motivo</Label>
|
||||
<Select value={bajaForm.motivo} onValueChange={(v) => setBajaForm(f => ({ ...f, motivo: v as typeof bajaForm.motivo }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="venta">Venta</SelectItem>
|
||||
<SelectItem value="desecho">Desecho</SelectItem>
|
||||
<SelectItem value="otro">Otro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Comentario (opcional)</Label>
|
||||
<Input value={bajaForm.comentario} onChange={e => setBajaForm(f => ({ ...f, comentario: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBajaModal(null)}>Cancelar</Button>
|
||||
<Button onClick={() => bajaMutation.mutate()} disabled={bajaMutation.isPending}>
|
||||
{bajaMutation.isPending ? 'Guardando...' : 'Dar de baja'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/web/components/layouts/dashboard-shell.tsx
Normal file
18
apps/web/components/layouts/dashboard-shell.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Header } from './header';
|
||||
|
||||
interface DashboardShellProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
headerContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
|
||||
// Navigation is handled by the parent layout.tsx which respects theme settings
|
||||
// DashboardShell only provides Header and content wrapper
|
||||
return (
|
||||
<>
|
||||
<Header title={title}>{headerContent}</Header>
|
||||
<main className="p-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
apps/web/components/layouts/header.tsx
Normal file
56
apps/web/components/layouts/header.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { themes, type ThemeName } from '@/themes';
|
||||
import { Button } from '@horux/shared-ui';
|
||||
import { TenantSelector } from '@/components/tenant-selector';
|
||||
import { MembershipSwitcher } from '@/components/membership-switcher';
|
||||
import { ContribuyenteSelector } from '@/components/contribuyente-selector';
|
||||
import { Sun, Moon, Palette } from 'lucide-react';
|
||||
|
||||
const themeIcons: Record<ThemeName, React.ReactNode> = {
|
||||
light: <Sun className="h-4 w-4" />,
|
||||
vibrant: <Palette className="h-4 w-4" />,
|
||||
corporate: <Palette className="h-4 w-4" />,
|
||||
dark: <Moon className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const themeOrder: ThemeName[] = ['light', 'dark'];
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Header({ title, children }: HeaderProps) {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
|
||||
const cycleTheme = () => {
|
||||
const currentIndex = themeOrder.indexOf(theme);
|
||||
const nextIndex = (currentIndex + 1) % themeOrder.length;
|
||||
setTheme(themeOrder[nextIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-background/95 backdrop-blur px-6">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<h1 className="text-xl font-semibold whitespace-nowrap">{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ContribuyenteSelector />
|
||||
<MembershipSwitcher />
|
||||
<TenantSelector />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={cycleTheme}
|
||||
title={`Tema: ${themes[theme].name}`}
|
||||
>
|
||||
{themeIcons[theme]}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
158
apps/web/components/layouts/sidebar-compact.tsx
Normal file
158
apps/web/components/layouts/sidebar-compact.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Calculator,
|
||||
Settings,
|
||||
LogOut,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Bell,
|
||||
Users,
|
||||
Building2,
|
||||
Scale,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { hasFeature, isGlobalAdminRfc, type Plan } from '@horux/shared';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
|
||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
|
||||
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
|
||||
{ name: 'Configuración', href: '/configuracion', icon: Settings, roles: ['owner'] },
|
||||
] as const;
|
||||
|
||||
const adminNavigation = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
];
|
||||
|
||||
export function SidebarCompact() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout: clearAuth } = useAuthStore();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const plan = (user?.plan || 'starter') as Plan;
|
||||
const role = user?.role || 'visor';
|
||||
const filteredNav = navigation.filter((item) => {
|
||||
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
|
||||
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
||||
const allNavigation = isGlobalAdmin
|
||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||
: filteredNav;
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
clearAuth();
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-40 h-screen border-r bg-card transition-all duration-300',
|
||||
expanded ? 'w-64' : 'w-16'
|
||||
)}
|
||||
onMouseEnter={() => setExpanded(true)}
|
||||
onMouseLeave={() => setExpanded(false)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/logo.jpg"
|
||||
alt="Horux360"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span className={cn(
|
||||
'font-bold text-lg whitespace-nowrap transition-opacity duration-300',
|
||||
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
|
||||
)}>
|
||||
Horux360
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-2 py-3">
|
||||
{allNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded px-2 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title={!expanded ? item.name : undefined}
|
||||
>
|
||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||
<span className={cn(
|
||||
'whitespace-nowrap transition-opacity duration-300',
|
||||
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
|
||||
)}>
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User & Logout */}
|
||||
<div className="border-t p-2">
|
||||
{expanded && (
|
||||
<div className="mb-2 px-2 py-1">
|
||||
<p className="text-xs font-medium truncate">{user?.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded px-2 py-2 text-sm font-medium text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors'
|
||||
)}
|
||||
title={!expanded ? 'Cerrar sesión' : undefined}
|
||||
>
|
||||
<LogOut className="h-5 w-5 flex-shrink-0" />
|
||||
<span className={cn(
|
||||
'whitespace-nowrap transition-opacity duration-300',
|
||||
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
|
||||
)}>
|
||||
Cerrar sesión
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
139
apps/web/components/layouts/sidebar-floating.tsx
Normal file
139
apps/web/components/layouts/sidebar-floating.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Calculator,
|
||||
Settings,
|
||||
LogOut,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Bell,
|
||||
Users,
|
||||
Building2,
|
||||
Scale,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { hasFeature, isGlobalAdminRfc, type Plan } from '@horux/shared';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
|
||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
|
||||
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
|
||||
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
|
||||
] as const;
|
||||
|
||||
const adminNavigation = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
];
|
||||
|
||||
export function SidebarFloating() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout: clearAuth } = useAuthStore();
|
||||
|
||||
const plan = (user?.plan || 'starter') as Plan;
|
||||
const role = user?.role || 'visor';
|
||||
const filteredNav = navigation.filter((item) => {
|
||||
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
|
||||
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
||||
const allNavigation = isGlobalAdmin
|
||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||
: filteredNav;
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
clearAuth();
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="fixed left-4 top-4 bottom-4 z-40 w-64 rounded-2xl border border-border/50 bg-card/80 backdrop-blur-xl shadow-2xl shadow-primary/5">
|
||||
<div className="flex h-full flex-col p-4">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 mb-6 px-2">
|
||||
<Image
|
||||
src="/logo.jpg"
|
||||
alt="Horux360"
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full shadow-lg shadow-primary/25"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-bold text-lg block">Horux360</span>
|
||||
<span className="text-xs text-muted-foreground">Análisis Fiscal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1">
|
||||
{allNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-primary/20 text-primary shadow-sm shadow-primary/20 border border-primary/30'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn(
|
||||
'h-5 w-5 transition-transform',
|
||||
isActive && 'scale-110'
|
||||
)} />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User & Logout */}
|
||||
<div className="mt-4 pt-4 border-t border-border/50">
|
||||
<div className="flex items-center gap-3 px-2 mb-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-secondary to-muted flex items-center justify-center">
|
||||
<span className="text-foreground font-medium">
|
||||
{user?.nombre?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{user?.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
186
apps/web/components/layouts/sidebar.tsx
Normal file
186
apps/web/components/layouts/sidebar.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Calculator,
|
||||
Settings,
|
||||
LogOut,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Bell,
|
||||
Users,
|
||||
Building2,
|
||||
UserCog,
|
||||
CreditCard,
|
||||
Send,
|
||||
Scale,
|
||||
FileCheck,
|
||||
FileWarning,
|
||||
Shield,
|
||||
Rocket,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { hasFeature, isGlobalAdminRfc, isDespachoTenant, type Plan } from '@horux/shared';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
feature?: string; // Required plan feature — hidden if tenant's plan lacks it
|
||||
roles?: string[]; // Allowed roles — hidden if user's role is not in the list
|
||||
/** Visible solo si el user es owner en algún tenant (no en el activo). */
|
||||
requireOwnerSomewhere?: boolean;
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Despacho', href: '/despachos', icon: ListChecks, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor', 'cliente'] },
|
||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner', 'cfo', 'supervisor', 'cliente'] },
|
||||
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
|
||||
const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout: clearAuth } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
clearAuth();
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
// Filter navigation based on plan features + user role
|
||||
const plan = (user?.plan || 'starter') as Plan;
|
||||
const role = user?.role || 'visor';
|
||||
const isOwnerSomewhere = (user?.tenants || []).some(t => t.isOwner);
|
||||
const isDespacho = isDespachoTenant(user?.tenantRfc);
|
||||
const filteredNav = navigation.filter((item) => {
|
||||
if (item.feature) {
|
||||
if (isDespacho) {
|
||||
// Despacho tenants: all features are enabled across all plans — skip check
|
||||
} else {
|
||||
// Horux360: use legacy plan feature gating
|
||||
if (!hasFeature(plan, item.feature)) return false;
|
||||
}
|
||||
}
|
||||
if (item.roles && !item.roles.includes(role)) return false;
|
||||
if (item.requireOwnerSomewhere && !isOwnerSomewhere) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const { data: contribuyentes } = useContribuyentes();
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
||||
// El admin global NO necesita "Configuración inicial" — su tenant raíz
|
||||
// (Horux 360) no tiene contribuyentes propios y nunca los tendrá.
|
||||
const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin;
|
||||
const allNavigation = isGlobalAdmin
|
||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||
: filteredNav;
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/logo.jpg"
|
||||
alt="Horux Despachos"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-bold text-xl">Horux</span>
|
||||
<span className="text-xs text-muted-foreground -mt-1">Despachos</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{showOnboarding && (
|
||||
<div className="px-3 py-2">
|
||||
<Link href="/onboarding">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 transition-colors">
|
||||
<Rocket className="h-4 w-4" />
|
||||
Configuración inicial
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{allNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User & Logout — admin/TI globales no muestran nombre+email para
|
||||
mantener el sidebar más limpio (ya tienen muchos items extras) */}
|
||||
<div className="border-t p-4">
|
||||
{!isGlobalAdmin && (
|
||||
<div className="mb-3 px-3">
|
||||
<p className="text-sm font-medium">{user?.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
Cerrar sesion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
141
apps/web/components/layouts/topnav.tsx
Normal file
141
apps/web/components/layouts/topnav.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Calculator,
|
||||
Settings,
|
||||
LogOut,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Bell,
|
||||
Users,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
Scale,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { hasFeature, isGlobalAdminRfc, type Plan } from '@horux/shared';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
|
||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
|
||||
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
|
||||
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
|
||||
] as const;
|
||||
|
||||
const adminNavigation = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
];
|
||||
|
||||
export function TopNav() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout: clearAuth } = useAuthStore();
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
|
||||
const plan = (user?.plan || 'starter') as Plan;
|
||||
const role = user?.role || 'visor';
|
||||
const filteredNav = navigation.filter((item) => {
|
||||
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
|
||||
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
||||
const allNavigation = isGlobalAdmin
|
||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||
: filteredNav;
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
clearAuth();
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-40 h-16 border-b bg-card">
|
||||
<div className="flex h-full items-center px-6">
|
||||
{/* Logo */}
|
||||
<Link href="/dashboard" className="flex items-center gap-2 mr-8">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-lg">H</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl">Horux360</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 flex items-center gap-1">
|
||||
{allNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-primary font-medium text-sm">
|
||||
{user?.nombre?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden md:inline">{user?.nombre}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg border bg-card shadow-lg">
|
||||
<div className="p-3 border-b">
|
||||
<p className="text-sm font-medium">{user?.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
114
apps/web/components/membership-switcher.tsx
Normal file
114
apps/web/components/membership-switcher.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { switchTenant } from '@/lib/api/auth';
|
||||
import { Building2, ChevronDown, Check, Loader2, Crown } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
|
||||
/**
|
||||
* Switcher para users con múltiples memberships (owner o contador con varias
|
||||
* empresas). Distinto del TenantSelector de admin global:
|
||||
* - Admin global: impersonación via X-View-Tenant (no cambia el JWT)
|
||||
* - Membership switcher: cambia de tenant *real* con nuevo JWT
|
||||
*
|
||||
* Se oculta si:
|
||||
* - El user tiene ≤1 membership
|
||||
* - El user es admin global (ya tiene su propio TenantSelector, sería redundante)
|
||||
*/
|
||||
export function MembershipSwitcher() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const { user, setUser, setTokens } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
const tenants = user?.tenants || [];
|
||||
const showSwitcher = !isGlobalAdmin && tenants.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.membership-switcher')) setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!showSwitcher) return null;
|
||||
|
||||
const activeTenant = tenants.find(t => t.id === user?.tenantId);
|
||||
|
||||
const handleSwitch = async (tenantId: string) => {
|
||||
if (tenantId === user?.tenantId) { setOpen(false); return; }
|
||||
setSwitching(true);
|
||||
try {
|
||||
const res = await switchTenant(tenantId);
|
||||
setTokens(res.accessToken, res.refreshToken);
|
||||
setUser(res.user);
|
||||
// Refresca todo el cache — las queries dependen del tenant activo
|
||||
queryClient.clear();
|
||||
setOpen(false);
|
||||
// Reload para que React Query re-fetche desde cero con el nuevo JWT
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || 'Error al cambiar de empresa');
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="membership-switcher relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={switching}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-[180px] truncate">
|
||||
{activeTenant?.nombre || user?.tenantName}
|
||||
</span>
|
||||
{switching
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||
}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-2 w-72 rounded-lg border bg-card shadow-lg z-50">
|
||||
<div className="p-2 border-b">
|
||||
<p className="text-xs text-muted-foreground px-2">Mis empresas</p>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-1">
|
||||
{tenants.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => handleSwitch(t.id)}
|
||||
disabled={switching}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left',
|
||||
t.id === user?.tenantId && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center text-xs font-medium">
|
||||
{t.nombre.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium truncate">{t.nombre}</p>
|
||||
{t.isOwner && <Crown className="h-3 w-3 text-amber-500 flex-shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{t.rfc} · {t.role}</p>
|
||||
</div>
|
||||
{t.id === user?.tenantId && <Check className="h-4 w-4 text-primary flex-shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
325
apps/web/components/obligaciones/tareas-tab.tsx
Normal file
325
apps/web/components/obligaciones/tareas-tab.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Button, Card, CardContent, Input, Label,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { Plus, Trash2, Edit, CheckCircle2, Circle, Sparkles } from 'lucide-react';
|
||||
|
||||
const RECURRENCIAS = [
|
||||
{ value: 'semanal', label: 'Semanal' },
|
||||
{ value: 'quincenal', label: 'Quincenal' },
|
||||
{ value: 'mensual', label: 'Mensual' },
|
||||
{ value: 'bimestral', label: 'Bimestral' },
|
||||
{ value: 'trimestral', label: 'Trimestral' },
|
||||
{ value: 'semestral', label: 'Semestral' },
|
||||
{ value: 'anual', label: 'Anual' },
|
||||
];
|
||||
|
||||
const DIAS_SEMANA = [
|
||||
{ value: 1, label: 'Lunes' },
|
||||
{ value: 2, label: 'Martes' },
|
||||
{ value: 3, label: 'Miércoles' },
|
||||
{ value: 4, label: 'Jueves' },
|
||||
{ value: 5, label: 'Viernes' },
|
||||
{ value: 6, label: 'Sábado' },
|
||||
{ value: 7, label: 'Domingo' },
|
||||
];
|
||||
|
||||
interface Tarea {
|
||||
id: string;
|
||||
contribuyenteId: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
recurrencia: string;
|
||||
diaSemana: number | null;
|
||||
diaMes: number | null;
|
||||
soloSupervisorCompleta: boolean;
|
||||
esDefault: boolean;
|
||||
active: boolean;
|
||||
orden: number;
|
||||
periodoActual: {
|
||||
id: string;
|
||||
fechaLimite: string;
|
||||
completada: boolean;
|
||||
completadaAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
recurrencia: string;
|
||||
diaSemana: number;
|
||||
diaMes: number;
|
||||
soloSupervisorCompleta: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
nombre: '',
|
||||
descripcion: '',
|
||||
recurrencia: 'mensual',
|
||||
diaSemana: 5,
|
||||
diaMes: 10,
|
||||
soloSupervisorCompleta: false,
|
||||
};
|
||||
|
||||
export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
|
||||
const tareasQuery = useQuery<Tarea[]>({
|
||||
queryKey: ['tareas', contribuyenteId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
|
||||
const { data } = await apiClient.get<Tarea[]>(`/tareas?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!contribuyenteId,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['tareas', contribuyenteId] });
|
||||
|
||||
const seedMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
|
||||
await apiClient.post(`/tareas/seed?${params}`);
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
nombre: form.nombre,
|
||||
descripcion: form.descripcion || null,
|
||||
recurrencia: form.recurrencia,
|
||||
diaSemana: form.recurrencia === 'semanal' || form.recurrencia === 'quincenal' ? form.diaSemana : null,
|
||||
diaMes: form.recurrencia === 'semanal' || form.recurrencia === 'quincenal' ? null : form.diaMes,
|
||||
soloSupervisorCompleta: form.soloSupervisorCompleta,
|
||||
};
|
||||
if (editingId) {
|
||||
await apiClient.patch(`/tareas/${editingId}`, payload);
|
||||
} else {
|
||||
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
|
||||
await apiClient.post(`/tareas?${params}`, payload);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => apiClient.delete(`/tareas/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const completarMutation = useMutation({
|
||||
mutationFn: async (periodoId: string) => apiClient.post(`/tareas/periodo/${periodoId}/completar`),
|
||||
onSuccess: invalidate,
|
||||
onError: (err: unknown) => {
|
||||
const e = err as { response?: { data?: { message?: string } } };
|
||||
alert(e.response?.data?.message || 'No se pudo marcar como completada');
|
||||
},
|
||||
});
|
||||
|
||||
const descompletarMutation = useMutation({
|
||||
mutationFn: async (periodoId: string) => apiClient.delete(`/tareas/periodo/${periodoId}/completar`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const handleEdit = (t: Tarea) => {
|
||||
setEditingId(t.id);
|
||||
setForm({
|
||||
nombre: t.nombre,
|
||||
descripcion: t.descripcion ?? '',
|
||||
recurrencia: t.recurrencia,
|
||||
diaSemana: t.diaSemana ?? 5,
|
||||
diaMes: t.diaMes ?? 10,
|
||||
soloSupervisorCompleta: t.soloSupervisorCompleta,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
if (!contribuyenteId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Selecciona un contribuyente para gestionar sus tareas.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const tareas = tareasQuery.data ?? [];
|
||||
const isWeekly = form.recurrencia === 'semanal' || form.recurrencia === 'quincenal';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{tareas.length === 0 && (
|
||||
<Button variant="outline" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Generar recomendaciones
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleNew}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Agregar tarea
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tareasQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : tareas.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No hay tareas configuradas. Usa "Generar recomendaciones" para crear las 4 tareas default
|
||||
(estados de cuenta, conciliación, contabilización, revisión fiscal preliminar).
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tareas.map(t => {
|
||||
const p = t.periodoActual;
|
||||
const fl = p ? new Date(p.fechaLimite) : null;
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const atrasada = !!fl && !p?.completada && fl < today;
|
||||
const recurrenciaLabel = RECURRENCIAS.find(r => r.value === t.recurrencia)?.label;
|
||||
const cuandoLabel = (t.recurrencia === 'semanal' || t.recurrencia === 'quincenal')
|
||||
? DIAS_SEMANA.find(d => d.value === t.diaSemana)?.label
|
||||
: `día ${t.diaMes}`;
|
||||
return (
|
||||
<Card key={t.id}>
|
||||
<CardContent className="py-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => p && (p.completada ? descompletarMutation.mutate(p.id) : completarMutation.mutate(p.id))}
|
||||
disabled={!p || completarMutation.isPending}
|
||||
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{p?.completada
|
||||
? <CheckCircle2 className="h-5 w-5 text-success" />
|
||||
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{t.nombre}
|
||||
</span>
|
||||
{t.soloSupervisorCompleta && (
|
||||
<span className="text-[10px] uppercase bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
|
||||
Supervisor
|
||||
</span>
|
||||
)}
|
||||
{atrasada && (
|
||||
<span className="text-[10px] uppercase bg-destructive/10 text-destructive rounded px-1.5 py-0.5">
|
||||
Atrasada
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{t.descripcion && (
|
||||
<p className="text-xs text-muted-foreground truncate">{t.descripcion}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{recurrenciaLabel} · {cuandoLabel}
|
||||
{fl && ` · vence ${fl.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEdit(t)} title="Editar">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => confirm(`¿Eliminar tarea "${t.nombre}"?`) && deleteMutation.mutate(t.id)}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showForm} onOpenChange={setShowForm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Editar tarea' : 'Nueva tarea'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Nombre</Label>
|
||||
<Input value={form.nombre} onChange={e => setForm(f => ({ ...f, nombre: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Descripción (opcional)</Label>
|
||||
<Input value={form.descripcion} onChange={e => setForm(f => ({ ...f, descripcion: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Recurrencia</Label>
|
||||
<select
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
|
||||
value={form.recurrencia}
|
||||
onChange={e => setForm(f => ({ ...f, recurrencia: e.target.value }))}
|
||||
>
|
||||
{RECURRENCIAS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{isWeekly ? 'Día de la semana' : 'Día del mes'}</Label>
|
||||
{isWeekly ? (
|
||||
<select
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
|
||||
value={form.diaSemana}
|
||||
onChange={e => setForm(f => ({ ...f, diaSemana: parseInt(e.target.value, 10) }))}
|
||||
>
|
||||
{DIAS_SEMANA.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
type="number" min={1} max={31}
|
||||
value={form.diaMes}
|
||||
onChange={e => setForm(f => ({ ...f, diaMes: parseInt(e.target.value, 10) || 1 }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.soloSupervisorCompleta}
|
||||
onChange={e => setForm(f => ({ ...f, soloSupervisorCompleta: e.target.checked }))}
|
||||
/>
|
||||
Solo supervisor/owner pueden marcarla como completada
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
|
||||
<Button onClick={() => saveMutation.mutate()} disabled={!form.nombre || saveMutation.isPending}>
|
||||
{editingId ? 'Guardar' : 'Crear'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
190
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/**
|
||||
* Onboarding persistence key.
|
||||
* If you later want this to come from env/config, move it to apps/web/config/onboarding.ts
|
||||
*/
|
||||
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, _hasHydrated } = useAuthStore();
|
||||
const [isNewUser, setIsNewUser] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const safePush = (path: string) => {
|
||||
// Avoid multiple navigations if user clicks quickly.
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (_hasHydrated && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, _hasHydrated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||
|
||||
// If the user has already seen onboarding, go to dashboard automatically.
|
||||
if (seen) {
|
||||
setIsNewUser(false);
|
||||
setLoading(true);
|
||||
const t = setTimeout(() => router.push('/dashboard'), 900);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, '1');
|
||||
setLoading(true);
|
||||
setTimeout(() => router.push('/dashboard'), 700);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (typeof window !== 'undefined') localStorage.removeItem(STORAGE_KEY);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
|
||||
|
||||
// Show loading while store hydrates
|
||||
if (!_hasHydrated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="animate-pulse text-slate-500">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen relative overflow-hidden bg-white">
|
||||
{/* Grid tech claro */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(to right, rgba(15,23,42,.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.2) 1px, transparent 1px)',
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Glow global azul (sutil) */}
|
||||
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
|
||||
|
||||
<div className="relative z-10 flex min-h-screen items-center justify-center p-6">
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<p className="text-sm font-semibold text-slate-800">Horux360</p>
|
||||
<p className="text-xs text-slate-500">Pantalla de inicio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-slate-500">{headerStatus}</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 md:p-8">
|
||||
{isNewUser ? (
|
||||
<div className="grid gap-8 md:grid-cols-2 md:items-center">
|
||||
{/* Left */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">
|
||||
Bienvenido a Horux360
|
||||
</h1>
|
||||
<p className="mt-2 text-sm md:text-base text-slate-600 max-w-md">
|
||||
Revisa este breve video para conocer el flujo. Después podrás continuar.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-semibold shadow-md hover:bg-blue-700 hover:shadow-lg transition-all"
|
||||
>
|
||||
{loading ? 'Cargando…' : 'Continuar'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => safePush('/login')}
|
||||
disabled={loading}
|
||||
className="px-5 py-3 rounded-xl font-medium text-slate-700 border border-slate-300 hover:bg-slate-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Ver más
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-xs text-slate-500">
|
||||
Usuario nuevo: muestra video • Usuario recurrente: redirección automática
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right (video) - elegante sin glow */}
|
||||
<div className="relative">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-blue-600/80 via-blue-500/40 to-transparent" />
|
||||
<div className="p-3">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
|
||||
<video src="/video-intro.mp4" controls className="w-full rounded-xl" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
|
||||
Video introductorio
|
||||
</span>
|
||||
<span>v1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-center">
|
||||
<div className="h-12 w-12 rounded-2xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||
<div className="h-3 w-3 rounded-full bg-blue-500 animate-pulse" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-semibold text-slate-800">
|
||||
Redirigiendo al dashboard…
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">Usuario recurrente detectado.</p>
|
||||
|
||||
<div className="mt-6 w-full max-w-sm h-2 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
|
||||
<div className="h-full w-2/3 bg-blue-600/80 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="mt-6 text-xs text-slate-500 hover:text-slate-700 underline underline-offset-4"
|
||||
>
|
||||
Ver video otra vez (reset demo)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
20
apps/web/components/periodo-selector.tsx
Normal file
20
apps/web/components/periodo-selector.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { PeriodSelector } from '@horux/shared-ui';
|
||||
import { usePeriodoStore } from '@/stores/periodo-store';
|
||||
|
||||
/**
|
||||
* Wrapper alrededor de `<PeriodSelector />` de shared-ui que persiste la
|
||||
* selección en `periodo-store`. Pasado como children al `<Header>` en las
|
||||
* páginas que lo usan (mismo patrón que /dashboard, /impuestos, etc).
|
||||
*/
|
||||
export function PeriodoSelector() {
|
||||
const { fechaInicio, fechaFin, setRango } = usePeriodoStore();
|
||||
return (
|
||||
<PeriodSelector
|
||||
fechaInicio={fechaInicio}
|
||||
fechaFin={fechaFin}
|
||||
onChange={setRango}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
apps/web/components/providers/query-provider.tsx
Normal file
22
apps/web/components/providers/query-provider.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
26
apps/web/components/providers/theme-provider.tsx
Normal file
26
apps/web/components/providers/theme-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { themes } from '@/themes';
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useThemeStore();
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTheme = themes[theme];
|
||||
const root = document.documentElement;
|
||||
|
||||
Object.entries(selectedTheme.cssVars).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value);
|
||||
});
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
138
apps/web/components/sat/FielUploadModal.tsx
Normal file
138
apps/web/components/sat/FielUploadModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { uploadFiel } from '@/lib/api/fiel';
|
||||
import type { FielStatus } from '@horux/shared';
|
||||
|
||||
interface FielUploadModalProps {
|
||||
onSuccess: (status: FielStatus) => void;
|
||||
onClose: () => void;
|
||||
contribuyenteId?: string | null;
|
||||
}
|
||||
|
||||
export function FielUploadModal({ onSuccess, onClose, contribuyenteId }: FielUploadModalProps) {
|
||||
const [cerFile, setCerFile] = useState<File | null>(null);
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Remove data URL prefix (e.g., "data:application/x-x509-ca-cert;base64,")
|
||||
const base64 = result.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!cerFile || !keyFile || !password) {
|
||||
setError('Todos los campos son requeridos');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const cerBase64 = await fileToBase64(cerFile);
|
||||
const keyBase64 = await fileToBase64(keyFile);
|
||||
|
||||
const result = await uploadFiel({
|
||||
cerFile: cerBase64,
|
||||
keyFile: keyBase64,
|
||||
password,
|
||||
}, contribuyenteId);
|
||||
|
||||
if (result.status) {
|
||||
onSuccess(result.status);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.response?.data?.error || err.message || 'Error al subir la FIEL';
|
||||
console.error('[FIEL Upload Frontend]', msg, err.response?.data);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cerFile, keyFile, password, onSuccess]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Configurar FIEL (e.firma)</CardTitle>
|
||||
<CardDescription>
|
||||
Sube tu certificado y llave privada para sincronizar CFDIs con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cer">Certificado (.cer)</Label>
|
||||
<Input
|
||||
id="cer"
|
||||
type="file"
|
||||
accept=".cer"
|
||||
onChange={(e) => setCerFile(e.target.files?.[0] || null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key">Llave Privada (.key)</Label>
|
||||
<Input
|
||||
id="key"
|
||||
type="file"
|
||||
accept=".key"
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Contrasena de la llave</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Ingresa la contrasena de tu FIEL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? 'Subiendo...' : 'Configurar FIEL'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
apps/web/components/sat/SyncHistory.tsx
Normal file
182
apps/web/components/sat/SyncHistory.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from '@horux/shared-ui';
|
||||
import { getSyncHistory, retrySync } from '@/lib/api/sat';
|
||||
import type { SatSyncJob } from '@horux/shared';
|
||||
|
||||
interface SyncHistoryProps {
|
||||
fielConfigured: boolean;
|
||||
contribuyenteId?: string | null;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
running: 'En progreso',
|
||||
completed: 'Completado',
|
||||
failed: 'Fallido',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
initial: 'Inicial',
|
||||
daily: 'Diaria',
|
||||
};
|
||||
|
||||
export function SyncHistory({ fielConfigured, contribuyenteId }: SyncHistoryProps) {
|
||||
const [jobs, setJobs] = useState<SatSyncJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const limit = 10;
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const data = await getSyncHistory(page, limit, contribuyenteId);
|
||||
setJobs(data.jobs);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Error fetching sync history:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fielConfigured) {
|
||||
fetchHistory();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fielConfigured, page, contribuyenteId]);
|
||||
|
||||
const handleRetry = async (jobId: string) => {
|
||||
try {
|
||||
await retrySync(jobId);
|
||||
fetchHistory();
|
||||
} catch (err) {
|
||||
console.error('Error retrying job:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fielConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historial de Sincronizaciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Cargando historial...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historial de Sincronizaciones</CardTitle>
|
||||
<CardDescription>
|
||||
Registro de todas las sincronizaciones con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">No hay sincronizaciones registradas.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historial de Sincronizaciones</CardTitle>
|
||||
<CardDescription>
|
||||
Registro de todas las sincronizaciones con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[job.status]}`}>
|
||||
{statusLabels[job.status]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{typeLabels[job.type]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{job.startedAt ? new Date(job.startedAt).toLocaleString('es-MX') : 'No iniciado'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{job.cfdisInserted} nuevos, {job.cfdisUpdated} actualizados
|
||||
</p>
|
||||
{job.errorMessage && (
|
||||
<p className="text-xs text-red-500 mt-1">{job.errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
{job.status === 'failed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRetry(job.id)}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
)}
|
||||
{job.status === 'running' && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{job.progressPercent}%</p>
|
||||
<p className="text-xs text-muted-foreground">{job.cfdisDownloaded} descargados</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="py-2 px-3 text-sm">
|
||||
Pagina {page} de {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
251
apps/web/components/sat/SyncStatus.tsx
Normal file
251
apps/web/components/sat/SyncStatus.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
|
||||
import { getSyncStatus, startSync } from '@/lib/api/sat';
|
||||
import type { SatSyncStatusResponse } from '@horux/shared';
|
||||
|
||||
interface SyncStatusProps {
|
||||
fielConfigured: boolean;
|
||||
onSyncStarted?: () => void;
|
||||
contribuyenteId?: string | null;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
running: 'En progreso',
|
||||
completed: 'Completado',
|
||||
failed: 'Fallido',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export function SyncStatus({ fielConfigured, onSyncStarted, contribuyenteId }: SyncStatusProps) {
|
||||
const [status, setStatus] = useState<SatSyncStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [startingSync, setStartingSync] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showCustomDate, setShowCustomDate] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const data = await getSyncStatus(contribuyenteId);
|
||||
setStatus(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching sync status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fielConfigured) {
|
||||
fetchStatus();
|
||||
// Actualizar cada 30 segundos si hay sync activo
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fielConfigured, contribuyenteId]);
|
||||
|
||||
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
|
||||
setStartingSync(true);
|
||||
setError('');
|
||||
try {
|
||||
const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
|
||||
|
||||
if (customDates && dateFrom && dateTo) {
|
||||
// Convertir a formato completo con hora
|
||||
params.dateFrom = `${dateFrom}T00:00:00`;
|
||||
params.dateTo = `${dateTo}T23:59:59`;
|
||||
}
|
||||
|
||||
await startSync(params, contribuyenteId);
|
||||
await fetchStatus();
|
||||
setShowCustomDate(false);
|
||||
onSyncStarted?.();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
|
||||
} finally {
|
||||
setStartingSync(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fielConfigured) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sincronizacion SAT</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu FIEL para habilitar la sincronizacion automatica
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
La sincronizacion con el SAT requiere una FIEL valida configurada.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sincronizacion SAT</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Cargando estado...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sincronizacion SAT</CardTitle>
|
||||
<CardDescription>
|
||||
Estado de la sincronizacion automatica de CFDIs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status?.hasActiveSync && status.currentJob && (
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 rounded text-sm ${statusColors[status.currentJob.status]}`}>
|
||||
{statusLabels[status.currentJob.status]}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{status.currentJob.type === 'initial' ? 'Sincronizacion inicial' : 'Sincronizacion diaria'}
|
||||
</span>
|
||||
</div>
|
||||
{status.currentJob.status === 'running' && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${status.currentJob.progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm mt-2">
|
||||
{status.currentJob.cfdisDownloaded} CFDIs descargados
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.lastCompletedJob && !status.hasActiveSync && (
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 rounded text-sm ${statusColors.completed}`}>
|
||||
Ultima sincronizacion exitosa
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{new Date(status.lastCompletedJob.completedAt!).toLocaleString('es-MX')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status.lastCompletedJob.cfdisInserted} CFDIs nuevos, {status.lastCompletedJob.cfdisUpdated} actualizados
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold">{status?.totalCfdisSynced || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">CFDIs sincronizados</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold">3:00 AM</p>
|
||||
<p className="text-sm text-muted-foreground">Sincronizacion diaria</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Formulario de fechas personalizadas */}
|
||||
{showCustomDate && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dateFrom">Fecha inicio</Label>
|
||||
<Input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
max={dateTo || undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dateTo">Fecha fin</Label>
|
||||
<Input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
min={dateFrom || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
disabled={startingSync || status?.hasActiveSync || !dateFrom || !dateTo}
|
||||
onClick={() => handleStartSync('initial', true)}
|
||||
className="flex-1"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizar periodo'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCustomDate(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => handleStartSync('daily')}
|
||||
className="flex-1"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => setShowCustomDate(!showCustomDate)}
|
||||
className="flex-1"
|
||||
>
|
||||
Periodo personalizado
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!status?.lastCompletedJob && (
|
||||
<Button
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => handleStartSync('initial')}
|
||||
className="w-full"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 años)'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
158
apps/web/components/tenant-selector.tsx
Normal file
158
apps/web/components/tenant-selector.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Building, ChevronDown, Check, X } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
|
||||
export function TenantSelector() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const { viewingTenantId, viewingTenantName, setViewingTenant, clearViewingTenant } = useTenantViewStore();
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
|
||||
const { data: tenants, isLoading } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: getTenants,
|
||||
enabled: isGlobalAdmin,
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.tenant-selector')) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Solo admin global — ningún otro admin puede cambiar de tenant
|
||||
if (!isGlobalAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentTenant = viewingTenantId
|
||||
? tenants?.find(t => t.id === viewingTenantId)
|
||||
: null;
|
||||
|
||||
const displayName = viewingTenantName || currentTenant?.nombre || user?.tenantName;
|
||||
const isViewingOther = viewingTenantId && viewingTenantId !== user?.tenantId;
|
||||
|
||||
return (
|
||||
<div className="tenant-selector relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isViewingOther
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Building className="h-4 w-4" />
|
||||
<span className="max-w-[150px] truncate">{displayName}</span>
|
||||
{isViewingOther && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearViewingTenant();
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
clearViewingTenant();
|
||||
queryClient.invalidateQueries();
|
||||
}
|
||||
}}
|
||||
className="ml-1 p-0.5 rounded hover:bg-primary/20 cursor-pointer"
|
||||
title="Volver a mi empresa"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-2 w-72 rounded-lg border bg-card shadow-lg z-50">
|
||||
<div className="p-2 border-b">
|
||||
<p className="text-xs text-muted-foreground px-2">Seleccionar cliente</p>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-1">
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Cargando...</div>
|
||||
) : tenants && tenants.length > 0 ? (
|
||||
<>
|
||||
{/* Option to go back to own tenant */}
|
||||
<button
|
||||
onClick={() => {
|
||||
clearViewingTenant();
|
||||
setOpen(false);
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
|
||||
!viewingTenantId && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Building className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium">{user?.tenantName}</p>
|
||||
<p className="text-xs text-muted-foreground">Mi empresa</p>
|
||||
</div>
|
||||
{!viewingTenantId && <Check className="h-4 w-4 text-primary" />}
|
||||
</button>
|
||||
|
||||
<div className="my-1 border-t" />
|
||||
|
||||
{/* Other tenants */}
|
||||
{tenants
|
||||
.filter(t => t.id !== user?.tenantId)
|
||||
.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
onClick={() => {
|
||||
setViewingTenant(tenant.id, tenant.nombre, tenant.rfc);
|
||||
setOpen(false);
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
|
||||
viewingTenantId === tenant.id && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center text-xs font-medium">
|
||||
{tenant.nombre.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium truncate">{tenant.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground">{tenant.rfc}</p>
|
||||
</div>
|
||||
{viewingTenantId === tenant.id && <Check className="h-4 w-4 text-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No hay otros clientes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user