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: `

{{ config.description }}

@if (config.pickFrom) { }

@if (result(); as r) {

Stock del lote {{ r.lot }}

@for (row of visibleRows(); track key(row)) { } @empty { }
Artículo@if (names.show()) {ItemCode}Descripción@if (names.show()) {ItemDescription}Almacén@if (names.show()) {WarehouseCode}Nombre@if (names.show()) {WarehouseName} En stock@if (names.show()) {Quantity}Comprometido@if (names.show()) {CommitQty}Cantidad a mover
{{ row.ItemCode }} {{ row.ItemDescription }} {{ row.WarehouseCode }} {{ row.WarehouseName }} {{ row.Quantity }} {{ row.Committed }} @if (isSelected(row)) { @if (qtyError(row); as e) { {{ e }} } } @else if (isLocked(row)) { Ya inmovilizado }
El lote no tiene stock en el almacén {{ fromWarehouse() }}.

Solicitud de traslado

@if (confirming()) { } } @if (result() && !confirming()) { }
`, 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>({}); private readonly message = signal(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 { 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(initialForm(this.config, todayIso())); protected set(change: Partial): 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 { 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(() => 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».' }); }