264 lines
15 KiB
TypeScript
264 lines
15 KiB
TypeScript
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
|
|
import { ActivatedRoute } from '@angular/router';
|
|
import { apiErrorMessage } from '../../../core/api';
|
|
import { FieldNames } from '../../../core/field-names';
|
|
import { todayIso } from '../../../core/format';
|
|
import { DateField, LookupField, SelectField, TextField, TextareaField } from '../../../shared/form';
|
|
import type { VhLookup } from '../../../shared/value-help/value-help.types';
|
|
import { SboWindow, type StatusMessage } from '../../../shared/window';
|
|
import { WAREHOUSE_LOOKUP } from '../../documents/document-layouts';
|
|
import { LOT_FLOWS, type LotFlow, MOTIVO_MAX, TEXT_MAX } from './lotes-flujo.config';
|
|
import {
|
|
type LotStockRow,
|
|
type TransferForm,
|
|
clampQuantity,
|
|
initialForm,
|
|
quantityError,
|
|
rowKey,
|
|
transferErrors,
|
|
transferPayload,
|
|
} from './lotes-flujo';
|
|
import { LotesFlujoApi } from './lotes-flujo-api';
|
|
|
|
const WAREHOUSE: VhLookup = { ...WAREHOUSE_LOOKUP, nameKey: 'WarehouseName' };
|
|
|
|
/**
|
|
* «Inmovilizar lotes» y «Liberar lotes» (TAGUMEDICA): se busca un lote por su número, se marcan los almacenes con stock y
|
|
* las cantidades, y se crea la solicitud de traslado en SAP con una línea por almacén de origen y el lote en cada una. La
|
|
* pantalla es la misma; el flujo (`data.flow`) fija el almacén de origen y de destino por defecto y qué textos son obligatorios.
|
|
*/
|
|
@Component({
|
|
selector: 'sbo-lote-transfer-page',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [DateField, LookupField, SboWindow, SelectField, TextField, TextareaField],
|
|
template: `
|
|
<sbo-window [title]="config.title" [status]="status()">
|
|
<p class="hint">{{ config.description }}</p>
|
|
|
|
<div class="sbo-hdr">
|
|
<div class="sbo-col">
|
|
<sbo-text-field label="Número de lote" dbName="BatchNumber" [value]="lot()" (valueChange)="lot.set($event)" (keydown.enter)="search()" />
|
|
</div>
|
|
<div class="sbo-col">
|
|
@if (config.pickFrom) {
|
|
<sbo-lookup-field label="Almacén de origen" dbName="FromWarehouseCode" [lookup]="warehouse" [value]="fromWarehouse()" (valueChange)="fromWarehouse.set($event)" resolveOnLeave />
|
|
}
|
|
</div>
|
|
</div>
|
|
<p class="row"><button type="button" class="btn main" [disabled]="searching() || !lot().trim()" (click)="search()">Buscar</button></p>
|
|
|
|
@if (result(); as r) {
|
|
<h3>Stock del lote {{ r.lot }}</h3>
|
|
<div class="wrap">
|
|
<table class="stock">
|
|
<thead>
|
|
<tr>
|
|
<th class="chk"></th><th [attr.title]="'ItemCode'">Artículo@if (names.show()) {<span class="db">ItemCode</span>}</th><th [attr.title]="'ItemDescription'">Descripción@if (names.show()) {<span class="db">ItemDescription</span>}</th><th [attr.title]="'WarehouseCode'">Almacén@if (names.show()) {<span class="db">WarehouseCode</span>}</th><th [attr.title]="'WarehouseName'">Nombre@if (names.show()) {<span class="db">WarehouseName</span>}</th>
|
|
<th class="num" [attr.title]="'Quantity'">En stock@if (names.show()) {<span class="db">Quantity</span>}</th><th class="num" [attr.title]="'CommitQty'">Comprometido@if (names.show()) {<span class="db">CommitQty</span>}</th><th class="num">Cantidad a mover</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (row of visibleRows(); track key(row)) {
|
|
<tr [class.locked]="isLocked(row)">
|
|
<td class="chk"><input type="checkbox" [checked]="isSelected(row)" [disabled]="isLocked(row)" [attr.aria-label]="'Mover ' + row.ItemCode + ' del almacén ' + row.WarehouseCode" (change)="toggle(row)" /></td>
|
|
<td class="code">{{ row.ItemCode }}</td>
|
|
<td>{{ row.ItemDescription }}</td>
|
|
<td class="code">{{ row.WarehouseCode }}</td>
|
|
<td>{{ row.WarehouseName }}</td>
|
|
<td class="num">{{ row.Quantity }}</td>
|
|
<td class="num">{{ row.Committed }}</td>
|
|
<td class="num">
|
|
@if (isSelected(row)) {
|
|
<input class="qty" inputmode="decimal" [attr.aria-label]="'Cantidad de ' + row.ItemCode + ' del almacén ' + row.WarehouseCode" [value]="quantities()[key(row)]" (input)="setQuantity(row, $any($event.target))" />
|
|
@if (qtyError(row); as e) {
|
|
<span class="err" role="alert">{{ e }}</span>
|
|
}
|
|
} @else if (isLocked(row)) {
|
|
<span class="muted">Ya inmovilizado</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
} @empty {
|
|
<tr class="none"><td colspan="8">El lote no tiene stock en el almacén {{ fromWarehouse() }}.</td></tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h3>Solicitud de traslado</h3>
|
|
<div class="sbo-hdr">
|
|
<div class="sbo-col">
|
|
<sbo-date-field label="Fecha" dbName="DocDate" required [value]="form().docDate" (valueChange)="set({ docDate: $event })" />
|
|
<sbo-select-field label="Serie" dbName="Series" [options]="series.value() ?? []" placeholder="(por defecto)" [value]="form().series" (valueChange)="set({ series: $event === null || $event === '' ? null : +$event })" />
|
|
<sbo-lookup-field label="Almacén destino" dbName="WarehouseCode" required [lookup]="warehouse" [value]="form().toWarehouse" (valueChange)="set({ toWarehouse: $event })" resolveOnLeave />
|
|
</div>
|
|
<div class="sbo-col">
|
|
<sbo-select-field label="Movimiento de inventario" dbName="U_SIS_MOVINV" required [options]="options().movimientoInventario" placeholder="" [value]="form().movimientoInventario" (valueChange)="set({ movimientoInventario: '' + ($event ?? '') })" />
|
|
<sbo-select-field label="Tipo de operación" dbName="U_EXX_TIPOOPER" required [options]="options().tipoOperacion" placeholder="" [value]="form().tipoOperacion" (valueChange)="set({ tipoOperacion: '' + ($event ?? '') })" />
|
|
<sbo-select-field label="Tipo de despacho" dbName="U_SIS_TIPDESP" required [options]="options().tipoDespacho" placeholder="" [value]="form().tipoDespacho" (valueChange)="set({ tipoDespacho: '' + ($event ?? '') })" />
|
|
<sbo-select-field label="Motivo de almacén" dbName="U_SIS_MOTALM" required [options]="options().movimientoAlmacen" placeholder="" [value]="form().movimientoAlmacen" (valueChange)="set({ movimientoAlmacen: '' + ($event ?? '') })" />
|
|
</div>
|
|
</div>
|
|
<sbo-textarea-field label="Motivo del estado" dbName="U_SIS_MOTEST" [rows]="3" required [value]="form().motivo" (valueChange)="set({ motivo: $event })" [error]="over(form().motivo, motivoMax)" />
|
|
<sbo-textarea-field label="Comentario del estado" dbName="U_SIS_COMEST" [rows]="3" [required]="config.requireDetail" [value]="form().comentario" (valueChange)="set({ comentario: $event })" [error]="over(form().comentario, textMax)" />
|
|
<sbo-textarea-field label="Acción a tomar" dbName="U_SIS_ACCTOM" [rows]="3" [required]="config.requireDetail" [value]="form().accion" (valueChange)="set({ accion: $event })" [error]="over(form().accion, textMax)" />
|
|
|
|
@if (confirming()) {
|
|
<p class="ask" role="alert">
|
|
¿Crear la solicitud de traslado de {{ selected().length }} {{ selected().length === 1 ? 'almacén' : 'almacenes' }} del lote {{ r.lot }} al almacén {{ form().toWarehouse }}?
|
|
<button type="button" class="btn main" [disabled]="saving()" (click)="create()">Sí, crear</button>
|
|
<button type="button" class="btn" [disabled]="saving()" (click)="confirming.set(false)">No</button>
|
|
</p>
|
|
}
|
|
}
|
|
@if (result() && !confirming()) {
|
|
<button sboActions class="btn main" type="button" [disabled]="saving() || problems().length > 0" [attr.title]="problems()[0] ?? null" (click)="confirming.set(true)">Generar solicitud de traslado</button>
|
|
}
|
|
</sbo-window>
|
|
`,
|
|
styles: `
|
|
:host { display: block; }
|
|
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
|
|
.hint { margin: 0 0 8px; color: var(--quiet); }
|
|
.row { margin: 4px 0 0; }
|
|
.wrap { overflow-x: auto; }
|
|
.stock { width: 100%; border-collapse: collapse; }
|
|
.stock th .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
|
|
.stock th { padding: 4px 8px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; text-align: left; white-space: nowrap; }
|
|
.stock td { padding: 3px 8px; border: 1px solid var(--line-soft); }
|
|
.stock .num { text-align: right; }
|
|
.stock .chk { width: 28px; text-align: center; }
|
|
.stock .code { font-family: var(--mono, monospace); }
|
|
.stock tr.locked td { background: var(--field-ro); color: var(--quiet); }
|
|
.stock tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
|
|
.qty { width: 90px; text-align: right; }
|
|
.err { display: block; color: var(--err); font-size: 11.5px; }
|
|
.muted { color: var(--quiet); }
|
|
.ask { margin: 12px 0 0; padding: 6px 10px; background: var(--warn-bg, var(--panel)); border: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
|
|
`,
|
|
})
|
|
export class LoteTransferPage {
|
|
private readonly api = inject(LotesFlujoApi);
|
|
protected readonly names = inject(FieldNames);
|
|
private readonly flow = inject(ActivatedRoute).snapshot.data['flow'] as LotFlow;
|
|
|
|
protected readonly config = LOT_FLOWS[this.flow];
|
|
protected readonly warehouse = WAREHOUSE;
|
|
protected readonly motivoMax = MOTIVO_MAX;
|
|
protected readonly textMax = TEXT_MAX;
|
|
|
|
// --- búsqueda ------------------------------------------------------------------------------------------------------
|
|
protected readonly lot = signal('');
|
|
protected readonly fromWarehouse = signal(this.config.fromWarehouse ?? '');
|
|
protected readonly searching = signal(false);
|
|
protected readonly result = signal<{ lot: string; rows: LotStockRow[] } | null>(null);
|
|
protected readonly quantities = signal<Record<string, string>>({});
|
|
private readonly message = signal<StatusMessage | null>(null);
|
|
|
|
/** Las filas que se ven: con origen elegido (liberar), solo las de ese almacén; si no, todas. */
|
|
protected readonly visibleRows = computed(() => {
|
|
const r = this.result();
|
|
if (!r) return [];
|
|
const from = this.config.pickFrom ? this.fromWarehouse() : null;
|
|
return from ? r.rows.filter((x) => x.WarehouseCode === from) : r.rows;
|
|
});
|
|
protected readonly key = rowKey;
|
|
|
|
protected async search(): Promise<void> {
|
|
const lot = this.lot().trim();
|
|
if (!lot || this.searching()) return;
|
|
this.searching.set(true);
|
|
this.message.set({ kind: 'info', text: 'Buscando el lote…' });
|
|
this.result.set(null);
|
|
this.quantities.set({});
|
|
this.confirming.set(false);
|
|
try {
|
|
const rows = await this.api.stock(lot);
|
|
if (!rows.length) throw new Error(`No se encontró stock para el lote «${lot}»`);
|
|
if (this.config.pickFrom && !rows.some((r) => r.WarehouseCode === this.fromWarehouse())) throw new Error(`El lote «${lot}» no tiene stock en el almacén ${this.fromWarehouse()}`);
|
|
this.result.set({ lot, rows });
|
|
this.form.set(initialForm(this.config, todayIso(), this.defaultSeries.value() ?? null));
|
|
this.message.set(null);
|
|
} catch (e) {
|
|
this.message.set({ kind: 'err', text: e instanceof Error && !('status' in e) ? e.message : apiErrorMessage(e) });
|
|
} finally {
|
|
this.searching.set(false);
|
|
}
|
|
}
|
|
|
|
// --- selección -----------------------------------------------------------------------------------------------------
|
|
protected isLocked(row: LotStockRow): boolean {
|
|
return row.WarehouseCode === this.config.lockedWarehouse;
|
|
}
|
|
protected isSelected(row: LotStockRow): boolean {
|
|
return rowKey(row) in this.quantities();
|
|
}
|
|
protected toggle(row: LotStockRow): void {
|
|
if (this.isLocked(row)) return;
|
|
const key = rowKey(row);
|
|
this.quantities.update((q) => {
|
|
if (key in q) {
|
|
const { [key]: _removed, ...rest } = q;
|
|
return rest;
|
|
}
|
|
// al marcar la fila se precarga con todo el stock (se puede bajar, no superar)
|
|
return { ...q, [key]: String(row.Quantity) };
|
|
});
|
|
}
|
|
protected setQuantity(row: LotStockRow, source: string | HTMLInputElement): void {
|
|
const clamped = clampQuantity(row, typeof source === 'string' ? source : source.value);
|
|
// si se recortó, el casillero debe mostrar el valor corregido aunque el valor guardado no haya cambiado
|
|
if (typeof source !== 'string') source.value = clamped;
|
|
this.quantities.update((q) => ({ ...q, [rowKey(row)]: clamped }));
|
|
}
|
|
protected qtyError(row: LotStockRow): string {
|
|
return quantityError(row, this.quantities()[rowKey(row)] ?? '');
|
|
}
|
|
protected readonly selected = computed(() => this.visibleRows().filter((r) => this.isSelected(r)).map((row) => ({ row, quantity: this.quantities()[rowKey(row)] ?? '' })));
|
|
|
|
// --- solicitud -----------------------------------------------------------------------------------------------------
|
|
protected readonly form = signal<TransferForm>(initialForm(this.config, todayIso()));
|
|
protected set(change: Partial<TransferForm>): void {
|
|
this.form.update((f) => ({ ...f, ...change }));
|
|
}
|
|
protected over = (text: string, max: number): string => (text.length > max ? `Máximo ${max} caracteres` : '');
|
|
|
|
protected readonly catalogs = resource({ loader: () => this.api.catalogs() });
|
|
protected readonly options = computed(() => this.catalogs.value() ?? { movimientoInventario: [], tipoOperacion: [], tipoDespacho: [], movimientoAlmacen: [] });
|
|
protected readonly series = resource({ loader: () => this.api.series().catch(() => []) });
|
|
/** La serie por defecto de SAP: queda elegida al buscar el lote (se puede cambiar o dejar en blanco). */
|
|
private readonly defaultSeries = resource({ loader: () => this.api.defaultSeries().catch(() => null) });
|
|
|
|
protected readonly problems = computed(() => {
|
|
const f = this.form();
|
|
const errors = transferErrors(this.config, f, this.selected());
|
|
if (f.motivo.length > MOTIVO_MAX || f.comentario.length > TEXT_MAX || f.accion.length > TEXT_MAX) errors.push('Algún texto es demasiado largo');
|
|
return errors;
|
|
});
|
|
|
|
protected readonly confirming = signal(false);
|
|
protected readonly saving = signal(false);
|
|
|
|
protected async create(): Promise<void> {
|
|
const r = this.result();
|
|
if (!r || this.saving() || this.problems().length) return;
|
|
this.saving.set(true);
|
|
try {
|
|
const doc = await this.api.create(transferPayload(r.lot, this.form(), this.selected()));
|
|
// éxito: se vuelve a la búsqueda y el aviso queda arriba (el panel del lote desaparece)
|
|
this.result.set(null);
|
|
this.lot.set('');
|
|
this.quantities.set({});
|
|
this.message.set({ kind: 'ok', text: `Solicitud de traslado creada en SAP (Doc. ${doc['DocNum'] ?? doc['DocEntry']}).` });
|
|
} catch (e) {
|
|
// el error deja el panel con todo lo completado para corregir y reintentar
|
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
|
} finally {
|
|
this.confirming.set(false);
|
|
this.saving.set(false);
|
|
}
|
|
}
|
|
|
|
protected readonly status = computed<StatusMessage>(() => this.message() ?? { kind: 'info', text: this.result() ? 'Marque los almacenes, complete la solicitud y genérela.' : 'Escriba el número de lote y pulse «Buscar».' });
|
|
}
|