fix(ui): reemplaza panel-debajo-del-listado por DetailPopup de tamaño fijo

El detalle de un item de listado (actividad, departamento, usuario, proyecto nuevo,
catalogo) ya no se apila debajo ni al costado del listado: ahora abre en un popup que
cubre la ventana, deja el listado inactivo detras, nunca se cierra con clic afuera (solo
con el boton Cerrar o una accion explicita del formulario) y mantiene un alto FIJO por
instancia para que cambiar de pestaña interna (Info/Subtareas/Dependencias/Comentarios/
Documentos) no reajuste su tamaño.

De paso corrige un bug real de stacking: ContentDialog no usaba portal a document.body,
así que un dialogo de confirmacion abierto desde dentro de un DetailPopup quedaba oculto
detras de este. Ahora ambos son portales con una jerarquia de z-index explicita
(Flyout 100 < DetailPopup 200 < ContentDialog 250 < Toast 300).

En el detalle de actividad, Subtareas y Dependencias pasan de estar apiladas dentro de la
pestaña Info a ser cada una su propia pestaña.

Regla documentada en project-md-templates/UI-UX-WINDOWS.md (componente DetailPopup) para
que aplique a futuros proyectos de escritorio Windows del grupo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-03 11:21:07 -05:00
parent 1d22adc42e
commit 1e6e7fecdd
10 changed files with 224 additions and 142 deletions
+49 -10
View File
@@ -118,10 +118,41 @@ openDialog({
**selección múltiple** (checkbox), fila con **hover** y **estado seleccionado** (`accent/.14`), y
**clic derecho** que abre el menú contextual de esa fila (ver §6). Alternativa por fila sin mouse
derecho: botón `⋯` que abre el mismo menú.
- **Master-Detail (Split View)** — patrón **nativo de Windows** (Correo, Configuración, Explorador):
lista angosta a la izquierda + panel de detalle a la derecha que se actualiza **en el sitio** al
seleccionar un ítem, sin modal. Preferir sobre "detalle en modal" cuando la pantalla es
principalmente de exploración/lectura de una lista larga.
- **DetailPopup** — patrón **por defecto** para ver/crear/editar el detalle de un ítem de un listado
de gestión (proyectos, actividades, usuarios, catálogos, etc.). Overlay que cubre la ventana y deja
**inactivo el listado detrás** (dim + sin interacción) mientras se usa el popup — a diferencia de un
panel lateral o de un panel apilado debajo del listado, el detalle NUNCA reduce ni empuja el
listado, y siempre aparece completo y en el mismo lugar sin importar el largo de la lista ni la
fila elegida. Reglas duras:
- **Nunca se cierra con clic afuera** (a diferencia de `Flyout`) — solo con el botón "Cerrar" de su
cabecera o una acción explícita del formulario (`Guardar`/`Cancelar`).
- **Alto FIJO por instancia**, no se ajusta al contenido: si el popup tiene pestañas o secciones
internas (p. ej. Info/Comentarios/Documentos), cambiar entre ellas **nunca** cambia el tamaño del
popup — solo el contenido interno hace scroll. El ancho/alto se elige según cuánto contenido
espera esa pantalla (un formulario simple de 2 campos no necesita el mismo alto que un detalle
con subtareas y dependencias), pero una vez abierto queda fijo.
- Header propio: título a la izquierda, botón "Cerrar" (ícono `X`) a la derecha.
- Contrato (React; adaptar al framework del proyecto):
`<DetailPopup open title onClose width height>{contenido}</DetailPopup>` — portal a
`document.body` (igual que `Flyout`/`ContentDialog`), **nunca** anidado dentro del árbol de la
página, para no quedar atrapado en el stacking context de un ancestro y terminar tapado por otro
overlay (bug real: sin portal, un `ContentDialog` abierto DESDE un `DetailPopup` quedaba oculto
detrás de este). Jerarquía de `z-index` entre overlays: `Flyout` (100) < `DetailPopup` (200) <
`ContentDialog` (250) < `Toast` (300) — así una confirmación abierta desde dentro de un
`DetailPopup` siempre queda por encima.
- Secciones internas relacionadas con el ítem (ej. una actividad con Info + Subtareas +
Dependencias + Comentarios + Documentos) van cada una en **su propia pestaña** dentro del popup,
nunca todas apiladas dentro de la pestaña "Info" — mismo criterio que pestañas reales a nivel de
pantalla completa (ver identidad web `UI-UX-LINKEDCORP.md`), aplicado ahora dentro del popup.
- **Master-Detail (Split View)** — patrón **nativo de Windows** (Correo, Explorador): lista angosta a
la izquierda + panel de detalle a la derecha que se actualiza **en el sitio** al seleccionar un
ítem, sin modal. Uso **restringido**: solo para pantallas cuya función principal es explorar/leer
una lista larga como actividad central de esa vista (no para listados de gestión con acciones
crear/editar — ahí va `DetailPopup`). Evitar cualquier variante que ubique el detalle "a un costado"
o "debajo" de un listado de gestión (panel lateral fijo, panel acoplado al borde, panel apilado
bajo la tabla): en la práctica el detalle se siente secundario y el listado pierde protagonismo o
queda "lejos" del detalle cuando la lista crece. Ante la duda entre Master-Detail y `DetailPopup`
para un listado de gestión, usar `DetailPopup`.
- **Menú contextual (clic derecho)** — el patrón de interacción **más característico de Windows**:
ítems con ícono + label + acelerador de teclado a la derecha (`Ctrl+E`, `Del`…), separadores,
submenú con flecha `▸` que se expande al pasar el mouse, ítem destructivo en `danger`. Debe estar
@@ -226,11 +257,14 @@ Alt+Tab); si es una decisión puntual ligada al contexto actual, es un `ContentD
1. **CommandBar** (`Nuevo`, `Actualizar`, acciones contextuales + overflow `···`).
2. **Elegir el patrón de datos** según el caso:
- **Listado + `DetailPopup`** (default para listados de gestión: proyectos, actividades, usuarios,
catálogos…): al hacer clic en una fila u optar "Nuevo", se abre un `DetailPopup` (overlay de
tamaño fijo, listado inactivo detrás, nunca se cierra con clic afuera) con el detalle/formulario
y sus acciones (`Guardar`, `Editar`, `Eliminar`). El listado se ve completo e intacto en cuanto
se cierra el popup.
- **Listado + Master-Detail**: lista con selección simple a la izquierda/arriba, detalle **en el
sitio** a la derecha/abajo. Preferido cuando el usuario navega/lee mucho (correo, catálogos).
- **Listado + ContentDialog de detalle**: al hacer clic en una fila se abre un `ContentDialog`
(o una ventana secundaria si el contenido es largo) con el detalle y acciones (`Editar`,
`Eliminar`). Preferido cuando el detalle es ocasional y la lista debe verse completa mientras se usa.
sitio** a la derecha/abajo, sin modal. Reservado para pantallas cuya función principal es
explorar/leer una lista larga (correo) — no para listados de gestión con crear/editar.
3. **Selección múltiple + acciones por lote**: casillas en la columna izquierda de la tabla; la barra
de estado muestra "`N` de `M` seleccionados"; las acciones de lote aparecen en la `CommandBar`
solo cuando hay selección.
@@ -261,11 +295,16 @@ Alt+Tab); si es una decisión puntual ligada al contexto actual, es un `ContentD
con el resto de Windows.
- ✅ Radios de esquina consistentes: `4px` controles, `8px` superficies elevadas y la ventana misma.
- ✅ Un solo set de íconos en toda la app (Lucide o Segoe Fluent Icons), heredando color del contexto.
- ✅ Master-Detail para listas de exploración; `ContentDialog`/ventana secundaria para detalle ocasional.
- ✅ `DetailPopup` (tamaño fijo, nunca se cierra con clic afuera) para ver/crear/editar el detalle de
un ítem de un listado de gestión; Master-Detail solo para pantallas de exploración/lectura pura.
- ✅ Foco visible solo con teclado (`:focus-visible`); hover visible siempre (mouse-first).
- ❌ `MessageBox`/`alert`/`confirm` del sistema. ❌ combos/selects/calendarios nativos sin estilar.
❌ mezclar sets de íconos. ❌ apilar listado + formulario + detalle en una sola pantalla sin
separación clara. ❌ usar `Toast` para pedir una decisión al usuario.
separación clara. ❌ usar `Toast` para pedir una decisión al usuario. ❌ panel de detalle lateral o
debajo del listado en pantallas de gestión (usar `DetailPopup`). ❌ que un `DetailPopup` cambie de
tamaño al moverse entre sus pestañas/secciones internas. ❌ montar `ContentDialog`/`DetailPopup` sin
portal a `document.body` (queda atrapado en el stacking context de un ancestro y puede terminar
tapado por otro overlay).
> Para que una app "se sienta Windows moderna": misma barra de título, mismo panel de navegación,
> mismos cuatro mecanismos de aviso (`ContentDialog`/`InfoBar`/`Toast`/`Flyout`) bien diferenciados,
+8 -3
View File
@@ -1,3 +1,4 @@
import { createPortal } from 'react-dom';
import { useUi } from '../context/ui-context.jsx';
import { Button } from './Button.jsx';
@@ -17,8 +18,11 @@ export function ContentDialogHost() {
if (!action.keepOpen) closeDialog();
}
return (
<div className="dialog-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) closeDialog(); }}>
// Regla dura: NUNCA se cierra con clic afuera (a diferencia de Flyout) — solo con un boton.
// Portal a document.body (igual que Flyout/DetailPopup): garantiza que quede SIEMPRE por
// 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-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
<div className="dialog-body">
@@ -33,6 +37,7 @@ export function ContentDialogHost() {
))}
</div>
</div>
</div>
</div>,
document.body
);
}
+25
View File
@@ -0,0 +1,25 @@
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
// Popup grande para ver/editar el detalle de un elemento de un listado (reemplaza el patron
// "panel debajo del listado"). Reglas duras:
// 1. NUNCA se cierra con clic afuera del popup — solo con el boton "Cerrar" de su header o una
// accion explicita del formulario (ej. Guardar/Cancelar).
// 2. Alto FIJO (no se ajusta al contenido): moverse entre pestañas/secciones internas del popup
// (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 }) {
if (!open) return null;
return createPortal(
<div className="detail-popup-overlay">
<div className="detail-popup" style={{ maxWidth: width, height }}>
<div className="detail-popup-header">
<div className="detail-popup-title">{title}</div>
<button className="icon-btn" onClick={onClose}><X /></button>
</div>
<div className="detail-popup-body">{children}</div>
</div>
</div>,
document.body
);
}
@@ -8,6 +8,7 @@ import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c }));
@@ -93,8 +94,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
]}
/>
{editing && (
<div className="panel-card" style={{ marginTop: 12, maxWidth: 480 }}>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('common_new')} onClose={() => setEditing(false)} width={480} height={380}>
<FieldRow>
{fields.map((f) => (
<Field key={f.key} label={f.label}>
@@ -120,8 +120,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
<Button size="sm" variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button size="sm" variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
</div>
</div>
)}
</DetailPopup>
</div>
);
}
@@ -7,6 +7,7 @@ import { Treeview } from '../../components/Treeview.jsx';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) {
@@ -73,8 +74,8 @@ export default function DepartmentsPanel() {
if (loading || !tree) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div className="panel-card" style={{ flex: '1 1 280px' }}>
<div>
<div className="panel-card">
<div className="toolbar">
<h3 style={{ margin: 0 }}>{t('org_departments')}</h3>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}</Button>
@@ -82,9 +83,7 @@ export default function DepartmentsPanel() {
<Treeview nodes={tree} onSelect={startEdit} selectedId={selected?.id} />
</div>
{editing && (
<div className="panel-card" style={{ flex: '1 1 280px', maxWidth: 360 }}>
<h3>{selected ? t('common_edit') : t('department_new')}</h3>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => setEditing(false)} width={480} height={360}>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
@@ -100,8 +99,7 @@ export default function DepartmentsPanel() {
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
{selected && <Button variant="danger" icon={Trash2} onClick={() => confirmDelete(selected)}>{t('common_delete')}</Button>}
</div>
</div>
)}
</DetailPopup>
</div>
);
}
@@ -10,6 +10,7 @@ import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
@@ -103,9 +104,7 @@ export default function UsersPanel() {
]}
/>
{editing && (
<div className="panel-card" style={{ marginTop: 16, maxWidth: 520 }}>
<h3>{selected ? t('common_edit') : t('user_new')}</h3>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
<FieldRow>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
@@ -136,8 +135,7 @@ export default function UsersPanel() {
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
</div>
</div>
)}
</DetailPopup>
</div>
);
}
@@ -23,7 +23,7 @@ function toForm(a) {
};
}
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, onClose }) {
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
@@ -84,15 +84,12 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
}
return (
<div className="panel-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>{activity.code} — {activity.name}</h3>
<Button variant="subtle" icon={X} onClick={onClose}>{t('common_close')}</Button>
</div>
<div>
<Tabs
tabs={[
{ key: 'detail', label: t('tab_info') },
{ key: 'subtasks', label: t('subtasks_title') },
{ key: 'dependencies', label: t('activity_dependencies') },
{ key: 'comments', label: t('tab_comments') },
{ key: 'documents', label: t('tab_documents') },
]}
@@ -128,7 +125,6 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
</FieldRow>
<Field label={t('activity_progress')}>
<ProgressBar value={Number(activity.progress_pct)} />
{hasSubtasks && <span className="text-muted" style={{ fontSize: '.68rem' }}>{t('subtasks_title')}</span>}
</Field>
</div>
) : (
@@ -179,9 +175,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
</FieldRow>
</div>
)}
</div>
)}
<div style={{ marginTop: 14 }}>
<h4>{t('subtasks_title')}</h4>
{tab === 'subtasks' && (
<div>
{(subtasks || []).map((s) => (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
<input type="checkbox" checked={!!s.is_done} onChange={() => toggleSubtask(s)} />
@@ -189,14 +187,16 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
<button className="icon-btn" onClick={() => removeSubtaskItem(s)}><Trash2 size={15} /></button>
</div>
))}
{(!subtasks || subtasks.length === 0) && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<input className="input" value={newSubtask} placeholder={t('subtask_new')} onChange={(e) => setNewSubtask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addSubtaskItem()} />
<Button variant="standard" icon={Plus} onClick={addSubtaskItem}>{t('common_new')}</Button>
</div>
</div>
)}
<div style={{ marginTop: 14 }}>
<h4>{t('activity_dependencies')}</h4>
{tab === 'dependencies' && (
<div>
{(activity.dependencies || []).length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
{(activity.dependencies || []).map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
@@ -212,7 +212,6 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
<Button variant="standard" icon={Plus} onClick={addDep}>{t('activity_add_dependency')}</Button>
</div>
</div>
</div>
)}
{tab === 'comments' && <CommentsPanel entityType="activity" entityId={activity.id} />}
@@ -12,6 +12,7 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
import { dueStatus } from '../../utils/dueStatus.js';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
@@ -128,17 +129,16 @@ export default function ProjectActivitiesTab({ projectId }) {
</div>
)}
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setSelected(null)} width={720} height={680}>
{selected && (
<div style={{ marginTop: 16 }}>
<ActivityDetailPanel
activity={selected}
projectId={projectId}
siblingActivities={activities}
onChanged={refreshAll}
onClose={() => setSelected(null)}
/>
</div>
)}
</DetailPopup>
</div>
);
}
@@ -13,6 +13,7 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
@@ -98,9 +99,7 @@ export default function ProyectosPage() {
onRowClick={(p) => navigate(`/proyectos/${p.id}`)}
/>
{creating && (
<div className="panel-card" style={{ marginTop: 16 }}>
<h3>{t('project_new')}</h3>
<DetailPopup open={creating} title={t('project_new')} onClose={() => setCreating(false)} width={720} height={720}>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
@@ -147,8 +146,7 @@ export default function ProyectosPage() {
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
</div>
</div>
)}
</DetailPopup>
</div>
);
}
+23 -2
View File
@@ -130,7 +130,8 @@ button { font-family: inherit; }
table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.list-table th {
text-align: left; padding: 10px 12px; font-size: .7rem; text-transform: uppercase; letter-spacing: .03em;
color: rgb(var(--muted)); border-bottom: 1px solid rgb(var(--line)); cursor: pointer; user-select: none;
color: rgb(var(--muted)); border-bottom: 1px solid rgb(var(--line)); cursor: pointer;
-webkit-user-select: none; user-select: none;
white-space: nowrap;
}
.list-table th:hover { color: rgb(var(--body)); }
@@ -174,7 +175,7 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
/* --- ContentDialog --- */
.dialog-overlay {
position: fixed; inset: 0; background: rgb(0 0 0 / .5);
display: flex; align-items: center; justify-content: center; z-index: 200; padding: 16px;
display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px;
}
.dialog-surface {
width: 420px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
@@ -258,6 +259,26 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.login-title { font-size: 1.1rem; font-weight: 700; }
.login-error { color: rgb(var(--danger)); font-size: .78rem; margin-bottom: 10px; }
/* --- DetailPopup (ver DetailPopup.jsx) ---
Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup
presenta la pantalla de info/edicion completa. Regla dura: NUNCA se cierra con clic afuera
(a diferencia de Flyout); solo con el boton "Cerrar" o una accion explicita del formulario. */
.detail-popup-overlay {
position: fixed; inset: 0; background: rgb(0 0 0 / .55);
display: flex; align-items: center; justify-content: center; z-index: 200; padding: 24px;
}
.detail-popup {
width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
box-shadow: 0 24px 64px rgb(0 0 0 / .45);
max-height: 90vh; display: flex; flex-direction: column; overflow: hidden;
}
.detail-popup-header {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 16px 20px; border-bottom: 1px solid rgb(var(--line)); flex: none;
}
.detail-popup-title { font-weight: 700; font-size: 1rem; }
.detail-popup-body { padding: 20px; overflow-y: auto; flex: 1; }
/* --- Expander (Configuracion) --- */
.expander { border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); margin-bottom: 10px; overflow: hidden; }
.expander-header { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; background: rgb(var(--surface)); }