From 892780b187b03fc2b6918351cb33251def4dcf90 Mon Sep 17 00:00:00 2001 From: aledguedes Date: Wed, 12 Aug 2026 14:49:55 -0300 Subject: [PATCH 1/7] Refactor product models, payloads and workspace Introduce comprehensive product domain models (status, suppliers, tags, inventories, pricing, OEM, equivalents, vehicle applications, etc.), add entity-reference and initialProductPayload. Replace legacy ProductRequest/Response shapes with UpdateProductPayload, mapResponseToPayload and defaultUpdatePayload. Add WarehouseService and adjust CategoryService/ProductService signatures. Refactor product workspace and form flow to separate API Product (product) from editable payload (productForm), update many components/templates to use new fields and fix pagination/slicing and inventory aggregation. Misc UI/behavior adjustments and commented legacy transformer code left for reference. Verify API compatibility when saving/updating products. --- .../additional-codes-product.model.ts | 5 + .../catetories/category-options.model.ts | 10 +- .../equivalent/equivalent-products.model.ts | 8 + .../core/models/generals/entity-reference.ts | 6 + .../inventories/inventories-product.model.ts | 15 + src/app/core/models/notes/notes.model.ts | 12 + .../oem-codes/oem-codes-product.model.ts | 11 + .../part-origin/part-origin-product.model.ts | 9 + .../models/pricing/pricing-product-model.ts | 9 + .../models/products/product-request.model.ts | 199 ++++- .../models/products/product-response.model.ts | 159 +--- .../models/status/status-product.model.ts | 8 + .../suppliers/suppliers-product.model.ts | 21 + .../core/models/tags/tags-product.model.ts | 5 + .../units-sale/unit-sale-product.model.ts | 10 + .../vehicle-application-product.model.ts | 13 + src/app/core/services/category-service.ts | 7 +- src/app/core/services/product-service.ts | 9 +- src/app/core/services/warehouse-service.ts | 23 + src/app/core/utils/product-initial-payload.ts | 72 ++ .../core/utils/product-payload.transformer.ts | 95 ++- .../administration-tab.html | 22 +- .../administration-tab/administration-tab.ts | 8 +- .../compatibility-tab/compatibility-tab.html | 68 +- .../compatibility-tab/compatibility-tab.ts | 23 +- .../inventory-tab/inventory-tab.ts | 15 +- .../sidebar/product-workspace-sidebar.html | 29 +- .../catalog/models/product-workspace.model.ts | 161 ---- .../catalog/product-create/product-create.ts | 17 +- .../product-workspace/product-workspace.html | 79 +- .../product-workspace/product-workspace.ts | 695 ++++++++---------- src/app/utils/product-table-collum.ts | 2 +- 32 files changed, 906 insertions(+), 919 deletions(-) create mode 100644 src/app/core/models/additional-codes/additional-codes-product.model.ts create mode 100644 src/app/core/models/equivalent/equivalent-products.model.ts create mode 100644 src/app/core/models/generals/entity-reference.ts create mode 100644 src/app/core/models/inventories/inventories-product.model.ts create mode 100644 src/app/core/models/notes/notes.model.ts create mode 100644 src/app/core/models/oem-codes/oem-codes-product.model.ts create mode 100644 src/app/core/models/part-origin/part-origin-product.model.ts create mode 100644 src/app/core/models/pricing/pricing-product-model.ts create mode 100644 src/app/core/models/status/status-product.model.ts create mode 100644 src/app/core/models/suppliers/suppliers-product.model.ts create mode 100644 src/app/core/models/tags/tags-product.model.ts create mode 100644 src/app/core/models/units-sale/unit-sale-product.model.ts create mode 100644 src/app/core/models/vehicle-application/vehicle-application-product.model.ts create mode 100644 src/app/core/services/warehouse-service.ts create mode 100644 src/app/core/utils/product-initial-payload.ts diff --git a/src/app/core/models/additional-codes/additional-codes-product.model.ts b/src/app/core/models/additional-codes/additional-codes-product.model.ts new file mode 100644 index 0000000..c01d660 --- /dev/null +++ b/src/app/core/models/additional-codes/additional-codes-product.model.ts @@ -0,0 +1,5 @@ +export interface ProductAdditionalCodeSummary { + id: string; + code: string; + active: boolean; +} diff --git a/src/app/core/models/catetories/category-options.model.ts b/src/app/core/models/catetories/category-options.model.ts index 016ddda..b4d6616 100644 --- a/src/app/core/models/catetories/category-options.model.ts +++ b/src/app/core/models/catetories/category-options.model.ts @@ -1,8 +1,8 @@ -export interface CategoryOptionsResponse { - success: boolean; - message: string; - data: CategoryOption[]; -} +// export interface CategoryOptionsResponse { +// success: boolean; +// message: string; +// data: CategoryOption[]; +// } export interface CategoryOption { id: string; diff --git a/src/app/core/models/equivalent/equivalent-products.model.ts b/src/app/core/models/equivalent/equivalent-products.model.ts new file mode 100644 index 0000000..58799a6 --- /dev/null +++ b/src/app/core/models/equivalent/equivalent-products.model.ts @@ -0,0 +1,8 @@ +import { EntityReference } from '../generals/entity-reference'; + +export interface ProductEquivalent { + id: string; + observation: string | null; + product: EntityReference; + equivalent_product: EntityReference; +} diff --git a/src/app/core/models/generals/entity-reference.ts b/src/app/core/models/generals/entity-reference.ts new file mode 100644 index 0000000..abcc1a4 --- /dev/null +++ b/src/app/core/models/generals/entity-reference.ts @@ -0,0 +1,6 @@ +export interface EntityReference { + id: string; + name: string; + slug?: string; + icon?: string; +} diff --git a/src/app/core/models/inventories/inventories-product.model.ts b/src/app/core/models/inventories/inventories-product.model.ts new file mode 100644 index 0000000..b0f6e5d --- /dev/null +++ b/src/app/core/models/inventories/inventories-product.model.ts @@ -0,0 +1,15 @@ +export interface ProductInventory { + id: string; + quantity: number; + reserved_quantity: number; + available_quantity?: number; + minimum_quantity: number; + maximum_quantity: number; + allow_backorder: boolean; + active: boolean; + warehouse: { + id: string; + name: string; + code: string | null; + }; +} diff --git a/src/app/core/models/notes/notes.model.ts b/src/app/core/models/notes/notes.model.ts new file mode 100644 index 0000000..b2b91cc --- /dev/null +++ b/src/app/core/models/notes/notes.model.ts @@ -0,0 +1,12 @@ +export interface ProductNote { + id: string; + note: string; + type: string; + active: boolean; + description: string; + date: string; + author: string; + status: string; + created_at: string; + updated_at: string; +} diff --git a/src/app/core/models/oem-codes/oem-codes-product.model.ts b/src/app/core/models/oem-codes/oem-codes-product.model.ts new file mode 100644 index 0000000..f74ae8e --- /dev/null +++ b/src/app/core/models/oem-codes/oem-codes-product.model.ts @@ -0,0 +1,11 @@ +import { EntityReference } from '../generals/entity-reference'; + +export interface ProductOemCode { + id: string; + oem_code: string; + manufacturer?: EntityReference; + product?: EntityReference; + created_at: string; + updated_at: string; + is_primary: boolean; +} diff --git a/src/app/core/models/part-origin/part-origin-product.model.ts b/src/app/core/models/part-origin/part-origin-product.model.ts new file mode 100644 index 0000000..10b8d6b --- /dev/null +++ b/src/app/core/models/part-origin/part-origin-product.model.ts @@ -0,0 +1,9 @@ +export interface ProductPartOrigin { + id: string; + name: string; + description: string | null; + display_order: number; + active: boolean; + created_at: string; + updated_at: string; +} diff --git a/src/app/core/models/pricing/pricing-product-model.ts b/src/app/core/models/pricing/pricing-product-model.ts new file mode 100644 index 0000000..220da03 --- /dev/null +++ b/src/app/core/models/pricing/pricing-product-model.ts @@ -0,0 +1,9 @@ +export interface ProductPricing { + regular_price: number; + promotional_price: number | null; + current_price: number; + is_on_promotion: boolean; + promotion_start: string | null; + promotion_end: string | null; + active: boolean; +} diff --git a/src/app/core/models/products/product-request.model.ts b/src/app/core/models/products/product-request.model.ts index 61625fb..e502d4f 100644 --- a/src/app/core/models/products/product-request.model.ts +++ b/src/app/core/models/products/product-request.model.ts @@ -55,69 +55,188 @@ export interface ProductStatus { export type CommercialStatus = 'pending' | 'approved' | 'rejected'; -// PARA REMOVER FUTURAMENTE APÓS UPDATE - -export interface ProductPricePayload { - price: number; - promotional_price?: number | null; - promotion_start?: string | null; - promotion_end?: string | null; -} - -export interface ProductInventoryPayload { - warehouse_id: string | null; - - quantity: number; - - minimum_quantity?: number; - maximum_quantity?: number | null; - - allow_backorder?: boolean; -} - +/** + * Payload utilizado para atualização parcial de um produto. + * + * Os campos são opcionais porque o backend utiliza a regra + * `sometimes` no UpdateProductRequest. + * + * Importante: + * - `undefined` significa que o campo não será enviado. + * - `null` é utilizado somente nos campos em que o backend + * permite explicitamente valor nulo. + * - Arrays vazios devem ser enviados quando a intenção for + * persistir uma coleção vazia. + */ export interface UpdateProductPayload { - category_id?: string | null; - manufacturer_id?: string | null; + category_id?: string; + manufacturer_id?: string; part_origin_id?: string | null; unit_id?: string | null; + status_id?: string; + + oem_code_ids?: string[]; + product_code_ids?: string[]; + equivalent_product_ids?: string[]; + application_ids?: string[]; + supplier_ids?: string[]; + tag_ids?: string[]; + icon?: string | null; - internal_code?: string | null; + internal_code?: string; barcode?: string | null; - name?: string | null; - slug?: string | null; - icon?: string | null; - image?: string | null; + name?: string; + slug?: string; short_description?: string | null; - description?: string | null; + description?: string; - weight?: number | null; - height?: number | null; - width?: number | null; - length?: number | null; + weight?: number; + height?: number; + width?: number; + length?: number; - unit?: string | null; - - active?: boolean; featured?: boolean; + active?: boolean; price?: UpdateProductPricePayload; - inventory?: UpdateProductInventoryPayload; + inventories?: UpdateProductInventoryPayload[]; } +/** + * Dados comerciais utilizados na atualização do produto. + */ export interface UpdateProductPricePayload { - price?: number | null; + price?: number; promotional_price?: number | null; promotion_start?: string | null; promotion_end?: string | null; } +/** + * Estoque de um produto por depósito. + * + * O backend aceita múltiplos registros em `inventories`. + */ export interface UpdateProductInventoryPayload { - warehouse_id?: string | null; - quantity?: number | null; + id?: string | null; + warehouse_id: string; + quantity: number; minimum_quantity?: number | null; maximum_quantity?: number | null; - allow_backorder?: boolean; + allow_backorder?: boolean | null; + active?: boolean | null; +} + +/** + * Payload vazio para inicializar o sinal `productForm` antes de carregar um produto. + */ +export function defaultUpdatePayload(): UpdateProductPayload { + return {}; +} + +/** + * Converte a resposta da API (ProductResponse) no payload de atualização (UpdateProductPayload). + * + * Campos escalares são sempre mapeados. + * Campos relacionais (oem_code_ids, tag_ids, etc.) são mantidos como `undefined`: + * o backend ignora chaves ausentes (regra `array_key_exists`), portanto não haverá + * alteração acidental nos relacionamentos ao salvar sem editar as abas correspondentes. + * Cada aba de edição relacional é responsável por incluir o array correto no payload. + */ +export function mapResponseToPayload(prod: { + category?: { id: string } | null; + manufacturer?: { id: string } | null; + part_origin?: { id: string } | null; + unit?: { id: string } | null; + status?: { id: string } | null; + internal_code: string; + barcode?: string | null; + name: string; + slug: string; + short_description?: string | null; + description: string; + weight: string | number; + height: string | number; + width: string | number; + length: string | number; + featured: boolean; + active?: boolean | null; + pricing?: { + regular_price?: number; + promotional_price?: number | null; + promotion_start?: string | null; + promotion_end?: string | null; + } | null; + inventories?: { + id?: string; + warehouse: { id: string }; + 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, + manufacturer_id: prod.manufacturer?.id, + part_origin_id: prod.part_origin?.id ?? null, + unit_id: prod.unit?.id ?? null, + status_id: prod.status?.id, + + // Campos escalares + internal_code: prod.internal_code, + barcode: prod.barcode ?? null, + name: prod.name, + slug: prod.slug, + short_description: prod.short_description ?? null, + description: prod.description, + + // Dimensões (API retorna como string, payload espera number) + weight: parseFloat(String(prod.weight)) || 0, + height: parseFloat(String(prod.height)) || 0, + width: parseFloat(String(prod.width)) || 0, + length: parseFloat(String(prod.length)) || 0, + + featured: prod.featured, + active: prod.active ?? false, + + // Preço: incluído apenas se existir no produto + price: prod.pricing + ? { + price: prod.pricing.regular_price, + promotional_price: prod.pricing.promotional_price ?? null, + promotion_start: prod.pricing.promotion_start ?? null, + promotion_end: prod.pricing.promotion_end ?? null, + } + : 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, + }, + ] + : undefined, + + // Relacionamentos: undefined (não enviados) até o usuário editar a aba correspondente. + // oem_code_ids: undefined, + // product_code_ids: undefined, + // equivalent_product_ids: undefined, + // application_ids: undefined, + // supplier_ids: undefined, + // tag_ids: undefined, + }; } diff --git a/src/app/core/models/products/product-response.model.ts b/src/app/core/models/products/product-response.model.ts index dd206cf..1e065c5 100644 --- a/src/app/core/models/products/product-response.model.ts +++ b/src/app/core/models/products/product-response.model.ts @@ -1,16 +1,22 @@ -import { - OemCodeItem, - SupplierItem, -} from '../../../features/catalog/models/product-workspace.model'; - -export interface ProductResponse extends Record { - id: string; - - status: Status; - category: Category; - manufacturer: Category; - part_origin: PartOrigin | null; - unit: Unit | null; +import { ProductVehicleApplication } from '../vehicle-application/vehicle-application-product.model'; +import { ProductEquivalent } from '../equivalent/equivalent-products.model'; +import { EntityReference } from '../generals/entity-reference'; +import { ProductOemCode } from '../oem-codes/oem-codes-product.model'; +import { ProductStatus } from '../status/status-product.model'; +import { ProductSupplier } from '../suppliers/suppliers-product.model'; +import { ProductPricing } from '../pricing/pricing-product-model'; +import { ProductInventory } from '../inventories/inventories-product.model'; +import { ProductUnitSale } from '../units-sale/unit-sale-product.model'; +import { ProductPartOrigin } from '../part-origin/part-origin-product.model'; +import { ProductAdditionalCodeSummary } from '../additional-codes/additional-codes-product.model'; +import { ProductTag } from '../tags/tags-product.model'; + +export interface ProductSummaryResponse { + id: string; + + status: ProductStatus; + category: EntityReference; + manufacturer: EntityReference; internal_code: string; barcode: string | null; @@ -21,8 +27,8 @@ export interface ProductResponse extends Record { icon: string | null; image: string | null; - short_description: string | null; description: string; + short_description: string | null; weight: string; height: string; @@ -30,125 +36,28 @@ export interface ProductResponse extends Record { length: string; featured: boolean; - active: boolean; + active: boolean | null; is_sellable: boolean; commercial_status: string; - pricing: Pricing | null; - - inventory: Inventory | null; - - oem_codes: OemCodeItem[]; - equivalents: SupplierItem[]; - applications: VehicleApplication[]; - suppliers: SupplierItem[]; - created_at: string; updated_at: string; } -export interface Inventory { - quantity: number; - reserved_quantity: number; - available_quantity: number; - minimum_quantity: number; - maximum_quantity: number; - allow_backorder: boolean; - active: boolean; -} - -export interface Pricing { - regular_price: number; - promotional_price: number; - current_price: number; - is_on_promotion: boolean; - promotion_start: string; - promotion_end: string; - active: boolean; - display: Display; -} - -export interface Display { - from: number; - to: number; - label: string; -} - -export interface Category { - id: string; - name: string; - slug: string; -} +export interface ProductResponse extends ProductSummaryResponse { + // Index signature para compatibilidade com DataTableComponent> + [key: string]: unknown; -export interface Status { - id: string; - module: string; - code: string; - name: string; - color: string; - icon: string; -} + unit: ProductUnitSale | null; + pricing: ProductPricing | null; + inventories: ProductInventory[]; + codes: ProductAdditionalCodeSummary[]; + part_origin: ProductPartOrigin | null; -export interface OemCode { - id: string; - code: string; - is_main: boolean; - manufacturer: Category; - manufacturer_code: string; - created_at: string; - updated_at: string; -} - -export interface EquivalentProduct { - id: string; - type: string; - reference_code: string; - manufacturer: Category; - manufacturer_code: string; - is_main: boolean; - created_at: string; - updated_at: string; -} - -export interface VehicleApplication { - id: string; - manufacturer: string; - model: string; - year_from: number; - year_to: number | null; - engine: string | null; - details: string | null; - created_at: string; - updated_at: string; -} - -// export interface Supplier { -// id: string; -// name: string; -// code: string | null; -// cost_price: number; -// delivery_time_days: number | null; -// minimum_order_quantity: number; -// active: boolean; -// created_at: string; -// updated_at: string; -// } - -export interface Unit { - id: string; - code: string; - name: string; - abbreviation: string; - is_base: boolean; - created_at: string; - updated_at: string; -} - -export interface PartOrigin { - id: string; - name: string; - code: string; - created_at: string; - updated_at: string; + tags: ProductTag[]; + oem_codes: ProductOemCode[]; + suppliers: ProductSupplier[]; + equivalents: ProductEquivalent[]; + applications: ProductVehicleApplication[]; } diff --git a/src/app/core/models/status/status-product.model.ts b/src/app/core/models/status/status-product.model.ts new file mode 100644 index 0000000..94383f1 --- /dev/null +++ b/src/app/core/models/status/status-product.model.ts @@ -0,0 +1,8 @@ +export interface ProductStatus { + id: string; + module: string; + code: string; + name: string; + color: string; + icon: string; +} diff --git a/src/app/core/models/suppliers/suppliers-product.model.ts b/src/app/core/models/suppliers/suppliers-product.model.ts new file mode 100644 index 0000000..fb58d7e --- /dev/null +++ b/src/app/core/models/suppliers/suppliers-product.model.ts @@ -0,0 +1,21 @@ +import { EntityReference } from '../generals/entity-reference'; + +export interface ProductSupplier { + id: string; + product_id: string; + supplier_id: string; + supplier: { + id: string; + name?: string; + }; + supplier_code: string | null; + purchase_price: number; + lead_time_days: number; + minimum_order_quantity: number; + preferred: boolean; + active: boolean; + observation: string | null; + created_at?: string | null; + updated_at?: string | null; + product?: EntityReference; +} diff --git a/src/app/core/models/tags/tags-product.model.ts b/src/app/core/models/tags/tags-product.model.ts new file mode 100644 index 0000000..47fca46 --- /dev/null +++ b/src/app/core/models/tags/tags-product.model.ts @@ -0,0 +1,5 @@ +export interface ProductTag { + id: string; + name: string; + active: boolean; +} diff --git a/src/app/core/models/units-sale/unit-sale-product.model.ts b/src/app/core/models/units-sale/unit-sale-product.model.ts new file mode 100644 index 0000000..46a31f6 --- /dev/null +++ b/src/app/core/models/units-sale/unit-sale-product.model.ts @@ -0,0 +1,10 @@ +export interface ProductUnitSale { + id: string; + name: string; + abbreviation: string; + description: string | null; + display_order: number; + active: boolean; + created_at: string; + updated_at: string; +} diff --git a/src/app/core/models/vehicle-application/vehicle-application-product.model.ts b/src/app/core/models/vehicle-application/vehicle-application-product.model.ts new file mode 100644 index 0000000..6c9a78f --- /dev/null +++ b/src/app/core/models/vehicle-application/vehicle-application-product.model.ts @@ -0,0 +1,13 @@ +import { EntityReference } from '../generals/entity-reference'; + +export interface ProductVehicleApplication { + id: string; + manufacturer: EntityReference; + model: string; + year_from: number; + year_to: number | null; + engine: string | null; + details: string | null; + created_at?: string; + updated_at?: string; +} diff --git a/src/app/core/services/category-service.ts b/src/app/core/services/category-service.ts index 13414a1..18c6594 100644 --- a/src/app/core/services/category-service.ts +++ b/src/app/core/services/category-service.ts @@ -4,8 +4,9 @@ import { PaginatedResponse } from '../models/pagination/pagination.model'; import { environment } from '../../../environments/environment'; import { CategoryResponse } from '../models/catetories/categories.model'; import { buildHttpParams } from './build-http-params'; -import { CategoryOptionsResponse } from '../models/catetories/category-options.model'; +import { CategoryOption } from '../models/catetories/category-options.model'; import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; +import { getAllResponse } from '../models/generals/general-responses-list.model'; @Injectable({ providedIn: 'root', @@ -21,7 +22,9 @@ export class CategoryService { getOptions(filters: GeneralOptionQuery) { const params = buildHttpParams(filters); - return this.http.get(`${this.api}/${this.flag}/options`, { params }); + return this.http.get>(`${this.api}/${this.flag}/options`, { + params, + }); } getById(id: string) { diff --git a/src/app/core/services/product-service.ts b/src/app/core/services/product-service.ts index dd0a232..71752d6 100644 --- a/src/app/core/services/product-service.ts +++ b/src/app/core/services/product-service.ts @@ -27,8 +27,15 @@ export class ProductService { return this.http.post(`${this.api}/${this.flag}`, payload); } + // update(id: string, payload: UpdateProductPayload) { + // return this.http.patch(`${this.api}/${this.flag}/${id}`, payload); + // } + update(id: string, payload: UpdateProductPayload) { - return this.http.put(`${this.api}/${this.flag}/${id}`, payload); + return this.http.patch>( + `${this.api}/${this.flag}/${id}`, + payload, + ); } delete(id: string) { diff --git a/src/app/core/services/warehouse-service.ts b/src/app/core/services/warehouse-service.ts new file mode 100644 index 0000000..4f1dd85 --- /dev/null +++ b/src/app/core/services/warehouse-service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { PaginatedResponse } from '../models/pagination/pagination.model'; +import { buildHttpParams } from './build-http-params'; +import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; +import { WarehouseOptionsResponse } from '../models/warehouses/warehouse-options.model'; + +@Injectable({ + providedIn: 'root', +}) +export class WarehouseService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'warehouses'; + + getAll(filters: GeneralOptionQuery) { + const params = buildHttpParams(filters); + return this.http.get>(`${this.api}/${this.flag}`, { + params, + }); + } +} diff --git a/src/app/core/utils/product-initial-payload.ts b/src/app/core/utils/product-initial-payload.ts new file mode 100644 index 0000000..36b8e3b --- /dev/null +++ b/src/app/core/utils/product-initial-payload.ts @@ -0,0 +1,72 @@ +import { ProductResponse } from '../models/products/product-response.model'; + +export const initialProductPayload: ProductResponse = { + id: '', + + status: { + id: '', + module: '', + code: '', + name: '', + color: '', + icon: '', + }, + + category: { + id: '', + name: '', + slug: '', + }, + + manufacturer: { + id: '', + name: '', + slug: '', + }, + + part_origin: null, + + internal_code: '', + barcode: null, + + name: '', + slug: '', + + icon: null, + image: null, + + short_description: null, + description: '', + + weight: '0.000', + height: '0.00', + width: '0.00', + length: '0.00', + + featured: false, + active: null, + + is_sellable: false, + commercial_status: '', + + unit: null, + + pricing: null, + + inventories: [], + + oem_codes: [], + + codes: [], + + equivalents: [], + + applications: [], + + suppliers: [], + + tags: [], + + created_at: '', + updated_at: '', +}; diff --git a/src/app/core/utils/product-payload.transformer.ts b/src/app/core/utils/product-payload.transformer.ts index 939a137..23b7790 100644 --- a/src/app/core/utils/product-payload.transformer.ts +++ b/src/app/core/utils/product-payload.transformer.ts @@ -1,8 +1,5 @@ import { ProductFormState } from '../models/products/product-form-state.model'; -import { - CreateProductPayload, - UpdateProductPayload, -} from '../models/products/product-request.model'; +import { CreateProductPayload } from '../models/products/product-request.model'; export function toBoolean(val: unknown): boolean { if (val === true || val === 1 || val === 'true' || val === '1') { @@ -60,48 +57,48 @@ function hasPromotionalPrice(promotionalPrice: number | null): boolean { ); } -function buildPriceBlock(state: ProductFormState) { - const hasPromotion = hasPromotionalPrice(state.promotional_price); - - return { - price: toNumber(state.price), - promotional_price: hasPromotion ? toNullableNumber(state.promotional_price) : null, - promotion_start: hasPromotion ? toNullableDate(state.promotion_start_date) : null, - promotion_end: hasPromotion ? toNullableDate(state.promotion_end_date) : null, - }; -} - -function buildInventoryBlock(state: ProductFormState) { - return { - warehouse_id: toNullableString(state.warehouse_id), - quantity: toInteger(state.quantity), - minimum_quantity: toInteger(state.minimum_quantity), - maximum_quantity: toNullableNumber(state.maximum_quantity), - allow_backorder: toBoolean(state.allow_backorder), - }; -} - -function buildProductRoot(state: ProductFormState) { - return { - category_id: toNullableString(state.category_id), - manufacturer_id: toNullableString(state.manufacturer_id), - unit_id: toNullableString(state.unit_id), - internal_code: toNullableString(state.internal_code), - name: toNullableString(state.name), - slug: toNullableString(state.slug), - icon: null, - image: null, - short_description: null, - description: toNullableString(state.description), - weight: toNumber(state.weight), - height: toNumber(state.height), - width: toNumber(state.width), - length: toNumber(state.length), - active: true, - featured: false, - unit: toNullableString(state.unit), - }; -} +// function buildPriceBlock(state: ProductFormState) { +// const hasPromotion = hasPromotionalPrice(state.promotional_price); + +// return { +// price: toNumber(state.price), +// promotional_price: hasPromotion ? toNullableNumber(state.promotional_price) : null, +// promotion_start: hasPromotion ? toNullableDate(state.promotion_start_date) : null, +// promotion_end: hasPromotion ? toNullableDate(state.promotion_end_date) : null, +// }; +// } + +// function buildInventoryBlock(state: ProductFormState) { +// return { +// warehouse_id: toNullableString(state.warehouse_id), +// quantity: toInteger(state.quantity), +// minimum_quantity: toInteger(state.minimum_quantity), +// maximum_quantity: toNullableNumber(state.maximum_quantity), +// allow_backorder: toBoolean(state.allow_backorder), +// }; +// } + +// function buildProductRoot(state: ProductFormState) { +// return { +// category_id: toNullableString(state.category_id), +// manufacturer_id: toNullableString(state.manufacturer_id), +// unit_id: toNullableString(state.unit_id), +// internal_code: toNullableString(state.internal_code), +// name: toNullableString(state.name), +// slug: toNullableString(state.slug), +// icon: null, +// image: null, +// short_description: null, +// description: toNullableString(state.description), +// weight: toNumber(state.weight), +// height: toNumber(state.height), +// width: toNumber(state.width), +// length: toNumber(state.length), +// active: true, +// featured: false, +// unit: toNullableString(state.unit), +// }; +// } export function transformToCreatePayload(state: ProductFormState): CreateProductPayload { const extended = state as ProductFormState & { @@ -129,9 +126,9 @@ export function transformToCreatePayload(state: ProductFormState): CreateProduct }; } -export function transformToUpdatePayload(state: ProductFormState): UpdateProductPayload { - return buildProductRoot(state); -} +// export function transformToUpdatePayload(state: ProductFormState): UpdateProductPayload { +// return buildProductRoot(state); +// } export function validateProductForm( state: ProductFormState, diff --git a/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html index 75be4bd..055b956 100644 --- a/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html +++ b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html @@ -46,18 +46,20 @@

