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:
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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user