feat(web): lotes y series en las líneas de entradas, salidas y transferencias de stock

Una ventana por línea con tabla editable; validación de suma de lotes y una serie por unidad.
Probado contra SBODemoCL: entrada de inventario 156 con SU01 y su lote.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 12:25:01 -05:00
1 parent a5592fac76
commit abc1cc4ac6
16 files changed
+752 -10

No files matched your search

@@ -43,3 +43,30 @@ export interface GridMultiAdd {
lookup: VhLookup;
toLine: (picked: ListRow) => GridLine;
}
/** Una columna de las filas de un detalle de línea (lotes, series). */
export interface DetailColumn {
key: string;
title: string;
type: 'text' | 'number' | 'date';
width?: string;
required?: boolean;
}
/**
* Detalle de una línea: una lista de filas propias de la línea (los lotes o los números de serie de un
* artículo) que se edita en una ventana aparte, abierta desde un botón de la grilla.
*/
export interface GridDetail {
/** Campo de la línea que guarda las filas (`BatchNumbers`, `SerialNumbers`). */
key: string;
/** Título de la ventana y de la columna de la grilla. */
title: string;
/** Si el detalle aplica a esta línea (p. ej. el artículo se gestiona por lote). */
applies: (line: GridLine) => boolean;
columns: readonly DetailColumn[];
/** Texto del botón de la grilla ("2 lotes · 10"). */
summary: (line: GridLine) => string;
/** Aviso bajo la tabla ("La suma de las cantidades debe ser 10"). */
hint?: (line: GridLine) => string;
}
@@ -0,0 +1,161 @@
import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } 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) : '');
/**
* 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.
*/
@Component({
selector: 'sbo-line-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="overlay">
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="title()" (keydown.escape)="closed.emit()">
<header>
<h2>{{ title() }}</h2>
@if (subtitle()) {
<span class="sub">{{ subtitle() }}</span>
}
</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>
</div>
@if (hint()) {
<p class="hint" [class.bad]="bad()">{{ hint() }}</p>
}
<footer>
@if (!readonly()) {
<button class="btn" type="button" (click)="add()">+ Añadir fila</button>
}
<span class="sp"></span>
<button class="btn" type="button" (click)="closed.emit()">{{ readonly() ? 'Cerrar' : 'Cancelar' }}</button>
@if (!readonly()) {
<button class="btn main" type="button" (click)="saved.emit(clean())">Aceptar</button>
}
</footer>
</div>
</div>
`,
styles: `
.overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--overlay); }
.dlg { display: flex; flex-direction: column; width: min(720px, 100%); max-height: 80vh; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; box-shadow: var(--shadow-pop); }
header { display: flex; align-items: baseline; gap: 10px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
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); }
.sp { flex: 1; }
`,
})
export class LineDetailDialog {
readonly title = input.required<string>();
readonly subtitle = input('');
readonly columns = input.required<readonly DetailColumn[]>();
readonly initial = input<readonly Row[]>([]);
readonly readonly = input(false);
/** Texto bajo la tabla, calculado por quien abre la ventana con las filas en edición. */
readonly hintFor = input<((rows: readonly Row[]) => { text: string; bad: boolean }) | undefined>(undefined);
readonly saved = output<Row[]>();
readonly closed = output<void>();
protected readonly rows = linkedSignal<Row[]>(() => this.initial().map((r) => ({ ...r })));
protected readonly hintState = computed(() => this.hintFor()?.(this.rows()) ?? { text: '', bad: false });
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] !== ''));
}
}
@@ -102,6 +102,26 @@ td.ro {
.pick button:hover {
background: var(--btn-h);
}
th.det,
td.det {
width: 130px;
padding: 0 4px;
text-align: center;
}
.detbtn {
width: 100%;
height: 22px;
padding: 0 6px;
background: var(--btn);
border: 1px solid var(--field-b);
border-radius: var(--radius);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.detbtn:hover {
background: var(--btn-h);
}
td.del,
th.del {
width: 30px;
+24 -1
View File
@@ -17,6 +17,9 @@
@for (c of columns(); track c.key) {
<th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required">{{ c.title }}</th>
}
@if (hasDetails()) {
<th class="det">Lotes / Series</th>
}
@if (!readonly()) {
<th class="del"><span class="sr">Quitar</span></th>
}
@@ -67,6 +70,13 @@
}
</td>
}
@if (hasDetails()) {
<td class="det">
@for (d of detailsOf(line); track d.key) {
<button type="button" class="detbtn" [attr.aria-label]="d.title + ' de la línea ' + (i + 1)" (click)="openDetail(i, d)">{{ d.summary(line) }}</button>
}
</td>
}
@if (!readonly()) {
<td class="del">
<button type="button" [attr.aria-label]="'Quitar la línea ' + (i + 1)" title="Quitar línea" (click)="remove(i)">×</button>
@@ -75,7 +85,7 @@
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="columns().length + (readonly() ? 1 : 2)">
<td [attr.colspan]="columns().length + (readonly() ? 1 : 2) + (hasDetails() ? 1 : 0)">
{{ readonly() ? 'El documento no tiene líneas.' : 'No hay líneas. Pulse «Añadir línea» para comenzar.' }}
</td>
</tr>
@@ -84,6 +94,19 @@
</table>
</div>
@if (detailTarget(); as t) {
<sbo-line-detail-dialog
[title]="t.detail.title"
[subtitle]="'Línea ' + (t.row + 1)"
[columns]="t.detail.columns"
[initial]="detailInitial()"
[readonly]="readonly()"
[hintFor]="detailHintFn()"
(saved)="detailSaved($event)"
(closed)="detailTarget.set(null)"
/>
}
@if (lookup(); as cfg) {
<sbo-value-help
[open]="true"
@@ -490,3 +490,93 @@ describe('LinesGrid: alineación de las columnas', () => {
expect(cells).toEqual(['left', 'right', 'right', 'center', 'left']);
});
});
describe('LinesGrid: detalles de línea (lotes)', () => {
const BATCHES = {
key: 'BatchNumbers',
title: 'Lotes',
applies: (l: GridLine) => l['_manageBatches'] === true || (Array.isArray(l['BatchNumbers']) && l['BatchNumbers'].length > 0),
columns: [
{ key: 'BatchNumber', title: 'Lote', type: 'text' as const },
{ key: 'Quantity', title: 'Cantidad', type: 'number' as const },
],
summary: (l: GridLine) => (Array.isArray(l['BatchNumbers']) && l['BatchNumbers'].length ? `${l['BatchNumbers'].length} lote(s)` : 'Indicar lotes'),
};
@Component({
imports: [LinesGrid],
template: `<sbo-lines-grid [columns]="columns" [(lines)]="lines" [readonly]="readonly()" [newLine]="newLine" [details]="details" />`,
})
class DetailHost {
columns = [{ key: 'ItemCode', title: 'Artículo' }, { key: 'Quantity', title: 'Cantidad', type: 'number' as const }];
lines = signal<readonly GridLine[]>([]);
readonly = signal(false);
newLine = () => ({});
details = [BATCHES];
}
async function make(lines: GridLine[], readonly = false) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({ imports: [DetailHost], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
const fixture = TestBed.createComponent(DetailHost);
fixture.componentInstance.lines.set(lines);
fixture.componentInstance.readonly.set(readonly);
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
await tick();
return { fixture, el: fixture.nativeElement as HTMLElement, tick };
}
it('solo las líneas a las que aplica el detalle muestran su botón', async () => {
const { el } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }, { ItemCode: 'A1', Quantity: 1 }]);
const buttons = [...el.querySelectorAll<HTMLButtonElement>('.detbtn')];
expect(buttons.map((b) => b.textContent?.trim())).toEqual(['Indicar lotes']);
expect(el.querySelector('th.det')?.textContent).toContain('Lotes / Series');
});
it('el botón abre la ventana, se añaden filas y al aceptar quedan en la línea', async () => {
const { fixture, el, tick } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }]);
(el.querySelector('.detbtn') as HTMLElement).click();
await tick();
expect(el.querySelector('sbo-line-detail-dialog [role=dialog]')).toBeTruthy();
(el.querySelector('sbo-line-detail-dialog footer .btn') as HTMLElement).click(); // + Añadir fila
await tick();
const inputs = el.querySelectorAll<HTMLInputElement>('sbo-line-detail-dialog tbody input');
inputs[0].value = 'LT-9';
inputs[0].dispatchEvent(new Event('change'));
inputs[1].value = '5';
inputs[1].dispatchEvent(new Event('change'));
await tick();
(el.querySelector('sbo-line-detail-dialog footer .btn.main') as HTMLElement).click(); // Aceptar
await tick();
expect(el.querySelector('sbo-line-detail-dialog')).toBeNull();
expect(fixture.componentInstance.lines()[0]['BatchNumbers']).toEqual([{ BatchNumber: 'LT-9', Quantity: 5 }]);
expect(el.querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote(s)');
});
it('cancelar descarta los cambios', async () => {
const { fixture, el, tick } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }]);
(el.querySelector('.detbtn') as HTMLElement).click();
await tick();
(el.querySelector('sbo-line-detail-dialog footer .btn') as HTMLElement).click();
await tick();
const cancel = [...el.querySelectorAll<HTMLElement>('sbo-line-detail-dialog footer .btn')].find((b) => b.textContent?.trim() === 'Cancelar')!;
cancel.click();
await tick();
expect(fixture.componentInstance.lines()[0]['BatchNumbers']).toBeUndefined();
});
it('en solo lectura se ven los lotes pero no se editan', async () => {
const { el, tick } = await make([{ ItemCode: 'SU01', Quantity: 3, BatchNumbers: [{ BatchNumber: 'LT001', Quantity: 3 }] }], true);
expect(el.querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote(s)');
(el.querySelector('.detbtn') as HTMLElement).click();
await tick();
const input = el.querySelector<HTMLInputElement>('sbo-line-detail-dialog tbody input')!;
expect(input.value).toBe('LT001');
expect(input.readOnly).toBe(true);
expect(el.querySelector('sbo-line-detail-dialog footer .btn.main')).toBeNull();
});
});
+40 -3
View File
@@ -16,7 +16,8 @@ import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
import { ColResize } from '../table/col-resize';
import { ValueHelp } from '../value-help/value-help';
import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
import type { GridColumn, GridDetail, GridLine, GridMultiAdd } from './grid.types';
import { LineDetailDialog } from './line-detail-dialog';
interface LookupTarget {
row: number;
@@ -31,7 +32,7 @@ interface LookupTarget {
@Component({
selector: 'sbo-lines-grid',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ValueHelp, ColResize],
imports: [ValueHelp, ColResize, LineDetailDialog],
templateUrl: './lines-grid.html',
styleUrl: './lines-grid.css',
})
@@ -45,6 +46,10 @@ export class LinesGrid {
/** Recalcula la línea tras un cambio (ej. total = cantidad × precio). */
readonly recalc = input<((line: GridLine, changedKey: string) => GridLine) | undefined>(undefined);
readonly multiAdd = input<GridMultiAdd>();
/** Detalles de línea (lotes, series): una columna de botones que abre su ventana de edición. */
readonly details = input<readonly GridDetail[]>([]);
/** Dado el detalle y las filas en edición, el aviso bajo la tabla de la ventana (ej. «suman 8 de 10»). */
readonly detailHint = input<((detail: GridDetail, line: GridLine, rows: readonly Record<string, unknown>[]) => { text: string; bad: boolean }) | undefined>(undefined);
readonly addLabel = input('Añadir línea');
/** Avisos de la grilla (ej. un código escrito que no existe), para la barra de estado del formulario. */
readonly notice = output<string>();
@@ -53,8 +58,22 @@ export class LinesGrid {
private readonly injector = inject(Injector);
protected readonly lookupTarget = signal<LookupTarget | null>(null);
protected readonly detailTarget = signal<{ row: number; detail: GridDetail } | null>(null);
protected readonly detailHintFn = computed(() => {
const t = this.detailTarget();
const hint = this.detailHint();
const line = t ? this.lines()[t.row] : undefined;
return t && hint && line ? (rows: readonly Record<string, unknown>[]) => hint(t.detail, line, rows) : undefined;
});
protected readonly multiOpen = signal(false);
protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config);
protected readonly hasDetails = computed(() => this.details().length > 0);
/** Filas con que se abre la ventana (estable mientras no cambie la línea: la ventana edita una copia). */
protected readonly detailInitial = computed<readonly Record<string, unknown>[]>(() => {
const t = this.detailTarget();
const line = t ? this.lines()[t.row] : undefined;
return (line?.[t?.detail.key ?? ''] as Record<string, unknown>[] | undefined) ?? [];
});
/**
* Ancho mínimo de la tabla: la suma de los anchos de sus columnas (las que no declaran uno, como la
@@ -66,7 +85,7 @@ export class LinesGrid {
const w = parseFloat(c.width ?? '');
return Number.isFinite(w) ? w : 220;
};
return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30));
return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30) + (this.hasDetails() ? 130 : 0));
});
protected readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple);
@@ -231,6 +250,24 @@ export class LinesGrid {
if (!focus()) afterNextRender(() => void focus(), { injector: this.injector });
}
// --- detalles de línea (lotes, series) ----------------------------------------------------
/** Los detalles que aplican a una línea. */
protected detailsOf(line: GridLine): readonly GridDetail[] {
return this.details().filter((d) => d.applies(line));
}
protected openDetail(row: number, detail: GridDetail): void {
this.detailTarget.set({ row, detail });
}
protected detailSaved(rows: Record<string, unknown>[]): void {
const t = this.detailTarget();
if (!t) return;
this.lines.update((ls) => ls.map((l, i) => (i === t.row ? { ...l, [t.detail.key]: rows } : l)));
this.detailTarget.set(null);
}
// --- listas de valores -----------------------------------------------------------------
protected openLookup(row: number, column: GridColumn): void {