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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user