Permisos: marketing (export completo, agenda/citas, cumpleanos, visitas, WACRM), gerencia gestiona usuarios y roles, recepcion edita expedientes, cosmetologas ven agenda, fix del sidebar que no dejaba seleccionar Leads
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -256,13 +256,47 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
const toggleGroup = (label: string) =>
|
||||
setOpenGroups((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]));
|
||||
|
||||
const SidebarContent = () => (
|
||||
// El menú se renderiza como función (no como componente) a propósito: como
|
||||
// componente definido dentro del layout, cada render crea un tipo nuevo y
|
||||
// React remonta el subárbol entero, perdiendo la posición de scroll (los
|
||||
// últimos items, p. ej. Leads WACRM, quedaban inalcanzables).
|
||||
const navDesktopRef = useRef<HTMLElement | null>(null);
|
||||
const navMobileRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// 1) El grupo que contiene la ruta activa siempre queda abierto
|
||||
useEffect(() => {
|
||||
const current = `${location.pathname}${location.search}`;
|
||||
const abiertos = menuTree
|
||||
.filter((e) => e.children?.some((c) => {
|
||||
const base = c.href.split('?')[0];
|
||||
return current === c.href || (current.startsWith(base) && (!c.href.includes('?') || current.includes(c.href.split('?')[1])));
|
||||
}))
|
||||
.map((e) => e.label);
|
||||
if (!abiertos.length) return;
|
||||
setOpenGroups((prev) => {
|
||||
const faltan = abiertos.filter((l) => !prev.includes(l));
|
||||
return faltan.length ? [...prev, ...faltan] : prev;
|
||||
});
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
// 2) Con el grupo ya abierto (re-render), deja visible el item activo.
|
||||
// Va en un efecto aparte a propósito: en el mismo tick el item todavía no
|
||||
// está en el DOM y no habría nada que desplazar.
|
||||
useEffect(() => {
|
||||
for (const nav of [navDesktopRef.current, navMobileRef.current]) {
|
||||
if (!nav || nav.scrollHeight <= nav.clientHeight) continue;
|
||||
const el = nav.querySelector<HTMLElement>('[data-active="1"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [location.pathname, location.search, openGroups, visibleLeaves]);
|
||||
|
||||
const renderSidebar = ({ navRef = null }: { navRef?: MutableRefObject<HTMLElement | null> | null } = {}) => (
|
||||
<>
|
||||
{/* Sucursal, como en el sistema legacy */}
|
||||
<p className="text-center text-sm font-medium text-white py-3 shrink-0" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
||||
Rosarito
|
||||
</p>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto py-2">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between px-4 py-2" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
||||
@@ -405,6 +439,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
<Link
|
||||
to={child.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
data-active={active ? '1' : undefined}
|
||||
className="flex-1 flex items-center pl-11 pr-4 py-2 text-[13px] transition-colors"
|
||||
style={{
|
||||
backgroundColor: active ? 'rgba(0,0,0,0.25)' : 'transparent',
|
||||
@@ -533,7 +568,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
className="hidden lg:flex flex-col w-[230px] fixed top-[50px] bottom-0 left-0 z-20"
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent />
|
||||
{renderSidebar({ navRef: navDesktopRef })}
|
||||
</aside>
|
||||
|
||||
{/* Drawer móvil */}
|
||||
@@ -546,7 +581,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
}`}
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent />
|
||||
{renderSidebar({ navRef: navMobileRef })}
|
||||
</aside>
|
||||
|
||||
{/* Contenido */}
|
||||
|
||||
@@ -335,7 +335,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
|
||||
<div className="flex flex-1">
|
||||
{/* Sidebar workspace */}
|
||||
<aside className="hidden lg:flex flex-col w-72 border-r p-5 gap-6" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
||||
<aside className="hidden lg:flex flex-col w-72 border-r p-5 gap-6 overflow-y-auto max-h-screen sticky top-0" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
||||
<div className="rounded-2xl p-4 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
|
||||
@@ -505,7 +505,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 lg:hidden" style={{ backgroundColor: 'rgba(62,44,28,0.4)' }} onClick={() => setMenuOpen(false)} aria-hidden="true" />
|
||||
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col overflow-y-auto lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||
<div className="flex items-center justify-between px-5 h-16 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<img src={LOGO_HOMENEST} alt="SKEEN" className="h-8 w-auto object-contain" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label="Cerrar menú">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -227,7 +227,31 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
return location.pathname === href || location.pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
const SidebarContent = ({ collapsed = false, showToggle = true }: { collapsed?: boolean; showToggle?: boolean }) => (
|
||||
// El menú lateral se renderiza como función (no como componente) a propósito:
|
||||
// definirlo como componente dentro del layout crea un tipo nuevo en cada
|
||||
// render, React desmonta y vuelve a montar todo el subárbol y se pierde la
|
||||
// posición de scroll, dejando inalcanzables los últimos items (WACRM).
|
||||
const navDesktopRef = useRef<HTMLElement | null>(null);
|
||||
const navMobileRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Al cambiar de ruta, deja visible el item activo (p. ej. Mensajes -> Leads).
|
||||
useEffect(() => {
|
||||
const activo = `${location.pathname}${location.search}`;
|
||||
const menuKey = visibleGroups.reduce((n, g) => n + g.items.length, 0);
|
||||
for (const nav of [navDesktopRef.current, navMobileRef.current]) {
|
||||
if (!nav || nav.scrollHeight <= nav.clientHeight) continue;
|
||||
const el = nav.querySelector<HTMLElement>('[data-active="1"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
void activo;
|
||||
void menuKey;
|
||||
}, [location.pathname, location.search, visibleGroups]);
|
||||
|
||||
const renderSidebar = ({ collapsed = false, showToggle = true, navRef = null }: {
|
||||
collapsed?: boolean;
|
||||
showToggle?: boolean;
|
||||
navRef?: MutableRefObject<HTMLElement | null> | null;
|
||||
}) => (
|
||||
<>
|
||||
<div className={`h-20 flex items-center border-b border-white/10 shrink-0 ${collapsed ? 'justify-center px-2' : 'px-5 lg:px-6'}`}>
|
||||
<img src={LOGO_SKEEN} alt="SKEEN" className={collapsed ? 'h-6 w-auto object-contain' : 'h-7 w-auto object-contain'} />
|
||||
@@ -244,7 +268,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-5 px-3">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto py-5 px-3">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && collapsed && (
|
||||
<div className="mb-5" title="Accesos rápidos">
|
||||
<div className="mx-3 mb-2 border-t border-white/10" />
|
||||
@@ -346,6 +370,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
data-active={active ? '1' : undefined}
|
||||
className={`${collapsed ? 'relative flex items-center justify-center w-10 h-10 rounded-xl' : 'flex-1 flex items-center space-x-3 px-3 py-2.5 rounded-xl'} text-sm font-medium transition-all ${
|
||||
active
|
||||
? 'bg-theme-accent text-theme-inverse shadow-soft'
|
||||
@@ -426,7 +451,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
className={`hidden lg:flex flex-col fixed h-full z-20 transition-all duration-200 ${collapsed ? 'w-16' : 'w-64'}`}
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent collapsed={collapsed} />
|
||||
{renderSidebar({ collapsed, showToggle: true, navRef: navDesktopRef })}
|
||||
</aside>
|
||||
|
||||
{sidebarOpen && (
|
||||
@@ -445,7 +470,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
<X size={22} />
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarContent />
|
||||
{renderSidebar({ navRef: navMobileRef })}
|
||||
</aside>
|
||||
|
||||
<div className={`flex-1 min-h-screen flex flex-col w-full transition-all duration-200 ${collapsed ? 'lg:ml-16' : 'lg:ml-64'}`}>
|
||||
|
||||
@@ -94,6 +94,7 @@ const UsuariosPanel: FC = () => {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateFrontendUser(editing.id, {
|
||||
login: form.login.trim().toLowerCase(),
|
||||
name: form.name.trim(),
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
@@ -281,9 +282,14 @@ const UsuariosPanel: FC = () => {
|
||||
label="Usuario (login)"
|
||||
value={form.login}
|
||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||
disabled={!!editing}
|
||||
placeholder="ej. recepcion1"
|
||||
/>
|
||||
{editing && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
El usuario es con el que se inicia sesión (sólo minúsculas, números, punto, guion y guion bajo).
|
||||
Cambiarlo queda registrado en Auditoría.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={form.name}
|
||||
|
||||
Reference in New Issue
Block a user