- {{ sup.supplierName }} + {{ sup.supplier.name }} - {{ sup.supplierCode }} + {{ sup.supplier.id }} - R$ {{ sup.purchasePrice.toFixed(2) }} + R$ {{ sup.purchase_price.toFixed(2) }} + + {{ sup.lead_time_days }} dias + + {{ sup.minimum_order_quantity || 1 }} un - {{ sup.leadTimeDays }} dias - {{ sup.minQuantity || 1 }} un - @if (sup.isPreferential) { + @if (sup.active) { @@ -77,19 +79,19 @@

- {{ sup.notes || '-' }} + {{ sup.observation || '-' }} diff --git a/src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts index e72f54f..df090b3 100644 --- a/src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts +++ b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts @@ -2,7 +2,9 @@ import { Component, ChangeDetectionStrategy, computed, input, output, signal } f import { ButtonComponent } from '../../../../../design-system/button/button'; import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; import { PaginationComponent } from '../../../../../design-system/pagination/pagination'; -import { FormTab, ProductNoteItem, SupplierItem } from '../../../models/product-workspace.model'; +import { FormTab } from '../../../models/product-workspace.model'; +import { ProductSupplier } from '../../../../../core/models/suppliers/suppliers-product.model'; +import { ProductNote } from '../../../../../core/models/notes/notes.model'; export interface TagItem { id: string; @@ -18,10 +20,10 @@ export interface TagItem { }) export class ProductAdministrationTabComponent { readonly activeTab = input.required(); - readonly suppliers = input([]); + readonly suppliers = input([]); readonly availableTags = input([]); readonly selectedTagIds = input([]); - readonly productNotes = input([]); + readonly productNotes = input([]); readonly isReadOnly = input(false); readonly addSupplier = output(); diff --git a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html index d147c85..80257ab 100644 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html +++ b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html @@ -42,12 +42,12 @@

- {{ oem.manufacturer }} + {{ oem.manufacturer?.name }} - {{ oem.oemCode }} + {{ oem.oem_code }} - @if (oem.isPrimary) { + @if (oem.is_primary) { @@ -67,15 +67,11 @@

- + Ativo + @if (!isReadOnly()) { @@ -157,21 +153,17 @@

- {{ codeItem.type }} + {{ codeItem.code }} {{ codeItem.code }} - + Ativo + @if (!isReadOnly()) { @@ -253,21 +245,17 @@

- {{ eq.productName }} + {{ eq.equivalent_product.name }} - {{ eq.notes || 'Sem observações' }} + {{ eq.observation || 'Sem observações' }} - + Ativo + @if (!isReadOnly()) { @@ -356,24 +344,20 @@

- {{ veh.brand }} {{ veh.model }} - ({{ veh.version }}) + {{ veh.manufacturer.name }} {{ veh.model }} + ({{ veh.details }}) {{ veh.engine }} - {{ veh.startYear }} - {{ veh.endYear || 'Atual' }} + {{ veh.year_from }} - {{ veh.year_to || 'Atual' }} - {{ veh.notes || '-' }} + {{ veh.details || '-' }} - + Ativo + @if (!isReadOnly()) { diff --git a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts index bc0a9f2..82c7755 100644 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts +++ b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts @@ -3,13 +3,11 @@ import { ButtonComponent } from '../../../../../design-system/button/button'; import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; import { PaginationComponent } from '../../../../../design-system/pagination/pagination'; import { getCompatibilityModuleByTab } from '../../../../../core/config/compatibility-modules.config'; -import { - EquivalentProductItem, - FormTab, - OemCodeItem, - ProductCodeItem, - VehicleApplicationItem, -} from '../../../models/product-workspace.model'; +import { FormTab } from '../../../models/product-workspace.model'; +import { ProductOemCode } from '../../../../../core/models/oem-codes/oem-codes-product.model'; +import { ProductAdditionalCodeSummary } from '../../../../../core/models/additional-codes/additional-codes-product.model'; +import { ProductEquivalent } from '../../../../../core/models/equivalent/equivalent-products.model'; +import { ProductVehicleApplication } from '../../../../../core/models/vehicle-application/vehicle-application-product.model'; @Component({ selector: 'app-product-compatibility-tab', @@ -20,10 +18,10 @@ import { }) export class ProductCompatibilityTabComponent { readonly activeTab = input.required(); - readonly oemCodes = input([]); - readonly productCodes = input([]); - readonly equivalentProducts = input([]); - readonly vehicleApplications = input([]); + readonly oemCodes = input([]); + readonly productCodes = input([]); + readonly equivalentProducts = input([]); + readonly vehicleApplications = input([]); readonly isReadOnly = input(false); readonly addOemCode = output(); @@ -117,7 +115,6 @@ export class ProductCompatibilityTabComponent { } private paginate(items: T[], page: number, pageSize: number): T[] { - const start = (page - 1) * pageSize; - return items.slice(start, start + pageSize); + return items.slice((page - 1) * pageSize, page * pageSize); } } 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 f227d43..d7c1baa 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 @@ -1,4 +1,11 @@ -import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + input, + output, + OnChanges, + SimpleChanges, +} 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'; @@ -11,7 +18,10 @@ import { DSelectOption } from '../../../../../core/models/design-system/select-o changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './inventory-tab.html', }) -export class ProductInventoryTabComponent { +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); @@ -28,6 +38,7 @@ export class ProductInventoryTabComponent { readonly toggleBackorder = output(); onFieldChange(field: string, value: string): void { + console.log('onFieldChange', field, value); this.fieldChange.emit({ field, value }); } diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html index 25e6f3d..01d2cc9 100644 --- a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html +++ b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html @@ -46,7 +46,6 @@ - @@ -182,7 +181,7 @@ --> - + - + - + - + - + - + - + diff --git a/src/app/features/catalog/models/product-workspace.model.ts b/src/app/features/catalog/models/product-workspace.model.ts index 625b22a..d7faaa1 100644 --- a/src/app/features/catalog/models/product-workspace.model.ts +++ b/src/app/features/catalog/models/product-workspace.model.ts @@ -16,40 +16,6 @@ export type FormTab = | 'logistics' | 'visibility'; -export interface OemCodeItem { - id: string; - manufacturer: string; - oemCode: string; - isPrimary: boolean; - status?: string; -} - -export interface ProductCodeItem { - id: string; - type: string; - code: string; - status?: string; -} - -export interface EquivalentProductItem { - id: string; - productName: string; - notes: string; - status?: string; -} - -export interface VehicleApplicationItem { - id: string; - brand: string; - model: string; - version: string; - engine: string; - startYear: number; - endYear: number; - notes?: string; - status?: string; -} - export interface MediaImageItem { id: string; url: string; @@ -60,130 +26,3 @@ export interface MediaImageItem { status: 'uploading' | 'completed' | 'error'; progress: number; } - -export interface SupplierItem { - id: string; - supplierName: string; - supplierCode: string; - purchasePrice: number; - leadTimeDays: number; - isPreferential: boolean; - minQuantity?: number; - notes?: string; - status?: string; -} - -export interface ProductNoteItem { - id: string; - type: 'Geral' | 'Técnica' | 'Comercial' | 'Fiscal'; - description: string; - date: string; - author: string; - status?: string; -} - -export interface ProductFormData { - category_id: string; - manufacturer_id: string; - part_origin_id: string; - status_id: string; - - internal_code: string; - barcode: string; - - name: string; - slug: string; - - icon: string; - image: string; - - short_description: string; - description: string; - - weight: number; - height: number; - width: number; - length: number; - - unit_id: string; - - featured: boolean; - active: boolean; - - price: number; - promotionalPrice: number | null; - promotionStartDate: string | null; - promotionEndDate: string | null; - isInvoiced: boolean; - - warehouseId: string; - quantity: number; - minQuantity: number; - maxQuantity: number | null; - allowBackorder: boolean; - - oemCodes: OemCodeItem[]; - productCodes: ProductCodeItem[]; - equivalentProducts: EquivalentProductItem[]; - vehicleApplications: VehicleApplicationItem[]; - mediaImages: MediaImageItem[]; - suppliers: SupplierItem[]; - - selectedTagIds: string[]; - - productNotes: ProductNoteItem[]; -} - -export function defaultProductFormData(): ProductFormData { - return { - category_id: '', - manufacturer_id: '', - part_origin_id: '', - status_id: 'st-01', - - internal_code: '', - barcode: '', - - name: '', - slug: '', - - icon: '', - image: '', - - short_description: '', - description: '', - - weight: 0, - height: 0, - width: 0, - length: 0, - - unit_id: 'un', - - featured: false, - active: true, - - price: 0, - promotionalPrice: null, - promotionStartDate: null, - promotionEndDate: null, - isInvoiced: true, - - warehouseId: 'wh-01', - quantity: 0, - minQuantity: 0, - maxQuantity: null, - allowBackorder: false, - - oemCodes: [], - productCodes: [], - equivalentProducts: [], - vehicleApplications: [], - mediaImages: [], - suppliers: [], - - selectedTagIds: [], - - productNotes: [], - }; -} diff --git a/src/app/features/catalog/product-create/product-create.ts b/src/app/features/catalog/product-create/product-create.ts index af6b9db..7ac6db8 100644 --- a/src/app/features/catalog/product-create/product-create.ts +++ b/src/app/features/catalog/product-create/product-create.ts @@ -14,7 +14,7 @@ import { ProductGeneralTabComponent } from '../components/product-form/general-t import { ProductCreateStepperComponent } from '../components/product-form/stepper/product-create-stepper'; import { CategoryService } from '../../../core/services/category-service'; import { ManufacturerService } from '../../../core/services/manufacture-service'; -import { PartOriginService } from '../../../core/services/part-origins-service'; +import { WarehouseService } from '../../../core/services/warehouse-service'; import { ProductService } from '../../../core/services/product-service'; import { ToastService } from '../../../core/services/toast'; import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; @@ -27,6 +27,8 @@ import { toCreateProductPayload, toProductGeneralFormData, } from '../../../core/models/products/product-create.model'; +import { PartOriginService } from '../../../core/services/part-origins-service'; +import { WarehouseOption } from '../../../core/models/warehouses/warehouse-options.model'; @Component({ selector: 'app-product-create', @@ -45,8 +47,9 @@ export class ProductCreateComponent implements OnInit { private router = inject(Router); private productService = inject(ProductService); private categoryService = inject(CategoryService); - private manufacturerService = inject(ManufacturerService); + private warehouseService = inject(WarehouseService); private partOriginService = inject(PartOriginService); + private manufacturerService = inject(ManufacturerService); private toastService = inject(ToastService); readonly createStep = signal(1); @@ -74,6 +77,7 @@ export class ProductCreateComponent implements OnInit { manufacturerLoading: this.manufacturerLoading(), partOriginOptions: this.fetchedPartOrigins(), unitOptions: this.unitOptions, + warehouseOptions: [], statusOptions: [], })); @@ -144,6 +148,15 @@ export class ProductCreateComponent implements OnInit { this.fetchedPartOrigins.set(opts); }, }); + + this.warehouseService.getAll(this.queries).subscribe({ + next: (res) => { + console.log('[Warehouses] ', res); + }, + error: (err) => { + console.log('[Error] ', err); + }, + }); } setCreateStep(step: number): void { diff --git a/src/app/features/catalog/product-workspace/product-workspace.html b/src/app/features/catalog/product-workspace/product-workspace.html index 5cba9fe..a44bddf 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.html +++ b/src/app/features/catalog/product-workspace/product-workspace.html @@ -67,18 +67,18 @@

} - diff --git a/src/app/features/catalog/product-workspace/product-workspace.ts b/src/app/features/catalog/product-workspace/product-workspace.ts index 60c9985..25cc488 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.ts +++ b/src/app/features/catalog/product-workspace/product-workspace.ts @@ -15,8 +15,6 @@ import { ProductGeneralTabComponent } from '../components/product-form/general-t import { ProductCommercialTabComponent } from '../components/product-form/commercial-tab/commercial-tab'; import { ProductInventoryTabComponent } from '../components/product-form/inventory-tab/inventory-tab'; import { ProductCompatibilityTabComponent } from '../components/product-form/compatibility-tab/compatibility-tab'; -// import { ProductMediaTabComponent } from '../components/product-form/media-tab/media-tab'; -// import { ProductAdministrationTabComponent } from '../components/product-form/administration-tab/administration-tab'; import { ProductWorkspaceSidebarComponent } from '../components/product-form/sidebar/product-workspace-sidebar'; import { ShellStateService } from '../../../core/services/shell-state'; import { ActivatedRoute, Router } from '@angular/router'; @@ -30,17 +28,17 @@ import { AutocompleteOption } from '../../../design-system/autocomplete-select/a import { DSelectOption } from '../../../core/models/design-system/select-option.model'; import { GeneralOptionQuery } from '../../../core/models/generals/general-option-query.model'; import { ProductResponse } from '../../../core/models/products/product-response.model'; -import { UpdateProductPayload } from '../../../core/models/products/product-request.model'; -import { isCompatibilityWorkspaceTab } from '../../../core/config/compatibility-modules.config'; -import { toProductGeneralFormData } from '../../../core/models/products/product-create.model'; import { - defaultProductFormData, - FormTab, - MediaImageItem, - ProductFormData, - ProductWorkspaceMode, -} from '../models/product-workspace.model'; + UpdateProductPayload, + mapResponseToPayload, + defaultUpdatePayload, +} from '../../../core/models/products/product-request.model'; +import { isCompatibilityWorkspaceTab } from '../../../core/config/compatibility-modules.config'; +import { ProductGeneralFormData } from '../../../core/models/products/product-create.model'; +import { FormTab, MediaImageItem, ProductWorkspaceMode } from '../models/product-workspace.model'; import { WarehousesService } from '../../../core/services/warehouses-service'; +import { initialProductPayload } from '../../../core/utils/product-initial-payload'; +import { UnitSaleService } from '../../../core/services/unit-sale-service'; @Component({ selector: 'app-product-workspace', @@ -65,10 +63,11 @@ export class ProductWorkspaceComponent implements OnInit { private location = inject(Location); private route = inject(ActivatedRoute); private toastService = inject(ToastService); + private statusService = inject(StatusService); readonly shellState = inject(ShellStateService); private productService = inject(ProductService); + private unitSaleService = inject(UnitSaleService); private categoryService = inject(CategoryService); - private statusService = inject(StatusService); private partOriginService = inject(PartOriginService); private warehouseService = inject(WarehousesService); private manufacturerService = inject(ManufacturerService); @@ -95,24 +94,48 @@ export class ProductWorkspaceComponent implements OnInit { readonly selectedImageForDelete = signal(null); readonly errors = signal>({}); - readonly productForm = signal(defaultProductFormData()); - readonly currentStatus = signal(null); + // Dual Signal: product armazena dados brutos da API; productForm armazena o payload mutável para envio. + readonly product = signal(initialProductPayload); + readonly productForm = signal(defaultUpdatePayload()); readonly categoryOptions = computed(() => this.fetchedCategories()); readonly manufacturerOptions = computed(() => this.fetchedManufacturers()); readonly statusOptions = computed(() => this.fetchedStatuses()); - readonly generalForm = computed(() => toProductGeneralFormData(this.productForm())); + readonly currentStatus = computed(() => this.product()?.status ?? null); + + readonly generalForm = computed(() => { + const p = this.productForm(); + return { + name: p.name || '', + slug: p.slug || '', + short_description: p.short_description || '', + description: p.description || '', + category_id: p.category_id || '', + manufacturer_id: p.manufacturer_id || '', + part_origin_id: p.part_origin_id || '', + status_id: p.status_id || '', + internal_code: p.internal_code || '', + barcode: p.barcode || '', + unit_id: p.unit_id || '', + weight: p.weight || 0, + height: p.height || 0, + width: p.width || 0, + length: p.length || 0, + featured: !!p.featured, + active: p.active !== undefined ? !!p.active : true, + }; + }); readonly generalFormOptions = computed(() => ({ + unitOptions: this.unitOptions(), + statusOptions: this.statusOptions(), categoryOptions: this.categoryOptions(), categoryLoading: this.categoryLoading(), + warehouseOptions: this.fetchedWarehouses(), + partOriginOptions: this.fetchedPartOrigins(), manufacturerOptions: this.manufacturerOptions(), manufacturerLoading: this.manufacturerLoading(), - partOriginOptions: this.fetchedPartOrigins(), - unitOptions: this.unitOptions, - statusOptions: this.statusOptions(), - warehouseOptions: this.fetchedWarehouses(), })); readonly isReadOnly = computed(() => this.mode() === 'view'); @@ -135,26 +158,9 @@ export class ProductWorkspaceComponent implements OnInit { limit: null, }; - readonly unitOptions: DSelectOption[] = [ - { label: 'Jogo', value: 'jogo' }, - { label: 'Peça', value: 'peça' }, - { label: 'Par', value: 'par' }, - { label: 'Kit', value: 'kit' }, - { label: 'Litro', value: 'litro' }, - { label: 'Metro', value: 'metro' }, - { label: 'Rolo', value: 'rolo' }, - { label: 'Caixa', value: 'caixa' }, - { label: 'Conjunto', value: 'conjunto' }, - ]; - - readonly availableTags = [ - { id: 'tag-1', label: 'Garantia 12 Meses' }, - { id: 'tag-2', label: 'Linha Leve' }, - { id: 'tag-3', label: 'Alto Giro' }, - { id: 'tag-4', label: 'Primeira Linha' }, - { id: 'tag-5', label: 'Importado' }, - { id: 'tag-6', label: 'Oferta Especial' }, - ]; + readonly unitOptions = signal([]); + + readonly availableTags = []; ngOnInit(): void { const url = this.router.url; @@ -230,13 +236,24 @@ export class ProductWorkspaceComponent implements OnInit { this.fetchedWarehouses.set(opts); }, }); + + this.unitSaleService.getOptions(query).subscribe({ + next: (res) => { + const opts: DSelectOption[] = (res.data || []).map((u) => ({ + label: u.label, + value: u.id, + })); + this.unitOptions.set(opts); + }, + }); } productById(productId: string): void { this.productService.getById(productId).subscribe({ next: (response) => { const prodData = response.data || response; - this.populateForm(prodData); + this.product.set(prodData); + this.productForm.set(mapResponseToPayload(prodData)); }, error: (error) => { console.error('Error loading product:', error); @@ -245,87 +262,6 @@ export class ProductWorkspaceComponent implements OnInit { }); } - populateForm(prod: ProductResponse): void { - this.currentStatus.set(prod.status || null); - const pricingObj = prod.pricing as unknown as Record | undefined; - const inventoryObj = prod.inventory as unknown as Record | undefined; - const rawPrice = prod['price'] as unknown as - | { - price?: number; - promotional_price?: number; - promotion_start?: string; - promotion_end?: string; - } - | undefined; - - this.productForm.set({ - category_id: prod.category?.id || (prod['category_id'] as string) || '', - manufacturer_id: prod.manufacturer?.id || (prod['manufacturer_id'] as string) || '', - part_origin_id: prod.part_origin?.id || (prod['part_origin_id'] as string) || '', - status_id: prod.status?.id || (prod['status_id'] as string) || 'st-01', - unit_id: prod.unit?.id || (prod['unit_id'] as string) || 'un', - - internal_code: prod.internal_code || '', - barcode: (prod['barcode'] as string) || '', - - name: prod.name || '', - slug: prod.slug || '', - - icon: prod.icon || '', - image: prod.image || '', - - short_description: (prod['short_description'] as string) || '', - description: prod.description || '', - - weight: parseFloat(prod.weight as string) || 0, - height: parseFloat(prod.height as string) || 0, - width: parseFloat(prod.width as string) || 0, - length: parseFloat(prod.length as string) || 0, - - featured: !!prod['featured'], - active: prod['active'] !== undefined ? !!prod['active'] : true, - - price: prod.pricing?.regular_price || rawPrice?.price || 0, - - promotionalPrice: prod.pricing?.promotional_price || rawPrice?.promotional_price || null, - - promotionStartDate: prod.pricing?.promotion_start || rawPrice?.promotion_start || null, - - promotionEndDate: prod.pricing?.promotion_end || rawPrice?.promotion_end || null, - - isInvoiced: - typeof pricingObj?.['is_invoiced'] === 'boolean' ? pricingObj['is_invoiced'] : true, - - warehouseId: (inventoryObj?.['warehouse_id'] as string) || 'wh-01', - - quantity: prod.inventory?.quantity || 0, - - minQuantity: prod.inventory?.minimum_quantity || 0, - - maxQuantity: prod.inventory?.maximum_quantity || null, - - allowBackorder: !!prod.inventory?.allow_backorder, - - oemCodes: (prod['oem_codes'] as ProductFormData['oemCodes']) || [], - - productCodes: (prod['product_codes'] as ProductFormData['productCodes']) || [], - - equivalentProducts: - (prod['equivalent_products'] as ProductFormData['equivalentProducts']) || [], - - vehicleApplications: - (prod['vehicle_applications'] as ProductFormData['vehicleApplications']) || [], - - mediaImages: (prod['media_images'] as ProductFormData['mediaImages']) || [], - - suppliers: (prod['suppliers'] as ProductFormData['suppliers']) || [], - - selectedTagIds: (prod['tags'] as string[]) || [], - - productNotes: (prod['notes'] as ProductFormData['productNotes']) || [], - }); - } - setActiveTab(tab: FormTab): void { console.log('tab', tab); this.activeTab.set(tab); @@ -343,7 +279,6 @@ export class ProductWorkspaceComponent implements OnInit { errs['internal_code'] || errs['category_id'] || errs['manufacturer_id'] || - errs['unit_id'] || errs['unit_id'] ); } @@ -368,7 +303,7 @@ export class ProductWorkspaceComponent implements OnInit { updateField(field: string, val: string): void { this.isFormDirty.set(true); - const fieldMapping: Record = { + const fieldMapping: Record = { name: 'name', internal_code: 'internal_code', barcode: 'barcode', @@ -379,9 +314,6 @@ export class ProductWorkspaceComponent implements OnInit { status_id: 'status_id', slug: 'slug', short_description: 'short_description', - promotion_start_date: 'promotionStartDate', - promotion_end_date: 'promotionEndDate', - warehouse_id: 'warehouseId', }; const key = fieldMapping[field]; if (key) { @@ -409,18 +341,27 @@ export class ProductWorkspaceComponent implements OnInit { updateNumberField(field: string, valStr: string): void { this.isFormDirty.set(true); const num = parseFloat(valStr) || 0; - const fieldMapping: Record = { + const fieldMapping: Record = { weight: 'weight', height: 'height', width: 'width', length: 'length', - price: 'price', - quantity: 'quantity', - min_quantity: 'minQuantity', }; const key = fieldMapping[field]; if (key) { this.productForm.update((p) => ({ ...p, [key]: num })); + } else if (field === 'price') { + this.productForm.update((p) => ({ + ...p, + price: { + ...(p.price || {}), + price: num, + }, + })); + } else if (field === 'quantity') { + this.updateInventoryField('quantity', num); + } else if (field === 'min_quantity') { + this.updateInventoryField('minimum_quantity', num); } if (this.errors()[field]) { @@ -436,177 +377,264 @@ export class ProductWorkspaceComponent implements OnInit { this.isFormDirty.set(true); const val = valStr.trim() === '' ? null : parseFloat(valStr); if (field === 'promotional_price') { - this.productForm.update((p) => ({ ...p, promotionalPrice: val })); + this.productForm.update((p) => ({ + ...p, + price: { + ...(p.price || {}), + promotional_price: val, + }, + })); } else if (field === 'max_quantity') { - this.productForm.update((p) => ({ ...p, maxQuantity: val })); + this.updateInventoryField('maximum_quantity', val); } } + private updateInventoryField(key: string, val: any): void { + this.productForm.update((p) => { + const invs = p.inventories || []; + const first = invs[0] || { warehouse_id: '', quantity: 0 }; + return { + ...p, + inventories: [ + { + ...first, + [key]: val, + }, + ], + }; + }); + } + updateDescription(val: string): void { this.isFormDirty.set(true); this.productForm.update((p) => ({ ...p, description: val })); } toggleIsInvoiced(): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ ...p, isInvoiced: !p.isInvoiced })); + // Não suportado diretamente no payload, mas mantido se necessário localmente. } toggleBackorder(): void { if (this.isReadOnly()) return; this.isFormDirty.set(true); - this.productForm.update((p) => ({ ...p, allowBackorder: !p.allowBackorder })); + this.productForm.update((p) => { + const invs = p.inventories || []; + const first = invs[0] || { warehouse_id: '', quantity: 0 }; + return { + ...p, + inventories: [ + { + ...first, + allow_backorder: !first.allow_backorder, + }, + ], + }; + }); } openAddOemModal(): void { const code = prompt('Digite o Código OEM:'); if (!code) return; const manufacturer = prompt('Digite o Fabricante/Montadora:', 'Volkswagen') || 'Montadora'; - this.productForm.update((p) => ({ + + // Simula a adição relacional: adiciona ao sinal product (UI) e adiciona o ID ao productForm + const newId = 'oem-' + Date.now(); + const newOem = { + id: newId, + oem_code: code.toUpperCase(), + manufacturer: { id: '', name: manufacturer, slug: '' }, + is_primary: this.product().oem_codes.length === 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + this.product.update((p) => ({ ...p, - oemCodes: [ - ...p.oemCodes, - { - id: 'oem-' + Date.now(), - manufacturer, - oemCode: code.toUpperCase(), - isPrimary: p.oemCodes.length === 0, - }, - ], + oem_codes: [...p.oem_codes, newOem], })); + + this.productForm.update((pf) => ({ + ...pf, + oem_code_ids: [...(pf.oem_code_ids || this.product().oem_codes.map((o) => o.id)), newId], + })); + this.isFormDirty.set(true); } setPrimaryOem(id: string): void { - this.productForm.update((p) => ({ + this.product.update((p) => ({ ...p, - oemCodes: p.oemCodes.map((o) => ({ ...o, isPrimary: o.id === id })), + oem_codes: p.oem_codes.map((o) => ({ ...o, is_primary: o.id === id })), })); this.isFormDirty.set(true); } removeOemCode(id: string): void { - this.productForm.update((p) => ({ + this.product.update((p) => ({ ...p, - oemCodes: p.oemCodes.filter((o) => o.id !== id), + oem_codes: p.oem_codes.filter((o) => o.id !== id), + })); + + this.productForm.update((pf) => ({ + ...pf, + oem_code_ids: (pf.oem_code_ids || this.product().oem_codes.map((o) => o.id)).filter( + (oId) => oId !== id, + ), })); + this.isFormDirty.set(true); } toggleOemStatus(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ - ...p, - oemCodes: p.oemCodes.map((o) => - o.id === id ? { ...o, status: o.status === 'Ativo' ? 'Inativo' : 'Ativo' } : o, - ), - })); + console.log(id); + // No backend, o status do OEM é atualizado individualmente } openAddProductCodeModal(): void { const code = prompt('Digite o Código:'); if (!code) return; const type = prompt('Digite o Tipo (Ex.: EAN-13, Código Fábrica):', 'EAN-13') || 'Geral'; - this.productForm.update((p) => ({ + + const newId = 'pc-' + Date.now(); + const newCode = { id: newId, type, code, active: true }; + + this.product.update((p) => ({ ...p, - productCodes: [...p.productCodes, { id: 'pc-' + Date.now(), type, code }], + codes: [...p.codes, newCode], })); + + this.productForm.update((pf) => ({ + ...pf, + product_code_ids: [...(pf.product_code_ids || this.product().codes.map((c) => c.id)), newId], + })); + this.isFormDirty.set(true); } removeProductCode(id: string): void { - this.productForm.update((p) => ({ + this.product.update((p) => ({ ...p, - productCodes: p.productCodes.filter((pc) => pc.id !== id), + codes: p.codes.filter((pc) => pc.id !== id), })); + + this.productForm.update((pf) => ({ + ...pf, + product_code_ids: (pf.product_code_ids || this.product().codes.map((c) => c.id)).filter( + (pcId) => pcId !== id, + ), + })); + this.isFormDirty.set(true); } toggleProductCodeStatus(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ - ...p, - productCodes: p.productCodes.map((pc) => - pc.id === id ? { ...pc, status: pc.status === 'Ativo' ? 'Inativo' : 'Ativo' } : pc, - ), - })); + console.log(id); + // No backend } openAddEquivalentModal(): void { const name = prompt('Digite o Nome do Produto Equivalente:'); if (!name) return; const notes = prompt('Observação de equivalência:', 'Compatibilidade direta') || ''; - this.productForm.update((p) => ({ + + const newId = 'eq-' + Date.now(); + const newEquivalent = { + id: newId, + observation: notes, + product: { id: '', name: '', slug: '' }, + equivalent_product: { id: newId, name, slug: '' }, + }; + + this.product.update((p) => ({ ...p, - equivalentProducts: [ - ...p.equivalentProducts, - { id: 'eq-' + Date.now(), productName: name, notes }, + equivalents: [...p.equivalents, newEquivalent], + })); + + this.productForm.update((pf) => ({ + ...pf, + equivalent_product_ids: [ + ...(pf.equivalent_product_ids || this.product().equivalents.map((e) => e.id)), + newId, ], })); + this.isFormDirty.set(true); } removeEquivalent(id: string): void { - this.productForm.update((p) => ({ + this.product.update((p) => ({ ...p, - equivalentProducts: p.equivalentProducts.filter((e) => e.id !== id), + equivalents: p.equivalents.filter((e) => e.id !== id), + })); + + this.productForm.update((pf) => ({ + ...pf, + equivalent_product_ids: ( + pf.equivalent_product_ids || this.product().equivalents.map((e) => e.id) + ).filter((eqId) => eqId !== id), })); + this.isFormDirty.set(true); } toggleEquivalentStatus(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ - ...p, - equivalentProducts: p.equivalentProducts.map((e) => - e.id === id ? { ...e, status: e.status === 'Ativo' ? 'Inativo' : 'Ativo' } : e, - ), - })); + console.log(id); + // No backend } openAddVehicleModal(): void { const brand = prompt('Marca do Veículo:', 'Volkswagen') || 'Volkswagen'; const model = prompt('Modelo:', 'Gol') || 'Modelo'; const engine = prompt('Motorização:', '1.6 8V') || '1.0'; - this.productForm.update((p) => ({ + + const newId = 'veh-' + Date.now(); + const newApplication = { + id: newId, + manufacturer: { id: '', name: brand, slug: '' }, + model, + year_from: 2015, + year_to: 2022, + engine, + details: 'Flex', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + this.product.update((p) => ({ ...p, - vehicleApplications: [ - ...p.vehicleApplications, - { - id: 'veh-' + Date.now(), - brand, - model, - version: 'Flex', - engine, - startYear: 2015, - endYear: 2022, - }, + applications: [...p.applications, newApplication], + })); + + this.productForm.update((pf) => ({ + ...pf, + application_ids: [ + ...(pf.application_ids || this.product().applications.map((a) => a.id)), + newId, ], })); + this.isFormDirty.set(true); } removeVehicleApplication(id: string): void { - this.productForm.update((p) => ({ + this.product.update((p) => ({ ...p, - vehicleApplications: p.vehicleApplications.filter((v) => v.id !== id), + applications: p.applications.filter((v) => v.id !== id), })); + + this.productForm.update((pf) => ({ + ...pf, + application_ids: (pf.application_ids || this.product().applications.map((a) => a.id)).filter( + (aId) => aId !== id, + ), + })); + this.isFormDirty.set(true); } toggleVehicleStatus(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ - ...p, - vehicleApplications: p.vehicleApplications.map((v) => - v.id === id ? { ...v, status: v.status === 'Ativo' ? 'Inativo' : 'Ativo' } : v, - ), - })); + console.log(id); + // No backend } onDragOver(e: DragEvent): void { @@ -635,65 +663,7 @@ export class ProductWorkspaceComponent implements OnInit { } handleFiles(files: File[]): void { - files.forEach((file) => { - if (file.size > 2 * 1024 * 1024) { - this.toastService.error( - 'Arquivo Excede Limite', - `O arquivo ${file.name} possui mais de 2 MB.`, - ); - return; - } - - const currentImages = this.productForm().mediaImages; - const newImg: MediaImageItem = { - id: 'img-' + Date.now() + Math.random().toString(36).substring(2, 5), - url: URL.createObjectURL(file), - name: file.name, - size: (file.size / (1024 * 1024)).toFixed(1) + ' MB', - isPrimary: currentImages.length === 0, - order: currentImages.length + 1, - status: 'uploading', - progress: 0, - }; - - this.productForm.update((p) => ({ - ...p, - mediaImages: [...p.mediaImages, newImg], - })); - this.simulateAsyncUpload(newImg.id); - }); - this.isFormDirty.set(true); - } - - simulateAsyncUpload(imgId: string): void { - let prog = 0; - const interval = setInterval(() => { - prog += 25; - this.productForm.update((p) => ({ - ...p, - mediaImages: p.mediaImages.map((img) => - img.id === imgId ? { ...img, progress: prog } : img, - ), - })); - - if (prog >= 100) { - clearInterval(interval); - this.productForm.update((p) => ({ - ...p, - mediaImages: p.mediaImages.map((img) => - img.id === imgId ? { ...img, status: 'completed' } : img, - ), - })); - } - }, 200); - } - - setPrimaryImage(id: string): void { - this.productForm.update((p) => ({ - ...p, - mediaImages: p.mediaImages.map((img) => ({ ...img, isPrimary: img.id === id })), - })); - this.isFormDirty.set(true); + // Media handling } confirmDeleteImage(img: MediaImageItem): void { @@ -702,17 +672,6 @@ export class ProductWorkspaceComponent implements OnInit { } executeDeleteImage(): void { - const img = this.selectedImageForDelete(); - if (img) { - this.productForm.update((p) => { - const filtered = p.mediaImages.filter((i) => i.id !== img.id); - if (img.isPrimary && filtered.length > 0) { - filtered[0] = { ...filtered[0], isPrimary: true }; - } - return { ...p, mediaImages: filtered }; - }); - this.toastService.success('Imagem Removida', `${img.name} foi removida da galeria.`); - } this.deleteImageDialogOpen.set(false); } @@ -720,102 +679,93 @@ export class ProductWorkspaceComponent implements OnInit { const name = prompt('Nome do Fornecedor:'); if (!name) return; const code = prompt('Código no Fornecedor:', 'SUP-01') || 'SUP-01'; - this.productForm.update((p) => ({ + + const newId = 'sup-' + Date.now(); + const newSupplier = { + id: newId, + supplier_name: name, + supplier_code: code, + pivot: { + purchase_price: '100.00', + lead_time_days: 5, + is_preferential: this.product().suppliers.length === 0, + }, + } as any; + + this.product.update((p) => ({ ...p, - suppliers: [ - ...p.suppliers, - { - id: 'sup-' + Date.now(), - supplierName: name, - supplierCode: code, - purchasePrice: 100.0, - leadTimeDays: 5, - isPreferential: p.suppliers.length === 0, - }, - ], + suppliers: [...p.suppliers, newSupplier], })); + + this.productForm.update((pf) => ({ + ...pf, + supplier_ids: [...(pf.supplier_ids || this.product().suppliers.map((s) => s.id)), newId], + })); + this.isFormDirty.set(true); } removeSupplier(id: string): void { - this.productForm.update((p) => ({ + this.product.update((p) => ({ ...p, suppliers: p.suppliers.filter((s) => s.id !== id), })); + + this.productForm.update((pf) => ({ + ...pf, + supplier_ids: (pf.supplier_ids || this.product().suppliers.map((s) => s.id)).filter( + (sId) => sId !== id, + ), + })); + this.isFormDirty.set(true); } setPreferentialSupplier(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ + this.product.update((p) => ({ ...p, - suppliers: p.suppliers.map((s) => ({ ...s, isPreferential: s.id === id })), + suppliers: p.suppliers.map((s) => ({ + ...s, + preferred: s.id === id, + })), })); + this.isFormDirty.set(true); } toggleSupplierStatus(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ - ...p, - suppliers: p.suppliers.map((s) => - s.id === id ? { ...s, status: s.status === 'Ativo' ? 'Inativo' : 'Ativo' } : s, - ), - })); + console.log(id); + // No backend } toggleTag(tagId: string): void { if (this.isReadOnly()) return; this.isFormDirty.set(true); - this.productForm.update((p) => { - const exists = p.selectedTagIds.includes(tagId); + + this.product.update((p) => { + const exists = p.tags.some((t) => t.id === tagId); return { ...p, - selectedTagIds: exists - ? p.selectedTagIds.filter((id) => id !== tagId) - : [...p.selectedTagIds, tagId], + tags: exists + ? p.tags.filter((t) => t.id !== tagId) + : [...p.tags, { id: tagId, name: '' } as any], }; }); - } - openAddNoteModal(): void { - const desc = prompt('Descrição da Nota/Observação:'); - if (!desc) return; - this.productForm.update((p) => ({ - ...p, - productNotes: [ - ...p.productNotes, - { - id: 'note-' + Date.now(), - type: 'Geral', - description: desc, - date: new Date().toLocaleDateString('pt-BR'), - author: 'Usuário do Sistema', - }, - ], - })); - this.isFormDirty.set(true); + this.productForm.update((pf) => { + const currentTags = pf.tag_ids || this.product().tags.map((t) => t.id); + const exists = currentTags.includes(tagId); + return { + ...pf, + tag_ids: exists ? currentTags.filter((id) => id !== tagId) : [...currentTags, tagId], + }; + }); } - removeNote(id: string): void { - this.productForm.update((p) => ({ - ...p, - productNotes: p.productNotes.filter((n) => n.id !== id), - })); - this.isFormDirty.set(true); - } + openAddNoteModal(): void {} - toggleNoteStatus(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ - ...p, - productNotes: p.productNotes.map((n) => - n.id === id ? { ...n, status: n.status === 'Ativo' ? 'Inativo' : 'Ativo' } : n, - ), - })); - } + removeNote(id: string): void {} + + toggleNoteStatus(id: string): void {} onCategorySearch(query: GeneralOptionQuery): void { this.categoryLoading.set(true); @@ -854,10 +804,10 @@ export class ProductWorkspaceComponent implements OnInit { const errs: Record = {}; const p = this.productForm(); - if (!p.name.trim()) { + if (!p.name?.trim()) { errs['name'] = 'O nome do produto é obrigatório.'; } - if (!p.internal_code.trim()) { + if (!p.internal_code?.trim()) { errs['internal_code'] = 'O código interno é obrigatório.'; } if (!p.category_id) { @@ -869,19 +819,12 @@ export class ProductWorkspaceComponent implements OnInit { if (!p.unit_id) { errs['unit_id'] = 'Selecione a unidade de medida.'; } - if (p.price <= 0) { - errs['price'] = 'O preço base deve ser maior que zero.'; - } this.errors.set(errs); if (Object.keys(errs).length > 0) { if (this.hasTabError('geral')) { this.activeTab.set('geral'); - } else if (this.hasTabError('comercial')) { - this.activeTab.set('comercial'); - } else if (this.hasTabError('inventario')) { - this.activeTab.set('inventario'); } return false; } @@ -899,52 +842,14 @@ export class ProductWorkspaceComponent implements OnInit { } this.isSaving.set(true); - const id = this.route.snapshot.paramMap.get('id'); - const p = this.productForm(); - - const payload: UpdateProductPayload = { - category_id: p.category_id, - manufacturer_id: p.manufacturer_id, - part_origin_id: p.part_origin_id, - unit_id: p.status_id, + const payload = this.productForm(); - internal_code: p.internal_code, - barcode: p.barcode, + console.log('[SAVE PRODUCT UPDATE]', payload); + return; - name: p.name, - slug: p.slug, - - short_description: p.short_description, - description: p.description, - - weight: p.weight, - height: p.height, - width: p.width, - length: p.length, - - unit: p.unit_id, - - featured: p.featured, - - price: { - price: p.price, - promotional_price: p.promotionalPrice, - promotion_start: p.promotionStartDate, - promotion_end: p.promotionEndDate, - }, - - inventory: { - warehouse_id: p.warehouseId, - quantity: p.quantity, - minimum_quantity: p.minQuantity, - maximum_quantity: p.maxQuantity, - allow_backorder: p.allowBackorder, - }, - }; - - this.productService.update(id!, payload as UpdateProductPayload).subscribe({ - next: () => { + this.productService.update(id!, payload).subscribe({ + next: (response) => { this.isSaving.set(false); this.isFormDirty.set(false); this.toastService.success( @@ -952,6 +857,10 @@ export class ProductWorkspaceComponent implements OnInit { 'Informações atualizadas no sistema.', ); + const prodData = response.data || response; + this.product.set(prodData); + this.productForm.set(mapResponseToPayload(prodData)); + if (!continueEditing) { this.router.navigate(['/catalog/products']); } diff --git a/src/app/utils/product-table-collum.ts b/src/app/utils/product-table-collum.ts index 4c552ec..d2d2267 100644 --- a/src/app/utils/product-table-collum.ts +++ b/src/app/utils/product-table-collum.ts @@ -29,7 +29,7 @@ export const productTableColumns: TableColumn[] = [ header: 'Estoque', width: '100px', align: 'center', - valueGetter: (p) => p.inventory?.available_quantity ?? 0, + valueGetter: (p) => p.inventories?.reduce((acc, inventory) => acc + inventory.quantity, 0) ?? 0, }, { key: 'status', From 4c2120ab0d8151533a7ccf9acf9b04a7f65a1280 Mon Sep 17 00:00:00 2001 From: aledguedes Date: Thu, 13 Aug 2026 09:54:53 -0300 Subject: [PATCH 2/7] 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); From 756b09fb57be195681090c9f33cf0cecee1c5dc0 Mon Sep 17 00:00:00 2001 From: aledguedes Date: Fri, 14 Aug 2026 17:05:28 -0300 Subject: [PATCH 3/7] Refactor OEM codes models, service and compatibility UI Introduce unified OemCode model and manufacturer helpers; replace legacy ProductOemCode with OemCode in product responses. Add ManufacturerSummaryResponse, ManufacturerOptionsQuery and ProductSummaryResponse models. Implement OemCodeService with endpoints for listing, options and product-scoped OEMs. Revamp compatibility tab template and component to support search, manufacturer filters, selection, pagination and new outputs/events. Wire OEM fetching and manufacturer-by-product options in ProductWorkspace; add unit sale service usage in ProductCreate. Minor API-model field adjustments and small logging added. Improves OEM UI/UX and backend integration. --- .../additional-codes-product.model.ts | 1 + .../equivalent/equivalent-products.model.ts | 1 + .../manufaturer-summary-response.model.ts | 5 + .../mnufacturer-options-query.model.ts | 6 + .../oem-codes/oem-codes-product.model.ts | 7 +- .../core/models/oem-codes/oem-codes.model.ts | 11 + .../models/products/product-create.model.ts | 1 + .../models/products/product-response.model.ts | 4 +- .../products/product-summary-response.ts | 6 + .../vehicle-application-product.model.ts | 1 + src/app/core/services/code-oem-service.ts | 43 ++ src/app/core/services/manufacture-service.ts | 6 + .../compatibility-tab/compatibility-tab.html | 387 +++++++++++++----- .../compatibility-tab/compatibility-tab.ts | 258 +++++++++--- .../catalog/product-create/product-create.ts | 26 +- .../product-workspace/product-workspace.html | 6 +- .../product-workspace/product-workspace.ts | 86 ++-- 17 files changed, 651 insertions(+), 204 deletions(-) create mode 100644 src/app/core/models/manufactureres/manufaturer-summary-response.model.ts create mode 100644 src/app/core/models/manufactureres/mnufacturer-options-query.model.ts create mode 100644 src/app/core/models/oem-codes/oem-codes.model.ts create mode 100644 src/app/core/models/products/product-summary-response.ts create mode 100644 src/app/core/services/code-oem-service.ts diff --git a/src/app/core/models/additional-codes/additional-codes-product.model.ts b/src/app/core/models/additional-codes/additional-codes-product.model.ts index c01d660..644be4e 100644 --- a/src/app/core/models/additional-codes/additional-codes-product.model.ts +++ b/src/app/core/models/additional-codes/additional-codes-product.model.ts @@ -2,4 +2,5 @@ export interface ProductAdditionalCodeSummary { id: string; code: string; active: boolean; + type: string; // remover possivelmente } diff --git a/src/app/core/models/equivalent/equivalent-products.model.ts b/src/app/core/models/equivalent/equivalent-products.model.ts index 58799a6..39d8c6f 100644 --- a/src/app/core/models/equivalent/equivalent-products.model.ts +++ b/src/app/core/models/equivalent/equivalent-products.model.ts @@ -5,4 +5,5 @@ export interface ProductEquivalent { observation: string | null; product: EntityReference; equivalent_product: EntityReference; + status?: boolean; // remover após produtos equivalentes } diff --git a/src/app/core/models/manufactureres/manufaturer-summary-response.model.ts b/src/app/core/models/manufactureres/manufaturer-summary-response.model.ts new file mode 100644 index 0000000..cf24122 --- /dev/null +++ b/src/app/core/models/manufactureres/manufaturer-summary-response.model.ts @@ -0,0 +1,5 @@ +export interface ManufacturerSummaryResponse { + id: string; + name: string; + slug: string; +} diff --git a/src/app/core/models/manufactureres/mnufacturer-options-query.model.ts b/src/app/core/models/manufactureres/mnufacturer-options-query.model.ts new file mode 100644 index 0000000..bfe2804 --- /dev/null +++ b/src/app/core/models/manufactureres/mnufacturer-options-query.model.ts @@ -0,0 +1,6 @@ +export interface ManufacturerOptionsQuery { + search: string | null; + page: number | null; + per_page: number | null; + manufacturer_id: string | null; +} diff --git a/src/app/core/models/oem-codes/oem-codes-product.model.ts b/src/app/core/models/oem-codes/oem-codes-product.model.ts index f74ae8e..97a5ef4 100644 --- a/src/app/core/models/oem-codes/oem-codes-product.model.ts +++ b/src/app/core/models/oem-codes/oem-codes-product.model.ts @@ -3,9 +3,6 @@ import { EntityReference } from '../generals/entity-reference'; export interface ProductOemCode { id: string; oem_code: string; - manufacturer?: EntityReference; - product?: EntityReference; - created_at: string; - updated_at: string; - is_primary: boolean; + manufacturer: EntityReference; + product: EntityReference; } diff --git a/src/app/core/models/oem-codes/oem-codes.model.ts b/src/app/core/models/oem-codes/oem-codes.model.ts new file mode 100644 index 0000000..f986b7c --- /dev/null +++ b/src/app/core/models/oem-codes/oem-codes.model.ts @@ -0,0 +1,11 @@ +import { ProductSummaryResponse } from '../products/product-summary-response'; +import { ManufacturerSummaryResponse } from '../manufactureres/manufaturer-summary-response.model'; + +export interface OemCode { + id: string; + label: string; + product: ProductSummaryResponse; + manufacturer: ManufacturerSummaryResponse; + created_at: string; + updated_at: string; +} diff --git a/src/app/core/models/products/product-create.model.ts b/src/app/core/models/products/product-create.model.ts index 5c41cbb..0ce6aae 100644 --- a/src/app/core/models/products/product-create.model.ts +++ b/src/app/core/models/products/product-create.model.ts @@ -140,6 +140,7 @@ export function toProductGeneralFormData(source: ProductGeneralFormSource): Prod } export function toCreateProductPayload(source: ProductGeneralFormSource): CreateProductPayload { + console.log('[CHEGAMOS AQUI PAYLOAD]', source); return { category_id: source.category_id, manufacturer_id: source.manufacturer_id, diff --git a/src/app/core/models/products/product-response.model.ts b/src/app/core/models/products/product-response.model.ts index 1e065c5..e71e142 100644 --- a/src/app/core/models/products/product-response.model.ts +++ b/src/app/core/models/products/product-response.model.ts @@ -1,7 +1,6 @@ import { ProductVehicleApplication } from '../vehicle-application/vehicle-application-product.model'; import { ProductEquivalent } from '../equivalent/equivalent-products.model'; import { EntityReference } from '../generals/entity-reference'; -import { ProductOemCode } from '../oem-codes/oem-codes-product.model'; import { ProductStatus } from '../status/status-product.model'; import { ProductSupplier } from '../suppliers/suppliers-product.model'; import { ProductPricing } from '../pricing/pricing-product-model'; @@ -10,6 +9,7 @@ import { ProductUnitSale } from '../units-sale/unit-sale-product.model'; import { ProductPartOrigin } from '../part-origin/part-origin-product.model'; import { ProductAdditionalCodeSummary } from '../additional-codes/additional-codes-product.model'; import { ProductTag } from '../tags/tags-product.model'; +import { OemCode } from '../oem-codes/oem-codes.model'; export interface ProductSummaryResponse { id: string; @@ -56,7 +56,7 @@ export interface ProductResponse extends ProductSummaryResponse { part_origin: ProductPartOrigin | null; tags: ProductTag[]; - oem_codes: ProductOemCode[]; + oem_codes: OemCode[]; suppliers: ProductSupplier[]; equivalents: ProductEquivalent[]; applications: ProductVehicleApplication[]; diff --git a/src/app/core/models/products/product-summary-response.ts b/src/app/core/models/products/product-summary-response.ts new file mode 100644 index 0000000..caad5e6 --- /dev/null +++ b/src/app/core/models/products/product-summary-response.ts @@ -0,0 +1,6 @@ +export interface ProductSummaryResponse { + id: string; + name: string; + slug: string; + internal_code: string; +} diff --git a/src/app/core/models/vehicle-application/vehicle-application-product.model.ts b/src/app/core/models/vehicle-application/vehicle-application-product.model.ts index 6c9a78f..4487b5c 100644 --- a/src/app/core/models/vehicle-application/vehicle-application-product.model.ts +++ b/src/app/core/models/vehicle-application/vehicle-application-product.model.ts @@ -10,4 +10,5 @@ export interface ProductVehicleApplication { details: string | null; created_at?: string; updated_at?: string; + status?: boolean; // remover possivelmente } diff --git a/src/app/core/services/code-oem-service.ts b/src/app/core/services/code-oem-service.ts new file mode 100644 index 0000000..e0b8a92 --- /dev/null +++ b/src/app/core/services/code-oem-service.ts @@ -0,0 +1,43 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { PaginatedResponse } from '../models/pagination/pagination.model'; +import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; +import { buildHttpParams } from './build-http-params'; +import { getAllResponse } from '../models/generals/general-responses-list.model'; +import { OemCode } from '../models/oem-codes/oem-codes.model'; +import { ManufacturerOptionsQuery } from '../models/manufactureres/mnufacturer-options-query.model'; + +@Injectable({ + providedIn: 'root', +}) +export class OemCodeService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'product-oem-codes'; + + getAll() { + return this.http.get>(`${this.api}/${this.flag}`); + } + + getOptions(filters: GeneralOptionQuery) { + const params = buildHttpParams(filters); + return this.http.get>(`${this.api}/${this.flag}/options`, { + params, + }); + } + + getByProduct(productId: string, filters: ManufacturerOptionsQuery) { + const params = buildHttpParams(filters); + return this.http.get>( + `${this.api}/${this.flag}/product/${productId}/options`, + { + params, + }, + ); + } + + getById(id: string) { + return this.http.get>(`${this.api}/${this.flag}/${id}`); + } +} diff --git a/src/app/core/services/manufacture-service.ts b/src/app/core/services/manufacture-service.ts index 6c2a82b..c365dd9 100644 --- a/src/app/core/services/manufacture-service.ts +++ b/src/app/core/services/manufacture-service.ts @@ -29,4 +29,10 @@ export class ManufacturerService { getById(id: string) { return this.http.get(`${this.api}/${this.flag}/${id}`); } + + getManufacturerByProduct(productId: string) { + return this.http.get( + `${this.api}/${this.flag}/product/${productId}`, + ); + } } diff --git a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html index 80257ab..97c8ebc 100644 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html +++ b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html @@ -1,94 +1,243 @@
- @switch (activeTab()) { @case ('oem') { - + + @if (activeTab() === 'oem') {
-
+ +

- - {{ getModule('oem').label }} + + Códigos OEM Registrados

-

{{ getModule('oem').description }}

+ +

+ Selecione um ou mais códigos de montadoras de referência para vincular a este produto +

- @if (!isReadOnly()) { - - Adicionar Código OEM - +
+ + + {{ selectedOemCodeIds().length }} de {{ oemTotalItens() }} selecionados + +
+
+ + +
+
+ +
+
+ +
+ + + + @if (oemSearchQuery()) { + + } +
+ + + @if (!isReadOnly()) { +
+ + + +
+ } +
+ + + @if (manufacturerByProductFilter().length > 0) { +
+ + Filtrar por: + + + + + @for (mfr of manufacturerByProductFilter(); track mfr.id) { + + } +
}
-
+ +
- - - - - + + + + + + + - @for (oem of oemPaginated(); track oem.id) { - - - + + - + + + + + + + + - } @empty { - } @@ -96,35 +245,35 @@

} - } @case ('codigos') { + } + + @if (activeTab() === 'codigos') {

- - {{ getModule('codigos').label }} + + Códigos do Produto (Product Codes)

+

- {{ getModule('codigos').description }} + EAN, DUN, Código do fabricante, Código paralelo e Código interno auxiliar

@@ -135,7 +284,9 @@

+

Fabricante de ReferênciaCódigo OEMPrincipalSituaçãoAções + Seleção + Código OEMFabricante / MontadoraData RegistroStatus do Vínculo
{{ oem.manufacturer?.name }} - {{ oem.oem_code }} + @for (oem of oemCodes(); track oem.id) { @let selected = isOemSelected(oem.id); + +
+ - @if (oem.is_primary) { + + + +
+ + {{ oem.label }} + + + @if (selected) { + + + Selecionado + + } +
+
- - Principal + {{ oem.manufacturer.name || 'Montadora' }} - } @else if (!isReadOnly()) { - - } @else { - - - } + {{ oem.created_at ? (oem.created_at | date: 'dd/MM/yyyy') : '01/08/2026' }} + + @if (selected) { - Ativo + + Vinculado ao Produto - - @if (!isReadOnly()) { - + Não Vinculado + }
- Nenhum código OEM cadastrado. + +
+ + +

+ Nenhum código OEM encontrado para o filtro aplicado. +

+ + +
Ações + @for (codeItem of productCodesPaginated(); track codeItem.id) { + - +
- {{ codeItem.code }} + {{ codeItem.type }} {{ codeItem.code }} - - Ativo - - @if (!isReadOnly()) {