feat(web): tablas en los maestros (direcciones, personas de contacto, precios por lista, stock por almacén)

sbo-rows-table reutilizable (también en la ventana de lotes). Direcciones y contactos se envían
completos con reemplazo; los precios solo por filas cambiadas. Probado contra SBODemoCL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 12:31:27 -05:00
1 parent e853b1b76c
commit 4e9d6a757d
14 files changed
+679 -96

No files matched your search

+7 -1
View File
@@ -48,9 +48,15 @@ export interface GridMultiAdd {
export interface DetailColumn {
key: string;
title: string;
type: 'text' | 'number' | 'date';
type: 'text' | 'number' | 'date' | 'select';
width?: string;
required?: boolean;
/** Solo se muestra (aunque la tabla sea editable). */
readonly?: boolean;
/** Opciones de una columna `select`. */
options?: readonly { value: string; label: string }[];
/** Decimales fijos de una columna numérica; sin él, hasta 3. */
decimals?: number;
}
/**
@@ -1,12 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } from '@angular/core';
import { formatNumber, parseDecimal } from '../../core/format';
import type { DetailColumn } from './grid.types';
import { RowsTable } from './rows-table';
type Row = Record<string, unknown>;
/** `2026-01-05T00:00:00Z` → `2026-01-05` (el formato del selector de fecha). */
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
/**
* Ventana con las filas de un detalle de línea (lotes, números de serie): tabla editable con «Añadir fila»
* y «Quitar». Solo emite `saved` al aceptar; cancelar descarta los cambios.
@@ -14,6 +11,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
@Component({
selector: 'sbo-line-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RowsTable],
template: `
<div class="overlay">
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="title()" (keydown.escape)="closed.emit()">
@@ -24,47 +22,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
}
</header>
<div class="body">
<table>
<thead>
<tr>
<th class="rn">#</th>
@for (c of columns(); track c.key) {
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th>
}
@if (!readonly()) {
<th class="del"></th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track $index; let i = $index) {
<tr>
<td class="rn">{{ i + 1 }}</td>
@for (c of columns(); track c.key) {
<td>
<input
[type]="c.type === 'date' ? 'date' : 'text'"
autocomplete="off"
[class.num]="c.type === 'number'"
[readOnly]="readonly()"
[attr.data-r]="i"
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
[value]="text(row, c)"
(change)="set(i, c, $any($event.target))"
/>
</td>
}
@if (!readonly()) {
<td class="del"><button type="button" [attr.aria-label]="'Quitar la fila ' + (i + 1)" (click)="remove(i)">×</button></td>
}
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="columns().length + 2">{{ readonly() ? 'Sin filas.' : 'Sin filas. Pulse «Añadir fila».' }}</td>
</tr>
}
</tbody>
</table>
<sbo-rows-table [columns]="columns()" [(rows)]="rows" [readonly]="readonly()" [emptyText]="readonly() ? 'Sin filas.' : 'Sin filas. Pulse «Añadir fila».'" />
</div>
@if (hint()) {
<p class="hint" [class.bad]="bad()">{{ hint() }}</p>
@@ -89,19 +47,6 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
h2 { margin: 0; font-size: 14px; font-weight: 600; }
.sub { font-size: 12px; color: var(--quiet); }
.body { overflow: auto; padding: 8px 12px; }
table { width: 100%; border-collapse: collapse; }
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
th.req::after { content: ' *'; color: var(--err); }
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); }
td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); }
td.del, th.del { width: 30px; text-align: center; }
input { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); }
input.num { text-align: right; }
input:hover:not([readonly]) { border-color: var(--field-b); }
input:focus { background: var(--field); border-color: var(--focus); outline: none; }
td.del button { padding: 2px 6px; background: none; border: none; color: var(--quiet); font-size: 15px; line-height: 1; cursor: pointer; border-radius: var(--radius); }
td.del button:hover { color: var(--err); background: var(--err-bg); }
tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
.hint { margin: 0; padding: 6px 12px; font-size: 12px; color: var(--quiet); border-top: 1px solid var(--line-soft); }
.hint.bad { color: var(--err); }
footer { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--line); background: var(--panel); }
@@ -125,35 +70,10 @@ export class LineDetailDialog {
protected readonly hint = computed(() => this.hintState().text);
protected readonly bad = computed(() => this.hintState().bad);
protected text(row: Row, c: DetailColumn): string {
const v = row[c.key];
if (c.type === 'date') return dateOnly(v);
if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, undefined);
return v === null || v === undefined ? '' : String(v);
}
protected add(): void {
this.rows.update((rs) => [...rs, {}]);
}
protected remove(index: number): void {
this.rows.update((rs) => rs.filter((_, i) => i !== index));
}
protected set(index: number, c: DetailColumn, input: HTMLInputElement): void {
const raw = input.value;
let value: unknown = raw;
if (c.type === 'number') {
const parsed = raw.trim() === '' ? null : parseDecimal(raw);
if (raw.trim() !== '' && parsed === null) {
input.value = this.text(this.rows()[index], c); // no es un número: se conserva el anterior
return;
}
value = parsed;
}
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
}
/** Las filas sin ningún dato no se guardan. */
protected clean(): Row[] {
return this.rows().filter((r) => this.columns().some((c) => r[c.key] !== null && r[c.key] !== undefined && r[c.key] !== ''));
+142
View File
@@ -0,0 +1,142 @@
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
import { formatNumber, parseDecimal } from '../../core/format';
import type { DetailColumn } from './grid.types';
type Row = Record<string, unknown>;
/** `2026-01-05T00:00:00Z` → `2026-01-05` (el formato del selector de fecha). */
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
/**
* Tabla editable de filas sencillas (direcciones, personas de contacto, precios, lotes…): texto, número,
* fecha o lista fija por columna, con botón para quitar cada fila. Quien la usa añade las filas
* (`rows.update(...)`) y decide qué hacer con ellas; cada cambio actualiza `rows`.
*/
@Component({
selector: 'sbo-rows-table',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="wrap">
<table>
<thead>
<tr>
<th class="rn">#</th>
@for (c of columns(); track c.key) {
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th>
}
@if (canRemove()) {
<th class="del"></th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track $index; let i = $index) {
<tr>
<td class="rn">{{ i + 1 }}</td>
@for (c of columns(); track c.key) {
<td [class.ro]="locked(c)">
@if (c.type === 'select') {
<select
[disabled]="locked(c)"
[attr.data-r]="i"
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
(change)="setSelect(i, c, $any($event.target).value)"
>
<option value=""></option>
@for (o of c.options ?? []; track o.value) {
<option [value]="o.value" [selected]="o.value === row[c.key]">{{ o.label }}</option>
}
</select>
} @else {
<input
[type]="c.type === 'date' ? 'date' : 'text'"
autocomplete="off"
[class.num]="c.type === 'number'"
[readOnly]="locked(c)"
[attr.data-r]="i"
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
[value]="text(row, c)"
(change)="set(i, c, $any($event.target))"
/>
}
</td>
}
@if (canRemove()) {
<td class="del"><button type="button" [attr.aria-label]="'Quitar la fila ' + (i + 1)" (click)="remove(i)">×</button></td>
}
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="columns().length + 2">{{ emptyText() }}</td>
</tr>
}
</tbody>
</table>
</div>
`,
styles: `
:host { display: block; }
.wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
th.req::after { content: ' *'; color: var(--err); }
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; }
td.ro { background: var(--field-ro); }
td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); }
td.del, th.del { width: 30px; text-align: center; }
input, select { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); }
input.num { text-align: right; }
input:hover:not([readonly]), select:hover:not(:disabled) { border-color: var(--field-b); }
input:focus, select:focus { background: var(--field); border-color: var(--focus); outline: none; }
td.del button { padding: 2px 6px; background: none; border: none; color: var(--quiet); font-size: 15px; line-height: 1; cursor: pointer; border-radius: var(--radius); }
td.del button:hover { color: var(--err); background: var(--err-bg); }
tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
`,
})
export class RowsTable {
readonly columns = input.required<readonly DetailColumn[]>();
readonly rows = model<Row[]>([]);
/** Toda la tabla en solo lectura. */
readonly readonly = input(false);
/** Se pueden quitar filas (por defecto, si la tabla es editable). */
readonly removable = input(true);
readonly emptyText = input('Sin filas.');
protected canRemove(): boolean {
return !this.readonly() && this.removable();
}
protected locked(c: DetailColumn): boolean {
return this.readonly() || !!c.readonly;
}
protected text(row: Row, c: DetailColumn): string {
const v = row[c.key];
if (c.type === 'date') return dateOnly(v);
if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, c.decimals);
return v === null || v === undefined ? '' : String(v);
}
protected remove(index: number): void {
this.rows.update((rs) => rs.filter((_, i) => i !== index));
}
protected setSelect(index: number, c: DetailColumn, value: string): void {
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
}
protected set(index: number, c: DetailColumn, input: HTMLInputElement): void {
const raw = input.value;
let value: unknown = raw;
if (c.type === 'number') {
const parsed = raw.trim() === '' ? null : parseDecimal(raw);
if (raw.trim() !== '' && parsed === null) {
input.value = this.text(this.rows()[index], c); // no es un número: se conserva el anterior
return;
}
value = parsed;
input.value = parsed === null ? '' : formatNumber(parsed, c.decimals);
}
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
}
}