2026-10-20 15:52:01 -05:00
import { ChangeDetectionStrategy , Component , computed , inject , resource , signal } from '@angular/core' ;
import { ActivatedRoute } from '@angular/router' ;
import { apiErrorMessage } from '../../../core/api' ;
2026-10-20 15:55:51 -05:00
import { FieldNames } from '../../../core/field-names' ;
2026-10-20 15:52:01 -05:00
import { todayIso } from '../../../core/format' ;
2026-10-20 15:54:53 -05:00
import { DateField , LookupField , SelectField , TextField , TextareaField } from '../../../shared/form' ;
2026-10-20 15:52:01 -05:00
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 ,
2026-10-20 15:54:53 -05:00
imports : [ DateField , LookupField , SboWindow , SelectField , TextField , TextareaField ],
2026-10-20 15:52:01 -05:00
template : `
<sbo-window [title]="config.title" [status]="status()">
<p class="hint">{{ config.description }}</p>
<div class="sbo-hdr">
<div class="sbo-col">
2026-10-20 15:55:51 -05:00
<sbo-text-field label="Número de lote" dbName="BatchNumber" [value]="lot()" (valueChange)="lot.set($event)" (keydown.enter)="search()" />
2026-10-20 15:52:01 -05:00
</div>
<div class="sbo-col">
@if (config.pickFrom) {
2026-10-20 15:55:51 -05:00
<sbo-lookup-field label="Almacén de origen" dbName="FromWarehouseCode" [lookup]="warehouse" [value]="fromWarehouse()" (valueChange)="fromWarehouse.set($event)" resolveOnLeave />
2026-10-20 15:52:01 -05:00
}
</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>
2026-10-20 15:55:51 -05:00
<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>
2026-10-20 15:52:01 -05:00
</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">
2026-10-20 15:55:51 -05:00
<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 />
2026-10-20 15:52:01 -05:00
</div>
<div class="sbo-col">
2026-10-20 15:55:51 -05:00
<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 ?? '') })" />
2026-10-20 15:52:01 -05:00
</div>
</div>
2026-10-20 15:55:51 -05:00
<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)" />
2026-10-20 15:52:01 -05:00
@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; }
2026-10-20 15:55:51 -05:00
.stock th .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
2026-10-20 15:52:01 -05:00
.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 );
2026-10-20 15:55:51 -05:00
protected readonly names = inject ( FieldNames );
2026-10-20 15:52:01 -05:00
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 });
2026-10-20 15:57:34 -05:00
this . form . set ( initialForm ( this . config , todayIso (), this . defaultSeries . value () ?? null ));
2026-10-20 15:52:01 -05:00
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 (() => []) });
2026-10-20 15:57:34 -05:00
/** 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 ) });
2026-10-20 15:52:01 -05:00
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».' });
}