feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia

Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico,
revocacion de JWT por version de sesion (028), control optimista de edicion (029),
logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion
de proyectos, accesibilidad de dialogos/controles e i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:36:02 -05:00
parent 8ed1d63505
commit e65111a7f4
112 changed files with 2285 additions and 651 deletions
+40 -9
View File
@@ -1,15 +1,18 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
// El panel desplegable se porta a document.body y se posiciona con `position: fixed` calculado
// desde el trigger (igual tecnica que Flyout) — evita que un ancestro con overflow:hidden/auto
// (Expander, DetailPopup, cualquier tarjeta con scroll propio) recorte la lista desplegada.
// options: [{ value, label }]
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
export function ComboBox({ id, options, value, onChange, placeholder = '', searchThreshold = 10 }) {
const { t } = useUi();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [pos, setPos] = useState(null); // { top, left, width, anchorTop, anchorBottom, ready }
const triggerRef = useRef(null);
const panelRef = useRef(null);
@@ -21,6 +24,7 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
anchorTop: rect.top, anchorBottom: rect.bottom, ready: false,
});
setOpen(true);
setActiveIndex(Math.max(0, options.findIndex((o) => String(o.value) === String(value))));
}
// Fase 2 (igual tecnica que Flyout): con el panel ya en el DOM (oculto) se mide su alto real y,
@@ -77,21 +81,43 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
useEffect(() => {
if (!open || !pos?.ready) return;
const target = panelRef.current?.querySelector('.combobox-search input') || panelRef.current?.querySelector('[role="option"]');
target?.focus();
}, [open, pos?.ready]);
function choose(option) { onChange(option.value); setOpen(false); setQuery(''); triggerRef.current?.focus(); }
function handleKey(event) {
if (event.key === 'Escape') { setOpen(false); triggerRef.current?.focus(); return; }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!open) { openPanel(); return; }
const next = (activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + filtered.length) % (filtered.length || 1);
setActiveIndex(next);
requestAnimationFrame(() => panelRef.current?.querySelectorAll('[role="option"]')[next]?.focus());
}
if (event.key === 'Enter' && open && filtered[activeIndex]) { event.preventDefault(); choose(filtered[activeIndex]); }
}
return (
<div className="combobox">
<button
type="button"
ref={triggerRef}
id={id}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={placeholder || selected?.label || t('common_select')}
className="input"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={() => (open ? setOpen(false) : openPanel())}
onKeyDown={handleKey}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
<ChevronDown size={14} />
</button>
{open && pos && createPortal(
<div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width: pos.width, visibility: pos.ready ? 'visible' : 'hidden' }}>
<div ref={panelRef} className="combobox-panel" role="listbox" aria-label={placeholder || t('common_select')} onKeyDown={handleKey} style={{ top: pos.top, left: pos.left, width: pos.width, visibility: pos.ready ? 'visible' : 'hidden' }}>
{options.length >= searchThreshold && (
<div className="combobox-search" style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
@@ -100,20 +126,25 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
className="input"
style={{ paddingLeft: 26 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
onChange={(e) => { setQuery(e.target.value); setActiveIndex(0); }}
placeholder={t('common_search')}
aria-label={t('common_search')}
/>
</div>
)}
{filtered.length === 0 && <div className="combobox-empty">Sin resultados</div>}
{filtered.map((o) => (
<div
{filtered.length === 0 && <div className="combobox-empty">{t('common_search_no_results')}</div>}
{filtered.map((o, i) => (
<button type="button"
key={o.value}
role="option"
aria-selected={String(o.value) === String(value)}
tabIndex={i === activeIndex ? 0 : -1}
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
onClick={() => choose(o)}
onFocus={() => setActiveIndex(i)}
>
{o.label}
</div>
</button>
))}
</div>,
document.body
+38 -12
View File
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Send, Pencil, Trash2, Save, X } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listComments, createComment, updateComment, deleteComment } from '../services/comments.js';
import { Button } from './Button.jsx';
import { useDraftForm, useUserDrafts } from '../hooks/useDraftForm.js';
const MANAGE_ROLES = ['superadmin', 'admin'];
@@ -13,15 +14,35 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
const { user: currentUser } = useAuth();
const runAction = useAction();
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
const [message, setMessage] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingText, setEditingText] = useState('');
// Borradores: comentario nuevo (por entidad) y edicion de un comentario existente.
const scope = `${entityType}-${entityId}`;
const newDraft = useDraftForm({ formId: 'comment-new', entityId: scope, initial: { message: '' }, label: t('comment_placeholder') });
const message = newDraft.values.message;
const setMessage = (v) => newDraft.setField('message', v);
const editingComment = (comments || []).find((c) => c.id === editingId);
const editDraft = useDraftForm({
formId: 'comment-edit', entityId: editingId, initial: { message: editingComment?.message || '', editVersion: editingComment?.edit_version },
label: `${t('common_edit')}: ${(editingComment?.message || '').slice(0, 40)}`, enabled: !!editingComment, onDiscard: () => setEditingId(null),
});
const editingText = editDraft.values.message;
const setEditingText = (v) => editDraft.setField('message', v);
// Al volver con un borrador de edicion de un comentario de esta entidad, se reabre la edicion.
const drafts = useUserDrafts();
useEffect(() => {
if (!comments || editingId) return;
const d = drafts.find((x) => x.formId === 'comment-edit' && comments.some((c) => String(c.id) === String(x.entityId)));
if (d) {
setEditingId(Number(d.entityId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comments]);
async function send() {
if (!message.trim()) return;
await runAction(async () => {
await createComment(entityType, entityId, message.trim());
setMessage('');
newDraft.clear();
await reload();
onChanged?.(); // refresca el contador de la pestaña "Comentarios" en el padre
});
@@ -33,21 +54,21 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
function startEdit(c) {
setEditingId(c.id);
setEditingText(c.message);
}
function cancelEdit() {
editDraft.discard();
setEditingId(null);
setEditingText('');
}
async function saveEdit(id) {
if (!editingText.trim()) return;
await runAction(async () => {
await updateComment(id, editingText.trim());
cancelEdit();
await editDraft.guardSave(() => runAction(async () => {
await updateComment(id, editingText.trim(), editDraft.values.editVersion);
editDraft.clear();
setEditingId(null);
await reload();
});
}));
}
function confirmDelete(c) {
@@ -90,7 +111,8 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
{editingId === c.id ? (
<div style={{ marginTop: 6 }}>
<input
className="input" value={editingText} autoFocus
className={`input ${editDraft.isDirty ? 'input-dirty' : ''}`} value={editingText} autoFocus
aria-label={editDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }}
/>
@@ -105,7 +127,11 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="input" value={message} placeholder={t('comment_placeholder')} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} />
<input
className={`input ${newDraft.isDirty ? 'input-dirty' : ''}`} value={message} placeholder={t('comment_placeholder')}
aria-label={newDraft.isDirty ? `${t('comment_placeholder')} (${t('draft_field_modified')})` : t('comment_placeholder')}
onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
/>
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
</div>
</div>
+6 -2
View File
@@ -1,6 +1,8 @@
import { createPortal } from 'react-dom';
import { useUi } from '../context/ui-context.jsx';
import { Button } from './Button.jsx';
import { useId } from 'react';
import { useModalFocus } from '../hooks/useModalFocus.js';
const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' };
@@ -8,6 +10,8 @@ const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', e
// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] })
export function ContentDialogHost() {
const { dialog, closeDialog, t } = useUi();
const titleId = useId();
const modalRef = useModalFocus(!!dialog);
if (!dialog) return null;
const tone = dialog.tone || 'info';
@@ -23,10 +27,10 @@ export function ContentDialogHost() {
// encima de cualquier DetailPopup abierto, sin depender del punto del arbol donde se monta.
return createPortal(
<div className="dialog-overlay">
<div className="dialog-surface">
<div className="dialog-surface" role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} ref={modalRef}>
<div className="dialog-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
<div className="dialog-body">
<div className="dialog-title">{dialog.title}</div>
<div className="dialog-title" id={titleId}>{dialog.title}</div>
<div className="dialog-message">{dialog.message}</div>
</div>
<div className="dialog-actions">
+9 -3
View File
@@ -1,5 +1,8 @@
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { useId } from 'react';
import { useModalFocus } from '../hooks/useModalFocus.js';
import { useUi } from '../context/ui-context.jsx';
// Popup grande para ver/editar el detalle de un elemento de un listado (reemplaza el patron
// "panel debajo del listado"). Reglas duras:
@@ -9,13 +12,16 @@ import { X } from 'lucide-react';
// (ej. Info/Comentarios/Documentos) nunca debe cambiar su tamaño — solo el contenido interno
// hace scroll. `height` es fijo por instancia; ajustar via prop segun cuanto contenido espera.
export function DetailPopup({ open, title, onClose, children, width = 720, height = 640 }) {
const titleId = useId();
const { t } = useUi();
const modalRef = useModalFocus(open, onClose);
if (!open) return null;
return createPortal(
<div className="detail-popup-overlay">
<div className="detail-popup" style={{ maxWidth: width, height }}>
<div className="detail-popup" style={{ maxWidth: width, height }} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} ref={modalRef}>
<div className="detail-popup-header">
<div className="detail-popup-title">{title}</div>
<button className="icon-btn" onClick={onClose}><X /></button>
<div className="detail-popup-title" id={titleId}>{title}</div>
<button className="icon-btn" aria-label={t('common_close')} onClick={onClose}><X /></button>
</div>
<div className="detail-popup-body">{children}</div>
</div>
+39
View File
@@ -0,0 +1,39 @@
import { useUi } from '../context/ui-context.jsx';
import { InfoBar } from './InfoBar.jsx';
import { Button } from './Button.jsx';
// Aviso dentro de un formulario con borrador (ver hooks/useDraftForm.js):
// - conflicto: el registro cambio en el servidor desde que se empezo a editar.
// - recuperado: se restauraron cambios sin guardar de una sesion/visita anterior.
export function DraftNotice({ draft }) {
const { t } = useUi();
if (!draft) return null;
if (draft.conflict) {
return (
<InfoBar tone="warning" dismissible={false}>
<div>{t(draft.values?.editVersion ? 'draft_conflict_reload_msg' : 'draft_conflict_msg')}</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
{draft.values?.editVersion ? (
<Button variant="danger" onClick={() => { draft.discard(); window.location.reload(); }}>{t('draft_conflict_reload')}</Button>
) : (
<>
<Button variant="standard" onClick={draft.keepMine}>{t('draft_keep_mine')}</Button>
<Button variant="danger" onClick={draft.discard}>{t('draft_use_server')}</Button>
</>
)}
</div>
</InfoBar>
);
}
if (draft.restored && draft.isDirty) {
return (
<InfoBar tone="info" dismissible={false}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ flex: 1 }}>{t('draft_restored_msg')}</span>
<Button variant="standard" onClick={draft.discard}>{t('draft_discard')}</Button>
</div>
</InfoBar>
);
}
return null;
}
+23 -5
View File
@@ -1,18 +1,36 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { useUserDrafts } from '../hooks/useDraftForm.js';
import { getActiveEditors } from '../utils/drafts.js';
import { DraftMark, useLeaveConfirm } from './NavigationGuard.jsx';
// draftForms: prefijos de formId de los formularios que viven dentro de esta seccion. Con eso la
// seccion se marca si tiene borradores, se abre sola al volver, y al plegarla con cambios en
// edicion pide confirmacion (plegar desmonta el contenido).
export function Expander({ icon: Icon, title, description, defaultOpen = false, draftForms, children }) {
const drafts = useUserDrafts();
const matches = (formId) => (draftForms || []).some((p) => formId.startsWith(p));
const hasDraft = drafts.some((d) => matches(d.formId));
const [open, setOpen] = useState(defaultOpen || hasDraft);
const confirmLeave = useLeaveConfirm();
async function toggle() {
if (!open) { setOpen(true); return; }
const editors = getActiveEditors().filter((e) => (draftForms || []).some((p) => e.key.includes(`:${p}`)));
if (editors.length && (await confirmLeave(editors)) === 'stay') return;
setOpen(false);
}
export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`expander ${open ? 'open' : ''}`}>
<div className="expander-header" onClick={() => setOpen((v) => !v)}>
<button type="button" className="expander-header" aria-expanded={open} onClick={toggle}>
{Icon && <Icon size={18} />}
<div>
<div className="expander-title">{title}</div>
<div className="expander-title">{title}{hasDraft && <DraftMark />}</div>
{description && <div className="expander-desc">{description}</div>}
</div>
<ChevronRight className="chevron" size={16} />
</div>
</button>
{open && <div className="expander-body">{children}</div>}
</div>
);
+18 -4
View File
@@ -1,8 +1,22 @@
export function Field({ label, children, hint }) {
import { useUi } from '../context/ui-context.jsx';
import { cloneElement, isValidElement, useId } from 'react';
import { ComboBox } from './ComboBox.jsx';
// dirty: el valor difiere de lo guardado (borrador pendiente). Se marca en rojo y con texto para
// lectores de pantalla, no solo con color.
export function Field({ label, children, hint, dirty }) {
const { t } = useUi();
const controlId = useId();
const control = isValidElement(children) && (['input', 'textarea', 'select'].includes(children.type) || children.type === ComboBox);
return (
<div className="field">
{label && <label className="field-label">{label}</label>}
{children}
<div className={`field ${dirty ? 'field-dirty' : ''}`}>
{label && (
control ? <label className="field-label" htmlFor={controlId}>
{label}
{dirty && <span className="field-dirty-tag"> · {t('draft_field_modified')}</span>}
</label> : <div className="field-label">{label}{dirty && <span className="field-dirty-tag"> · {t('draft_field_modified')}</span>}</div>
)}
{control ? cloneElement(children, { id: children.props.id || controlId }) : children}
{hint && <span className="text-muted" style={{ fontSize: '.72rem' }}>{hint}</span>}
</div>
);
+5 -3
View File
@@ -45,8 +45,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
<tr>
{dragEnabled && <th style={{ width: 28 }} />}
{columns.map((col) => (
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
<th key={col.key} aria-sort={col.sortable !== false && sort.key === col.key ? (sort.dir === 1 ? 'ascending' : 'descending') : undefined}>
{col.sortable === false ? col.label : <button type="button" className="table-sort-button" onClick={() => toggleSort(col.key)}>{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}</button>}
</th>
))}
{rowActions && <th style={{ width: 40 }} />}
@@ -57,6 +57,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
<tr
key={getRowKey(row)}
onClick={() => onRowClick?.(row)}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }}
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
{...(dragEnabled ? getRowProps(row) : {})}
>
@@ -71,7 +73,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
{rowActions && (
<td onClick={(e) => e.stopPropagation()}>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical /></button>
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
)}>
{rowActions(row).map((action, i) => (
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
@@ -0,0 +1,94 @@
import { useCallback, useEffect } from 'react';
import { useBlocker } from 'react-router-dom';
import { useUi } from '../context/ui-context.jsx';
import { getActiveEditors, isGuardBypassed } from '../utils/drafts.js';
const urlChanged = (a, b) => a.pathname !== b.pathname || a.search !== b.search;
// Aviso de tres opciones cuando se sale de una seccion con cambios sin guardar. Devuelve
// 'stay' | 'keep' | 'discard'. Reutilizable para navegacion (menu/pestañas) y para cerrar popups.
export function useLeaveConfirm() {
const { t, openDialog } = useUi();
return useCallback((editors) => new Promise((resolve) => {
const names = editors.map((e) => e.label).filter(Boolean);
openDialog({
tone: 'warning',
title: t('draft_leave_title'),
message: (
<div>
<div>{t('draft_leave_msg')}</div>
{names.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{names.map((n) => <li key={n}>{n}</li>)}
</ul>
)}
<div className="text-muted" style={{ marginTop: 8, fontSize: '.78rem' }}>{t('draft_leave_hint')}</div>
</div>
),
actions: [
{ label: t('draft_discard'), variant: 'danger', onClick: () => { editors.forEach((e) => e.discard?.()); resolve('discard'); } },
{ label: t('draft_keep_pending'), variant: 'standard', onClick: () => resolve('keep') },
{ label: t('draft_keep_editing'), variant: 'accent', onClick: () => resolve('stay') },
],
});
}), [openDialog, t]);
}
// Cierre de un popup/editor con cambios: mismo aviso de tres opciones que al cambiar de menu.
// Uso: const guardedClose = useGuardedClose(); ... onClose={() => guardedClose(draft, () => setOpen(false))}
export function useGuardedClose() {
const confirmLeave = useLeaveConfirm();
return useCallback(async (draft, close) => {
if (!draft?.isDirty) { draft?.discard?.(); close(); return; }
const choice = await confirmLeave([{ label: draft.label, discard: draft.discard }]);
if (choice !== 'stay') close();
}, [confirmLeave]);
}
// Montado una vez dentro del area autenticada (AppShell). Intercepta cambios de ruta o de
// parametros (?tab=, ?activity=, menu lateral, atras/adelante) mientras haya un formulario con
// cambios, y avisa con la advertencia nativa del navegador ante F5 o cierre de la pestaña.
export function NavigationGuard() {
const confirmLeave = useLeaveConfirm();
const blocker = useBlocker(({ currentLocation, nextLocation }) => (
!isGuardBypassed() && urlChanged(currentLocation, nextLocation) && getActiveEditors().length > 0
));
useEffect(() => {
if (blocker.state !== 'blocked') return;
let active = true;
confirmLeave(getActiveEditors()).then((choice) => {
if (!active) return;
if (choice === 'stay') blocker.reset();
else blocker.proceed();
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocker.state]);
useEffect(() => {
function onBeforeUnload(e) {
if (isGuardBypassed() || getActiveEditors().length === 0) return;
// Los cambios ya quedan como borrador; la advertencia nativa evita una salida por error.
e.preventDefault();
e.returnValue = '';
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, []);
return null;
}
// Marca accesible de "cambios sin guardar" para pestañas, menus, tarjetas y titulos: punto rojo
// + texto para lectores de pantalla (no depende solo del color).
export function DraftMark({ title }) {
const { t } = useUi();
const text = title || t('draft_pending_mark');
return (
<span className="draft-mark" title={text}>
<span aria-hidden="true" className="draft-dot" />
<span className="sr-only">{text}</span>
</span>
);
}
@@ -1,4 +1,6 @@
import { NavLink } from 'react-router-dom';
import { useUserDrafts } from '../hooks/useDraftForm.js';
import { DraftMark } from './NavigationGuard.jsx';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useUi } from '../context/ui-context.jsx';
@@ -34,6 +36,10 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices));
const brandName = branding.name || t('appName');
// Menus con borradores pendientes (el borrador guarda la ruta donde se estaba editando).
const drafts = useUserDrafts();
const hasDraftUnder = (to) => drafts.some((d) => typeof d.path === 'string'
&& (to === '/' ? d.path === '/' || d.path.startsWith('/?') : d.path === to || d.path.startsWith(`${to}/`) || d.path.startsWith(`${to}?`)));
return (
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
{branding.logoUrl && (
@@ -50,6 +56,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
<NavLink key={item.to} to={item.to} end={item.end} onClick={onNavigate} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<item.icon />
{!compact && <span>{t(item.labelKey)}</span>}
{hasDraftUnder(item.to) && <DraftMark />}
</NavLink>
))}
</div>
+38 -3
View File
@@ -1,12 +1,47 @@
import { DraftMark } from './NavigationGuard.jsx';
import { useEffect, useId, useRef } from 'react';
export function Tabs({ tabs, active, onChange }) {
const id = useId();
const listRef = useRef(null);
const tabRefs = useRef({});
useEffect(() => {
const panel = listRef.current?.nextElementSibling;
if (!panel) return;
panel.id = `${id}-panel`;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', `${id}-${active}`);
}, [active, id]);
function onKeyDown(event, index) {
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : delta ? (index + delta + tabs.length) % tabs.length : null;
if (next === null) return;
event.preventDefault();
onChange(tabs[next].key);
tabRefs.current[tabs[next].key]?.focus();
}
return (
<div className="tabs">
{tabs.map((tab) => (
<button key={tab.key} className={`tab-item ${active === tab.key ? 'active' : ''}`} onClick={() => onChange(tab.key)}>
<div className="tabs" role="tablist" ref={listRef}>
{tabs.map((tab, index) => (
<button
key={tab.key}
type="button"
id={`${id}-${tab.key}`}
ref={(node) => { tabRefs.current[tab.key] = node; }}
role="tab"
aria-selected={active === tab.key}
aria-controls={`${id}-panel`}
tabIndex={active === tab.key ? 0 : -1}
onKeyDown={(event) => onKeyDown(event, index)}
className={`tab-item ${active === tab.key ? 'active' : ''} ${tab.dirty ? 'tab-dirty' : ''}`}
onClick={() => onChange(tab.key)}
>
{tab.label}
{/* count=0 SI se muestra (badge "0") -- solo se omite si la pestaña ni siquiera manda
la prop (undefined), para pestañas que no tienen numero que mostrar (Info/Gantt). */}
{tab.count !== undefined && <span className="tab-badge">{tab.count}</span>}
{/* Pestaña con cambios sin guardar (borrador pendiente). */}
{tab.dirty && <DraftMark />}
</button>
))}
</div>