diff --git a/apps/web/README.md b/apps/web/README.md
index b889f7a..89e664a 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -135,3 +135,35 @@ Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-er
- Entradas: `title`, `resource`, `columns`, `filters`, `extraFields`, `baseFilter`, `orderby`, `pageSize` (50), `createLabel`; salidas `(open)` y `(create)`.
El estado de búsqueda/paginación (`core/sl/sl-list.ts`) y la tabla (`sbo-data-table`) son compartidos con `sbo-value-help`.
+
+### Ventana de formulario (`shared/window`)
+
+Marco de las pantallas `nuevo` y `:docEntry`, con la estructura del mockup:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+ …
+ …
+
+
+
+
+
+
+```
+
+- `sbo-window`: título, insignia de estado (`tone`: `info` Nueva, `ok` Abierta, `muted` Cerrada, `err` Cancelada, `warn`), cuerpo, zona de acciones (`sboActions` a la izquierda, `sboActionsEnd` a la derecha) y **barra de estado** (`{ kind: 'info' | 'ok' | 'warn' | 'err', text }`, con `role="status"`).
+- `sbo-toolbar` + botones `.tb` + `sbo-icon` (iconos SVG en línea: `new find first prev next last print`).
+- `sbo-tabs` / `sbo-tab`: los paneles ocultos siguen en el DOM (conservan el estado); `[(selected)]`; flechas ←/→.
+- `sbo-menu-button`: lista de acciones que se abre hacia arriba (o `direction="down"`); se cierra con Esc o clic fuera; opciones `disabled`.
+- `sbo-badge`: la misma insignia que usan las listas.
diff --git a/apps/web/src/app/shared/list/data-table.css b/apps/web/src/app/shared/list/data-table.css
index 02724f7..e07aabd 100644
--- a/apps/web/src/app/shared/list/data-table.css
+++ b/apps/web/src/app/shared/list/data-table.css
@@ -75,26 +75,3 @@ tr.none td {
width: 30px;
text-align: center;
}
-.badge {
- display: inline-block;
- padding: 0 8px;
- border: 1px solid var(--line);
- border-radius: var(--radius-pill);
- font-size: 11.5px;
- color: var(--quiet);
-}
-.tone-ok {
- color: var(--ok);
- border-color: var(--ok);
- background: var(--ok-bg);
-}
-.tone-warn {
- color: var(--warn);
- border-color: var(--warn);
- background: var(--warn-bg);
-}
-.tone-err {
- color: var(--err);
- border-color: var(--err);
- background: var(--err-bg);
-}
diff --git a/apps/web/src/app/shared/list/data-table.html b/apps/web/src/app/shared/list/data-table.html
index 73f254c..4299007 100644
--- a/apps/web/src/app/shared/list/data-table.html
+++ b/apps/web/src/app/shared/list/data-table.html
@@ -44,7 +44,7 @@
@for (c of columns(); track c.key) {
@if (tone(row, c); as t) {
- {{ cell(row, c) }}
+ {{ cell(row, c) }}
} @else {
{{ cell(row, c) }}
}
diff --git a/apps/web/src/app/shared/list/data-table.ts b/apps/web/src/app/shared/list/data-table.ts
index 473d02d..46804b2 100644
--- a/apps/web/src/app/shared/list/data-table.ts
+++ b/apps/web/src/app/shared/list/data-table.ts
@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+import { Badge } from '../window/badge';
import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types';
/**
@@ -8,6 +9,7 @@ import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type To
@Component({
selector: 'sbo-data-table',
changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [Badge],
templateUrl: './data-table.html',
styleUrl: './data-table.css',
})
diff --git a/apps/web/src/app/shared/list/list.types.ts b/apps/web/src/app/shared/list/list.types.ts
index 4419a54..1ed3359 100644
--- a/apps/web/src/app/shared/list/list.types.ts
+++ b/apps/web/src/app/shared/list/list.types.ts
@@ -1,7 +1,7 @@
import { formatDate, formatNumber } from '../../core/format';
export type ListRow = Record;
-export type Tone = 'ok' | 'warn' | 'err' | 'muted';
+export type Tone = 'ok' | 'warn' | 'err' | 'info' | 'muted';
export interface ListColumn {
/** Campo del Service Layer; también se usa en `$select` y, si es buscable, en el filtro. */
diff --git a/apps/web/src/app/shared/window/badge.ts b/apps/web/src/app/shared/window/badge.ts
new file mode 100644
index 0000000..dd4d9c9
--- /dev/null
+++ b/apps/web/src/app/shared/window/badge.ts
@@ -0,0 +1,42 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import type { Tone } from '../list/list.types';
+
+/** Insignia de estado (Abierto, Cerrado, Cancelado, Nueva...) con el color de su tono. */
+@Component({
+ selector: 'sbo-badge',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: ``,
+ styles: `
+ .badge {
+ display: inline-block;
+ padding: 0 8px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ font-size: 11.5px;
+ color: var(--quiet);
+ white-space: nowrap;
+ }
+ .tone-ok {
+ color: var(--ok);
+ border-color: var(--ok);
+ background: var(--ok-bg);
+ }
+ .tone-warn {
+ color: var(--warn);
+ border-color: var(--warn);
+ background: var(--warn-bg);
+ }
+ .tone-err {
+ color: var(--err);
+ border-color: var(--err);
+ background: var(--err-bg);
+ }
+ .tone-info {
+ color: var(--focus);
+ border-color: var(--focus);
+ }
+ `,
+})
+export class Badge {
+ readonly tone = input('muted');
+}
diff --git a/apps/web/src/app/shared/window/icon.ts b/apps/web/src/app/shared/window/icon.ts
new file mode 100644
index 0000000..07179ef
--- /dev/null
+++ b/apps/web/src/app/shared/window/icon.ts
@@ -0,0 +1,52 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+export type IconName = 'new' | 'find' | 'first' | 'prev' | 'next' | 'last' | 'print';
+
+/** Iconos lineales de la barra de herramientas (SVG en línea, sin fuentes ni librerías). */
+@Component({
+ selector: 'sbo-icon',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+ styles: `
+ :host {
+ display: inline-flex;
+ }
+ svg {
+ width: 16px;
+ height: 16px;
+ flex: none;
+ }
+ `,
+})
+export class Icon {
+ readonly name = input.required();
+}
diff --git a/apps/web/src/app/shared/window/index.ts b/apps/web/src/app/shared/window/index.ts
new file mode 100644
index 0000000..70d6e00
--- /dev/null
+++ b/apps/web/src/app/shared/window/index.ts
@@ -0,0 +1,6 @@
+export { Badge } from './badge';
+export { Icon, type IconName } from './icon';
+export { type MenuItem, MenuButton } from './menu-button';
+export { Tab, Tabs } from './tabs';
+export { Toolbar } from './toolbar';
+export { type StatusKind, type StatusMessage, type WindowBadge, SboWindow } from './window';
diff --git a/apps/web/src/app/shared/window/menu-button.ts b/apps/web/src/app/shared/window/menu-button.ts
new file mode 100644
index 0000000..6ad2d08
--- /dev/null
+++ b/apps/web/src/app/shared/window/menu-button.ts
@@ -0,0 +1,90 @@
+import { ChangeDetectionStrategy, Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
+
+export interface MenuItem {
+ id: string;
+ label: string;
+ disabled?: boolean;
+}
+
+/** Botón que despliega una lista de acciones (ej. "Copiar de", "Copiar a"). Se cierra con Esc o al hacer clic fuera. */
+@Component({
+ selector: 'sbo-menu-button',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @if (open()) {
+
+ @for (item of items(); track item.id) {
+
+ }
+
+ }
+ `,
+ styles: `
+ :host {
+ position: relative;
+ display: inline-block;
+ }
+ .list {
+ position: absolute;
+ right: 0;
+ z-index: 20;
+ min-width: 250px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ box-shadow: var(--shadow-pop);
+ }
+ .list:not(.up) {
+ top: 30px;
+ }
+ .list.up {
+ bottom: 30px;
+ }
+ .list button {
+ display: block;
+ width: 100%;
+ padding: 7px 12px;
+ background: none;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ }
+ .list button:hover:not(:disabled) {
+ background: var(--row-sel);
+ }
+ .list button:disabled {
+ opacity: 0.45;
+ cursor: default;
+ }
+ `,
+})
+export class MenuButton {
+ readonly label = input.required();
+ readonly items = input.required();
+ readonly disabled = input(false);
+ readonly direction = input<'up' | 'down'>('up');
+ readonly picked = output();
+
+ protected readonly open = signal(false);
+ private readonly host = inject>(ElementRef);
+
+ @HostListener('document:click', ['$event'])
+ protected onDocumentClick(event: Event): void {
+ if (this.open() && !this.host.nativeElement.contains(event.target as Node)) this.open.set(false);
+ }
+
+ protected choose(item: MenuItem): void {
+ if (item.disabled) return;
+ this.open.set(false);
+ this.picked.emit(item.id);
+ }
+}
diff --git a/apps/web/src/app/shared/window/tabs.ts b/apps/web/src/app/shared/window/tabs.ts
new file mode 100644
index 0000000..87927a1
--- /dev/null
+++ b/apps/web/src/app/shared/window/tabs.ts
@@ -0,0 +1,102 @@
+import { ChangeDetectionStrategy, Component, contentChildren, effect, input, model, signal } from '@angular/core';
+
+let nextTabId = 0;
+
+/** Una pestaña: su contenido se mantiene en el DOM (oculto) para conservar el estado del formulario. */
+@Component({
+ selector: 'sbo-tab',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: ` `,
+ styles: ':host { display: block; } [hidden] { display: none; }',
+})
+export class Tab {
+ readonly label = input.required();
+ readonly tabId = `sbo-tab-${++nextTabId}`;
+ readonly panelId = `${this.tabId}-panel`;
+ readonly active = signal(false);
+}
+
+/** Pestañas del formulario (Contenido, Logística, Finanzas...). Flechas ←/→ para moverse entre ellas. */
+@Component({
+ selector: 'sbo-tabs',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @for (t of tabs(); track t.tabId; let i = $index) {
+
+ }
+
+
+ `,
+ styles: `
+ :host {
+ display: block;
+ margin-top: 14px;
+ }
+ .tabs {
+ display: flex;
+ gap: 2px;
+ padding-left: 4px;
+ border-bottom: 1px solid var(--line);
+ overflow-x: auto;
+ }
+ .tab {
+ margin-bottom: -1px;
+ padding: 4px 16px;
+ border: 1px solid var(--line);
+ border-bottom: none;
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+ background: var(--grid-head);
+ white-space: nowrap;
+ cursor: pointer;
+ }
+ .tab[aria-selected='true'] {
+ padding-bottom: 5px;
+ background: var(--panel);
+ font-weight: 600;
+ }
+ .panels {
+ padding: 10px;
+ border: 1px solid var(--line);
+ border-top: none;
+ background: var(--panel);
+ }
+ `,
+})
+export class Tabs {
+ readonly ariaLabel = input('Secciones');
+ readonly selected = model(0);
+ protected readonly tabs = contentChildren(Tab);
+
+ constructor() {
+ effect(() => {
+ const current = this.selected();
+ this.tabs().forEach((t, i) => t.active.set(i === current));
+ });
+ }
+
+ protected select(index: number): void {
+ this.selected.set(index);
+ }
+
+ protected move(step: number): void {
+ const count = this.tabs().length;
+ if (count === 0) return;
+ const next = (this.selected() + step + count) % count;
+ this.selected.set(next);
+ queueMicrotask(() => document.getElementById(this.tabs()[next].tabId)?.focus());
+ }
+}
diff --git a/apps/web/src/app/shared/window/toolbar.ts b/apps/web/src/app/shared/window/toolbar.ts
new file mode 100644
index 0000000..5dd7a14
--- /dev/null
+++ b/apps/web/src/app/shared/window/toolbar.ts
@@ -0,0 +1,25 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+
+/**
+ * Barra de herramientas del documento (Nueva, Buscar, navegación...). Los botones son
+ * ` |