feat(web): pantalla de permisos de menú (/admin/permisos)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 22:17:50 -05:00
1 parent d900a96740
commit 4d4df17d07
11 files changed
+496 -5

No files matched your search

+2 -2
View File
@@ -56,9 +56,9 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
Pendiente, en orden:
1. **Paso 9 — formulario de documentos** en `nuevo` / `:docEntry`: cargar el documento, **Crear** / **Actualizar** (control de concurrencia con el `odata.etag` de SAP vía `If-Match`), navegación primero/anterior/siguiente/último, socio de negocio, totales con las tasas de `SalesTaxCodes`, solo lectura si está cerrado/cancelado, "Copiar de/a". Empezar por Órdenes de venta y replicar. Quitar campos vacíos al guardar; `B1S-ReplaceCollectionsOnPatch` para las líneas. Pendiente decidir/ pedir autorización para probar escrituras reales.
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes`/`PurchaseTaxCodes`, solo lectura), probado solo con SAP simulado y **sin verlo aún en el navegador**. Falta: probar escrituras reales (pedir autorización), "Copiar de/a", botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra.
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
3. Pantalla web de administración de permisos (el API ya existe: `/api/permissions`).
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
5. Migrar reportes/PDF vía API Gateway (módulo y cola aparte; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro).
6. Revisar las vulnerabilidades de `npm audit` y definir una política para `U_WEB_PASS` (texto plano, claves cortas) antes de producción.
+4
View File
@@ -135,6 +135,10 @@ Lo usan las pantallas con `partner` en su `DocumentScreen` (ventas y compras); e
- **Navegación** primero / anterior / siguiente / último por `DocEntry`.
- La lógica sin Angular está en `document-model.ts` (totales, payload, validación, navegación) y el acceso HTTP en `core/sl/sl-document.ts`.
### Permisos de menú (`/admin/permisos`)
Pantalla del módulo "Administración", solo para quien tiene acceso total (`*`; el API de permisos exige ser administrador). Lista los usuarios web con permisos y, al elegir uno, muestra los módulos y pantallas con casillas (el módulo completo se guarda como `/modulo/*`, las pantallas sueltas como rutas). "Acceso total" guarda `*`; "Administración" no tiene casilla: solo llega con acceso total. Los patrones que no corresponden a ninguna pantalla actual se conservan. Un usuario nuevo se agrega por su `U_WEB_USER` y no existe hasta pulsar Guardar. El servidor impide quitar al último administrador. La conversión casillas ↔ patrones está en `features/admin/permissions-model.ts`.
### `sbo-list-view` y `sbo-filter-bar`
Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-erp:
+13 -2
View File
@@ -43,10 +43,20 @@ const devRoutes: Routes = [
},
];
/** Administración: el permiso de menú `/admin/permisos` solo lo da el acceso total (`*`). */
const adminRoutes: Routes = [
{
path: 'admin/permisos',
canActivate: [permissionGuard],
data: { path: '/admin/permisos', title: 'Permisos de menú' },
loadComponent: () => import('./features/admin/permissions.page').then((m) => m.PermissionsPage),
},
];
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const documentPaths = new Set(DOCUMENT_SCREENS.map((s) => s.path));
const builtPaths = new Set([...DOCUMENT_SCREENS.map((s) => s.path), '/admin/permisos']);
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !documentPaths.has(item.path))
.filter((item) => !builtPaths.has(item.path))
.map(
(item): Route => ({
path: item.path.slice(1),
@@ -74,6 +84,7 @@ export const routes: Routes = [
},
...devRoutes,
...documentRoutes,
...adminRoutes,
...pendingRoutes,
],
},
@@ -36,7 +36,7 @@ describe('MenuService', () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
await p;
expect(menu.modules()).toHaveLength(4);
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'socios', 'admin']);
});
it('si falla marca el error y permite reintentar', async () => {
@@ -0,0 +1,58 @@
import type { MenuModule } from '@basic-erp/sap-types';
import { describe, expect, it } from 'vitest';
import { samePatterns, toPatterns, toSelection, toggleModule, togglePath } from './permissions-model';
const menu: MenuModule[] = [
{ id: 'ventas', label: 'Ventas', items: [{ path: '/ventas/a', label: 'A' }, { path: '/ventas/b', label: 'B' }] },
{ id: 'socios', label: 'Socios', items: [{ path: '/socios/c', label: 'C' }] },
{ id: 'admin', label: 'Administración', items: [{ path: '/admin/permisos', label: 'Permisos' }] },
];
describe('toSelection', () => {
it('un patrón de módulo marca todas sus pantallas', () => {
const s = toSelection(['/ventas/*'], menu);
expect([...s.paths]).toEqual(['/ventas/a', '/ventas/b']);
expect(s.all).toBe(false);
});
it('"*" es acceso total', () => {
const s = toSelection(['*'], menu);
expect(s.all).toBe(true);
expect(s.paths.size).toBe(0);
});
it('conserva los patrones que no corresponden a ninguna pantalla', () => {
expect(toSelection(['/ventas/a', '/futuro/*'], menu).extras).toEqual(['/futuro/*']);
});
});
describe('toPatterns', () => {
it('módulo completo → /modulo/*; incompleto → rutas sueltas', () => {
let s = toSelection([], menu);
s = toggleModule(s, menu[0], true);
s = togglePath(s, '/socios/c', true);
expect(toPatterns(s, menu)).toEqual(['/ventas/*', '/socios/*']);
s = togglePath(s, '/ventas/b', false);
expect(toPatterns(s, menu)).toEqual(['/ventas/a', '/socios/*']);
});
it('acceso total ignora lo demás', () => {
expect(toPatterns({ all: true, paths: new Set(['/ventas/a']), extras: ['/x/*'] }, menu)).toEqual(['*']);
});
it('ida y vuelta conserva los extras y no otorga Administración', () => {
const patterns = ['/ventas/*', '/futuro/*'];
expect(toPatterns(toSelection(patterns, menu), menu)).toEqual(patterns);
});
it('sin nada marcado no hay patrones', () => {
expect(toPatterns(toSelection([], menu), menu)).toEqual([]);
});
});
describe('samePatterns', () => {
it('compara sin importar el orden', () => {
expect(samePatterns(['a', 'b'], ['b', 'a'])).toBe(true);
expect(samePatterns(['a'], ['a', 'b'])).toBe(false);
});
});
@@ -0,0 +1,56 @@
import { type MenuModule, isPathAllowed } from '@basic-erp/sap-types';
/** Lo que el administrador marca en pantalla para un usuario. */
export interface Selection {
/** Acceso total (`*`). */
all: boolean;
/** Rutas de menú marcadas (incluye las cubiertas por un patrón `/modulo/*`). */
paths: ReadonlySet<string>;
/** Patrones que no corresponden a ninguna pantalla actual (ej. de una versión futura): se conservan tal cual. */
extras: readonly string[];
}
/** Módulos que se otorgan con casillas; "Administración" solo llega con acceso total. */
export const grantableModules = (menu: readonly MenuModule[]): readonly MenuModule[] => menu.filter((m) => m.id !== 'admin');
export function toSelection(patterns: readonly string[], menu: readonly MenuModule[]): Selection {
const all = patterns.includes('*');
const known = menu.flatMap((m) => m.items.map((i) => i.path));
const paths = new Set(known.filter((p) => !all && isPathAllowed(patterns, p)));
const extras = patterns.filter((p) => p !== '*' && !known.some((k) => isPathAllowed([p], k)));
return { all, paths, extras };
}
/**
* Patrones a guardar: `*` si es acceso total; si no, `/modulo/*` cuando están todas las pantallas
* del módulo y rutas sueltas en otro caso, más los patrones desconocidos conservados.
*/
export function toPatterns(sel: Selection, menu: readonly MenuModule[]): string[] {
if (sel.all) return ['*'];
const out: string[] = [];
for (const m of grantableModules(menu)) {
const checked = m.items.filter((i) => sel.paths.has(i.path));
if (checked.length === m.items.length && m.items.length > 0) out.push(`/${m.id}/*`);
else out.push(...checked.map((i) => i.path));
}
return [...out, ...sel.extras];
}
export function togglePath(sel: Selection, path: string, on: boolean): Selection {
const paths = new Set(sel.paths);
if (on) paths.add(path);
else paths.delete(path);
return { ...sel, paths };
}
export function toggleModule(sel: Selection, module: MenuModule, on: boolean): Selection {
const paths = new Set(sel.paths);
for (const i of module.items) {
if (on) paths.add(i.path);
else paths.delete(i.path);
}
return { ...sel, paths };
}
export const samePatterns = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && [...a].sort().every((p, i) => p === [...b].sort()[i]);
@@ -0,0 +1,28 @@
:host { display: block; }
h1 { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
h2 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }
.hint, .quiet { margin: 0 0 12px; color: var(--quiet); }
.cols { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 16px; align-items: start; }
@media (max-width: 700px) { .cols { grid-template-columns: 1fr; } }
.users, .detail { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); padding: 10px 12px; }
.add { display: flex; gap: 6px; margin-bottom: 8px; }
.add input { flex: 1; min-width: 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
li > button {
width: 100%; text-align: left; padding: 4px 8px; background: transparent; color: var(--ink);
border: 1px solid transparent; border-radius: var(--radius); cursor: pointer;
}
li > button:hover { background: var(--btn-h); }
li > button.on { background: var(--btn-h); border-color: var(--line); font-weight: 600; }
fieldset { margin: 10px 0 0; border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 10px 8px; }
legend { padding: 0 4px; font-weight: 600; }
.items { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 4px 12px; }
label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.all { margin-bottom: 4px; font-weight: 600; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.danger { color: var(--err); }
.msg { margin: 10px 0 0; }
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
.msg.warn { color: var(--warn); }
.err { color: var(--err); }
@@ -0,0 +1,84 @@
<h1>Permisos de menú</h1>
<p class="hint">Qué pantallas puede abrir cada usuario web. El acceso total incluye esta pantalla. Un usuario sin permisos no ve ningún menú.</p>
<div class="cols">
<section class="users" aria-label="Usuarios">
<form class="add" (submit)="$event.preventDefault(); add()">
<input
class="sbo-in"
placeholder="Usuario web (U_WEB_USER)"
aria-label="Usuario web a agregar"
[value]="newName()"
(input)="newName.set($any($event.target).value)"
/>
<button class="btn" type="submit" [disabled]="!newName().trim()">Agregar</button>
</form>
@if (loading()) {
<p class="quiet">Cargando…</p>
} @else if (loadError()) {
<p class="err" role="alert">{{ loadError() }}</p>
}
<ul>
@for (n of names(); track n) {
<li>
<button type="button" [class.on]="n === current()" (click)="pick(n)">{{ n }}</button>
</li>
} @empty {
@if (!loading() && !loadError()) {
<li class="quiet">Aún no hay usuarios con permisos.</li>
}
}
</ul>
</section>
<section class="detail" aria-label="Permisos del usuario">
@if (selection(); as sel) {
<h2>{{ current() }}</h2>
<label class="all">
<input type="checkbox" [checked]="sel.all" (change)="toggleAll($any($event.target).checked)" />
Acceso total (administrador)
</label>
@if (!sel.all) {
@for (m of modules; track m.id) {
<fieldset>
<legend>
<label>
<input
type="checkbox"
[checked]="moduleState(m.id) === 'all'"
[indeterminate]="moduleState(m.id) === 'some'"
(change)="toggleMod(m.id, $any($event.target).checked)"
/>
{{ m.label }}
</label>
</legend>
<div class="items">
@for (i of m.items; track i.path) {
<label>
<input type="checkbox" [checked]="has(i.path)" (change)="toggleItem(i.path, $any($event.target).checked)" />
{{ i.label }}
</label>
}
</div>
</fieldset>
}
@if (sel.extras.length) {
<p class="quiet">Otros patrones conservados: {{ sel.extras.join(', ') }}</p>
}
}
<div class="actions">
<button class="btn main" type="button" [disabled]="saving() || !dirty()" (click)="save()">Guardar</button>
<button class="btn" type="button" [disabled]="saving() || !dirty()" (click)="discard()">Descartar cambios</button>
<button class="btn danger" type="button" [disabled]="saving()" (click)="remove()">Quitar todos los permisos</button>
</div>
} @else {
<p class="quiet">Elija un usuario de la lista o agregue uno.</p>
}
@if (message(); as m) {
<p class="msg" [class]="'msg ' + m.kind" role="status">{{ m.text }}</p>
}
</section>
</div>
@@ -0,0 +1,96 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { SessionStore } from '../../core/auth/session.store';
import { PermissionsPage } from './permissions.page';
describe('PermissionsPage', () => {
let fixture: ComponentFixture<PermissionsPage>;
let ctrl: HttpTestingController;
const el = () => fixture.nativeElement as HTMLElement;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
const click = async (selector: string, index = 0) => {
(el().querySelectorAll(selector)[index] as HTMLElement).click();
await tick();
};
const byText = (selector: string, text: string) =>
[...el().querySelectorAll<HTMLElement>(selector)].find((e) => e.textContent?.trim().startsWith(text))!;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PermissionsPage],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
TestBed.inject(SessionStore); // sin sesión: solo necesitamos que exista
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(PermissionsPage);
await tick();
ctrl.expectOne('/api/permissions').flush([
{ webUser: 'ana', paths: ['/ventas/*', '/socios/clientes'] },
{ webUser: 'luis', paths: ['*'] },
]);
await tick();
});
it('lista los usuarios con permisos', () => {
expect([...el().querySelectorAll('.users li button')].map((b) => b.textContent?.trim())).toEqual(['ana', 'luis']);
});
it('al elegir un usuario marca sus pantallas (el módulo completo por /ventas/*)', async () => {
await click('.users li button', 0);
const checked = [...el().querySelectorAll<HTMLInputElement>('fieldset .items input')].filter((i) => i.checked);
expect(checked.length).toBe(7); // 6 de ventas + Clientes
expect(el().querySelector<HTMLButtonElement>('.actions .main')!.disabled).toBe(true); // sin cambios
});
it('el acceso total oculta las casillas y no ofrece Administración como casilla', async () => {
await click('.users li button', 1);
expect(el().querySelector('fieldset')).toBeNull();
await click('.users li button', 0);
expect(byText('legend label', 'Administración')).toBeUndefined();
});
it('guarda los patrones resultantes con PUT', async () => {
await click('.users li button', 0);
const inv = byText('legend label', 'Inventario').querySelector('input')!;
inv.click();
await tick();
expect(el().querySelector<HTMLButtonElement>('.actions .main')!.disabled).toBe(false);
await click('.actions .main');
const req = ctrl.expectOne((r) => r.method === 'PUT');
expect(req.request.url).toBe('/api/permissions/ana');
expect(req.request.body).toEqual({ paths: ['/ventas/*', '/inventario/*', '/socios/clientes'] });
req.flush({ webUser: 'ana', paths: [] });
await tick();
ctrl.expectOne('/api/permissions').flush([{ webUser: 'ana', paths: ['/ventas/*', '/inventario/*', '/socios/clientes'] }]);
await tick();
expect(el().querySelector('.msg')?.textContent).toContain('Permisos de ana guardados');
});
it('muestra el error del servidor, por ejemplo al quitar el último administrador', async () => {
await click('.users li button', 1);
await click('.actions .danger');
ctrl.expectOne((r) => r.method === 'DELETE' && r.url === '/api/permissions/luis').flush(
{ message: 'No se puede quitar al último administrador' },
{ status: 409, statusText: 'Conflict' },
);
await tick();
expect(el().querySelector('.msg.err')?.textContent).toContain('último administrador');
});
it('agregar un usuario lo selecciona sin guardar hasta pulsar Guardar', async () => {
const input = el().querySelector<HTMLInputElement>('.add input')!;
input.value = 'marta';
input.dispatchEvent(new Event('input'));
await tick();
await click('.add .btn');
expect([...el().querySelectorAll('.users li button')].map((b) => b.textContent?.trim())).toContain('marta');
expect(el().querySelector('.detail h2')?.textContent).toBe('marta');
ctrl.expectNone((r) => r.method === 'PUT');
});
});
@@ -0,0 +1,148 @@
import { HttpClient, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { MENU, type UserPermissions } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { SessionStore } from '../../core/auth/session.store';
import { MenuService } from '../../core/menu/menu.service';
import {
type Selection,
grantableModules,
samePatterns,
toPatterns,
toSelection,
toggleModule,
togglePath,
} from './permissions-model';
/**
* Administración de permisos de menú por usuario web (solo acceso total): lista de usuarios con
* permisos y, al elegir uno, las pantallas que puede abrir. Los cambios se guardan juntos.
*/
@Component({
selector: 'sbo-permissions-page',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './permissions.page.html',
styleUrl: './permissions.page.css',
})
export class PermissionsPage {
private readonly http = inject(HttpClient);
private readonly session = inject(SessionStore);
private readonly menuService = inject(MenuService);
protected readonly modules = grantableModules(MENU);
private readonly users = httpResource<UserPermissions[]>(() => `${API_URL}/permissions`);
/** Usuarios con permisos guardados, más los recién agregados que aún no se guardaron. */
private readonly added = signal<readonly string[]>([]);
protected readonly names = computed(() => {
const saved = this.users.hasValue() ? this.users.value().map((u) => u.webUser) : [];
return [...saved, ...this.added().filter((n) => !saved.includes(n))].sort((a, b) => a.localeCompare(b));
});
protected readonly loading = computed(() => this.users.isLoading());
protected readonly loadError = computed(() => (this.users.error() ? apiErrorMessage(this.users.error()) : ''));
protected readonly current = signal<string | null>(null);
protected readonly selection = signal<Selection | null>(null);
protected readonly newName = signal('');
protected readonly saving = signal(false);
protected readonly message = signal<{ kind: 'ok' | 'err' | 'warn'; text: string } | null>(null);
private savedPatterns(name: string): string[] {
return (this.users.hasValue() ? this.users.value() : []).find((u) => u.webUser === name)?.paths ?? [];
}
protected readonly dirty = computed(() => {
const name = this.current();
const sel = this.selection();
return !!name && !!sel && !samePatterns(toPatterns(sel, MENU), this.savedPatterns(name));
});
protected pick(name: string): void {
this.current.set(name);
this.selection.set(toSelection(this.savedPatterns(name), MENU));
this.message.set(null);
}
protected add(): void {
const name = this.newName().trim();
if (!name) return;
if (!this.names().some((n) => n.toLowerCase() === name.toLowerCase())) this.added.update((a) => [...a, name]);
this.newName.set('');
this.pick(this.names().find((n) => n.toLowerCase() === name.toLowerCase()) ?? name);
}
protected toggleAll(on: boolean): void {
this.selection.update((s) => s && { ...s, all: on });
}
protected toggleItem(path: string, on: boolean): void {
this.selection.update((s) => s && togglePath(s, path, on));
}
protected toggleMod(id: string, on: boolean): void {
const mod = this.modules.find((m) => m.id === id);
if (mod) this.selection.update((s) => s && toggleModule(s, mod, on));
}
protected moduleState(id: string): 'all' | 'some' | 'none' {
const mod = this.modules.find((m) => m.id === id);
const sel = this.selection();
if (!mod || !sel) return 'none';
const n = mod.items.filter((i) => sel.paths.has(i.path)).length;
return n === 0 ? 'none' : n === mod.items.length ? 'all' : 'some';
}
protected has(path: string): boolean {
return this.selection()?.paths.has(path) ?? false;
}
protected discard(): void {
const name = this.current();
if (name) this.pick(name);
}
protected async save(): Promise<void> {
const name = this.current();
const sel = this.selection();
if (!name || !sel || this.saving()) return;
this.saving.set(true);
try {
await firstValueFrom(this.http.put(`${API_URL}/permissions/${encodeURIComponent(name)}`, { paths: toPatterns(sel, MENU) }));
this.afterChange(name);
this.message.set({ kind: 'ok', text: `Permisos de ${name} guardados.` });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.saving.set(false);
}
}
protected async remove(): Promise<void> {
const name = this.current();
if (!name || this.saving()) return;
this.saving.set(true);
try {
await firstValueFrom(this.http.delete(`${API_URL}/permissions/${encodeURIComponent(name)}`));
this.added.update((a) => a.filter((n) => n !== name));
this.current.set(null);
this.selection.set(null);
this.afterChange(null);
this.message.set({ kind: 'ok', text: `Se quitaron todos los permisos de ${name}.` });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.saving.set(false);
}
}
/** Recarga la lista; si cambió el menú del propio administrador, también el menú lateral. */
private afterChange(name: string | null): void {
this.users.reload();
this.added.update((a) => a.filter((n) => n !== name));
if (name === null || name === this.session.user()?.webUser) {
this.menuService.reset();
void this.menuService.ensureLoaded();
}
}
}
+6
View File
@@ -61,6 +61,12 @@ export const MENU: readonly MenuModule[] = [
{ path: '/socios/proveedores', label: 'Proveedores' },
],
},
{
id: 'admin',
label: 'Administración',
// Solo acceso total ("*"): el API de permisos exige ser administrador.
items: [{ path: '/admin/permisos', label: 'Permisos de menú' }],
},
];
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */