Files
basic-erp/apps/web/src/app/features/client/lotes/lote-transfer.page.ts
T

263 lines
15 KiB
TypeScript
Raw Normal View History

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».' });
}