Update: nueva version Horux Despachos

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

View File

@@ -0,0 +1,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';

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export { BarChart } from './bar-chart';

View 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>
);
}

View 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';
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}