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

+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 {