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:
2026-09-18 03:23:44 +00:00
parent c38783eb31
commit cebf8d663d
13 changed files with 354 additions and 47 deletions

View File

@@ -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 */}

View File

@@ -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ú">

View File

@@ -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'}`}>

View File

@@ -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}

View File

@@ -15,7 +15,8 @@ const ROLE_RANK: Record<FrontendRole, number> = {
psicologa: 50,
farmacia: 50,
gerencia: 50,
marketing: 10,
// marketing pasó a 50: puede agendar citas y escribir en el WACRM
marketing: 50,
lectura: 10,
};
@@ -24,7 +25,7 @@ const ROLE_RANK: Record<FrontendRole, number> = {
// /configuracion (solo admin del sistema), /mis-ventas, /cumpleanos y /wacrm/*.
const MENU_ROLES: Record<string, FrontendRole[]> = {
'/' : ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'],
'/agenda': ['admin', 'recepcion', 'medico', 'gerencia'],
'/agenda': ['admin', 'recepcion', 'medico', 'gerencia', 'cosmetologia', 'marketing'],
'/mis-ventas': ['medico'],
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/pos': ['admin', 'recepcion', 'gerencia'],
@@ -47,10 +48,13 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin'],
'/cumpleanos': ['admin', 'recepcion'],
'/wacrm/messages': ['admin', 'recepcion'],
'/wacrm/leads': ['admin', 'recepcion'],
'/configuracion': ['admin', 'gerencia'],
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
// El chat se lista explícitamente para dejar fuera a marketing (antes, al no
// estar listado, lo veía cualquier rol).
'/chat': ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'lectura'],
};
interface AuthContextValue {

View File

@@ -105,9 +105,14 @@ const Agenda: FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { openPatient } = usePatientDrawer();
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor)
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
// restringida del médico), pero arranca en su propia columna si tiene
// empleado vinculado.
const isMedico = user?.role === 'medico';
const isCosmetologa = user?.role === 'cosmetologia';
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null;
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -120,6 +125,7 @@ const Agenda: FC = () => {
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState(() => {
if (medicoEmployeeId) return String(medicoEmployeeId);
if (propiaColumnaId) return String(propiaColumnaId);
const saved = readAgendaPrefs().doctorFilter;
return typeof saved === 'string' ? saved : '';
});

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
import Layout from '../components/Layout';
@@ -11,6 +11,7 @@ import AuditoriaPanel from '../components/AuditoriaPanel';
import ImpactoModal from '../components/ImpactoModal';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor } from '../services/odoo';
import { useAuth } from '../lib/auth';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
@@ -45,8 +46,17 @@ const defaultSettings: ClinicSettings = {
const STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const { user } = useAuth();
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y
// comisiones) queda para administración del sistema.
const esAdmin = user?.role === 'admin';
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles')),
[esAdmin],
);
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'clinica';
const tabPedida = searchParams.get('tab') || 'clinica';
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
const setTab = (t: string) => setSearchParams({ tab: t });
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
const sub = searchParams.get('sub');
@@ -85,6 +95,7 @@ const Configuracion: FC = () => {
const [tcSaving, setTcSaving] = useState(false);
useEffect(() => {
if (!esAdmin) return;
odooApi.getExchangeRate()
.then((res) => {
if (res.current) {
@@ -123,6 +134,7 @@ const Configuracion: FC = () => {
const [impactoOpen, setImpactoOpen] = useState(false);
useEffect(() => {
if (!esAdmin) return;
odooApi.getCommissionDefault()
.then((res) => setCommissionDefault(String(res.commission_pct ?? 0)))
.catch(() => {/* odoo no disponible, ignorar */});
@@ -184,7 +196,7 @@ const Configuracion: FC = () => {
{/* Tabs de secciones */}
<div className="flex flex-wrap gap-1.5 mb-6">
{TABS.map((t) => (
{tabsVisibles.map((t) => (
<button
key={t.key}
type="button"

View File

@@ -133,6 +133,7 @@ const Pacientes: FC = () => {
const [entryDetail, setEntryDetail] = useState<
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
>(null);
const [exporting, setExporting] = useState(false);
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
const [onlyVip, setOnlyVip] = useState(false);
const [onlyRecent, setOnlyRecent] = useState(false);
@@ -415,13 +416,30 @@ const Pacientes: FC = () => {
].sort((a, b) => b.date.localeCompare(a.date))
: [];
// Exportación COMPLETA: pide al servidor todos los pacientes del filtro
// (no sólo la página visible). Hoy son ~7.2k filas.
const exportPatients = async () => {
try {
setExporting(true);
const res = await odooApi.exportPatients({
search: search || undefined,
incomplete: onlyIncomplete ? 1 : undefined,
vip: onlyVip ? 1 : undefined,
recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined),
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
});
if (res.status !== 'success') {
toast.error('Error al exportar');
return;
}
const todos = res.patients;
await exportToExcel({
filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Pacientes',
title: 'Pacientes SKEEN',
subtitle: `${patients.length} pacientes de la vista actual`,
subtitle: res.truncado
? `${res.total} de ${res.total_disponible} pacientes (límite ${res.limite})`
: `${res.total} pacientes (exportación completa)`,
columns: [
{ header: 'Expediente', key: 'patient_id' },
{ header: 'Nombre', key: 'name' },
@@ -436,24 +454,26 @@ const Pacientes: FC = () => {
{ header: 'Fuente', key: 'source' },
{ header: 'VIP', key: 'vip' },
],
rows: patients.map((p) => ({
rows: todos.map((p) => ({
patient_id: p.patient_id, name: p.name, phone: p.phone, email: p.email,
birth_date: p.birth_date, gender: p.gender || '', last_visit: p.last_visit,
total_visits: p.total_visits, wallet_points: p.wallet_points,
total_spent: p.total_spent, source: p.source, vip: p.is_vip ? 'Sí' : 'No',
})),
});
toast.success(`Excel descargado (${patients.length} pacientes de la vista actual)`);
toast.success(`Excel descargado (${res.total} pacientes)`);
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Layout title="Pacientes" subtitle="Directorio de pacientes">
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
<Button variant="outline" onClick={exportPatients} disabled={patients.length === 0}>
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>

View File

@@ -1120,7 +1120,7 @@ export const odooApi = {
return data;
},
async updateFrontendUser(id: number, payload: Partial<{ name: string; role: FrontendRole; active: boolean; password: string; must_change_password: boolean; allowed_menus: string[] | null }>): Promise<{ status: string; user: FrontendUser; message?: string }> {
async updateFrontendUser(id: number, payload: { name?: string; login?: string; role?: FrontendRole; active?: boolean; password?: string; must_change_password?: boolean; allowed_menus?: string[] | null }): Promise<{ status: string; user: FrontendUser; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
return data;
},
@@ -1335,6 +1335,12 @@ export const odooApi = {
return data;
},
/** Exportación completa: TODOS los pacientes del filtro, sin paginar. */
async exportPatients(params?: { search?: string; incomplete?: number; vip?: number; recent?: number; doctor_id?: number }): Promise<{ status: string; patients: Patient[]; total: number; total_disponible: number; truncado: boolean; limite: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/export`, { params: params || {} });
return data;
},
async getPatient(id: number): Promise<{ status: string; patient: Patient }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}`);
return data;