Oleada B: fusion Expedientes en Pacientes (+chip sin visita 6m), sidebar colapsable SKEEN, edicion completa de citas, autocompletado de paciente, bloqueos excluidos de slots

This commit is contained in:
2026-09-11 10:38:08 +00:00
parent 0b45abc197
commit 4f49188deb
9 changed files with 295 additions and 65 deletions

View File

@@ -26,13 +26,13 @@ import {
ClipboardPlus,
Banknote,
Percent,
FolderClock,
Star,
Pencil,
ChevronUp,
ChevronDown,
MessageCircle,
CircleDollarSign,
PanelsTopLeft,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -66,7 +66,6 @@ const menuGroups = [
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
@@ -114,6 +113,19 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const { user, canSee, hasRole, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
// medianas (menores a 1440px) el estado inicial es colapsado.
const [collapsed, setCollapsed] = useState<boolean>(() => {
const saved = localStorage.getItem('skeen_sidebar_collapsed');
if (saved !== null) return saved === '1';
return window.innerWidth < 1440;
});
const toggleCollapsed = () => {
setCollapsed((v) => {
localStorage.setItem('skeen_sidebar_collapsed', v ? '0' : '1');
return !v;
});
};
const visibleGroups = useMemo(
() =>
@@ -215,14 +227,51 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
const SidebarContent = () => (
const SidebarContent = ({ collapsed = false, showToggle = true }: { collapsed?: boolean; showToggle?: boolean }) => (
<>
<div className="h-20 flex items-center px-5 lg:px-6 border-b border-white/10 shrink-0">
<img src={LOGO_SKEEN} alt="SKEEN" className="h-7 w-auto object-contain" />
<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'} />
{!collapsed && showToggle && (
<button
type="button"
onClick={toggleCollapsed}
title="Colapsar menú"
aria-label="Colapsar menú"
className="ml-auto p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors"
>
<PanelsTopLeft size={16} />
</button>
)}
</div>
<nav className="flex-1 overflow-y-auto py-5 px-3">
{quickItems.length > 0 && quick.mode === 'menu' && (
{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" />
<ul className="space-y-1 flex flex-col items-center">
{quickItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
title={item.label}
aria-label={item.label}
className={`flex items-center justify-center w-10 h-10 rounded-xl transition-all ${
active ? 'bg-theme-accent text-theme-inverse' : 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
}`}
>
<Icon size={18} />
</Link>
</li>
);
})}
</ul>
</div>
)}
{quickItems.length > 0 && quick.mode === 'menu' && !collapsed && (
<div className="mb-6">
<div className="flex items-center justify-between px-3 mb-2">
<p className="text-[10px] uppercase tracking-wider text-white/40 font-heading">Accesos rápidos</p>
@@ -280,42 +329,54 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
)}
{visibleGroups.map((group) => (
<div key={group.label} className="mb-6">
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
{collapsed ? (
<div className="mx-3 mb-2 border-t border-white/10" title={group.label} />
) : (
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
)}
<ul className="space-y-1">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
const pinned = quick.isPinned(item.href);
return (
<li key={item.label} className="group flex items-center gap-1">
<li key={item.label} className={`group flex items-center ${collapsed ? 'justify-center' : 'gap-1'}`}>
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex-1 flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
title={item.label}
aria-label={item.label}
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'
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
}`}
>
<Icon size={18} />
<span>{item.label}</span>
{!collapsed && <span>{item.label}</span>}
{item.href === '/chat' && chatUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
collapsed ? (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-brand-coral" title={`${chatUnread} sin leer`} />
) : (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)
)}
{active && <ChevronRight size={14} className={item.href === '/chat' && chatUnread > 0 ? '' : 'ml-auto'} />}
{active && !collapsed && <ChevronRight size={14} className={item.href === '/chat' && chatUnread > 0 ? '' : 'ml-auto'} />}
</Link>
<button
type="button"
onClick={() => quick.toggle(item.href)}
className={`shrink-0 transition-all ${
pinned ? 'text-amber-400' : 'text-white/30 opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
>
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
</button>
{!collapsed && (
<button
type="button"
onClick={() => quick.toggle(item.href)}
className={`shrink-0 transition-all ${
pinned ? 'text-amber-400' : 'text-white/30 opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
>
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
</button>
)}
</li>
);
})}
@@ -324,15 +385,17 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
))}
</nav>
<div className="p-4 border-t border-white/10 shrink-0">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-theme-accent/20 flex items-center justify-center text-sm font-bold text-theme-accent">
<div className={`border-t border-white/10 shrink-0 ${collapsed ? 'p-2 flex flex-col items-center gap-2' : 'p-4'}`}>
<div className={`flex items-center ${collapsed ? 'flex-col gap-2' : 'space-x-3'}`}>
<div className="w-10 h-10 rounded-full bg-theme-accent/20 flex items-center justify-center text-sm font-bold text-theme-accent" title={user?.name || 'Usuario'}>
{initials || 'US'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
<p className="text-xs text-white/50 truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
</div>
{!collapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
<p className="text-xs text-white/50 truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
</div>
)}
<button
onClick={handleLogout}
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
@@ -342,14 +405,28 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<LogOut size={18} />
</button>
</div>
{collapsed && (
<button
type="button"
onClick={toggleCollapsed}
title="Expandir menú"
aria-label="Expandir menú"
className="p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors"
>
<PanelsTopLeft size={16} />
</button>
)}
</div>
</>
);
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
<aside className="hidden lg:flex flex-col w-64 fixed h-full z-20" style={{ backgroundColor: 'var(--sidebar-bg)' }}>
<SidebarContent />
<aside
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} />
</aside>
{sidebarOpen && (
@@ -371,7 +448,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<SidebarContent />
</aside>
<div className="flex-1 lg:ml-64 min-h-screen flex flex-col w-full">
<div className={`flex-1 min-h-screen flex flex-col w-full transition-all duration-200 ${collapsed ? 'lg:ml-16' : 'lg:ml-64'}`}>
<header className="backdrop-blur border-b sticky top-0 z-10" style={{ backgroundColor: 'var(--header-bg)', borderColor: 'var(--border)' }}>
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">