/* global React */ // Sidebar + Topbar + CommandPalette const { useState, useEffect, useRef, useMemo } = React; function Sidebar({ modules, activeId, onNavigate, collapsed, onToggle }) { const I = window.Icons; return ( ); } function Topbar({ user, roleId, activeModule, onNavigate, onOpenDetalle, onLogout, onUpdateProfile }) { const I = window.Icons; const [paletteOpen, setPaletteOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false); const inputRef = useRef(null); // Global ⌘K / Ctrl+K useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); setPaletteOpen(o => !o); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); const openPalette = () => setPaletteOpen(true); return ( <> Compromisos Internacionales {activeModule} Buscar por ID interno, recomendación, institución… setMenuOpen(o => !o)} aria-haspopup="true" aria-expanded={menuOpen} > {user.name} {user.role} {user.initials} {menuOpen && ( <> setMenuOpen(false)} /> {user.initials} {user.name} {user.role} {user.email} {user.org} { setMenuOpen(false); setProfileOpen(true); }}> Editar perfil { setMenuOpen(false); onLogout && onLogout(); }}> Cerrar sesión > )} setPaletteOpen(false)} onNavigate={(id) => { setPaletteOpen(false); onNavigate && onNavigate(id); }} onOpenDetalle={(id) => { setPaletteOpen(false); onOpenDetalle && onOpenDetalle(id); }} /> {profileOpen && ( setProfileOpen(false)} onSave={(data) => { onUpdateProfile && onUpdateProfile(roleId, data); setProfileOpen(false); }} /> )} > ); } // ---------------- Modal: editar perfil ---------------- function ProfileModal({ user, onClose, onSave }) { const I = window.Icons; const [name, setName] = useState(user.name || ''); const [email, setEmail] = useState(user.email || ''); const [cargo, setCargo] = useState(user.cargo || user.role || ''); const [org, setOrg] = useState(user.org || ''); const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.trim()); const canSave = name.trim().length >= 2 && emailOk; return ( e.stopPropagation()}> Editar perfil Nombre completo setName(e.target.value)} placeholder="Nombre y apellido" /> Correo institucional setEmail(e.target.value)} placeholder="nombre@institucion.gov.py" /> Cargo o función setCargo(e.target.value)} placeholder="Ej. Coordinador de seguimiento" /> Organización / área setOrg(e.target.value)} /> Cancelar onSave({ name: name.trim(), email: email.trim(), cargo: cargo.trim(), org: org.trim() })} > Guardar cambios ); } // ============================================================ // CommandPalette — ⌘K search across recomendaciones + módulos // ============================================================ function CommandPalette({ open, onClose, onNavigate, onOpenDetalle }) { const I = window.Icons; const [q, setQ] = useState(''); const [sel, setSel] = useState(0); const inputRef = useRef(null); useEffect(() => { if (open) { setQ(''); setSel(0); setTimeout(() => inputRef.current && inputRef.current.focus(), 30); } }, [open]); // Build results const { groups, flat } = useMemo(() => { const compromisos = window.COMPROMISOS || []; const instituciones = window.INSTITUCIONES_CATALOG || []; const norm = (s) => (s || '').toString().toLowerCase(); const query = norm(q.trim()); const NAV_ITEMS = [ { id: 'tablero', label: 'Tablero de Control', icon: 'LayoutDashboard', kind: 'nav' }, { id: 'compromisos', label: 'Compromisos', icon: 'ListChecks', kind: 'nav' }, { id: 'mis_compromisos', label: 'Mis Compromisos', icon: 'ListChecks', kind: 'nav' }, { id: 'alertas', label: 'Alertas y Plazos', icon: 'Bell', kind: 'nav' }, { id: 'mensajeria', label: 'Mensajería', icon: 'MessageSquare', kind: 'nav' }, { id: 'reportes', label: 'Reportes', icon: 'FileBarChart', kind: 'nav' }, { id: 'instituciones', label: 'Instituciones', icon: 'Building2', kind: 'nav' }, { id: 'usuarios', label: 'Usuarios', icon: 'Users', kind: 'nav' }, { id: 'portal', label: 'Portal Público', icon: 'Globe', kind: 'nav' }, ]; const ACTIONS = [ { id: 'nueva_recomendacion', label: 'Registrar nueva recomendación', icon: 'PlusCircle', kind: 'action' }, { id: 'cargar_avance', label: 'Cargar avance de compromiso', icon: 'Upload', kind: 'action' }, ]; const matchesNav = NAV_ITEMS.filter(n => !query || norm(n.label).includes(query)); const matchesAct = ACTIONS.filter(n => !query || norm(n.label).includes(query)); const matchesRec = compromisos.filter(c => { if (!query) return true; return norm(c.id).includes(query) || norm(c.text).includes(query) || norm(c.inst).includes(query) || norm(c.mecanismo).includes(query); }).slice(0, 8); const matchesInst = instituciones.filter(inst => { if (!query) return false; return norm(inst.nombre).includes(query) || norm(inst.sigla).includes(query); }).slice(0, 5); const groups = []; if (matchesRec.length) groups.push({ label: 'Recomendaciones', items: matchesRec.map(c => ({ kind: 'rec', id: c.id, primary: c.id, secondary: c.text, meta: `${c.mecanismo} · ${c.inst}`, icon: 'FileText' })) }); if (matchesInst.length) groups.push({ label: 'Instituciones', items: matchesInst.map(i => ({ kind: 'inst', id: i.id, primary: i.sigla, secondary: i.nombre, meta: i.tipo, icon: 'Building2' })) }); if (matchesNav.length) groups.push({ label: 'Ir a', items: matchesNav.map(n => ({ kind: 'nav', id: n.id, primary: n.label, icon: n.icon })) }); if (matchesAct.length) groups.push({ label: 'Acciones rápidas', items: matchesAct.map(n => ({ kind: 'action', id: n.id, primary: n.label, icon: n.icon })) }); const flat = groups.flatMap(g => g.items); return { groups, flat }; }, [q]); useEffect(() => { setSel(0); }, [q]); const runItem = (it) => { if (!it) return; if (it.kind === 'rec') onOpenDetalle(it.id); else if (it.kind === 'inst') onNavigate('instituciones'); else if (it.kind === 'nav') onNavigate(it.id); else if (it.kind === 'action') onNavigate(it.id); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(flat.length - 1, s + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); } else if (e.key === 'Enter') { e.preventDefault(); runItem(flat[sel]); } }; if (!open) return null; let idx = -1; return ( e.stopPropagation()}> setQ(e.target.value)} onKeyDown={onKey} /> ESC {groups.length === 0 && ( Sin resultados para “{q}” )} {groups.map(group => ( {group.label} {group.items.map(it => { idx += 1; const active = idx === sel; const localIdx = idx; return ( setSel(localIdx)} onClick={() => runItem(it)} > {React.createElement(I[it.icon] || I.FileText, { size: 15 })} {it.primary} {it.secondary && {it.secondary}} {it.meta && {it.meta}} ); })} ))} ↑↓ navegar ↵ abrir esc cerrar CGR · Búsqueda global ); } // ---------------- Multi-select reutilizable (filtros con checkboxes) ---------------- function MultiSelect({ options, selected, onChange, placeholder, allLabel }) { const I = window.Icons; const [open, setOpen] = useState(false); const [pos, setPos] = useState(null); const btnRef = useRef(null); const norm = (options || []).map(o => (typeof o === 'string' ? { value: o, label: o } : o)); const sel = selected || []; const toggle = (v) => onChange(sel.indexOf(v) !== -1 ? sel.filter(x => x !== v) : sel.concat([v])); let label = allLabel || placeholder || 'Todas'; if (sel.length === 1) { const f = norm.find(o => o.value === sel[0]); label = f ? f.label : sel[0]; } else if (sel.length > 1) label = sel.length + ' seleccionadas'; // Posición fija calculada desde el botón → el panel no se recorta dentro del panel de filtros. const openMenu = () => { const el = btnRef.current; if (el) { const r = el.getBoundingClientRect(); const w = Math.max(r.width, 240); const left = Math.min(r.left, window.innerWidth - w - 12); setPos({ left: Math.max(8, left), top: r.bottom + 4, width: w, maxH: Math.max(160, window.innerHeight - r.bottom - 20) }); } setOpen(true); }; return ( (open ? setOpen(false) : openMenu())} aria-haspopup="true" aria-expanded={open}> {label} {open && pos && ReactDOM.createPortal( <> setOpen(false)} /> {sel.length > 0 && onChange([])}>Limpiar selección ({sel.length})} {norm.map(o => ( toggle(o.value)} /> {o.label} ))} {norm.length === 0 && Sin opciones} >, document.body )} ); } Object.assign(window, { Sidebar, Topbar, CommandPalette, MultiSelect });