From 4c2120ab0d8151533a7ccf9acf9b04a7f65a1280 Mon Sep 17 00:00:00 2001 From: aledguedes Date: Thu, 13 Aug 2026 09:54:53 -0300 Subject: [PATCH] Inventory tab: inline multi-warehouse table Overhaul inventory UI and data flow: add TableHeaderOption model and table-values, introduce reserved_quantity to UpdateProductInventoryPayload and mapResponseToPayload, and replace the old inventory form with a full-featured inline multi-warehouse table. The ProductInventoryTab component now uses signals/computeds/local state, provides add/change/remove/toggles, computes totals and stock badges, and emits inventoriesChange. ProductWorkspace passes inventories and handles onInventoriesChange. Minor UX / template improvements and safer payload mapping included. --- .../models/generals/table-inventory.model.ts | 8 + .../models/products/product-request.model.ts | 26 +- .../inventory-tab/inventory-tab.html | 487 ++++++++++++++---- .../inventory-tab/inventory-tab.ts | 303 ++++++++++- .../inventory-tab/utils/table-values.ts | 61 +++ .../product-workspace/product-workspace.html | 7 +- .../product-workspace/product-workspace.ts | 23 +- 7 files changed, 763 insertions(+), 152 deletions(-) create mode 100644 src/app/core/models/generals/table-inventory.model.ts create mode 100644 src/app/features/catalog/components/product-form/inventory-tab/utils/table-values.ts diff --git a/src/app/core/models/generals/table-inventory.model.ts b/src/app/core/models/generals/table-inventory.model.ts new file mode 100644 index 0000000..5cc5958 --- /dev/null +++ b/src/app/core/models/generals/table-inventory.model.ts @@ -0,0 +1,8 @@ +export interface TableHeaderOption { + key: string; + label: string; + align?: 'left' | 'center' | 'right'; + minWidth?: string; + width?: string; + showInReadOnly?: boolean; +} diff --git a/src/app/core/models/products/product-request.model.ts b/src/app/core/models/products/product-request.model.ts index e502d4f..6aacfe6 100644 --- a/src/app/core/models/products/product-request.model.ts +++ b/src/app/core/models/products/product-request.model.ts @@ -128,6 +128,7 @@ export interface UpdateProductInventoryPayload { maximum_quantity?: number | null; allow_backorder?: boolean | null; active?: boolean | null; + reserved_quantity?: number; } /** @@ -174,14 +175,13 @@ export function mapResponseToPayload(prod: { id?: string; warehouse: { id: string }; quantity: number; + reserved_quantity?: number; minimum_quantity?: number | null; maximum_quantity?: number | null; allow_backorder?: boolean; active?: boolean; }[]; }): UpdateProductPayload { - const firstInventory = prod.inventories?.[0]; - return { // Referências (IDs de entidades relacionadas) category_id: prod.category?.id, @@ -218,17 +218,17 @@ export function mapResponseToPayload(prod: { : undefined, // Inventário: incluído apenas se o produto já tiver estoque cadastrado - inventories: firstInventory - ? [ - { - warehouse_id: firstInventory.warehouse.id, - quantity: firstInventory.quantity, - minimum_quantity: firstInventory.minimum_quantity ?? null, - maximum_quantity: firstInventory.maximum_quantity ?? null, - allow_backorder: firstInventory.allow_backorder ?? false, - active: firstInventory.active ?? true, - }, - ] + inventories: prod.inventories + ? prod.inventories.map((inv) => ({ + id: inv.id, + warehouse_id: inv.warehouse.id, + quantity: inv.quantity, + reserved_quantity: inv.reserved_quantity || 0, + minimum_quantity: inv.minimum_quantity ?? null, + maximum_quantity: inv.maximum_quantity ?? null, + allow_backorder: inv.allow_backorder ?? false, + active: inv.active ?? true, + })) : undefined, // Relacionamentos: undefined (não enviados) até o usuário editar a aba correspondente. diff --git a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html index 212d41a..e8a4de2 100644 --- a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html +++ b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html @@ -1,117 +1,402 @@ -
-
-

- - Configuração de Estoque Inicial e Inventário -

-

- As informações abaixo serão utilizadas para criar automaticamente o registro correspondente na tabela de inventário, vinculando o produto ao depósito selecionado. -

-
+
+ +
+
+
+

+ + Estoque & Logística por Múltiplos Depósitos +

+

+ Edição direta e simplificada na tabela: ajuste saldos, limites e regras sem sair da + página. +

+
- -
- - - - - -
+ @if (!isReadOnly()) { + + Adicionar Depósito + + } +
-
- - - - - -
+ +
+ +
+
+ Saldo Físico Total + +
+
+ {{ totalPhysical() }} + un. +
+
- -
-
-

Permitir Backorder (Venda sem Estoque)

-

- Define se o produto poderá ser vendido mesmo quando não houver estoque disponível em saldo físico. -

+ +
+
+ Reservado + +
+
+ {{ totalReserved() }} + un. +
+
+ + +
+
+ Disponível + +
+
+ {{ totalAvailable() }} + un. +
+
+ + +
+
+ Depósitos + +
+
+ {{ activeWarehousesCount() }} + + de {{ localInventories().length }} ativos + +
+
- +
+ + Regra Comercial do Produto: + + @if (anyBackorderAllowed()) { + + + Venda sem estoque (Backorder) permitida em ao menos 1 depósito + + } @else { + + Venda condicional ao estoque disponível + + } +
+ + @if (errors()['inventories']) { + + + {{ errors()['inventories'] }} + + } +
- -
-
- - Campos Gerenciados Automaticamente pelo Sistema + + @if (localInventories().length > 0) { +
+
+ + Depósitos & Saldo Físico Inline ({{ localInventories().length }}) + + + + Edição instantânea na própria linha +
-
-
- ID (id) - - Gerado Auto (UUID) - -
+ +
+ + + + @for (header of tableHeaders; track header.key) { @if (!isReadOnly() || + header.showInReadOnly) { + + } } + + + + @for (item of localInventories(); track item.id || $index; let idx = $index) { @let + levelBadge = getStockLevelBadge(item); + + + -
- Qtd. Reservada (reserved) - 0 (Inicial) -
+ + -
- Status (active) - - Ativo (true) - -
+ + + + + + + + + + + + + + + + + + + + @if (!isReadOnly()) { + + } + + } + +
+ {{ header.label }} +
+ @let itemWhOptions = getWarehouseOptionsForItem(item); @if (!isReadOnly() && + itemWhOptions.length > 0) { +
+ +
+
+ +
+ +
-
- Produto (product_id) - Vínculo Automático -
+ +
+ + {{ getWarehouseCode(item.warehouse_id) }} + + + + {{ levelBadge.label }} + +
+
+ } @else { + +
+
+ +
+
+
+ + {{ getWarehouseName(item.warehouse_id) }} + + + {{ getWarehouseCode(item.warehouse_id) }} + +
+
+ + + {{ levelBadge.label }} + +
+
+
+ } +
+ @if (!isReadOnly()) { +
+ + + un. + +
+ } @else { + + {{ item.quantity }} un. + + } +
+ + {{ $any(item).reservedQuantity || 0 }} + + + + {{ (item.quantity || 0) - ($any(item).reservedQuantity || 0) }} + + + @if (!isReadOnly()) { +
+ + + un. + +
+ } @else { + + {{ item.minimum_quantity ?? 0 }} un. + + } +
+ @if (!isReadOnly()) { +
+ + + un. + +
+ } @else { + + {{ item.maximum_quantity !== null && item.maximum_quantity !== undefined ? + item.maximum_quantity + ' un.' : 'Sem limite' }} + + } +
+ + + + + +
+
+
+ } @else { + +
+
+
+ +
+

+ Nenhum depósito vinculado a este produto +

+

+ Adicione um depósito para registrar o saldo físico inicial e definir os limites de segurança + diretamente na tela. +

+
+ + @if (!isReadOnly()) { + + Adicionar Primeiro Depósito + + }
+ }
diff --git a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts index d7c1baa..a3521e5 100644 --- a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts +++ b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts @@ -3,54 +3,303 @@ import { ChangeDetectionStrategy, input, output, - OnChanges, - SimpleChanges, + computed, + signal, + effect, + untracked, } from '@angular/core'; -import { InputComponent } from '../../../../../design-system/input/input'; -import { SelectComponent } from '../../../../../design-system/select/select'; import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; import { DSelectOption } from '../../../../../core/models/design-system/select-option.model'; +import { ButtonComponent } from '../../../../../design-system/button/button'; +import { TableHeaderOption } from '../../../../../core/models/generals/table-inventory.model'; +import { tableHeaders } from './utils/table-values'; +import { UpdateProductInventoryPayload } from '../../../../../core/models/products/product-request.model'; + +export type LocalInventoryItem = UpdateProductInventoryPayload & { + isDraft?: boolean; +}; @Component({ selector: 'app-product-inventory-tab', standalone: true, - imports: [InputComponent, SelectComponent, AppIconComponent], + imports: [AppIconComponent, ButtonComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './inventory-tab.html', }) -export class ProductInventoryTabComponent implements OnChanges { - ngOnChanges(changes: SimpleChanges): void { - console.log('[INVENTORY TAB - CHANGES]', changes); - } - readonly formWarehouseId = input(''); - readonly formQuantity = input(0); - readonly formMinQuantity = input(0); - readonly formMaxQuantity = input(null); - readonly formAllowBackorder = input(false); +export class ProductInventoryTabComponent { + readonly fieldChange = output<{ field: string; value: string }>(); + readonly numberFieldChange = output<{ field: string; value: string }>(); + readonly nullableNumberFieldChange = output<{ field: string; value: string }>(); + readonly inventories = input([]); readonly warehouseOptions = input([]); readonly isReadOnly = input(false); readonly errors = input>({}); - readonly fieldChange = output<{ field: string; value: string }>(); - readonly numberFieldChange = output<{ field: string; value: string }>(); - readonly nullableNumberFieldChange = output<{ field: string; value: string }>(); - readonly toggleBackorder = output(); + readonly inventoriesChange = output(); + + private removedItemsCache = new Map>(); + readonly tableHeaders: TableHeaderOption[] = tableHeaders; + + // Estado Local ÚNICO para a Interface + readonly localInventories = signal([]); + + // Computeds apontando estritamente para o localInventories + readonly totalPhysical = computed(() => + this.localInventories().reduce((sum, item) => sum + (Number(item.quantity) || 0), 0), + ); + + readonly totalReserved = computed(() => + this.localInventories().reduce((sum, item) => sum + (Number(item.reserved_quantity) || 0), 0), + ); + + readonly totalAvailable = computed(() => + this.localInventories().reduce( + (sum, item) => + sum + + (item.active !== false + ? (Number(item.quantity) || 0) - (Number(item.reserved_quantity) || 0) + : 0), + 0, + ), + ); + + readonly activeWarehousesCount = computed( + () => this.localInventories().filter((item) => item.active !== false).length, + ); + + readonly anyBackorderAllowed = computed(() => + this.localInventories().some((item) => item.active !== false && item.allow_backorder), + ); + + readonly availableWarehouseOptions = computed(() => { + const currentItems = this.localInventories() || []; + const usedWhIds = new Set(currentItems.map((i) => String(i.warehouse_id))); + return this.warehouseOptions().filter((opt) => !usedWhIds.has(String(opt.value))); + }); + + constructor() { + effect(() => { + const incoming = this.inventories() || []; + untracked(() => { + this.localInventories.set(incoming); + }); + }); + } + + getWarehouseOptionsForItem(item: UpdateProductInventoryPayload): DSelectOption[] { + const currentItems = this.localInventories() || []; + const otherUsedWhIds = new Set( + currentItems + .filter((i) => (i.id && item.id ? i.id !== item.id : i !== item)) + .map((i) => String(i.warehouse_id)), + ); + return this.warehouseOptions().filter((opt) => !otherUsedWhIds.has(String(opt.value))); + } + + getWarehouseCode(warehouse_id: string): string { + if (!warehouse_id) return 'N/A'; + if (warehouse_id === 'wh-01') return 'DC-SP'; + if (warehouse_id === 'wh-02') return 'DF-PR'; + if (warehouse_id === 'wh-03') return 'DD-RJ'; + return String(warehouse_id).toUpperCase(); + } + + addWarehouseDirect(): void { + if (this.isReadOnly()) return; + + const available = this.availableWarehouseOptions(); + + // Garante um item selecionável mesmo que a lista esteja vazia + const firstWh = + available && available.length > 0 + ? available[0] + : this.warehouseOptions() && this.warehouseOptions().length > 0 + ? this.warehouseOptions()[0] + : null; + + const whId = firstWh ? String(firstWh.value) : 'wh-01'; + const cached = this.removedItemsCache.get(whId); + + const newItem: LocalInventoryItem = { + id: cached?.id || `draft-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, + warehouse_id: whId, + quantity: cached?.quantity !== undefined ? cached.quantity : 0, + reserved_quantity: cached?.reserved_quantity !== undefined ? cached.reserved_quantity : 0, + minimum_quantity: cached?.minimum_quantity !== undefined ? cached.minimum_quantity : 0, + maximum_quantity: cached?.maximum_quantity !== undefined ? cached.maximum_quantity : null, + allow_backorder: cached?.allow_backorder !== undefined ? cached.allow_backorder : false, + active: cached?.active !== undefined ? cached.active : true, + }; + + this.localInventories.update((current) => [...current, newItem]); + this.emitToParent(); + } + + private emitToParent(): void { + const activeItems = this.localInventories() + .filter((item) => !item.isDraft) // se usar a flag isDraft + .map(({ id, ...payload }) => { + // Checa se é um ID de rascunho + const isDraftId = typeof id === 'string' && id.startsWith('draft-'); + + // Se for draft, envia sem a chave id. Se for item existente, envia o id original + return isDraftId ? payload : { id, ...payload }; + }); + + this.inventoriesChange.emit(activeItems); + } + + updateQuantity(index: number, value: string | number): void { + if (this.isReadOnly()) return; + const num = Math.max(0, Number(value) || 0); + const currentItem = this.localInventories()[index]; + if (!currentItem) return; + + this.localInventories.update((current) => + current.map((item, i) => (i === index ? { ...item, quantity: num } : item)), + ); + this.emitToParent(); + } + + adjustQuantity(index: number, delta: number): void { + if (this.isReadOnly()) return; + const current = Number(this.localInventories()[index]?.quantity) || 0; + const nextVal = Math.max(0, current + delta); + this.updateQuantity(index, nextVal); + } + + updateMinQuantity(index: number, value: string | number): void { + if (this.isReadOnly()) return; + const num = Math.max(0, Number(value) || 0); + const currentItem = this.localInventories()[index]; + if (!currentItem) return; + + this.localInventories.update((current) => + current.map((item, i) => (i === index ? { ...item, minimum_quantity: num } : item)), + ); + this.emitToParent(); + } + + updateMaxQuantity(index: number, value: string): void { + if (this.isReadOnly()) return; + const num = value.trim() === '' ? null : Math.max(0, Number(value) || 0); + const currentItem = this.localInventories()[index]; + if (!currentItem) return; - onFieldChange(field: string, value: string): void { - console.log('onFieldChange', field, value); - this.fieldChange.emit({ field, value }); + this.localInventories.update((current) => + current.map((item, i) => (i === index ? { ...item, maximum_quantity: num } : item)), + ); + this.emitToParent(); } - onNumberFieldChange(field: string, value: string): void { - this.numberFieldChange.emit({ field, value }); + changeWarehouse(index: number, newWarehouseId: string): void { + if (this.isReadOnly()) return; + const oldItem = this.localInventories()[index]; + if (oldItem && oldItem.warehouse_id) { + this.removedItemsCache.set(String(oldItem.warehouse_id), { ...oldItem }); + } + + const cached = this.removedItemsCache.get(String(newWarehouseId)); + + const updatedItem: LocalInventoryItem = { + ...oldItem, + warehouse_id: newWarehouseId, + quantity: cached?.quantity !== undefined ? cached.quantity : oldItem.quantity, + reserved_quantity: + cached?.reserved_quantity !== undefined + ? cached.reserved_quantity + : oldItem.reserved_quantity, + minimum_quantity: + cached?.minimum_quantity !== undefined ? cached.minimum_quantity : oldItem.minimum_quantity, + maximum_quantity: + cached?.maximum_quantity !== undefined ? cached.maximum_quantity : oldItem.maximum_quantity, + allow_backorder: + cached?.allow_backorder !== undefined ? cached.allow_backorder : oldItem.allow_backorder, + active: cached?.active !== undefined ? cached.active : oldItem.active, + }; + + this.localInventories.update((current) => + current.map((item, i) => (i === index ? updatedItem : item)), + ); + this.emitToParent(); + } + + removeItem(index: number): void { + if (this.isReadOnly()) return; + + const currentList = this.localInventories(); + const target = currentList[index]; + if (!target) return; + + if (target.warehouse_id) { + this.removedItemsCache.set(String(target.warehouse_id), { ...target }); + } + + this.localInventories.update((current) => current.filter((_, i) => i !== index)); + this.emitToParent(); + } + + toggleBackorder(index: number): void { + if (this.isReadOnly()) return; + const currentItem = this.localInventories()[index]; + if (!currentItem) return; + + this.localInventories.update((current) => + current.map((item, i) => + i === index ? { ...item, allow_backorder: !item.allow_backorder } : item, + ), + ); + this.emitToParent(); + } + + toggleActive(index: number): void { + if (this.isReadOnly()) return; + const currentItem = this.localInventories()[index]; + if (!currentItem) return; + + this.localInventories.update((current) => + current.map((item, i) => (i === index ? { ...item, active: !item.active } : item)), + ); + this.emitToParent(); } - onNullableNumberFieldChange(field: string, value: string): void { - this.nullableNumberFieldChange.emit({ field, value }); + getStockLevelBadge(item: UpdateProductInventoryPayload) { + if (item.active === false) { + return { + label: 'Inativo', + class: + 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-400 border-gray-200 dark:border-slate-600', + icon: 'slash', + }; + } + const avail = (item.quantity || 0) - (item.reserved_quantity || 0); + if (avail <= 0) { + return { + label: 'Sem Estoque', + class: + 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300 border-red-200 dark:border-red-800/50', + icon: 'x-circle', + }; + } + if ((item.minimum_quantity || 0) > 0 && avail < (item.minimum_quantity || 0)) { + return { + label: 'Abaixo do Mínimo', + class: + 'bg-amber-50 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 border-amber-200 dark:border-amber-800/50', + icon: 'alert-triangle', + }; + } + return { + label: 'Estoque OK', + class: + 'bg-emerald-50 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800/50', + icon: 'check-circle', + }; } - onToggleBackorder(): void { - this.toggleBackorder.emit(); + getWarehouseName(warehouse_id: string): string { + const found = this.warehouseOptions().find((w) => String(w.value) === String(warehouse_id)); + return found?.label || warehouse_id; } } diff --git a/src/app/features/catalog/components/product-form/inventory-tab/utils/table-values.ts b/src/app/features/catalog/components/product-form/inventory-tab/utils/table-values.ts new file mode 100644 index 0000000..a43da74 --- /dev/null +++ b/src/app/features/catalog/components/product-form/inventory-tab/utils/table-values.ts @@ -0,0 +1,61 @@ +import { TableHeaderOption } from '../../../../../../core/models/generals/table-inventory.model'; + +export const tableHeaders: TableHeaderOption[] = [ + { + key: 'warehouse', + label: 'Depósito', + align: 'left', + minWidth: 'min-w-[240px]', + showInReadOnly: true, + }, + { + key: 'quantity', + label: 'Saldo Físico (un)', + align: 'center', + minWidth: 'min-w-[180px]', + showInReadOnly: true, + }, + { + key: 'reservedQuantity', + label: 'Reservado', + align: 'center', + minWidth: 'min-w-[100px]', + showInReadOnly: true, + }, + { + key: 'availableQuantity', + label: 'Disponível', + align: 'center', + minWidth: 'min-w-[100px]', + showInReadOnly: true, + }, + { + key: 'minQuantity', + label: 'Est. Mínimo', + align: 'left', + minWidth: 'min-w-[110px]', + showInReadOnly: true, + }, + { + key: 'maxQuantity', + label: 'Est. Máximo', + align: 'left', + minWidth: 'min-w-[110px]', + showInReadOnly: true, + }, + { + key: 'allowBackorder', + label: 'Backorder', + align: 'center', + minWidth: 'min-w-[110px]', + showInReadOnly: true, + }, + { + key: 'active', + label: 'Status', + align: 'center', + minWidth: 'min-w-[90px]', + showInReadOnly: true, + }, + { key: 'actions', label: 'Ações', align: 'center', width: 'w-[60px]', showInReadOnly: false }, +]; diff --git a/src/app/features/catalog/product-workspace/product-workspace.html b/src/app/features/catalog/product-workspace/product-workspace.html index a44bddf..682a65d 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.html +++ b/src/app/features/catalog/product-workspace/product-workspace.html @@ -116,11 +116,7 @@

} @if (isCompatibilityTab()) { ({ ...p, inventories })); + if (this.errors()['inventories']) { + this.errors.update((e) => { + const copy = { ...e }; + delete copy['inventories']; + return copy; + }); + } + } + toggleBackorder(): void { if (this.isReadOnly()) return; this.isFormDirty.set(true); @@ -845,17 +859,14 @@ export class ProductWorkspaceComponent implements OnInit { const id = this.route.snapshot.paramMap.get('id'); const payload = this.productForm(); - console.log('[SAVE PRODUCT UPDATE]', payload); - return; + // console.log('[SAVE PRODUCT UPDATE]', payload); + // return; this.productService.update(id!, payload).subscribe({ next: (response) => { this.isSaving.set(false); this.isFormDirty.set(false); - this.toastService.success( - 'Produto salvo com sucesso.', - 'Informações atualizadas no sistema.', - ); + this.toastService.success(response.message, 'Informações atualizadas no sistema.'); const prodData = response.data || response; this.product.set(prodData);