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
@@ -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] !== ''));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user