diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index e7d549a..a1c65a1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -19,26 +19,28 @@ export const routes: Routes = [ { path: 'catalog/products/new', loadComponent: () => - import('./features/catalog/product-create/product-create').then((m) => m.ProductCreateComponent), + import('./features/catalog/products/product-create/product-create').then( + (m) => m.ProductCreateComponent, + ), }, { path: 'catalog/products/:id/edit', loadComponent: () => - import('./features/catalog/product-workspace/product-workspace').then( + import('./features/catalog/products/product-workspace/product-workspace').then( (m) => m.ProductWorkspaceComponent, ), }, { path: 'catalog/products/:id/view', loadComponent: () => - import('./features/catalog/product-workspace/product-workspace').then( + import('./features/catalog/products/product-workspace/product-workspace').then( (m) => m.ProductWorkspaceComponent, ), }, { path: 'catalog/products/:id', loadComponent: () => - import('./features/catalog/product-workspace/product-workspace').then( + import('./features/catalog/products/product-workspace/product-workspace').then( (m) => m.ProductWorkspaceComponent, ), }, diff --git a/src/app/app.ts b/src/app/app.ts index a00c3a7..55f6cbb 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { afterNextRender, ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { AppShellComponent } from './components/app-shell/app-shell'; +import { AppInitializerService } from './core/initialization/app-initializer.service'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -9,4 +10,12 @@ import { AppShellComponent } from './components/app-shell/app-shell'; templateUrl: './app.html', styleUrl: './app.css', }) -export class App {} +export class App { + private readonly initializer = inject(AppInitializerService); + + constructor() { + afterNextRender(() => { + void this.initializer.initialize(); + }); + } +} diff --git a/src/app/core/initialization/app-initializer.service.ts b/src/app/core/initialization/app-initializer.service.ts new file mode 100644 index 0000000..0a27c7c --- /dev/null +++ b/src/app/core/initialization/app-initializer.service.ts @@ -0,0 +1,33 @@ +import { inject, Injectable } from '@angular/core'; +import { CategoryStore } from '../store/category-store/category-store'; +import { ManufacturerStore } from '../store/manufacturer-store/manufacturer-store'; +import { OemCodeStore } from '../store/oem-code-store/oem-code-store'; +import { StatusStore } from '../store/status-store/status-store'; +import { UnitSaleStore } from '../store/unit-sale/unit-sale-store'; +import { WarehouseStore } from '../store/warehouse/warehouse-store'; +import { PartOriginStore } from '../store/part-origin/part-origin-store'; + +@Injectable({ + providedIn: 'root', +}) +export class AppInitializerService { + private readonly statusStore = inject(StatusStore); + private readonly oemCodeStore = inject(OemCodeStore); + private readonly categoryStore = inject(CategoryStore); + private readonly unitSaleStore = inject(UnitSaleStore); + private readonly warehouseStore = inject(WarehouseStore); + private readonly partOriginStore = inject(PartOriginStore); + private readonly manufacturerStore = inject(ManufacturerStore); + + async initialize(): Promise { + await Promise.all([ + this.oemCodeStore.loadInitial(), + this.statusStore.hydrate(), + this.unitSaleStore.hydrate(), + this.categoryStore.hydrate(), + this.warehouseStore.hydrate(), + this.partOriginStore.hydrate(), + this.manufacturerStore.hydrate(), + ]); + } +} 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..644be4e --- /dev/null +++ b/src/app/core/models/additional-codes/additional-codes-product.model.ts @@ -0,0 +1,6 @@ +export interface ProductAdditionalCodeSummary { + id: string; + code: string; + active: boolean; + type: string; // remover possivelmente +} diff --git a/src/app/core/models/catetories/category-options.model.ts b/src/app/core/models/catetories/category-options.model.ts index 016ddda..daf447c 100644 --- a/src/app/core/models/catetories/category-options.model.ts +++ b/src/app/core/models/catetories/category-options.model.ts @@ -1,12 +1,6 @@ -export interface CategoryOptionsResponse { - success: boolean; - message: string; - data: CategoryOption[]; -} +import { GeneralOption } from '../generals/general-options-response.model'; -export interface CategoryOption { - id: string; - label: string; - description: string; +export interface CategoryOption extends GeneralOption { icon: string; + description: string; } diff --git a/src/app/core/models/catetories/category-query-cache.model.ts b/src/app/core/models/catetories/category-query-cache.model.ts new file mode 100644 index 0000000..e2acff1 --- /dev/null +++ b/src/app/core/models/catetories/category-query-cache.model.ts @@ -0,0 +1,8 @@ +import { AutocompleteOption } from '../design-system/auto-complete.model'; +import { getAllResponse } from '../generals/general-responses-list.model'; +import { GeneralOptionQuery } from '../generals/general-option-query.model'; + +export interface CategoryQueryCache { + query: GeneralOptionQuery; + response: getAllResponse; +} diff --git a/src/app/core/models/design-system/auto-complete.model.ts b/src/app/core/models/design-system/auto-complete.model.ts new file mode 100644 index 0000000..eca1f62 --- /dev/null +++ b/src/app/core/models/design-system/auto-complete.model.ts @@ -0,0 +1,7 @@ +import { GeneralOption } from '../generals/general-options-response.model'; + +export interface AutocompleteOption extends GeneralOption { + icon: string; + disabled: boolean; + description: string; +} diff --git a/src/app/core/models/design-system/select-option.model.ts b/src/app/core/models/design-system/select-option.model.ts index f9e6634..03d8b68 100644 --- a/src/app/core/models/design-system/select-option.model.ts +++ b/src/app/core/models/design-system/select-option.model.ts @@ -1,5 +1,5 @@ -export interface DSelectOption { - label: string; - value: string | number; - disabled?: boolean; +import { GeneralOption } from '../generals/general-options-response.model'; + +export interface SelectOption extends GeneralOption { + disabled: boolean; } 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..39d8c6f --- /dev/null +++ b/src/app/core/models/equivalent/equivalent-products.model.ts @@ -0,0 +1,9 @@ +import { EntityReference } from '../generals/entity-reference'; + +export interface ProductEquivalent { + id: string; + observation: string | null; + product: EntityReference; + equivalent_product: EntityReference; + status?: boolean; // remover após produtos equivalentes +} 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/generals/general-option-query.model.ts b/src/app/core/models/generals/general-option-query.model.ts index 4a47c78..04473aa 100644 --- a/src/app/core/models/generals/general-option-query.model.ts +++ b/src/app/core/models/generals/general-option-query.model.ts @@ -1,7 +1,9 @@ export interface GeneralOptionQuery { search: string | null; active: boolean | null; - limit: number | null; + per_page: number | null; + page: number | null; + manufacturer_id: string | null; } // ALTERNATIVA PRA PAGINAÇÃO diff --git a/src/app/core/models/generals/general-options-response.model.ts b/src/app/core/models/generals/general-options-response.model.ts new file mode 100644 index 0000000..d43f276 --- /dev/null +++ b/src/app/core/models/generals/general-options-response.model.ts @@ -0,0 +1,5 @@ +export interface GeneralOption { + value: string; + label: string; + sublabel: string; +} 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/indexed-db/indexed-db.model.ts b/src/app/core/models/indexed-db/indexed-db.model.ts new file mode 100644 index 0000000..a19d72f --- /dev/null +++ b/src/app/core/models/indexed-db/indexed-db.model.ts @@ -0,0 +1,11 @@ +export interface CacheEntry { + data: T; + cachedAt: number; + expiresAt: number; + version: number; +} + +export interface CacheChangedEvent { + type: 'updated' | 'removed'; + key: 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/manufactureres/manufacturer-query-cache.model.ts b/src/app/core/models/manufactureres/manufacturer-query-cache.model.ts new file mode 100644 index 0000000..97384eb --- /dev/null +++ b/src/app/core/models/manufactureres/manufacturer-query-cache.model.ts @@ -0,0 +1,8 @@ +import { getAllResponse } from '../generals/general-responses-list.model'; +import { GeneralOptionQuery } from '../generals/general-option-query.model'; +import { AutocompleteOption } from '../design-system/auto-complete.model'; + +export interface ManufacturerQueryCache { + query: GeneralOptionQuery; + response: getAllResponse; +} diff --git a/src/app/core/models/manufactureres/manufaturer-options.model.ts b/src/app/core/models/manufactureres/manufaturer-options.model.ts index 13dda21..5e7a0c2 100644 --- a/src/app/core/models/manufactureres/manufaturer-options.model.ts +++ b/src/app/core/models/manufactureres/manufaturer-options.model.ts @@ -1,9 +1,3 @@ -export interface ManufacturerOptionsResponse { - success: boolean; - message: string; - data: ManufacturerOption[]; -} - export interface ManufacturerOption { id: string; label: string; 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/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..97a5ef4 --- /dev/null +++ b/src/app/core/models/oem-codes/oem-codes-product.model.ts @@ -0,0 +1,8 @@ +import { EntityReference } from '../generals/entity-reference'; + +export interface ProductOemCode { + id: string; + oem_code: string; + manufacturer: EntityReference; + product: EntityReference; +} diff --git a/src/app/core/models/oem-codes/oem-codes-query-cache.model.ts b/src/app/core/models/oem-codes/oem-codes-query-cache.model.ts new file mode 100644 index 0000000..f7cb22f --- /dev/null +++ b/src/app/core/models/oem-codes/oem-codes-query-cache.model.ts @@ -0,0 +1,10 @@ +// oem-code-query-cache.model.ts + +import { GeneralOptionQuery } from '../generals/general-option-query.model'; +import { PaginatedResponse } from '../pagination/pagination.model'; +import { OemCode } from './oem-codes.model'; + +export interface OemCodeQueryCache { + query: GeneralOptionQuery; + response: PaginatedResponse; +} 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..950dad3 --- /dev/null +++ b/src/app/core/models/oem-codes/oem-codes.model.ts @@ -0,0 +1,9 @@ +import { ManufacturerSummaryResponse } from '../manufactureres/manufaturer-summary-response.model'; + +export interface OemCode { + id: string; + oem_code: string; + manufacturer: ManufacturerSummaryResponse; + created_at: string; + updated_at: string; +} 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/part-origin/part-origin-query-cache.model.ts b/src/app/core/models/part-origin/part-origin-query-cache.model.ts new file mode 100644 index 0000000..b6faef1 --- /dev/null +++ b/src/app/core/models/part-origin/part-origin-query-cache.model.ts @@ -0,0 +1,8 @@ +import { SelectOption } from '../design-system/select-option.model'; +import { GeneralOptionQuery } from '../generals/general-option-query.model'; +import { getAllResponse } from '../generals/general-responses-list.model'; + +export interface PartOriginQueryCache { + query: GeneralOptionQuery; + response: getAllResponse; +} diff --git a/src/app/core/models/PartOriginResponse.model.ts b/src/app/core/models/part-origin/part-origin-response.ts similarity index 85% rename from src/app/core/models/PartOriginResponse.model.ts rename to src/app/core/models/part-origin/part-origin-response.ts index f07b405..03cf999 100644 --- a/src/app/core/models/PartOriginResponse.model.ts +++ b/src/app/core/models/part-origin/part-origin-response.ts @@ -1,8 +1,7 @@ export interface PartOriginResponse { id: string; name: string; - abbreviation: string; + active: boolean; description: string; display_order: number; - active: boolean; } 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-create.model.ts b/src/app/core/models/products/product-create.model.ts index 5c41cbb..a1f91e9 100644 --- a/src/app/core/models/products/product-create.model.ts +++ b/src/app/core/models/products/product-create.model.ts @@ -1,5 +1,5 @@ -import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; -import { DSelectOption } from '../design-system/select-option.model'; +import { AutocompleteOption } from '../design-system/auto-complete.model'; +import { SelectOption } from '../design-system/select-option.model'; /** Estado interno do formulário de criação (campos do POST) */ export interface ProductCreateFormState { @@ -87,14 +87,12 @@ export interface ProductGeneralFormData { } export interface ProductGeneralFormOptions { + unitOptions: SelectOption[]; + statusOptions: SelectOption[]; + warehouseOptions: SelectOption[]; + partOriginOptions: SelectOption[]; categoryOptions: AutocompleteOption[]; - categoryLoading: boolean; manufacturerOptions: AutocompleteOption[]; - manufacturerLoading: boolean; - partOriginOptions: DSelectOption[]; - unitOptions: DSelectOption[]; - statusOptions: DSelectOption[]; - warehouseOptions: DSelectOption[]; } export interface ProductGeneralFormSource { @@ -140,6 +138,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-request.model.ts b/src/app/core/models/products/product-request.model.ts index 61625fb..6aacfe6 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; + reserved_quantity?: number; +} + +/** + * 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; + reserved_quantity?: number; + minimum_quantity?: number | null; + maximum_quantity?: number | null; + allow_backorder?: boolean; + active?: boolean; + }[]; +}): UpdateProductPayload { + 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: 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. + // 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..e71e142 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 { 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'; +import { OemCode } from '../oem-codes/oem-codes.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: 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/status/status-options.model.ts b/src/app/core/models/status/status-options.model.ts index 29f165b..a7302e5 100644 --- a/src/app/core/models/status/status-options.model.ts +++ b/src/app/core/models/status/status-options.model.ts @@ -1,12 +1,5 @@ -export interface StatusOptionsResponse { - success: boolean; - message: string; - data: StatusOption[]; -} +import { GeneralOption } from '../generals/general-options-response.model'; -export interface StatusOption { - id: string; - label: string; - description: string; +export interface StatusOption extends GeneralOption { icon: string; } 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/status/status-query-cache.model.ts b/src/app/core/models/status/status-query-cache.model.ts new file mode 100644 index 0000000..8c400fa --- /dev/null +++ b/src/app/core/models/status/status-query-cache.model.ts @@ -0,0 +1,8 @@ +import { getAllResponse } from '../generals/general-responses-list.model'; +import { GeneralOptionQuery } from '../generals/general-option-query.model'; +import { SelectOption } from '../design-system/select-option.model'; + +export interface StatusQueryCache { + query: GeneralOptionQuery; + response: getAllResponse; +} 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/units-sale/unit-sale-query-cache.model.ts b/src/app/core/models/units-sale/unit-sale-query-cache.model.ts new file mode 100644 index 0000000..e1cc651 --- /dev/null +++ b/src/app/core/models/units-sale/unit-sale-query-cache.model.ts @@ -0,0 +1,8 @@ +import { SelectOption } from '../design-system/select-option.model'; +import { getAllResponse } from '../generals/general-responses-list.model'; +import { GeneralOptionQuery } from '../generals/general-option-query.model'; + +export interface UnitSaleQueryCache { + query: GeneralOptionQuery; + response: getAllResponse; +} diff --git a/src/app/core/models/unit-sale.model.ts b/src/app/core/models/units-sale/unit-sale.model.ts similarity index 100% rename from src/app/core/models/unit-sale.model.ts rename to src/app/core/models/units-sale/unit-sale.model.ts 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..4487b5c --- /dev/null +++ b/src/app/core/models/vehicle-application/vehicle-application-product.model.ts @@ -0,0 +1,14 @@ +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; + status?: boolean; // remover possivelmente +} diff --git a/src/app/core/models/warehouses/warehouse-options.model.ts b/src/app/core/models/warehouses/warehouse-options.model.ts index 927f29f..e143478 100644 --- a/src/app/core/models/warehouses/warehouse-options.model.ts +++ b/src/app/core/models/warehouses/warehouse-options.model.ts @@ -1,9 +1,3 @@ -export interface WarehouseOptionsResponse { - success: boolean; - message: string; - data: WarehouseOption[]; -} - export interface WarehouseOption { id: string; label: string; diff --git a/src/app/core/models/warehouses/warehouse-query-cache.model.ts b/src/app/core/models/warehouses/warehouse-query-cache.model.ts new file mode 100644 index 0000000..5cf5b22 --- /dev/null +++ b/src/app/core/models/warehouses/warehouse-query-cache.model.ts @@ -0,0 +1,8 @@ +import { getAllResponse } from '../generals/general-responses-list.model'; +import { GeneralOptionQuery } from '../generals/general-option-query.model'; +import { SelectOption } from '../design-system/select-option.model'; + +export interface WarehouseQueryCache { + query: GeneralOptionQuery; + response: getAllResponse; +} diff --git a/src/app/core/services/category-service.ts b/src/app/core/services/category-service.ts index 13414a1..e92dac4 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,10 +22,24 @@ 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) { return this.http.get(`${this.api}/${this.flag}/${id}`); } + + create(data: FormData) { + return this.http.post(`${this.api}/${this.flag}`, data); + } + + update(id: string, data: FormData) { + return this.http.post(`${this.api}/${this.flag}/${id}`, data); + } + + delete(id: string) { + return this.http.delete(`${this.api}/${this.flag}/${id}`); + } } 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..7afb990 --- /dev/null +++ b/src/app/core/services/code-oem-service.ts @@ -0,0 +1,50 @@ +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, + }, + ); + } + + getByManufacturer(filters: GeneralOptionQuery) { + const params = buildHttpParams(filters); + return this.http.get>(`${this.api}/${this.flag}/by-manufacturer`, { + params, + }); + } + + getById(id: string) { + return this.http.get>(`${this.api}/${this.flag}/${id}`); + } +} diff --git a/src/app/core/services/inital-loader-service.ts b/src/app/core/services/inital-loader-service.ts new file mode 100644 index 0000000..8694a0f --- /dev/null +++ b/src/app/core/services/inital-loader-service.ts @@ -0,0 +1,22 @@ +import { inject, Injectable } from '@angular/core'; +import { ManufacturerStore } from '../store/manufacturer-store/manufacturer-store'; + +@Injectable({ + providedIn: 'root', +}) +export class InitialDataLoader { + private readonly manufacturerStore = inject(ManufacturerStore); + + // categoryStore + // countryStore + // etc. + + load(): void { + // this.manufacturerStore.loadInitialOptions(); + // this.categoryStore.loadInitialOptions(); + // this.countryStore.loadInitialOptions(); + // ... + } +} + +// 11. A única ressalva: duas abas abrindo simultaneamente diff --git a/src/app/core/services/manufacture-service.ts b/src/app/core/services/manufacture-service.ts index 6c2a82b..a8417c3 100644 --- a/src/app/core/services/manufacture-service.ts +++ b/src/app/core/services/manufacture-service.ts @@ -5,7 +5,9 @@ import { environment } from '../../../environments/environment'; import { buildHttpParams } from './build-http-params'; import { ManufacturerResponse } from '../models/manufactureres/manufacturer-response.model'; import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; -import { ManufacturerOptionsResponse } from '../models/manufactureres/manufaturer-options.model'; +import { ManufacturerOption } from '../models/manufactureres/manufaturer-options.model'; +import { getAllResponse } from '../models/generals/general-responses-list.model'; +import { GeneralOption } from '../models/generals/general-options-response.model'; @Injectable({ providedIn: 'root', @@ -21,7 +23,7 @@ export class ManufacturerService { getOptions(filters: GeneralOptionQuery) { const params = buildHttpParams(filters); - return this.http.get(`${this.api}/${this.flag}/options`, { + return this.http.get>(`${this.api}/${this.flag}/options`, { params, }); } @@ -29,4 +31,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/core/services/part-origins-service.ts b/src/app/core/services/part-origins-service.ts index bb2da6c..4b923ef 100644 --- a/src/app/core/services/part-origins-service.ts +++ b/src/app/core/services/part-origins-service.ts @@ -2,7 +2,7 @@ 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 { PartOriginResponse } from '../models/PartOriginResponse.model'; +import { PartOriginResponse } from '../models/part-origin/part-origin-response'; import { buildHttpParams } from './build-http-params'; import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; diff --git a/src/app/core/services/product-by-id-service.ts b/src/app/core/services/product-by-id-service.ts new file mode 100644 index 0000000..6a8d857 --- /dev/null +++ b/src/app/core/services/product-by-id-service.ts @@ -0,0 +1,46 @@ +import { inject, Injectable } from '@angular/core'; +import { Observable, of, shareReplay, tap, finalize, map } from 'rxjs'; + +import { ProductResponse } from '../models/products/product-response.model'; +import { ProductService } from './product-service'; + +@Injectable({ + providedIn: 'root', +}) +export class ProductByIdService { + private readonly products = new Map(); + private readonly pending = new Map>(); + + private readonly productService = inject(ProductService); + + getProduct(id: string): Observable { + const cached = this.products.get(id); + if (cached) { + return of(cached); + } + + const pending = this.pending.get(id); + if (pending) { + return pending; + } + + const request$ = this.productService.getById(id).pipe( + map((response) => response.data), + tap((product) => { + this.products.set(id, product); + }), + finalize(() => { + this.pending.delete(id); + }), + shareReplay(1), + ); + this.pending.set(id, request$); + + return request$; + } + + removeProduct(id: string): void { + this.products.delete(id); + this.pending.delete(id); + } +} 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/query-service.ts b/src/app/core/services/query-service.ts new file mode 100644 index 0000000..72810bc --- /dev/null +++ b/src/app/core/services/query-service.ts @@ -0,0 +1,31 @@ +import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; + +export function createDefaultQuery(): GeneralOptionQuery { + return { + search: null, + active: null, + per_page: null, + page: null, + manufacturer_id: null, + }; +} + +export function isDefaultQuery(query: GeneralOptionQuery): boolean { + return ( + query.search === null && + query.active === null && + query.per_page === null && + query.page === null && + query.manufacturer_id === null + ); +} + +export function isSameQuery(first: GeneralOptionQuery, second: GeneralOptionQuery): boolean { + return ( + first.search === second.search && + first.active === second.active && + first.per_page === second.per_page && + first.page === second.page && + first.manufacturer_id === second.manufacturer_id + ); +} diff --git a/src/app/core/services/status-service.ts b/src/app/core/services/status-service.ts index e2e9c6f..54fdca7 100644 --- a/src/app/core/services/status-service.ts +++ b/src/app/core/services/status-service.ts @@ -4,8 +4,9 @@ import { PaginatedResponse } from '../models/pagination/pagination.model'; import { ProductResponse } from '../models/products/product-response.model'; import { environment } from '../../../environments/environment'; import { buildHttpParams } from './build-http-params'; +import { getAllResponse } from '../models/generals/general-responses-list.model'; +import { GeneralOption } from '../models/generals/general-options-response.model'; import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; -import { StatusOptionsResponse } from '../models/status/status-options.model'; @Injectable({ providedIn: 'root', @@ -19,11 +20,13 @@ export class StatusService { return this.http.get>(`${this.api}/${this.flag}`); } - getOptions(module: string, limit: string | null = null) { + getOptions(module: string, query: GeneralOptionQuery) { const params = buildHttpParams({ + ...query, module: module, - limit: limit, }); - return this.http.get(`${this.api}/${this.flag}/options`, { params }); + return this.http.get>(`${this.api}/${this.flag}/options`, { + params, + }); } } diff --git a/src/app/core/services/unit-sale-service.ts b/src/app/core/services/unit-sale-service.ts index 808bc24..c5914b3 100644 --- a/src/app/core/services/unit-sale-service.ts +++ b/src/app/core/services/unit-sale-service.ts @@ -4,7 +4,9 @@ import { PaginatedResponse } from '../models/pagination/pagination.model'; import { environment } from '../../../environments/environment'; import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; import { buildHttpParams } from './build-http-params'; -import { UnitSaleOptionsResponse } from '../models/unit-sale.model'; +import { UnitSaleOptionsResponse } from '../models/units-sale/unit-sale.model'; +import { getAllResponse } from '../models/generals/general-responses-list.model'; +import { GeneralOption } from '../models/generals/general-options-response.model'; @Injectable({ providedIn: 'root' }) export class UnitSaleService { @@ -18,6 +20,8 @@ export class UnitSaleService { 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, + }); } } diff --git a/src/app/core/services/warehouses-service.ts b/src/app/core/services/warehouse-service.ts similarity index 51% rename from src/app/core/services/warehouses-service.ts rename to src/app/core/services/warehouse-service.ts index 93638cc..a1f14bd 100644 --- a/src/app/core/services/warehouses-service.ts +++ b/src/app/core/services/warehouse-service.ts @@ -1,25 +1,32 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { PaginatedResponse } from '../models/pagination/pagination.model'; import { environment } from '../../../environments/environment'; -import { WarehouseOptionsResponse } from '../models/warehouses/warehouse-options.model'; -import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; +import { PaginatedResponse } from '../models/pagination/pagination.model'; import { buildHttpParams } from './build-http-params'; +import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; +import { WarehouseOption } from '../models/warehouses/warehouse-options.model'; +import { getAllResponse } from '../models/generals/general-responses-list.model'; +import { GeneralOption } from '../models/generals/general-options-response.model'; @Injectable({ providedIn: 'root', }) -export class WarehousesService { +export class WarehouseService { private http = inject(HttpClient); private api = environment.apiUrl; private flag = 'warehouses'; - getAll() { - return this.http.get>(`${this.api}/${this.flag}`); + getAll(filters: GeneralOptionQuery) { + const params = buildHttpParams(filters); + return this.http.get>(`${this.api}/${this.flag}`, { + params, + }); } 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, + }); } } diff --git a/src/app/core/store/GeneralStorageData/app-cache-service.ts b/src/app/core/store/GeneralStorageData/app-cache-service.ts new file mode 100644 index 0000000..9a85421 --- /dev/null +++ b/src/app/core/store/GeneralStorageData/app-cache-service.ts @@ -0,0 +1,44 @@ +import { Injectable, inject } from '@angular/core'; +import { IndexedDbService } from './indexed-db-service'; +import { AppChannelService } from './app-chanel-service'; +@Injectable({ + providedIn: 'root', +}) +export class AppCacheService { + private readonly db = inject(IndexedDbService); + private readonly channel = inject(AppChannelService); + + async get(key: string): Promise { + try { + const entry = await this.db.get(key); + + return entry?.data ?? null; + } catch { + return null; + } + } + + async set(key: string, data: T, ttlMs: number, version = 1): Promise { + try { + await this.db.set(key, data, ttlMs, version); + + this.channel.notifyUpdated(key); + } catch { + // Cache nunca deve impedir a aplicação de funcionar. + } + } + + async remove(key: string): Promise { + try { + await this.db.remove(key); + + this.channel.notifyRemoved(key); + } catch { + // Cache nunca deve impedir a aplicação de funcionar. + } + } + + subscribe(key: string, callback: () => void): () => void { + return this.channel.subscribe(key, callback); + } +} diff --git a/src/app/core/store/GeneralStorageData/app-chanel-service.ts b/src/app/core/store/GeneralStorageData/app-chanel-service.ts new file mode 100644 index 0000000..ae9b3b3 --- /dev/null +++ b/src/app/core/store/GeneralStorageData/app-chanel-service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { CacheChangedEvent } from '../../models/indexed-db/indexed-db.model'; + +@Injectable({ + providedIn: 'root', +}) +export class AppChannelService { + private readonly channel = new BroadcastChannel('reccos_shop_cache'); + + private readonly listeners = new Map void>>(); + + constructor() { + this.channel.onmessage = (event: MessageEvent) => { + const message = event.data; + + const callbacks = this.listeners.get(message.key); + + callbacks?.forEach((callback) => callback()); + }; + } + + notifyUpdated(key: string): void { + this.channel.postMessage({ + type: 'updated', + key, + } satisfies CacheChangedEvent); + } + + notifyRemoved(key: string): void { + this.channel.postMessage({ + type: 'removed', + key, + } satisfies CacheChangedEvent); + } + + subscribe(key: string, callback: () => void): () => void { + let callbacks = this.listeners.get(key); + + if (!callbacks) { + callbacks = new Set(); + this.listeners.set(key, callbacks); + } + + callbacks.add(callback); + + return () => { + callbacks?.delete(callback); + + if (callbacks?.size === 0) { + this.listeners.delete(key); + } + }; + } +} diff --git a/src/app/core/store/GeneralStorageData/indexed-db-service.ts b/src/app/core/store/GeneralStorageData/indexed-db-service.ts new file mode 100644 index 0000000..5ea7e0e --- /dev/null +++ b/src/app/core/store/GeneralStorageData/indexed-db-service.ts @@ -0,0 +1,157 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; + +import { CacheEntry } from '../../models/indexed-db/indexed-db.model'; + +@Injectable({ + providedIn: 'root', +}) +export class IndexedDbService { + private readonly DB_NAME = 'reccos_shop_db'; + private readonly DB_VERSION = 1; + private readonly STORE_NAME = 'cache'; + + private readonly platformId = inject(PLATFORM_ID); + + private readonly dbPromise = this.createDbPromise(); + + private createDbPromise(): Promise { + if (!isPlatformBrowser(this.platformId)) { + return Promise.resolve(null); + } + + return this.openDatabase(); + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.DB_NAME, this.DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(this.STORE_NAME)) { + db.createObjectStore(this.STORE_NAME); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); + } + + async get(key: string): Promise | null> { + const db = await this.dbPromise; + + if (!db) { + return null; + } + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.STORE_NAME, 'readwrite'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.get(key); + + request.onsuccess = () => { + const entry = request.result as CacheEntry | undefined; + + if (!entry) { + resolve(null); + return; + } + + if (entry.expiresAt <= Date.now()) { + store.delete(key); + resolve(null); + return; + } + + resolve(entry); + }; + + request.onerror = () => { + reject(request.error); + }; + }); + } + + async set(key: string, data: T, ttlMs: number, version = 1): Promise { + const db = await this.dbPromise; + + if (!db) { + return; + } + + const now = Date.now(); + + const entry: CacheEntry = { + data, + cachedAt: now, + expiresAt: now + ttlMs, + version, + }; + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.STORE_NAME, 'readwrite'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.put(entry, key); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); + } + + async remove(key: string): Promise { + const db = await this.dbPromise; + + if (!db) { + return; + } + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.STORE_NAME, 'readwrite'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.delete(key); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); + } + + async clear(): Promise { + const db = await this.dbPromise; + + if (!db) { + return; + } + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.STORE_NAME, 'readwrite'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.clear(); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); + } +} diff --git a/src/app/core/store/GeneralStorageData/session-storage-service.ts b/src/app/core/store/GeneralStorageData/session-storage-service.ts new file mode 100644 index 0000000..84ff9c5 --- /dev/null +++ b/src/app/core/store/GeneralStorageData/session-storage-service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class SessionStorageService { + get(key: string): T | null { + const value = sessionStorage.getItem(key); + + if (!value) { + return null; + } + + try { + return JSON.parse(value) as T; + } catch { + sessionStorage.removeItem(key); + return null; + } + } + + set(key: string, value: T): void { + sessionStorage.setItem(key, JSON.stringify(value)); + } + + remove(key: string): void { + sessionStorage.removeItem(key); + } +} diff --git a/src/app/core/store/base/option-cache-store.ts b/src/app/core/store/base/option-cache-store.ts new file mode 100644 index 0000000..bab9125 --- /dev/null +++ b/src/app/core/store/base/option-cache-store.ts @@ -0,0 +1,159 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { AppCacheService } from '../GeneralStorageData/app-cache-service'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { getAllResponse } from '../../models/generals/general-responses-list.model'; +import { createDefaultQuery, isDefaultQuery } from '../../services/query-service'; + +@Injectable() +export abstract class OptionCacheStore { + protected readonly cache = inject(AppCacheService); + + protected readonly CACHE_TTL = 1000 * 60 * 60 * 6; + + protected abstract readonly INITIAL_KEY: string; + protected abstract readonly QUERY_KEY: string; + + /** + * Responsável por buscar as opções na API. + * + * O Store concreto pode sobrescrever o retorno do serviço, + * realizando aqui a adaptação necessária para TOption. + */ + protected abstract fetchOptions(query: GeneralOptionQuery): Observable>; + + protected readonly _options = signal | null>(null); + + readonly options = this._options.asReadonly(); + + /** + * Lista pronta para ser consumida pela aplicação. + */ + readonly optionList = computed(() => this._options()?.data ?? []); + + protected readonly defaultQuery = createDefaultQuery(); + + protected currentQuery = this.defaultQuery; + + protected abstract isSameCachedQuery(cached: TQueryCache, query: GeneralOptionQuery): boolean; + + protected abstract getCachedResponse(cached: TQueryCache): getAllResponse; + + protected abstract createQueryCache( + query: GeneralOptionQuery, + response: getAllResponse, + ): TQueryCache; + + constructor() { + this.subscribeToCacheChanges(); + } + + /** + * Hidrata o Store ao iniciar a aplicação. + * + * 1. Procura os dados iniciais no IndexedDB. + * 2. Se encontrar, popula o Signal. + * 3. Se não encontrar, busca na API e grava no cache. + */ + async hydrate(): Promise { + const cached = await this.cache.get>(this.INITIAL_KEY); + + if (cached) { + this._options.set(cached); + return; + } + + await this.loadFromApi(this.defaultQuery, this.INITIAL_KEY); + } + + /** + * Fluxo utilizado para buscas filtradas. + * + * Quando a consulta é a consulta padrão, volta a utilizar + * os dados iniciais. + */ + async loadOptions(query: GeneralOptionQuery): Promise { + this.currentQuery = query; + + if (isDefaultQuery(query)) { + await this.cache.remove(this.QUERY_KEY); + await this.hydrate(); + return; + } + + await this.loadQuery(query); + } + + /** + * Carrega uma consulta específica. + */ + private async loadQuery(query: GeneralOptionQuery): Promise { + const cached = await this.cache.get(this.QUERY_KEY); + + if (cached && this.isSameCachedQuery(cached, query)) { + this._options.set(this.getCachedResponse(cached)); + + return; + } + + this.fetchOptions(query).subscribe({ + next: async (response) => { + this._options.set(response); + + const cachedData = this.createQueryCache(query, response); + + await this.cache.set(this.QUERY_KEY, cachedData, this.CACHE_TTL); + }, + + error: () => { + this._options.set(null); + }, + }); + } + + /** + * Busca dados na API e persiste no cache. + */ + private async loadFromApi(query: GeneralOptionQuery, cacheKey: string): Promise { + this.fetchOptions(query).subscribe({ + next: async (response) => { + this._options.set(response); + + await this.cache.set(cacheKey, response, this.CACHE_TTL); + }, + + error: () => { + this._options.set(null); + }, + }); + } + + /** + * Mantém o Signal sincronizado quando outra instância da + * aplicação altera o cache inicial. + */ + private subscribeToCacheChanges(): void { + this.cache.subscribe(this.INITIAL_KEY, async () => { + if (!isDefaultQuery(this.currentQuery)) { + return; + } + + const cached = await this.cache.get>(this.INITIAL_KEY); + + if (cached) { + this._options.set(cached); + } + }); + } + + /** + * Limpa o estado do Store e os respectivos caches. + */ + async clear(): Promise { + this._options.set(null); + + await this.cache.remove(this.INITIAL_KEY); + await this.cache.remove(this.QUERY_KEY); + } +} diff --git a/src/app/core/store/base/paginated-cache-store.ts b/src/app/core/store/base/paginated-cache-store.ts new file mode 100644 index 0000000..c88cb41 --- /dev/null +++ b/src/app/core/store/base/paginated-cache-store.ts @@ -0,0 +1,123 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { AppCacheService } from '../GeneralStorageData/app-cache-service'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { createDefaultQuery } from '../../services/query-service'; + +@Injectable() +export abstract class PaginatedCacheStore { + protected readonly cache = inject(AppCacheService); + + protected abstract isDefaultQuery(query: GeneralOptionQuery): boolean; + + protected abstract extractData(response: TResponse): TData[]; + + protected abstract isSameCachedQuery(cached: TQueryCache, query: GeneralOptionQuery): boolean; + + protected abstract extractCachedData(cached: TQueryCache): TData[]; + + protected abstract createQueryCache(query: GeneralOptionQuery, response: TResponse): TQueryCache; + + protected readonly CACHE_TTL = 1000 * 60 * 60 * 6; + + protected abstract readonly INITIAL_KEY: string; + protected abstract readonly QUERY_KEY: string; + + protected abstract fetch(query: GeneralOptionQuery): Observable; + + protected readonly _data = signal([]); + + readonly data = this._data.asReadonly(); + + protected readonly defaultQuery = createDefaultQuery(); + + protected currentQuery = this.defaultQuery; + + constructor() { + this.subscribeToCacheChanges(); + } + + async loadInitial(query: GeneralOptionQuery = this.defaultQuery): Promise { + this.currentQuery = query; + + const cached = await this.cache.get(this.INITIAL_KEY); + + if (cached) { + this._data.set(this.extractData(cached)); + return; + } + + this.loadFromApi(query, this.INITIAL_KEY); + } + + async load(query: GeneralOptionQuery): Promise { + this.currentQuery = query; + + if (this.isDefaultQuery(query)) { + await this.cache.remove(this.QUERY_KEY); + await this.loadInitial(); + return; + } + + await this.loadQuery(query); + } + + private async loadQuery(query: GeneralOptionQuery): Promise { + const cached = await this.cache.get(this.QUERY_KEY); + + if (cached && this.isSameCachedQuery(cached, query)) { + this._data.set(this.extractCachedData(cached)); + return; + } + + this.fetch(query).subscribe({ + next: async (response) => { + this._data.set(this.extractData(response)); + + const cachedData = this.createQueryCache(query, response); + + await this.cache.set(this.QUERY_KEY, cachedData, this.CACHE_TTL); + }, + + error: () => { + this._data.set([]); + }, + }); + } + + private loadFromApi(query: GeneralOptionQuery, cacheKey: string): void { + this.fetch(query).subscribe({ + next: async (response) => { + this._data.set(this.extractData(response)); + + await this.cache.set(cacheKey, response, this.CACHE_TTL); + }, + + error: () => { + this._data.set([]); + }, + }); + } + + private subscribeToCacheChanges(): void { + this.cache.subscribe(this.INITIAL_KEY, async () => { + if (!this.isDefaultQuery(this.currentQuery)) { + return; + } + + const cached = await this.cache.get(this.INITIAL_KEY); + + if (cached) { + this._data.set(this.extractData(cached)); + } + }); + } + + async clear(): Promise { + this._data.set([]); + + await this.cache.remove(this.INITIAL_KEY); + await this.cache.remove(this.QUERY_KEY); + } +} diff --git a/src/app/core/store/category-store/category-store.ts b/src/app/core/store/category-store/category-store.ts new file mode 100644 index 0000000..370b3c0 --- /dev/null +++ b/src/app/core/store/category-store/category-store.ts @@ -0,0 +1,50 @@ +import { inject, Injectable } from '@angular/core'; +import { CategoryService } from '../../services/category-service'; +import { CategoryOption } from '../../models/catetories/category-options.model'; +import { CategoryQueryCache } from '../../models/catetories/category-query-cache.model'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { getAllResponse } from '../../models/generals/general-responses-list.model'; +import { isSameQuery } from '../../services/query-service'; +import { OptionCacheStore } from '../base/option-cache-store'; +import { AutocompleteOption } from '../../models/design-system/auto-complete.model'; +import { map } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class CategoryStore extends OptionCacheStore { + private readonly service = inject(CategoryService); + + protected readonly INITIAL_KEY = 'categories:initial'; + protected readonly QUERY_KEY = 'categories:query'; + + protected fetchOptions(query: GeneralOptionQuery) { + return this.service.getOptions(query).pipe( + map((r: getAllResponse) => ({ + ...r, + data: r.data.map((option: CategoryOption) => ({ + ...option, + disabled: false, + })), + })), + ); + } + + protected isSameCachedQuery(cached: CategoryQueryCache, query: GeneralOptionQuery): boolean { + return isSameQuery(cached.query, query); + } + + protected getCachedResponse(cached: CategoryQueryCache): getAllResponse { + return cached.response; + } + + protected createQueryCache( + query: GeneralOptionQuery, + response: getAllResponse, + ): CategoryQueryCache { + return { + query, + response, + }; + } +} diff --git a/src/app/core/store/manufacturer-store/manufacturer-store.ts b/src/app/core/store/manufacturer-store/manufacturer-store.ts new file mode 100644 index 0000000..5811883 --- /dev/null +++ b/src/app/core/store/manufacturer-store/manufacturer-store.ts @@ -0,0 +1,58 @@ +import { inject, Injectable } from '@angular/core'; +import { ManufacturerService } from '../../services/manufacture-service'; +import { ManufacturerQueryCache } from '../../models/manufactureres/manufacturer-query-cache.model'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { getAllResponse } from '../../models/generals/general-responses-list.model'; +import { isSameQuery } from '../../services/query-service'; +import { OptionCacheStore } from '../base/option-cache-store'; +import { map } from 'rxjs'; +import { GeneralOption } from '../../models/generals/general-options-response.model'; +import { AutocompleteOption } from '../../models/design-system/auto-complete.model'; + +@Injectable({ + providedIn: 'root', +}) +export class ManufacturerStore extends OptionCacheStore< + AutocompleteOption, + ManufacturerQueryCache +> { + private readonly service = inject(ManufacturerService); + + protected readonly INITIAL_KEY = 'manufacturers:initial'; + protected readonly QUERY_KEY = 'manufacturers:query'; + + protected fetchOptions(query: GeneralOptionQuery) { + return this.service.getOptions(query).pipe( + map((r: getAllResponse) => ({ + ...r, + data: r.data.map((option: GeneralOption) => ({ + ...option, + disabled: false, + icon: 'chevrons-right', + hasSubOptions: true, + description: option.sublabel, + })), + })), + ); + } + + protected isSameCachedQuery(cached: ManufacturerQueryCache, query: GeneralOptionQuery): boolean { + return isSameQuery(cached.query, query); + } + + protected getCachedResponse( + cached: ManufacturerQueryCache, + ): getAllResponse { + return cached.response; + } + + protected createQueryCache( + query: GeneralOptionQuery, + response: getAllResponse, + ): ManufacturerQueryCache { + return { + query, + response, + }; + } +} diff --git a/src/app/core/store/oem-code-store/oem-code-store.ts b/src/app/core/store/oem-code-store/oem-code-store.ts new file mode 100644 index 0000000..3e5348c --- /dev/null +++ b/src/app/core/store/oem-code-store/oem-code-store.ts @@ -0,0 +1,52 @@ +import { Injectable, inject } from '@angular/core'; +import { isSameQuery } from '../../services/query-service'; +import { PaginatedCacheStore } from '../base/paginated-cache-store'; +import { OemCode } from '../../models/oem-codes/oem-codes.model'; +import { PaginatedResponse } from '../../models/pagination/pagination.model'; +import { OemCodeQueryCache } from '../../models/oem-codes/oem-codes-query-cache.model'; +import { OemCodeService } from '../../services/code-oem-service'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; + +@Injectable({ + providedIn: 'root', +}) +export class OemCodeStore extends PaginatedCacheStore< + OemCode, + PaginatedResponse, + OemCodeQueryCache +> { + private readonly service = inject(OemCodeService); + + protected readonly INITIAL_KEY = 'oem-codes:initial'; + protected readonly QUERY_KEY = 'oem-codes:query'; + + protected fetch(query: GeneralOptionQuery) { + return this.service.getByManufacturer(query); + } + + protected isDefaultQuery(query: GeneralOptionQuery): boolean { + return query.active == null; + } + + protected extractData(response: PaginatedResponse): OemCode[] { + return response.data ?? []; + } + + protected isSameCachedQuery(cached: OemCodeQueryCache, query: GeneralOptionQuery): boolean { + return isSameQuery(cached.query, query); + } + + protected extractCachedData(cached: OemCodeQueryCache): OemCode[] { + return cached.response.data ?? []; + } + + protected createQueryCache( + query: GeneralOptionQuery, + response: PaginatedResponse, + ): OemCodeQueryCache { + return { + query, + response, + }; + } +} diff --git a/src/app/core/store/part-origin/part-origin-store.ts b/src/app/core/store/part-origin/part-origin-store.ts new file mode 100644 index 0000000..f492ad3 --- /dev/null +++ b/src/app/core/store/part-origin/part-origin-store.ts @@ -0,0 +1,54 @@ +import { inject, Injectable } from '@angular/core'; +import { isSameQuery } from '../../services/query-service'; +import { OptionCacheStore } from '../base/option-cache-store'; +import { PartOriginService } from '../../services/part-origins-service'; +import { PartOriginResponse } from '../../models/part-origin/part-origin-response'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { PartOriginQueryCache } from '../../models/part-origin/part-origin-query-cache.model'; +import { SelectOption } from '../../models/design-system/select-option.model'; +import { map } from 'rxjs'; +import { getAllResponse } from '../../models/generals/general-responses-list.model'; +import { PaginatedResponse } from '../../models/pagination/pagination.model'; + +@Injectable({ + providedIn: 'root', +}) +export class PartOriginStore extends OptionCacheStore { + private readonly service = inject(PartOriginService); + + protected readonly QUERY_KEY = 'part-origin:query'; + protected readonly INITIAL_KEY = 'part-origin:initial'; + + protected fetchOptions(query: GeneralOptionQuery) { + return this.service.getAll(query).pipe( + map((res: PaginatedResponse) => ({ + success: res.success, + message: res.message, + data: res.data.map((item: PartOriginResponse) => ({ + label: item.name, + value: item.id, + disabled: false, + sublabel: item.description, + })), + })), + ); + } + + protected isSameCachedQuery(cached: PartOriginQueryCache, query: GeneralOptionQuery): boolean { + return isSameQuery(cached.query, query); + } + + protected getCachedResponse(cached: PartOriginQueryCache): getAllResponse { + return cached.response; + } + + protected createQueryCache( + query: GeneralOptionQuery, + response: getAllResponse, + ): PartOriginQueryCache { + return { + query, + response, + }; + } +} diff --git a/src/app/core/store/status-store/status-store.ts b/src/app/core/store/status-store/status-store.ts new file mode 100644 index 0000000..d43fb52 --- /dev/null +++ b/src/app/core/store/status-store/status-store.ts @@ -0,0 +1,54 @@ +import { computed, inject, Injectable } from '@angular/core'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { getAllResponse } from '../../models/generals/general-responses-list.model'; +import { isSameQuery } from '../../services/query-service'; +import { OptionCacheStore } from '../base/option-cache-store'; +import { StatusService } from '../../services/status-service'; +import { StatusQueryCache } from '../../models/status/status-query-cache.model'; +import { SelectOption } from '../../models/design-system/select-option.model'; +import { map } from 'rxjs'; +import { GeneralOption } from '../../models/generals/general-options-response.model'; + +@Injectable({ + providedIn: 'root', +}) +export class StatusStore extends OptionCacheStore { + private readonly service = inject(StatusService); + + protected readonly INITIAL_KEY = 'statuses:initial'; + protected readonly QUERY_KEY = 'statuses:query'; + + readonly statusOptions = computed(() => this.options()?.data ?? []); + + protected fetchOptions(query: GeneralOptionQuery) { + return this.service.getOptions('PRODUCT', query).pipe( + map((response: getAllResponse) => { + return { + ...response, + data: response.data.map((option: GeneralOption) => ({ + ...option, + disabled: false, + })), + }; + }), + ); + } + + protected isSameCachedQuery(cached: StatusQueryCache, query: GeneralOptionQuery): boolean { + return isSameQuery(cached.query, query); + } + + protected getCachedResponse(cached: StatusQueryCache): getAllResponse { + return cached.response; + } + + protected createQueryCache( + query: GeneralOptionQuery, + response: getAllResponse, + ): StatusQueryCache { + return { + query, + response, + }; + } +} diff --git a/src/app/core/store/unit-sale/unit-sale-store.ts b/src/app/core/store/unit-sale/unit-sale-store.ts new file mode 100644 index 0000000..6f27964 --- /dev/null +++ b/src/app/core/store/unit-sale/unit-sale-store.ts @@ -0,0 +1,54 @@ +import { computed, inject, Injectable } from '@angular/core'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { getAllResponse } from '../../models/generals/general-responses-list.model'; +import { isSameQuery } from '../../services/query-service'; +import { OptionCacheStore } from '../base/option-cache-store'; +import { UnitSaleService } from '../../services/unit-sale-service'; +import { UnitSaleQueryCache } from '../../models/units-sale/unit-sale-query-cache.model'; +import { SelectOption } from '../../models/design-system/select-option.model'; +import { map } from 'rxjs'; +import { GeneralOption } from '../../models/generals/general-options-response.model'; + +@Injectable({ + providedIn: 'root', +}) +export class UnitSaleStore extends OptionCacheStore { + private readonly service = inject(UnitSaleService); + + protected readonly INITIAL_KEY = 'unit-sales:initial'; + protected readonly QUERY_KEY = 'unit-sales:query'; + + readonly manufacturerOptions = computed(() => this.options()?.data ?? []); + + protected fetchOptions(query: GeneralOptionQuery) { + return this.service.getOptions(query).pipe( + map((response: getAllResponse) => { + return { + ...response, + data: response.data.map((option: GeneralOption) => ({ + ...option, + disabled: false, + })), + }; + }), + ); + } + + protected isSameCachedQuery(cached: UnitSaleQueryCache, query: GeneralOptionQuery): boolean { + return isSameQuery(cached.query, query); + } + + protected getCachedResponse(cached: UnitSaleQueryCache): getAllResponse { + return cached.response; + } + + protected createQueryCache( + query: GeneralOptionQuery, + response: getAllResponse, + ): UnitSaleQueryCache { + return { + query, + response, + }; + } +} diff --git a/src/app/core/store/warehouse/warehouse-store.ts b/src/app/core/store/warehouse/warehouse-store.ts new file mode 100644 index 0000000..256e390 --- /dev/null +++ b/src/app/core/store/warehouse/warehouse-store.ts @@ -0,0 +1,56 @@ +import { computed, inject, Injectable } from '@angular/core'; +import { GeneralOptionQuery } from '../../models/generals/general-option-query.model'; +import { getAllResponse } from '../../models/generals/general-responses-list.model'; +import { isSameQuery } from '../../services/query-service'; +import { OptionCacheStore } from '../base/option-cache-store'; +import { WarehouseQueryCache } from '../../models/warehouses/warehouse-query-cache.model'; +import { WarehouseService } from '../../services/warehouse-service'; +import { GeneralOption } from '../../models/generals/general-options-response.model'; +import { SelectOption } from '../../models/design-system/select-option.model'; +import { map } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class WarehouseStore extends OptionCacheStore { + private readonly service = inject(WarehouseService); + + protected readonly INITIAL_KEY = 'warehouses:initial'; + protected readonly QUERY_KEY = 'warehouses:query'; + + readonly warehouseOptions = computed(() => this.options()?.data ?? []); + + protected fetchOptions(query: GeneralOptionQuery) { + return this.service.getOptions(query).pipe( + map((response: getAllResponse) => { + return { + ...response, + data: response.data.map((option: any) => ({ + value: option.id ?? option.value, + label: option.label, + sublabel: option.description ?? option.sublabel ?? '', + disabled: false, + })), + }; + }), + ); + } + + protected isSameCachedQuery(cached: WarehouseQueryCache, query: GeneralOptionQuery): boolean { + return isSameQuery(cached.query, query); + } + + protected getCachedResponse(cached: WarehouseQueryCache): getAllResponse { + return cached.response; + } + + protected createQueryCache( + query: GeneralOptionQuery, + response: getAllResponse, + ): WarehouseQueryCache { + return { + query, + response, + }; + } +} 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/design-system/autocomplete-select/autocomplete-select.ts b/src/app/design-system/autocomplete-select/autocomplete-select.ts index 4b8737d..270c6a1 100644 --- a/src/app/design-system/autocomplete-select/autocomplete-select.ts +++ b/src/app/design-system/autocomplete-select/autocomplete-select.ts @@ -12,13 +12,7 @@ import { } from '@angular/core'; import { AppIconComponent } from '../icon/app-icon'; import { GeneralOptionQuery } from '../../core/models/generals/general-option-query.model'; -import { DSelectOption } from '../../core/models/design-system/select-option.model'; - -export interface AutocompleteOption extends DSelectOption { - sublabel?: string; - category?: string; - icon?: string; -} +import { AutocompleteOption } from '../../core/models/design-system/auto-complete.model'; @Component({ selector: 'app-autocomplete-select', @@ -39,11 +33,13 @@ export class AutocompleteSelectComponent implements OnDestroy { readonly required = input(false); readonly error = input(undefined); readonly helperText = input(undefined); - readonly loading = input(false); + // readonly loading = input(false); readonly remoteSearch = input(false); readonly emptyText = input('Nenhum resultado encontrado.'); readonly selectId = input('autocomplete-' + Math.random().toString(36).substring(2, 7)); + readonly loading = signal(false); + readonly valueChange = output(); readonly optionSelect = output(); readonly searchQueryChange = output(); @@ -58,7 +54,7 @@ export class AutocompleteSelectComponent implements OnDestroy { readonly selectedOption = computed(() => { const val = this.value(); if (val === undefined || val === null || val === '') return null; - const found = this.options().find((opt) => String(opt.value) === String(val)); + const found = this.options().find((opt) => String(opt.label) === String(val)); if (found) return found; // Fallback: search by label if value was populated as label @@ -94,7 +90,7 @@ export class AutocompleteSelectComponent implements OnDestroy { return opts.filter( (opt) => opt.label.toLowerCase().includes(q) || - String(opt.value).toLowerCase().includes(q) || + String(opt.label).toLowerCase().includes(q) || (opt.sublabel && opt.sublabel.toLowerCase().includes(q)), ); }); @@ -136,9 +132,11 @@ export class AutocompleteSelectComponent implements OnDestroy { this.filterQuery.set(text); const result: GeneralOptionQuery = { + search: null, active: null, - search: text, - limit: 10, + per_page: null, + page: null, + manufacturer_id: null, }; if (!this.isOpen()) { @@ -160,7 +158,7 @@ export class AutocompleteSelectComponent implements OnDestroy { } if (opt.disabled) return; - this.valueChange.emit(String(opt.value)); + this.valueChange.emit(String(opt.label)); this.optionSelect.emit(opt); this.closeDropdown(); } @@ -174,9 +172,11 @@ export class AutocompleteSelectComponent implements OnDestroy { this.valueChange.emit(''); this.filterQuery.set(''); const result: GeneralOptionQuery = { - active: null, search: null, - limit: 10, + active: null, + per_page: null, + page: null, + manufacturer_id: null, }; this.searchQueryChange.emit(result); if (this.isOpen()) { diff --git a/src/app/design-system/pagination/pagination.html b/src/app/design-system/pagination/pagination.html index 9e22872..2b8850a 100644 --- a/src/app/design-system/pagination/pagination.html +++ b/src/app/design-system/pagination/pagination.html @@ -28,7 +28,7 @@ title="Itens por página" > @for (opt of pageSizeOptions(); track opt) { - + }
(undefined); readonly value = input(''); readonly placeholder = input('Selecione uma opção'); - readonly options = input.required(); + readonly options = input.required(); readonly disabled = input(false); readonly required = input(false); readonly error = input(undefined); diff --git a/src/app/features/catalog/categories/categories.css b/src/app/features/catalog/categories/categories.css new file mode 100644 index 0000000..380b1fc --- /dev/null +++ b/src/app/features/catalog/categories/categories.css @@ -0,0 +1 @@ +/* Categories component styles */ diff --git a/src/app/features/catalog/categories/categories.html b/src/app/features/catalog/categories/categories.html new file mode 100644 index 0000000..023dc9e --- /dev/null +++ b/src/app/features/catalog/categories/categories.html @@ -0,0 +1,81 @@ + + +
+ + Nova Categoria + +
+
+ + + +
+ + + +
+ +
+ + Limpar + +
+
+ + +
+ + + +
+ + + + + + diff --git a/src/app/features/catalog/categories/categories.ts b/src/app/features/catalog/categories/categories.ts new file mode 100644 index 0000000..b69ce7e --- /dev/null +++ b/src/app/features/catalog/categories/categories.ts @@ -0,0 +1,274 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { PageHeaderComponent } from '../../../design-system/page-header/page-header'; +import { ToolbarComponent } from '../../../design-system/toolbar/toolbar'; +import { SearchInputComponent } from '../../../design-system/input/search-input'; +import { SelectComponent } from '../../../design-system/select/select'; +import { ButtonComponent } from '../../../design-system/button/button'; +import { DataTableComponent } from '../../../design-system/data-table/data-table'; +import { PaginationComponent } from '../../../design-system/pagination/pagination'; +import { ConfirmDialogComponent } from '../../../design-system/dialog/confirm-dialog'; +import { CategoryFormComponent } from './category-form/category-form'; +import { ToastService } from '../../../core/services/toast'; +import { CategoryService } from '../../../core/services/category-service'; +import { CategoryResponse } from '../../../core/models/catetories/categories.model'; +import { TableAction, TableColumn } from '../../../core/models/list-table/list-table.model'; + +@Component({ + selector: 'app-categories-page', + standalone: true, + imports: [ + PageHeaderComponent, + ToolbarComponent, + SearchInputComponent, + SelectComponent, + ButtonComponent, + DataTableComponent, + PaginationComponent, + ConfirmDialogComponent, + CategoryFormComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './categories.html', + styleUrl: './categories.css', +}) +export class CategoriesPageComponent implements OnInit { + private router = inject(Router); + private toastService = inject(ToastService); + private readonly categoryService = inject(CategoryService); + + readonly loading = signal(false); + readonly searchQuery = signal(''); + readonly selectedStatus = signal(''); + readonly currentPage = signal(1); + readonly pageSize = signal(10); + readonly totalItems = signal(0); + + // Category Form Drawer State + readonly isFormOpen = signal(false); + readonly formMode = signal<'create' | 'edit' | 'view'>('create'); + readonly selectedCategory = signal(null); + + // Delete Dialog State + readonly deleteDialogOpen = signal(false); + readonly selectedCategoryForDelete = signal(null); + readonly isDeleting = signal(false); + + readonly allCategories = signal([]); + + // Helper map to lookup category names by id for parent resolution + readonly categoryMap = computed(() => { + const map = new Map(); + for (const cat of this.allCategories()) { + map.set(cat.id, cat.name); + } + return map; + }); + + readonly columns: TableColumn[] = [ + { + key: 'icon', + header: 'Ícone', + width: '70px', + align: 'center', + type: 'icon', + iconGetter: (c) => c.icon || 'folder', + }, + { key: 'name', header: 'Nome da Categoria' }, + { + key: 'parent_id', + header: 'Categoria Pai', + width: '180px', + valueGetter: (c) => { + if (!c.parent_id) return 'Categoria Raiz'; + return this.categoryMap().get(c.parent_id) || 'Categoria Pai'; + }, + }, + { key: 'slug', header: 'Slug / URL', width: '160px' }, + { key: 'display_order', header: 'Ordem', width: '80px', align: 'center' }, + { + key: 'active', + header: 'Status', + width: '120px', + align: 'center', + type: 'badge', + badgeConfig: (c) => ({ + text: c.active ? 'Ativo' : 'Inativo', + variant: c.active ? 'success' : 'neutral', + }), + }, + { + key: 'created_at', + header: 'Criado em', + width: '120px', + align: 'center', + type: 'date', + }, + ]; + + readonly actions: TableAction[] = [ + { + id: 'view', + label: 'Visualizar', + icon: 'eye', + colorClass: 'text-gray-400 hover:text-[#5A8DEE] hover:bg-gray-100 dark:hover:bg-slate-700', + title: 'Visualizar Detalhes', + handler: (c) => this.viewCategory(c), + }, + { + id: 'edit', + label: 'Editar', + icon: 'edit', + colorClass: 'text-gray-400 hover:text-[#4F8A6B] hover:bg-gray-100 dark:hover:bg-slate-700', + title: 'Editar Categoria', + handler: (c) => this.editCategory(c), + }, + { + id: 'delete', + label: 'Excluir', + icon: 'trash-2', + colorClass: 'text-gray-400 hover:text-[#D66A6A] hover:bg-gray-100 dark:hover:bg-slate-700', + title: 'Excluir Categoria', + handler: (c) => this.confirmDelete(c), + }, + ]; + + ngOnInit(): void { + this.getPaginationAllCategories(); + } + + getPaginationAllCategories(): void { + this.loading.set(true); + this.categoryService.getAll().subscribe({ + next: (response) => { + this.allCategories.set(response.data); + if (response.meta) { + this.totalItems.set(response.meta.total); + this.currentPage.set(response.meta.current_page); + this.pageSize.set(response.meta.per_page); + } else { + this.totalItems.set(response.data.length); + } + this.loading.set(false); + }, + error: (error) => { + this.loading.set(false); + this.toastService.error( + 'Erro ao buscar categorias', + error.message || 'Falha ao carregar lista de categorias.', + ); + }, + }); + } + + readonly filteredCategories = computed(() => { + const q = this.searchQuery().toLowerCase().trim(); + const st = this.selectedStatus(); + + return this.allCategories().filter((c) => { + const matchesQ = + !q || + c.name.toLowerCase().includes(q) || + c.slug.toLowerCase().includes(q) || + (c.description || '').toLowerCase().includes(q); + const matchesSt = !st || (st === 'active' ? c.active : !c.active); + return matchesQ && matchesSt; + }); + }); + + readonly paginatedCategories = computed(() => { + const all = this.filteredCategories(); + const page = this.currentPage(); + const size = this.pageSize(); + const start = (page - 1) * size; + return all.slice(start, start + size); + }); + + onSearchChange(query: string): void { + this.searchQuery.set(query); + this.currentPage.set(1); + } + + onStatusChange(status: string): void { + this.selectedStatus.set(status); + this.currentPage.set(1); + } + + onPageChange(page: number): void { + this.currentPage.set(page); + } + + onPageSizeChange(size: number): void { + this.pageSize.set(size); + this.currentPage.set(1); + } + + resetFilters(): void { + this.searchQuery.set(''); + this.selectedStatus.set(''); + this.currentPage.set(1); + this.toastService.info('Filtros limpos', 'Todos os parâmetros de busca foram resetados.'); + } + + newCategory(): void { + this.selectedCategory.set(null); + this.formMode.set('create'); + this.isFormOpen.set(true); + } + + editCategory(cat: CategoryResponse): void { + this.selectedCategory.set(cat); + this.formMode.set('edit'); + this.isFormOpen.set(true); + } + + viewCategory(cat: CategoryResponse): void { + this.selectedCategory.set(cat); + this.formMode.set('view'); + this.isFormOpen.set(true); + } + + closeForm(): void { + this.isFormOpen.set(false); + this.selectedCategory.set(null); + } + + onCategorySaved(): void { + this.getPaginationAllCategories(); + } + + confirmDelete(cat: CategoryResponse): void { + this.selectedCategoryForDelete.set(cat); + this.deleteDialogOpen.set(true); + } + + executeDelete(): void { + const cat = this.selectedCategoryForDelete(); + if (!cat) return; + + this.isDeleting.set(true); + this.categoryService.delete(cat.id).subscribe({ + next: () => { + this.isDeleting.set(false); + this.deleteDialogOpen.set(false); + this.allCategories.update((list) => list.filter((c) => c.id !== cat.id)); + this.toastService.success( + 'Categoria Excluída', + `A categoria "${cat.name}" foi removida com sucesso.`, + ); + }, + error: (err) => { + this.isDeleting.set(false); + this.deleteDialogOpen.set(false); + this.toastService.error('Erro ao excluir', err.message || 'Falha ao remover categoria.'); + }, + }); + } +} diff --git a/src/app/features/catalog/categories/category-form/category-form.css b/src/app/features/catalog/categories/category-form/category-form.css new file mode 100644 index 0000000..a4aa3bb --- /dev/null +++ b/src/app/features/catalog/categories/category-form/category-form.css @@ -0,0 +1,17 @@ +/* Custom scrollbar for icon picker */ +.custom-scrollbar::-webkit-scrollbar { + width: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.4); + border-radius: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.7); +} diff --git a/src/app/features/catalog/categories/category-form/category-form.html b/src/app/features/catalog/categories/category-form/category-form.html new file mode 100644 index 0000000..0bad9d0 --- /dev/null +++ b/src/app/features/catalog/categories/category-form/category-form.html @@ -0,0 +1,433 @@ + + +
+ + +
+
+
+ +
+
+ + {{ name() ? name() : 'Nova Categoria de Catálogo' }} + + + {{ slug() ? '/' + slug() : '/slug-da-categoria' }} + +
+
+ +
+ @if (active()) { + + + Ativo + + } @else { + + + Inativo + + } +
+
+ + +
+
+ +

+ Identificação da Categoria +

+
+ + +
+
+ + {{ name().length }}/255 +
+ + @if (formErrors()['name']) { +

+ + {{ formErrors()['name'] }} +

+ } +
+ + +
+
+ + @if (!isReadOnly()) { + + } +
+
+
+ / +
+ +
+ @if (formErrors()['slug']) { +

+ + {{ formErrors()['slug'] }} +

+ } @else { +

+ Usado para compor links permanentes e indexação (SEO). Apenas letras minúsculas, números e hífens. +

+ } +
+
+ + +
+
+ +

+ Hierarquia e Posição +

+
+ +
+ +
+ +
+ +
+ +
+
+

+ Vincule a uma categoria pai para criar subníveis e agrupamentos no catálogo. +

+
+ + +
+ +
+ + + +
+ @if (formErrors()['display_order']) { +

{{ formErrors()['display_order'] }}

+ } +
+
+
+ + +
+
+ +

+ Identidade Visual & Mídia +

+
+ +
+ +
+ + Ícone Representativo + +
+ +
+ + + @if (iconPickerOpen()) { +
+
+ +
+ +
+
+ +
+ @for (ic of filteredIcons(); track ic.name) { + + } +
+ + +
+ + +
+
+ } +
+ + +
+ +
+ + @if (image() && !isReadOnly()) { + + } +
+

+ Opcional. Imagem destacada para exibição em banners do e-commerce. +

+
+
+ + + @if (image()) { +
+
+ Pré-visualização da categoria +
+
+ + Pré-visualização da Imagem + + + {{ image() }} + +
+
+ } +
+ + +
+
+ +

+ Descrição e Conteúdo +

+
+ +
+ + +

+ Informações auxiliares exibidas para o comprador e utilizadas em mecanismos de busca. +

+
+
+ + +
+
+
+ + Disponibilidade da Categoria + + + {{ active() ? 'Categoria visível no menu de navegação e filtros do catálogo.' : 'Categoria oculta e indisponível para novos produtos.' }} + +
+ + +
+
+ +
+ + +
+ + {{ isReadOnly() ? 'Fechar' : 'Cancelar' }} + + + @if (!isReadOnly()) { + + {{ mode() === 'edit' ? 'Salvar Alterações' : 'Cadastrar Categoria' }} + + } +
+
diff --git a/src/app/features/catalog/categories/category-form/category-form.ts b/src/app/features/catalog/categories/category-form/category-form.ts new file mode 100644 index 0000000..281fa4a --- /dev/null +++ b/src/app/features/catalog/categories/category-form/category-form.ts @@ -0,0 +1,399 @@ +import { + Component, + ChangeDetectionStrategy, + input, + output, + signal, + computed, + effect, + inject +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ButtonComponent } from '../../../../design-system/button/button'; +import { AppIconComponent } from '../../../../design-system/icon/app-icon'; +import { DrawerComponent } from '../../../../design-system/drawer/drawer'; +import { CategoryService } from '../../../../core/services/category'; +import { ToastService } from '../../../../core/services/toast'; +import { Category, CreateCategoryPayload, UpdateCategoryPayload } from '../../../../core/models/category'; + +export interface CategoryFormData { + parent_id: string | null; + name: string; + slug: string; + description: string; + display_order: number; + icon: string; + image: string; + active: boolean; +} + +export interface CuratedIcon { + name: string; + label: string; + category: string; +} + +export const CURATED_CATEGORY_ICONS: CuratedIcon[] = [ + { name: 'folder', label: 'Pasta Padrão', category: 'Geral' }, + { name: 'box', label: 'Caixa / Peça', category: 'Geral' }, + { name: 'package', label: 'Pacote / Kit', category: 'Geral' }, + { name: 'layers', label: 'Camadas / Sistema', category: 'Geral' }, + { name: 'tag', label: 'Etiqueta', category: 'Geral' }, + { name: 'wrench', label: 'Ferramenta / Mecânica', category: 'Mecânica' }, + { name: 'settings', label: 'Engrenagem / Config', category: 'Mecânica' }, + { name: 'sliders', label: 'Ajuste / Calibração', category: 'Mecânica' }, + { name: 'activity', label: 'Atividade / Motor', category: 'Mecânica' }, + { name: 'zap', label: 'Elétrica / Ignição', category: 'Elétrica' }, + { name: 'battery', label: 'Bateria / Energia', category: 'Elétrica' }, + { name: 'cpu', label: 'Módulo Eletrônico', category: 'Elétrica' }, + { name: 'truck', label: 'Caminhão / Pesados', category: 'Veicular' }, + { name: 'car', label: 'Automóvel / Leves', category: 'Veicular' }, + { name: 'disc', label: 'Disco / Freios', category: 'Freios' }, + { name: 'shield', label: 'Proteção / Segurança', category: 'Segurança' }, + { name: 'droplet', label: 'Fluido / Óleo', category: 'Fluidos' }, + { name: 'wind', label: 'Ar / Climatização', category: 'Arrefecimento' }, + { name: 'gauge', label: 'Pressão / Medição', category: 'Sensores' }, + { name: 'grid', label: 'Grade / Estrutura', category: 'Carroceria' } +]; + +@Component({ + selector: 'app-category-form', + standalone: true, + imports: [ + CommonModule, + ButtonComponent, + AppIconComponent, + DrawerComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './category-form.html', + styleUrl: './category-form.css' +}) +export class CategoryFormComponent { + private categoryService = inject(CategoryService); + private toastService = inject(ToastService); + + readonly isOpen = input(false); + readonly mode = input<'create' | 'edit' | 'view'>('create'); + readonly category = input(null); + readonly categoriesList = input([]); + + readonly closeForm = output(); + readonly categorySaved = output(); + + // Reactive State Signals + readonly isSubmitting = signal(false); + readonly isAutoSlug = signal(true); + readonly iconPickerOpen = signal(false); + readonly iconSearchQuery = signal(''); + + // Form Field Signals + readonly parentId = signal(null); + readonly name = signal(''); + readonly slug = signal(''); + readonly description = signal(''); + readonly displayOrder = signal(0); + readonly icon = signal('folder'); + readonly image = signal(''); + readonly active = signal(true); + + // Validation Errors + readonly formErrors = signal>({}); + + constructor() { + effect(() => { + const open = this.isOpen(); + const cat = this.category(); + const currentMode = this.mode(); + + if (open) { + this.formErrors.set({}); + this.iconPickerOpen.set(false); + this.iconSearchQuery.set(''); + + if (currentMode === 'edit' || currentMode === 'view') { + if (cat) { + this.parentId.set(cat.parent_id || null); + this.name.set(cat.name || ''); + this.slug.set(cat.slug || ''); + this.description.set(cat.description || ''); + this.displayOrder.set(typeof cat.display_order === 'number' ? cat.display_order : 0); + this.icon.set(cat.icon || 'folder'); + this.image.set(cat.image || ''); + this.active.set(cat.active !== undefined ? Boolean(cat.active) : true); + this.isAutoSlug.set(false); + } + } else { + // Create Mode Reset + this.parentId.set(null); + this.name.set(''); + this.slug.set(''); + this.description.set(''); + this.displayOrder.set(this.suggestNextDisplayOrder()); + this.icon.set('folder'); + this.image.set(''); + this.active.set(true); + this.isAutoSlug.set(true); + } + } + }); + } + + // Parent Category Options filtered to avoid cyclical hierarchy + readonly parentCategoryOptions = computed(() => { + const list = this.categoriesList(); + const currentCat = this.category(); + const currentId = currentCat?.id; + + return list.filter((c) => { + if (!currentId) return true; + // Do not allow selecting self as parent + if (c.id === currentId) return false; + // Do not allow selecting children of self if hierarchical + if (c.parent_id === currentId) return false; + return true; + }); + }); + + // Filtered Curated Icons list + readonly filteredIcons = computed(() => { + const q = this.iconSearchQuery().toLowerCase().trim(); + if (!q) return CURATED_CATEGORY_ICONS; + return CURATED_CATEGORY_ICONS.filter( + (item) => item.name.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || item.category.toLowerCase().includes(q) + ); + }); + + // Derived Title & Subtitle + readonly formTitle = computed(() => { + switch (this.mode()) { + case 'edit': + return 'Editar Categoria'; + case 'view': + return 'Detalhes da Categoria'; + case 'create': + default: + return 'Nova Categoria'; + } + }); + + readonly isReadOnly = computed(() => this.mode() === 'view'); + + // Slug Helper: generates slug from text + generateSlug(text: string): string { + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + private suggestNextDisplayOrder(): number { + const list = this.categoriesList(); + if (list.length === 0) return 1; + const max = Math.max(...list.map((c) => c.display_order || 0), 0); + return max + 1; + } + + // Event Handlers for Inputs + onNameChange(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.name.set(value); + + if (this.isAutoSlug()) { + const generated = this.generateSlug(value); + this.slug.set(generated); + } + + if (this.formErrors()['name']) { + this.clearFieldError('name'); + } + } + + onSlugChange(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.slug.set(value.trim()); + + if (this.formErrors()['slug']) { + this.clearFieldError('slug'); + } + } + + toggleAutoSlug(): void { + if (this.isReadOnly()) return; + const newState = !this.isAutoSlug(); + this.isAutoSlug.set(newState); + if (newState && this.name()) { + this.slug.set(this.generateSlug(this.name())); + } + } + + onParentChange(event: Event): void { + const val = (event.target as HTMLSelectElement).value; + this.parentId.set(val === '' ? null : val); + } + + onDescriptionChange(event: Event): void { + const val = (event.target as HTMLTextAreaElement).value; + this.description.set(val); + } + + onDisplayOrderChange(event: Event): void { + const val = parseInt((event.target as HTMLInputElement).value, 10); + this.displayOrder.set(isNaN(val) || val < 0 ? 0 : val); + } + + incrementDisplayOrder(): void { + if (this.isReadOnly()) return; + this.displayOrder.update((n) => n + 1); + } + + decrementDisplayOrder(): void { + if (this.isReadOnly()) return; + this.displayOrder.update((n) => (n > 0 ? n - 1 : 0)); + } + + onImageChange(event: Event): void { + const val = (event.target as HTMLInputElement).value; + this.image.set(val.trim()); + } + + clearImage(): void { + if (this.isReadOnly()) return; + this.image.set(''); + } + + onIconChange(event: Event): void { + const val = (event.target as HTMLInputElement).value; + this.icon.set(val.trim() || 'folder'); + } + + selectIcon(iconName: string): void { + if (this.isReadOnly()) return; + this.icon.set(iconName); + this.iconPickerOpen.set(false); + } + + toggleIconPicker(): void { + if (this.isReadOnly()) return; + this.iconPickerOpen.update((v) => !v); + } + + onActiveToggle(): void { + if (this.isReadOnly()) return; + this.active.update((v) => !v); + } + + private clearFieldError(field: string): void { + this.formErrors.update((errs) => { + const copy = { ...errs }; + delete copy[field]; + return copy; + }); + } + + // Client-side Validation + validateForm(): boolean { + const errors: Record = {}; + const nameVal = this.name().trim(); + const slugVal = this.slug().trim(); + const orderVal = this.displayOrder(); + + if (!nameVal) { + errors['name'] = 'O nome da categoria é obrigatório.'; + } else if (nameVal.length > 255) { + errors['name'] = 'O nome não pode exceder 255 caracteres.'; + } + + if (!slugVal) { + errors['slug'] = 'O slug da categoria é obrigatório.'; + } else if (slugVal.length > 255) { + errors['slug'] = 'O slug não pode exceder 255 caracteres.'; + } else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slugVal)) { + errors['slug'] = 'Formato inválido. Use apenas letras minúsculas, números e hífens.'; + } + + if (orderVal < 0) { + errors['display_order'] = 'A ordem de exibição deve ser um número maior ou igual a 0.'; + } + + this.formErrors.set(errors); + return Object.keys(errors).length === 0; + } + + // Submit Handler + onSubmit(): void { + if (this.isReadOnly()) { + this.close(); + return; + } + + if (!this.validateForm()) { + this.toastService.error('Formulário Inválido', 'Corrija os campos indicados antes de salvar.'); + return; + } + + this.isSubmitting.set(true); + + const payload: CreateCategoryPayload | UpdateCategoryPayload = { + parent_id: this.parentId(), + name: this.name().trim(), + slug: this.slug().trim(), + description: this.description().trim() || null, + display_order: this.displayOrder(), + icon: this.icon().trim() || 'folder', + image: this.image().trim() || null, + active: this.active() + }; + + if (this.mode() === 'edit' && this.category()?.id) { + const id = this.category()!.id; + this.categoryService.update(id, payload).subscribe({ + next: (response) => { + this.isSubmitting.set(false); + this.toastService.success('Categoria Atualizada', `A categoria "${response.data.name}" foi salva com sucesso.`); + this.categorySaved.emit(response.data); + this.close(); + }, + error: (err) => { + this.isSubmitting.set(false); + const msg = err.error?.message || err.message || 'Erro ao atualizar categoria.'; + this.toastService.error('Falha ao Salvar', msg); + if (err.error?.errors) { + const serverErrors: Record = {}; + for (const key of Object.keys(err.error.errors)) { + serverErrors[key] = Array.isArray(err.error.errors[key]) ? err.error.errors[key][0] : String(err.error.errors[key]); + } + this.formErrors.set(serverErrors); + } + } + }); + } else { + this.categoryService.create(payload as CreateCategoryPayload).subscribe({ + next: (response) => { + this.isSubmitting.set(false); + this.toastService.success('Categoria Criada', `A categoria "${response.data.name}" foi cadastrada com sucesso.`); + this.categorySaved.emit(response.data); + this.close(); + }, + error: (err) => { + this.isSubmitting.set(false); + const msg = err.error?.message || err.message || 'Erro ao criar categoria.'; + this.toastService.error('Falha ao Salvar', msg); + if (err.error?.errors) { + const serverErrors: Record = {}; + for (const key of Object.keys(err.error.errors)) { + serverErrors[key] = Array.isArray(err.error.errors[key]) ? err.error.errors[key][0] : String(err.error.errors[key]); + } + this.formErrors.set(serverErrors); + } + } + }); + } + } + + close(): void { + this.closeForm.emit(); + } +} 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 deleted file mode 100644 index d147c85..0000000 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html +++ /dev/null @@ -1,414 +0,0 @@ -
- @switch (activeTab()) { @case ('oem') { - -
-
-
-

- - {{ getModule('oem').label }} -

-

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

-
- - @if (!isReadOnly()) { - - Adicionar Código OEM - - } -
- -
- - - - - - - - - - - - @for (oem of oemPaginated(); track oem.id) { - - - - - - - - } @empty { - - - - } - -
Fabricante de ReferênciaCódigo OEMPrincipalSituaçãoAções
{{ oem.manufacturer }} - {{ oem.oemCode }} - - @if (oem.isPrimary) { - - - Principal - - } @else if (!isReadOnly()) { - - } @else { - - - } - - - - @if (!isReadOnly()) { - - } -
- Nenhum código OEM cadastrado. -
-
- - @if (oemCodes().length > 0) { -
- -
- } -
- } @case ('codigos') { - -
-
-
-

- - {{ getModule('codigos').label }} -

-

- {{ getModule('codigos').description }} -

-
- - @if (!isReadOnly()) { - - Adicionar Código - - } -
- -
- - - - - - - - - - - @for (codeItem of productCodesPaginated(); track codeItem.id) { - - - - - - - } @empty { - - - - } - -
Tipo do CódigoCódigoSituaçãoAções
- {{ codeItem.type }} - - {{ codeItem.code }} - - - - @if (!isReadOnly()) { - - } -
- Nenhum código complementar cadastrado. -
-
- - @if (productCodes().length > 0) { -
- -
- } -
- } @case ('equivalentes') { - -
-
-
-

- - {{ getModule('equivalentes').label }} -

-

- {{ getModule('equivalentes').description }} -

-
- - @if (!isReadOnly()) { - - Adicionar Equivalente - - } -
- -
- - - - - - - - - - - @for (eq of equivalentPaginated(); track eq.id) { - - - - - - - } @empty { - - - - } - -
Produto EquivalenteObservaçãoSituaçãoAções
- {{ eq.productName }} - - {{ eq.notes || 'Sem observações' }} - - - - @if (!isReadOnly()) { - - } -
- Nenhum produto equivalente vinculado. -
-
- - @if (equivalentProducts().length > 0) { -
- -
- } -
- } @case ('compatibilidade') { - -
-
-
-

- - {{ getModule('compatibilidade').label }} -

-

- {{ getModule('compatibilidade').description }} -

-
- - @if (!isReadOnly()) { - - Adicionar Aplicação Veicular - - } -
- -
- - - - - - - - - - - - - @for (veh of vehiclePaginated(); track veh.id) { - - - - - - - - - } @empty { - - - - } - -
Aplicação Veicular (Marca/Modelo/Versão)MotorAnosObservaçõesSituaçãoAções
- {{ veh.brand }} {{ veh.model }} - ({{ veh.version }}) - {{ veh.engine }} - {{ veh.startYear }} - {{ veh.endYear || 'Atual' }} - {{ veh.notes || '-' }} - - - @if (!isReadOnly()) { - - } -
- Nenhuma aplicação veicular vinculada. -
-
- - @if (vehicleApplications().length > 0) { -
- -
- } -
- } } -
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 deleted file mode 100644 index bc0a9f2..0000000 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Component, ChangeDetectionStrategy, computed, input, output, signal } from '@angular/core'; -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'; - -@Component({ - selector: 'app-product-compatibility-tab', - standalone: true, - imports: [ButtonComponent, AppIconComponent, PaginationComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './compatibility-tab.html', -}) -export class ProductCompatibilityTabComponent { - readonly activeTab = input.required(); - readonly oemCodes = input([]); - readonly productCodes = input([]); - readonly equivalentProducts = input([]); - readonly vehicleApplications = input([]); - readonly isReadOnly = input(false); - - readonly addOemCode = output(); - readonly setPrimaryOem = output(); - readonly removeOemCode = output(); - readonly toggleOemStatus = output(); - - readonly addProductCode = output(); - readonly removeProductCode = output(); - readonly toggleProductCodeStatus = output(); - - readonly addEquivalent = output(); - readonly removeEquivalent = output(); - readonly toggleEquivalentStatus = output(); - - readonly addVehicleApplication = output(); - readonly removeVehicleApplication = output(); - readonly toggleVehicleStatus = output(); - - readonly oemPage = signal(1); - readonly oemPageSize = signal(10); - readonly productCodesPage = signal(1); - readonly productCodesPageSize = signal(10); - readonly equivalentPage = signal(1); - readonly equivalentPageSize = signal(10); - readonly vehiclePage = signal(1); - readonly vehiclePageSize = signal(10); - - readonly oemPaginated = computed(() => - this.paginate(this.oemCodes(), this.oemPage(), this.oemPageSize()), - ); - readonly productCodesPaginated = computed(() => - this.paginate(this.productCodes(), this.productCodesPage(), this.productCodesPageSize()), - ); - readonly equivalentPaginated = computed(() => - this.paginate(this.equivalentProducts(), this.equivalentPage(), this.equivalentPageSize()), - ); - readonly vehiclePaginated = computed(() => - this.paginate(this.vehicleApplications(), this.vehiclePage(), this.vehiclePageSize()), - ); - - getModule(tab: FormTab) { - return getCompatibilityModuleByTab(tab)!; - } - - onAddOemCode(): void { - this.addOemCode.emit(); - } - onSetPrimaryOem(id: string): void { - this.setPrimaryOem.emit(id); - } - onRemoveOemCode(id: string): void { - this.removeOemCode.emit(id); - } - onToggleOemStatus(id: string): void { - if (this.isReadOnly()) return; - this.toggleOemStatus.emit(id); - } - - onAddProductCode(): void { - this.addProductCode.emit(); - } - onRemoveProductCode(id: string): void { - this.removeProductCode.emit(id); - } - onToggleProductCodeStatus(id: string): void { - if (this.isReadOnly()) return; - this.toggleProductCodeStatus.emit(id); - } - - onAddEquivalent(): void { - this.addEquivalent.emit(); - } - onRemoveEquivalent(id: string): void { - this.removeEquivalent.emit(id); - } - onToggleEquivalentStatus(id: string): void { - if (this.isReadOnly()) return; - this.toggleEquivalentStatus.emit(id); - } - - onAddVehicleApplication(): void { - this.addVehicleApplication.emit(); - } - onRemoveVehicleApplication(id: string): void { - this.removeVehicleApplication.emit(id); - } - onToggleVehicleStatus(id: string): void { - if (this.isReadOnly()) return; - this.toggleVehicleStatus.emit(id); - } - - private paginate(items: T[], page: number, pageSize: number): T[] { - const start = (page - 1) * pageSize; - return items.slice(start, start + pageSize); - } -} 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 deleted file mode 100644 index 212d41a..0000000 --- a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html +++ /dev/null @@ -1,117 +0,0 @@ -
-
-

- - 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. -

-
- - -
- - - - - -
- -
- - - - - -
- - -
-
-

Permitir Backorder (Venda sem Estoque)

-

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

-
- - -
- - -
-
- - Campos Gerenciados Automaticamente pelo Sistema -
- -
-
- ID (id) - - Gerado Auto (UUID) - -
- -
- Produto (product_id) - Vínculo Automático -
- -
- Qtd. Reservada (reserved) - 0 (Inicial) -
- -
- Status (active) - - Ativo (true) - -
-
-
-
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 deleted file mode 100644 index f227d43..0000000 --- a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Component, ChangeDetectionStrategy, input, output } 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'; - -@Component({ - selector: 'app-product-inventory-tab', - standalone: true, - imports: [InputComponent, SelectComponent, AppIconComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './inventory-tab.html', -}) -export class ProductInventoryTabComponent { - readonly formWarehouseId = input(''); - readonly formQuantity = input(0); - readonly formMinQuantity = input(0); - readonly formMaxQuantity = input(null); - readonly formAllowBackorder = input(false); - - 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(); - - onFieldChange(field: string, value: string): void { - this.fieldChange.emit({ field, value }); - } - - onNumberFieldChange(field: string, value: string): void { - this.numberFieldChange.emit({ field, value }); - } - - onNullableNumberFieldChange(field: string, value: string): void { - this.nullableNumberFieldChange.emit({ field, value }); - } - - onToggleBackorder(): void { - this.toggleBackorder.emit(); - } -} 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-workspace/product-workspace.ts b/src/app/features/catalog/product-workspace/product-workspace.ts deleted file mode 100644 index 60c9985..0000000 --- a/src/app/features/catalog/product-workspace/product-workspace.ts +++ /dev/null @@ -1,1019 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - OnInit, - signal, -} from '@angular/core'; -import { Location } from '@angular/common'; -import { PageHeaderComponent } from '../../../design-system/page-header/page-header'; -import { ButtonComponent } from '../../../design-system/button/button'; -import { ConfirmDialogComponent } from '../../../design-system/dialog/confirm-dialog'; -import { AppIconComponent } from '../../../design-system/icon/app-icon'; -import { ProductGeneralTabComponent } from '../components/product-form/general-tab/general-tab'; -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'; -import { ProductService } from '../../../core/services/product-service'; -import { CategoryService } from '../../../core/services/category-service'; -import { ManufacturerService } from '../../../core/services/manufacture-service'; -import { StatusService } from '../../../core/services/status-service'; -import { ToastService } from '../../../core/services/toast'; -import { PartOriginService } from '../../../core/services/part-origins-service'; -import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; -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'; -import { WarehousesService } from '../../../core/services/warehouses-service'; - -@Component({ - selector: 'app-product-workspace', - standalone: true, - imports: [ - PageHeaderComponent, - ButtonComponent, - ConfirmDialogComponent, - AppIconComponent, - ProductGeneralTabComponent, - ProductCommercialTabComponent, - ProductInventoryTabComponent, - ProductCompatibilityTabComponent, - ProductWorkspaceSidebarComponent, - ], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './product-workspace.html', - styleUrl: './product-workspace.css', -}) -export class ProductWorkspaceComponent implements OnInit { - private router = inject(Router); - private location = inject(Location); - private route = inject(ActivatedRoute); - private toastService = inject(ToastService); - readonly shellState = inject(ShellStateService); - private productService = inject(ProductService); - private categoryService = inject(CategoryService); - private statusService = inject(StatusService); - private partOriginService = inject(PartOriginService); - private warehouseService = inject(WarehousesService); - private manufacturerService = inject(ManufacturerService); - - readonly activeTab = signal('geral'); - readonly mode = signal('edit'); - - readonly isSaving = signal(false); - readonly isDragging = signal(false); - readonly isFormDirty = signal(false); - readonly categoryLoading = signal(false); - readonly manufacturerLoading = signal(false); - readonly productCreatedSuccessBanner = signal(false); - - readonly fetchedStatuses = signal([]); - readonly fetchedWarehouses = signal([]); - readonly fetchedPartOrigins = signal([]); - readonly fetchedCategories = signal([]); - readonly fetchedManufacturers = signal([]); - - readonly deleteProductDialogOpen = signal(false); - readonly unsavedChangesDialogOpen = signal(false); - readonly deleteImageDialogOpen = signal(false); - readonly selectedImageForDelete = signal(null); - readonly errors = signal>({}); - - readonly productForm = signal(defaultProductFormData()); - readonly currentStatus = signal(null); - - readonly categoryOptions = computed(() => this.fetchedCategories()); - readonly manufacturerOptions = computed(() => this.fetchedManufacturers()); - readonly statusOptions = computed(() => this.fetchedStatuses()); - - readonly generalForm = computed(() => toProductGeneralFormData(this.productForm())); - - readonly generalFormOptions = computed(() => ({ - categoryOptions: this.categoryOptions(), - categoryLoading: this.categoryLoading(), - manufacturerOptions: this.manufacturerOptions(), - manufacturerLoading: this.manufacturerLoading(), - partOriginOptions: this.fetchedPartOrigins(), - unitOptions: this.unitOptions, - statusOptions: this.statusOptions(), - warehouseOptions: this.fetchedWarehouses(), - })); - - readonly isReadOnly = computed(() => this.mode() === 'view'); - - readonly isCompatibilityTab = computed(() => isCompatibilityWorkspaceTab(this.activeTab())); - - readonly headerTitle = computed(() => - this.mode() === 'edit' ? 'Editar Produto' : 'Visualizar Produto', - ); - - readonly breadcrumbs = computed(() => [ - { label: 'Catálogo', route: '/catalog/products' }, - { label: 'Produtos', route: '/catalog/products' }, - { label: this.headerTitle() }, - ]); - - private queries: GeneralOptionQuery = { - search: null, - active: null, - 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' }, - ]; - - ngOnInit(): void { - const url = this.router.url; - const id = this.route.snapshot.paramMap.get('id'); - const state = this.location.getState() as any; - - this.productCreatedSuccessBanner.set(!!state?.['productJustCreated']); - - this.loadInitialOptions(this.queries); - - if (id) { - this.mode.set(url.includes('/view') ? 'view' : 'edit'); - this.productById(id); - } - } - - loadInitialOptions(query: GeneralOptionQuery): void { - this.categoryLoading.set(true); - this.categoryService.getOptions(query).subscribe({ - next: (res) => { - const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ - label: c.label, - value: c.id, - sublabel: c.description, - icon: c.icon || 'folder', - })); - this.fetchedCategories.set(opts); - this.categoryLoading.set(false); - }, - error: () => this.categoryLoading.set(false), - }); - - this.manufacturerLoading.set(true); - this.manufacturerService.getOptions(query).subscribe({ - next: (res) => { - const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ - label: m.label, - value: m.id, - sublabel: m.description, - })); - this.fetchedManufacturers.set(opts); - this.manufacturerLoading.set(false); - }, - error: () => this.manufacturerLoading.set(false), - }); - - this.statusService.getOptions('PRODUCT').subscribe({ - next: (res) => { - const opts: DSelectOption[] = (res.data || []).map((s) => ({ - label: s.label, - value: s.id, - })); - this.fetchedStatuses.set(opts); - }, - }); - - this.partOriginService.getAll(this.queries).subscribe({ - next: (res) => { - const opts: DSelectOption[] = (res.data || []).map((p) => ({ - label: p.name, - value: p.id, - })); - this.fetchedPartOrigins.set(opts); - }, - }); - - this.warehouseService.getOptions(query).subscribe({ - next: (res) => { - const opts: DSelectOption[] = (res.data || []).map((w) => ({ - label: w.label, - value: w.id, - })); - this.fetchedWarehouses.set(opts); - }, - }); - } - - productById(productId: string): void { - this.productService.getById(productId).subscribe({ - next: (response) => { - const prodData = response.data || response; - this.populateForm(prodData); - }, - error: (error) => { - console.error('Error loading product:', error); - this.toastService.error('Erro', 'Não foi possível carregar os dados do produto.'); - }, - }); - } - - 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); - } - - closeSuccessBanner(): void { - this.productCreatedSuccessBanner.set(false); - } - - hasTabError(tab: FormTab): boolean { - const errs = this.errors(); - if (tab === 'geral') { - return !!( - errs['name'] || - errs['internal_code'] || - errs['category_id'] || - errs['manufacturer_id'] || - errs['unit_id'] || - errs['unit_id'] - ); - } - if (tab === 'comercial') { - return !!( - errs['price'] || - errs['promotional_price'] || - errs['promotion_start_date'] || - errs['promotion_end_date'] - ); - } - if (tab === 'inventario') { - return !!( - errs['warehouse_id'] || - errs['quantity'] || - errs['min_quantity'] || - errs['max_quantity'] - ); - } - return false; - } - - updateField(field: string, val: string): void { - this.isFormDirty.set(true); - const fieldMapping: Record = { - name: 'name', - internal_code: 'internal_code', - barcode: 'barcode', - category_id: 'category_id', - manufacturer_id: 'manufacturer_id', - part_origin_id: 'part_origin_id', - unit_id: 'unit_id', - 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) { - this.productForm.update((p) => ({ ...p, [key]: val })); - } - - if (this.errors()[field]) { - this.errors.update((e) => { - const copy = { ...e }; - delete copy[field]; - return copy; - }); - } - } - - updateBooleanField(field: string, val: boolean): void { - this.isFormDirty.set(true); - if (field === 'active') { - this.productForm.update((p) => ({ ...p, active: val })); - } else if (field === 'featured') { - this.productForm.update((p) => ({ ...p, featured: val })); - } - } - - updateNumberField(field: string, valStr: string): void { - this.isFormDirty.set(true); - const num = parseFloat(valStr) || 0; - 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 })); - } - - if (this.errors()[field]) { - this.errors.update((e) => { - const copy = { ...e }; - delete copy[field]; - return copy; - }); - } - } - - updateNullableNumberField(field: string, valStr: string): void { - this.isFormDirty.set(true); - const val = valStr.trim() === '' ? null : parseFloat(valStr); - if (field === 'promotional_price') { - this.productForm.update((p) => ({ ...p, promotionalPrice: val })); - } else if (field === 'max_quantity') { - this.productForm.update((p) => ({ ...p, maxQuantity: 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 })); - } - - toggleBackorder(): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ ...p, allowBackorder: !p.allowBackorder })); - } - - 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) => ({ - ...p, - oemCodes: [ - ...p.oemCodes, - { - id: 'oem-' + Date.now(), - manufacturer, - oemCode: code.toUpperCase(), - isPrimary: p.oemCodes.length === 0, - }, - ], - })); - this.isFormDirty.set(true); - } - - setPrimaryOem(id: string): void { - this.productForm.update((p) => ({ - ...p, - oemCodes: p.oemCodes.map((o) => ({ ...o, isPrimary: o.id === id })), - })); - this.isFormDirty.set(true); - } - - removeOemCode(id: string): void { - this.productForm.update((p) => ({ - ...p, - oemCodes: p.oemCodes.filter((o) => o.id !== 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, - ), - })); - } - - 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) => ({ - ...p, - productCodes: [...p.productCodes, { id: 'pc-' + Date.now(), type, code }], - })); - this.isFormDirty.set(true); - } - - removeProductCode(id: string): void { - this.productForm.update((p) => ({ - ...p, - productCodes: p.productCodes.filter((pc) => pc.id !== 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, - ), - })); - } - - 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) => ({ - ...p, - equivalentProducts: [ - ...p.equivalentProducts, - { id: 'eq-' + Date.now(), productName: name, notes }, - ], - })); - this.isFormDirty.set(true); - } - - removeEquivalent(id: string): void { - this.productForm.update((p) => ({ - ...p, - equivalentProducts: p.equivalentProducts.filter((e) => e.id !== 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, - ), - })); - } - - 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) => ({ - ...p, - vehicleApplications: [ - ...p.vehicleApplications, - { - id: 'veh-' + Date.now(), - brand, - model, - version: 'Flex', - engine, - startYear: 2015, - endYear: 2022, - }, - ], - })); - this.isFormDirty.set(true); - } - - removeVehicleApplication(id: string): void { - this.productForm.update((p) => ({ - ...p, - vehicleApplications: p.vehicleApplications.filter((v) => v.id !== 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, - ), - })); - } - - onDragOver(e: DragEvent): void { - e.preventDefault(); - this.isDragging.set(true); - } - - onDragLeave(e: DragEvent): void { - e.preventDefault(); - this.isDragging.set(false); - } - - onFileDrop(e: DragEvent): void { - e.preventDefault(); - this.isDragging.set(false); - if (e.dataTransfer?.files) { - this.handleFiles(Array.from(e.dataTransfer.files)); - } - } - - onFileSelected(e: Event): void { - const input = e.target as HTMLInputElement; - if (input.files) { - this.handleFiles(Array.from(input.files)); - } - } - - 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); - } - - confirmDeleteImage(img: MediaImageItem): void { - this.selectedImageForDelete.set(img); - this.deleteImageDialogOpen.set(true); - } - - 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); - } - - openAddSupplierModal(): void { - const name = prompt('Nome do Fornecedor:'); - if (!name) return; - const code = prompt('Código no Fornecedor:', 'SUP-01') || 'SUP-01'; - this.productForm.update((p) => ({ - ...p, - suppliers: [ - ...p.suppliers, - { - id: 'sup-' + Date.now(), - supplierName: name, - supplierCode: code, - purchasePrice: 100.0, - leadTimeDays: 5, - isPreferential: p.suppliers.length === 0, - }, - ], - })); - this.isFormDirty.set(true); - } - - removeSupplier(id: string): void { - this.productForm.update((p) => ({ - ...p, - suppliers: p.suppliers.filter((s) => s.id !== id), - })); - this.isFormDirty.set(true); - } - - setPreferentialSupplier(id: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => ({ - ...p, - suppliers: p.suppliers.map((s) => ({ ...s, isPreferential: s.id === id })), - })); - } - - 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, - ), - })); - } - - toggleTag(tagId: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.productForm.update((p) => { - const exists = p.selectedTagIds.includes(tagId); - return { - ...p, - selectedTagIds: exists - ? p.selectedTagIds.filter((id) => id !== tagId) - : [...p.selectedTagIds, tagId], - }; - }); - } - - 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); - } - - removeNote(id: string): void { - this.productForm.update((p) => ({ - ...p, - productNotes: p.productNotes.filter((n) => n.id !== id), - })); - this.isFormDirty.set(true); - } - - 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, - ), - })); - } - - onCategorySearch(query: GeneralOptionQuery): void { - this.categoryLoading.set(true); - this.categoryService.getOptions(query).subscribe({ - next: (res) => { - const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ - label: c.label, - value: c.id, - sublabel: c.description, - icon: c.icon || 'folder', - })); - this.fetchedCategories.set(opts); - this.categoryLoading.set(false); - }, - error: () => this.categoryLoading.set(false), - }); - } - - onManufacturerSearch(query: GeneralOptionQuery): void { - this.manufacturerLoading.set(true); - this.manufacturerService.getOptions(query).subscribe({ - next: (res) => { - const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ - label: m.label, - value: m.id, - sublabel: m.description, - })); - this.fetchedManufacturers.set(opts); - this.manufacturerLoading.set(false); - }, - error: () => this.manufacturerLoading.set(false), - }); - } - - validateForm(): boolean { - const errs: Record = {}; - const p = this.productForm(); - - if (!p.name.trim()) { - errs['name'] = 'O nome do produto é obrigatório.'; - } - if (!p.internal_code.trim()) { - errs['internal_code'] = 'O código interno é obrigatório.'; - } - if (!p.category_id) { - errs['category_id'] = 'Selecione uma categoria.'; - } - if (!p.manufacturer_id) { - errs['manufacturer_id'] = 'Selecione um fabricante.'; - } - 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; - } - - return true; - } - - saveProduct(continueEditing: boolean): void { - if (!this.validateForm()) { - this.toastService.error( - 'Não foi possível salvar o produto.', - 'Verifique os campos obrigatórios destacados.', - ); - return; - } - - 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, - - internal_code: p.internal_code, - barcode: p.barcode, - - 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.isSaving.set(false); - this.isFormDirty.set(false); - this.toastService.success( - 'Produto salvo com sucesso.', - 'Informações atualizadas no sistema.', - ); - - if (!continueEditing) { - this.router.navigate(['/catalog/products']); - } - }, - error: (err) => { - this.isSaving.set(false); - console.error('Error saving product:', err); - this.toastService.error( - 'Erro ao salvar o produto', - err.error?.message || err.message || 'Ocorreu um erro inesperado no servidor.', - ); - }, - }); - } - - onCancel(): void { - if (this.isFormDirty()) { - this.unsavedChangesDialogOpen.set(true); - } else { - this.navigateBack(); - } - } - - navigateBack(): void { - this.router.navigate(['/catalog/products']); - } - - executeExitWithoutSave(): void { - this.unsavedChangesDialogOpen.set(false); - this.navigateBack(); - } - - switchToEdit(): void { - const id = this.route.snapshot.paramMap.get('id') || '1'; - this.router.navigate(['/catalog/products', id, 'edit']); - this.mode.set('edit'); - } - - confirmDeleteProduct(): void { - this.deleteProductDialogOpen.set(true); - } - - executeDeleteProduct(): void { - const id = this.route.snapshot.paramMap.get('id'); - if (id) { - this.productService.delete(id).subscribe({ - next: () => { - this.deleteProductDialogOpen.set(false); - this.toastService.success( - 'Produto Excluído', - 'O produto foi removido do catálogo com sucesso.', - ); - this.router.navigate(['/catalog/products']); - }, - error: () => { - this.deleteProductDialogOpen.set(false); - this.toastService.error('Erro', 'Não foi possível excluir o produto.'); - }, - }); - } else { - this.deleteProductDialogOpen.set(false); - this.navigateBack(); - } - } -} diff --git a/src/app/features/catalog/product-create/product-create.html b/src/app/features/catalog/products/product-create/product-create.html similarity index 100% rename from src/app/features/catalog/product-create/product-create.html rename to src/app/features/catalog/products/product-create/product-create.html diff --git a/src/app/features/catalog/product-create/product-create.ts b/src/app/features/catalog/products/product-create/product-create.ts similarity index 64% rename from src/app/features/catalog/product-create/product-create.ts rename to src/app/features/catalog/products/product-create/product-create.ts index af6b9db..80eac71 100644 --- a/src/app/features/catalog/product-create/product-create.ts +++ b/src/app/features/catalog/products/product-create/product-create.ts @@ -7,26 +7,34 @@ import { signal, } from '@angular/core'; import { Router } from '@angular/router'; -import { PageHeaderComponent } from '../../../design-system/page-header/page-header'; -import { ButtonComponent } from '../../../design-system/button/button'; -import { ConfirmDialogComponent } from '../../../design-system/dialog/confirm-dialog'; -import { ProductGeneralTabComponent } from '../components/product-form/general-tab/general-tab'; -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 { ProductService } from '../../../core/services/product-service'; -import { ToastService } from '../../../core/services/toast'; -import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; -import { DSelectOption } from '../../../core/models/design-system/select-option.model'; -import { GeneralOptionQuery } from '../../../core/models/generals/general-option-query.model'; +import { PageHeaderComponent } from '../../../../design-system/page-header/page-header'; +import { ButtonComponent } from '../../../../design-system/button/button'; +import { ConfirmDialogComponent } from '../../../../design-system/dialog/confirm-dialog'; +import { ProductGeneralTabComponent } from '../product-form/general-tab/general-tab'; +import { ProductCreateStepperComponent } from '../product-form/stepper/product-create-stepper'; +import { CategoryService } from '../../../../core/services/category-service'; +import { ManufacturerService } from '../../../../core/services/manufacture-service'; +import { WarehouseService } from '../../../../core/services/warehouse-service'; +import { ProductService } from '../../../../core/services/product-service'; +import { ToastService } from '../../../../core/services/toast'; +import { SelectOption } from '../../../../core/models/design-system/select-option.model'; +import { GeneralOptionQuery } from '../../../../core/models/generals/general-option-query.model'; import { createFormToGeneralSource, defaultProductCreateFormState, ProductCreateFormState, toCreateProductPayload, toProductGeneralFormData, -} from '../../../core/models/products/product-create.model'; +} from '../../../../core/models/products/product-create.model'; +import { PartOriginService } from '../../../../core/services/part-origins-service'; +import { UnitSaleService } from '../../../../core/services/unit-sale-service'; +import { AutocompleteOption } from '../../../../core/models/design-system/auto-complete.model'; +import { StatusStore } from '../../../../core/store/status-store/status-store'; +import { CategoryStore } from '../../../../core/store/category-store/category-store'; +import { UnitSaleStore } from '../../../../core/store/unit-sale/unit-sale-store'; +import { WarehouseStore } from '../../../../core/store/warehouse/warehouse-store'; +import { PartOriginStore } from '../../../../core/store/part-origin/part-origin-store'; +import { ManufacturerStore } from '../../../../core/store/manufacturer-store/manufacturer-store'; @Component({ selector: 'app-product-create', @@ -44,9 +52,11 @@ import { export class ProductCreateComponent implements OnInit { private router = inject(Router); private productService = inject(ProductService); + private unitSaleService = inject(UnitSaleService); 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); @@ -60,23 +70,38 @@ export class ProductCreateComponent implements OnInit { readonly categoryLoading = signal(false); readonly fetchedCategories = signal([]); readonly manufacturerLoading = signal(false); - readonly fetchedManufacturers = signal([]); - readonly fetchedPartOrigins = signal([]); + readonly fetchedManufacturers = signal[]>([]); + readonly fetchedPartOrigins = signal([]); + + readonly statusStore = inject(StatusStore); + readonly categoryStore = inject(CategoryStore); + readonly unitSaleStore = inject(UnitSaleStore); + readonly warehouseStore = inject(WarehouseStore); + readonly partOriginStore = inject(PartOriginStore); + readonly manufacturerStore = inject(ManufacturerStore); readonly generalForm = computed(() => toProductGeneralFormData(createFormToGeneralSource(this.createForm())), ); readonly generalFormOptions = computed(() => ({ - categoryOptions: this.fetchedCategories(), - categoryLoading: this.categoryLoading(), - manufacturerOptions: this.fetchedManufacturers(), - manufacturerLoading: this.manufacturerLoading(), - partOriginOptions: this.fetchedPartOrigins(), - unitOptions: this.unitOptions, - statusOptions: [], + unitOptions: this.unitSaleStore.optionList(), + statusOptions: this.statusStore.optionList(), + categoryOptions: this.categoryStore.optionList(), + warehouseOptions: this.warehouseStore.optionList(), + partOriginOptions: this.partOriginStore.optionList(), + manufacturerOptions: this.manufacturerStore.optionList(), })); + // readonly generalFormOptions = computed(() => ({ + // categoryOptions: this.fetchedCategories(), + // manufacturerOptions: this.fetchedManufacturers(), + // partOriginOptions: this.fetchedPartOrigins(), + // unitOptions: this.unitOptions(), + // warehouseOptions: [], + // statusOptions: [], + // })); + readonly breadcrumbs = [ { label: 'Catálogo', route: '/catalog/products' }, { label: 'Produtos', route: '/catalog/products' }, @@ -86,20 +111,12 @@ export class ProductCreateComponent implements OnInit { private queries: GeneralOptionQuery = { search: null, active: null, - limit: null, + per_page: null, + page: null, + manufacturer_id: 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 unitOptions = signal([]); ngOnInit(): void { this.loadInitialOptions(this.queries); @@ -111,9 +128,11 @@ export class ProductCreateComponent implements OnInit { next: (res) => { const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ label: c.label, - value: c.id, - sublabel: c.description, + value: c.value, + sublabel: c.sublabel, icon: c.icon || 'folder', + description: c.description, + disabled: false, })); this.fetchedCategories.set(opts); this.categoryLoading.set(false); @@ -124,10 +143,10 @@ export class ProductCreateComponent implements OnInit { this.manufacturerLoading.set(true); this.manufacturerService.getOptions(query).subscribe({ next: (res) => { - const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ + const opts: Partial[] = (res.data || []).map((m) => ({ label: m.label, - value: m.id, - sublabel: m.description, + value: m.value, + sublabel: m.sublabel, })); this.fetchedManufacturers.set(opts); this.manufacturerLoading.set(false); @@ -137,13 +156,36 @@ export class ProductCreateComponent implements OnInit { this.partOriginService.getAll(this.queries).subscribe({ next: (res) => { - const opts: DSelectOption[] = (res.data || []).map((p) => ({ + const opts: SelectOption[] = (res.data || []).map((p) => ({ label: p.name, value: p.id, + sublabel: p.description, + disabled: false, })); this.fetchedPartOrigins.set(opts); }, }); + + this.warehouseService.getAll(this.queries).subscribe({ + next: (res) => { + console.log('[Warehouses] ', res); + }, + error: (err) => { + console.log('[Error] ', err); + }, + }); + + this.unitSaleService.getOptions(query).subscribe({ + next: (res) => { + const opts: SelectOption[] = (res.data || []).map((u) => ({ + label: u.label, + value: u.value, + sublabel: u.sublabel, + disabled: false, + })); + this.unitOptions.set(opts); + }, + }); } setCreateStep(step: number): void { @@ -222,9 +264,11 @@ export class ProductCreateComponent implements OnInit { next: (res) => { const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ label: c.label, - value: c.id, - sublabel: c.description, + value: c.value, + disabled: false, + sublabel: c.sublabel, icon: c.icon || 'folder', + description: c.description, })); this.fetchedCategories.set(opts); this.categoryLoading.set(false); @@ -239,8 +283,11 @@ export class ProductCreateComponent implements OnInit { next: (res) => { const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ label: m.label, - value: m.id, - sublabel: m.description, + value: m.value, + disabled: false, + sublabel: m.sublabel, + icon: 'folder', + description: '', })); this.fetchedManufacturers.set(opts); this.manufacturerLoading.set(false); diff --git a/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html b/src/app/features/catalog/products/product-form/administration-tab/administration-tab.html similarity index 94% rename from src/app/features/catalog/components/product-form/administration-tab/administration-tab.html rename to src/app/features/catalog/products/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/products/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/products/product-form/administration-tab/administration-tab.ts similarity index 88% rename from src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts rename to src/app/features/catalog/products/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/products/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/commercial-tab/commercial-tab.html b/src/app/features/catalog/products/product-form/commercial-tab/commercial-tab.html similarity index 100% rename from src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.html rename to src/app/features/catalog/products/product-form/commercial-tab/commercial-tab.html diff --git a/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.ts b/src/app/features/catalog/products/product-form/commercial-tab/commercial-tab.ts similarity index 100% rename from src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.ts rename to src/app/features/catalog/products/product-form/commercial-tab/commercial-tab.ts diff --git a/src/app/features/catalog/products/product-form/compatibility-tab/compatibility-tab.html b/src/app/features/catalog/products/product-form/compatibility-tab/compatibility-tab.html new file mode 100644 index 0000000..e260f2b --- /dev/null +++ b/src/app/features/catalog/products/product-form/compatibility-tab/compatibility-tab.html @@ -0,0 +1,573 @@ +
+ + @if (activeTab() === 'oem') { +
+ +
+
+

+ + Códigos OEM Registrados +

+ +

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

+
+ +
+ + + {{ selectedOemCodeIds().length }} de {{ oemTotalItens() }} selecionados + +
+
+ + +
+
+ +
+
+ +
+ + + + @if (oemSearchQuery()) { + + } +
+ + + @if (!isReadOnly()) { +
+ + + +
+ } +
+ + + @if (oemCodes().length > 0) { +
+ + Filtrar por: + + + + + @for (mfr of manufacturers(); track mfr.id) { + + } +
+ } +
+ + +
+ + + + + + + + + + + + + + @for (oem of oemCodes(); track oem.id) { @let selected = isOemSelected(oem.id); + + + + + + + + + + + + + + + + + + } @empty { + + + + } + +
+ Seleção + Código OEMFabricante / MontadoraData RegistroStatus do Vínculo
+ + +
+ + {{ oem.oem_code }} + + + @if (selected) { + + + Selecionado + + } +
+
+ + {{ oem.manufacturer.name || 'Montadora' }} + + + {{ oem.created_at ? (oem.created_at | date: 'dd/MM/yyyy') : '01/08/2026' }} + + @if (selected) { + + + Vinculado ao Produto + + } @else { + + Não Vinculado + + } +
+
+ + +

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

+ + +
+
+
+ + + @if (oemTotalItens() > 0) { +
+ +
+ } +
+ } + + + @if (activeTab() === 'codigos') { +
+
+
+

+ + Códigos do Produto (Product Codes) +

+ +

+ EAN, DUN, Código do fabricante, Código paralelo e Código interno auxiliar +

+
+ + @if (!isReadOnly()) { + + Adicionar Código + + } +
+ +
+ + + + + + + + + + + + @for (codeItem of productCodesPaginated(); track codeItem.id) { + + + + + + + + } @empty { + + + + } + +
Tipo do CódigoCódigoSituaçãoAções
+ {{ codeItem.type }} + + {{ codeItem.code }} + + @if (!isReadOnly()) { + + } +
+ Nenhum código complementar cadastrado. +
+
+ + @if (productCodes().length > 0) { +
+ +
+ } +
+ } + + + @if (activeTab() === 'equivalentes') { +
+
+
+

+ + Produtos Equivalentes +

+ +

+ Relacionamento entre produtos equivalentes e marcas cruzadas +

+
+ + @if (!isReadOnly()) { + + Adicionar Equivalente + + } +
+ +
+ + + + + + + + + + + + @for (eq of equivalentPaginated(); track eq.id) { + + + + + + + + + + } @empty { + + + + } + +
Produto EquivalenteObservaçãoSituaçãoAções
+ {{ eq.product.name }} + + {{ eq.observation || 'Sem observações' }} + + + + @if (!isReadOnly()) { + + } +
+ Nenhum produto equivalente vinculado. +
+
+ + @if (equivalentProducts().length > 0) { +
+ +
+ } +
+ } + + + @if (activeTab() === 'compatibilidade') { +
+
+
+

+ + Compatibilidade Veicular +

+ +

+ Relacionamento entre o produto e suas aplicações veiculares +

+
+ + @if (!isReadOnly()) { + + Adicionar Aplicação Veicular + + } +
+ +
+ + + + + + + + + + + + + + @for (veh of vehiclePaginated(); track veh.id) { + + + + + + + + + + + + + + } @empty { + + + + } + +
Aplicação Veicular (Marca/Modelo/Versão)MotorAnosObservaçõesSituaçãoAções
+ {{ veh.manufacturer.name }} {{ veh.model }} + ({{ veh.model }}) + {{ veh.engine }} + {{ veh.year_from }} - {{ veh.year_to || 'Atual' }} + {{ veh.details || '-' }} + + + @if (!isReadOnly()) { + + } +
+ Nenhuma aplicação veicular vinculada. +
+
+ + @if (vehicleApplications().length > 0) { +
+ +
+ } +
+ } +
diff --git a/src/app/features/catalog/products/product-form/compatibility-tab/compatibility-tab.ts b/src/app/features/catalog/products/product-form/compatibility-tab/compatibility-tab.ts new file mode 100644 index 0000000..4d20f70 --- /dev/null +++ b/src/app/features/catalog/products/product-form/compatibility-tab/compatibility-tab.ts @@ -0,0 +1,253 @@ +import { Component, ChangeDetectionStrategy, computed, input, output, signal } from '@angular/core'; +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 { FormTab } from '../../../models/product-workspace.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'; +import { OemCode } from '../../../../../core/models/oem-codes/oem-codes.model'; +import { DatePipe } from '@angular/common'; +import { GeneralOptionQuery } from '../../../../../core/models/generals/general-option-query.model'; +import { ManufacturerOption } from '../../../../../core/models/manufactureres/manufaturer-options.model'; + +@Component({ + selector: 'app-product-compatibility-tab', + standalone: true, + imports: [ButtonComponent, AppIconComponent, PaginationComponent, DatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './compatibility-tab.html', +}) +export class ProductCompatibilityTabComponent { + readonly activeTab = input.required(); + readonly oemCodes = input([]); + readonly selectedOemCodeIds = input([]); + readonly productCodes = input([]); + readonly equivalentProducts = input([]); + readonly vehicleApplications = input([]); + readonly isReadOnly = input(false); + readonly totalItensOemCodes = input(0); + readonly manufacturers = input([]); + + // Outputs for OEM selection + readonly selectedOemCodeIdsChange = output(); + readonly oemCodeIdsChange = output(); + + // Outputs for other tabs + readonly addProductCode = output(); + readonly removeProductCode = output(); + readonly toggleProductCodeStatus = output(); + + readonly addEquivalent = output(); + readonly toggleEquivalentStatus = output(); + readonly removeEquivalent = output(); + + readonly addVehicleApplication = output(); + readonly toggleVehicleStatus = output(); + readonly removeVehicleApplication = output(); + + // OEM Search & Filter Signals + readonly oemSearchQuery = signal(''); + readonly selectedMfrFilter = signal(null); + readonly oemPagination = signal({ + page: 1, + per_page: 10, + }); + + private emitOemFilters(): void { + this.oemFiltersChange.emit({ + search: this.oemSearchQuery(), + manufacturer_id: this.selectedMfrFilter(), + page: this.oemPagination().page, + per_page: this.oemPagination().per_page, + active: null, + }); + } + + // OEM Outputs pagination + readonly oemFiltersChange = output(); + + readonly addOemCode = output(); + readonly setPrimaryOem = output(); + readonly removeOemCode = output(); + readonly toggleOemStatus = output(); + + readonly oemTotalItens = computed(() => this.totalItensOemCodes()); + + // Helper: check if an OEM code ID is selected + isOemSelected(id: string): boolean { + return this.selectedOemCodeIds().includes(id); + } + + // Toggle selection for a single OEM code + toggleOemSelection(id: string): void { + if (this.isReadOnly()) return; + const current = [...this.selectedOemCodeIds()]; + const index = current.indexOf(id); + let updated: string[]; + + if (index > -1) { + updated = current.filter((item) => item !== id); + } else { + updated = [...current, id]; + } + + this.selectedOemCodeIdsChange.emit(updated); + this.oemCodeIdsChange.emit(updated); + } + + // Select all currently visible OEM codes + selectAllVisibleOem(): void { + if (this.isReadOnly()) return; + const currentSet = new Set(this.selectedOemCodeIds()); + this.oemCodes().forEach((item) => currentSet.add(item.id)); + const updated = Array.from(currentSet); + + this.selectedOemCodeIdsChange.emit(updated); + this.oemCodeIdsChange.emit(updated); + } + + // Deselect all currently visible OEM codes + deselectAllOem(): void { + if (this.isReadOnly()) return; + const visibleIds = new Set(this.oemCodes().map((i) => i.id)); + const updated = this.selectedOemCodeIds().filter((id) => !visibleIds.has(id)); + + this.selectedOemCodeIdsChange.emit(updated); + this.oemCodeIdsChange.emit(updated); + } + + onOemSearchInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + + this.oemSearchQuery.set(value); + + this.oemPagination.update((pagination) => ({ + ...pagination, + page: 1, + })); + + this.emitOemFilters(); + } + + clearOemSearch(): void { + this.oemSearchQuery.set(''); + + this.oemPagination.update((pagination) => ({ + ...pagination, + page: 1, + })); + + this.emitOemFilters(); + } + + setMfrFilter(manufacturerId: string | null): void { + this.selectedMfrFilter.set(manufacturerId); + + this.oemPagination.update((pagination) => ({ + ...pagination, + page: 1, + })); + + this.emitOemFilters(); + } + + onOemPageChange(page: number): void { + this.oemPagination.update((pagination) => ({ + ...pagination, + page, + })); + + this.emitOemFilters(); + } + + onOemPageSizeChange(perPage: number): void { + this.oemPagination.update((pagination) => ({ + ...pagination, + page: 1, + per_page: perPage, + })); + + this.emitOemFilters(); + } + + // Other tabs pagination + readonly productCodesPage = signal(1); + readonly productCodesPageSize = signal(5); + readonly productCodesPaginated = computed(() => { + const list = this.productCodes(); + const start = (this.productCodesPage() - 1) * this.productCodesPageSize(); + return list.slice(start, start + this.productCodesPageSize()); + }); + + readonly equivalentPage = signal(1); + readonly equivalentPageSize = signal(5); + readonly equivalentPaginated = computed(() => { + const list = this.equivalentProducts(); + const start = (this.equivalentPage() - 1) * this.equivalentPageSize(); + return list.slice(start, start + this.equivalentPageSize()); + }); + + readonly vehiclePage = signal(1); + readonly vehiclePageSize = signal(5); + readonly vehiclePaginated = computed(() => { + const list = this.vehicleApplications(); + const start = (this.vehiclePage() - 1) * this.vehiclePageSize(); + return list.slice(start, start + this.vehiclePageSize()); + }); + + // Events for other tabs + onAddProductCode(): void { + this.addProductCode.emit(); + } + + onRemoveProductCode(id: string): void { + this.removeProductCode.emit(id); + } + + onAddEquivalent(): void { + this.addEquivalent.emit(); + } + + onToggleEquivalentStatus(id: string): void { + this.toggleEquivalentStatus.emit(id); + } + + onRemoveEquivalent(id: string): void { + this.removeEquivalent.emit(id); + } + + onAddVehicleApplication(): void { + this.addVehicleApplication.emit(); + } + + onToggleVehicleStatus(id: string): void { + this.toggleVehicleStatus.emit(id); + } + + onRemoveVehicleApplication(id: string): void { + this.removeVehicleApplication.emit(id); + } + + getModule(tab: FormTab) { + return getCompatibilityModuleByTab(tab)!; + } + + onAddOemCode(): void { + this.addOemCode.emit(); + } + + onSetPrimaryOem(id: string): void { + this.setPrimaryOem.emit(id); + } + + onRemoveOemCode(id: string): void { + this.removeOemCode.emit(id); + } + + onToggleOemStatus(id: string): void { + if (this.isReadOnly()) return; + this.toggleOemStatus.emit(id); + } +} diff --git a/src/app/features/catalog/components/product-form/general-tab/general-tab.html b/src/app/features/catalog/products/product-form/general-tab/general-tab.html similarity index 95% rename from src/app/features/catalog/components/product-form/general-tab/general-tab.html rename to src/app/features/catalog/products/product-form/general-tab/general-tab.html index 6afa355..21983aa 100644 --- a/src/app/features/catalog/components/product-form/general-tab/general-tab.html +++ b/src/app/features/catalog/products/product-form/general-tab/general-tab.html @@ -27,9 +27,7 @@ [isCreateMode]="isCreateMode()" [errors]="errors()" [categoryOptions]="options().categoryOptions" - [categoryLoading]="options().categoryLoading" [manufacturerOptions]="options().manufacturerOptions" - [manufacturerLoading]="options().manufacturerLoading" [partOriginOptions]="options().partOriginOptions" [statusOptions]="options().statusOptions" (fieldChange)="onFieldChange($event.field, $event.value)" diff --git a/src/app/features/catalog/components/product-form/general-tab/general-tab.ts b/src/app/features/catalog/products/product-form/general-tab/general-tab.ts similarity index 100% rename from src/app/features/catalog/components/product-form/general-tab/general-tab.ts rename to src/app/features/catalog/products/product-form/general-tab/general-tab.ts diff --git a/src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts b/src/app/features/catalog/products/product-form/general-tab/sections/basic-info-section.ts similarity index 89% rename from src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts rename to src/app/features/catalog/products/product-form/general-tab/sections/basic-info-section.ts index d271cc0..2a53ee1 100644 --- a/src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts +++ b/src/app/features/catalog/products/product-form/general-tab/sections/basic-info-section.ts @@ -30,9 +30,17 @@ import { InputComponent } from '../../../../../../design-system/input/input'; [error]="errors()['name']" (valueChange)="fieldChange.emit({ field: 'name', value: $event })" /> + + - @if (!isCreateMode()) { +

- - -
diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts b/src/app/features/catalog/products/product-form/sidebar/product-workspace-sidebar.ts similarity index 99% rename from src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts rename to src/app/features/catalog/products/product-form/sidebar/product-workspace-sidebar.ts index cc805bb..6eacb9c 100644 --- a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts +++ b/src/app/features/catalog/products/product-form/sidebar/product-workspace-sidebar.ts @@ -66,7 +66,6 @@ export class ProductWorkspaceSidebarComponent implements AfterViewInit { } setActiveTab(tab: FormTab, event?: MouseEvent): void { - console.log('[setActiveTab]', tab); this.tabChange.emit(tab); if (event?.currentTarget) { diff --git a/src/app/features/catalog/components/product-form/stepper/product-create-stepper.html b/src/app/features/catalog/products/product-form/stepper/product-create-stepper.html similarity index 100% rename from src/app/features/catalog/components/product-form/stepper/product-create-stepper.html rename to src/app/features/catalog/products/product-form/stepper/product-create-stepper.html diff --git a/src/app/features/catalog/components/product-form/stepper/product-create-stepper.ts b/src/app/features/catalog/products/product-form/stepper/product-create-stepper.ts similarity index 100% rename from src/app/features/catalog/components/product-form/stepper/product-create-stepper.ts rename to src/app/features/catalog/products/product-form/stepper/product-create-stepper.ts diff --git a/src/app/features/catalog/product-workspace/product-workspace.css b/src/app/features/catalog/products/product-workspace/product-workspace.css similarity index 100% rename from src/app/features/catalog/product-workspace/product-workspace.css rename to src/app/features/catalog/products/product-workspace/product-workspace.css diff --git a/src/app/features/catalog/product-workspace/product-workspace.html b/src/app/features/catalog/products/product-workspace/product-workspace.html similarity index 77% rename from src/app/features/catalog/product-workspace/product-workspace.html rename to src/app/features/catalog/products/product-workspace/product-workspace.html index 5cba9fe..9d8b1c7 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.html +++ b/src/app/features/catalog/products/product-workspace/product-workspace.html @@ -67,18 +67,18 @@

} @if (isCompatibilityTab()) { } - diff --git a/src/app/features/catalog/products/product-workspace/product-workspace.ts b/src/app/features/catalog/products/product-workspace/product-workspace.ts new file mode 100644 index 0000000..7fdd5ba --- /dev/null +++ b/src/app/features/catalog/products/product-workspace/product-workspace.ts @@ -0,0 +1,963 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { Location } from '@angular/common'; +import { PageHeaderComponent } from '../../../../design-system/page-header/page-header'; +import { ButtonComponent } from '../../../../design-system/button/button'; +import { ConfirmDialogComponent } from '../../../../design-system/dialog/confirm-dialog'; +import { AppIconComponent } from '../../../../design-system/icon/app-icon'; +import { ProductGeneralTabComponent } from '../product-form/general-tab/general-tab'; +import { ProductCommercialTabComponent } from '../product-form/commercial-tab/commercial-tab'; +import { ProductInventoryTabComponent } from '../product-form/inventory-tab/inventory-tab'; +import { ProductCompatibilityTabComponent } from '../product-form/compatibility-tab/compatibility-tab'; +import { ProductWorkspaceSidebarComponent } from '../product-form/sidebar/product-workspace-sidebar'; +import { ShellStateService } from '../../../../core/services/shell-state'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ProductService } from '../../../../core/services/product-service'; +import { ToastService } from '../../../../core/services/toast'; +import { GeneralOptionQuery } from '../../../../core/models/generals/general-option-query.model'; +import { ProductResponse } from '../../../../core/models/products/product-response.model'; +import { + UpdateProductPayload, + mapResponseToPayload, + defaultUpdatePayload, + UpdateProductInventoryPayload, +} 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 { initialProductPayload } from '../../../../core/utils/product-initial-payload'; +import { OemCodeService } from '../../../../core/services/code-oem-service'; +import { ManufacturerOption } from '../../../../core/models/manufactureres/manufaturer-options.model'; +import { OemCode } from '../../../../core/models/oem-codes/oem-codes.model'; +import { ProductByIdService } from '../../../../core/services/product-by-id-service'; +import { StatusStore } from '../../../../core/store/status-store/status-store'; +import { CategoryStore } from '../../../../core/store/category-store/category-store'; +import { UnitSaleStore } from '../../../../core/store/unit-sale/unit-sale-store'; +import { WarehouseStore } from '../../../../core/store/warehouse/warehouse-store'; +import { ManufacturerStore } from '../../../../core/store/manufacturer-store/manufacturer-store'; +import { PartOriginStore } from '../../../../core/store/part-origin/part-origin-store'; + +@Component({ + selector: 'app-product-workspace', + standalone: true, + imports: [ + PageHeaderComponent, + ButtonComponent, + ConfirmDialogComponent, + AppIconComponent, + ProductGeneralTabComponent, + ProductCommercialTabComponent, + ProductInventoryTabComponent, + ProductCompatibilityTabComponent, + ProductWorkspaceSidebarComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './product-workspace.html', + styleUrl: './product-workspace.css', +}) +export class ProductWorkspaceComponent implements OnInit { + private router = inject(Router); + private location = inject(Location); + private route = inject(ActivatedRoute); + private toastService = inject(ToastService); + readonly shellState = inject(ShellStateService); + private productService = inject(ProductService); + private oemCodeService = inject(OemCodeService); + private productByIdService = inject(ProductByIdService); + + readonly statusStore = inject(StatusStore); + readonly categoryStore = inject(CategoryStore); + readonly unitSaleStore = inject(UnitSaleStore); + readonly warehouseStore = inject(WarehouseStore); + readonly partOriginStore = inject(PartOriginStore); + readonly manufacturerStore = inject(ManufacturerStore); + + readonly activeTab = signal('geral'); + readonly mode = signal('edit'); + + readonly isSaving = signal(false); + readonly isDragging = signal(false); + readonly isFormDirty = signal(false); + readonly deleteImageDialogOpen = signal(false); + readonly deleteProductDialogOpen = signal(false); + readonly unsavedChangesDialogOpen = signal(false); + readonly productCreatedSuccessBanner = signal(false); + + readonly oemCodeIds = signal([]); + readonly errors = signal>({}); + + readonly manufacturers = signal([]); + readonly manufacturersByProduct = signal([]); + readonly selectedImageForDelete = signal(null); + + readonly modifiedTabs = signal>(new Set()); + + readonly product = signal(initialProductPayload); + readonly productForm = signal(defaultUpdatePayload()); + + 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.unitSaleStore.optionList(), + statusOptions: this.statusStore.optionList(), + categoryOptions: this.categoryStore.optionList(), + warehouseOptions: this.warehouseStore.optionList(), + partOriginOptions: this.partOriginStore.optionList(), + manufacturerOptions: this.manufacturerStore.optionList(), + })); + + readonly isReadOnly = computed(() => this.mode() === 'view'); + + readonly isCompatibilityTab = computed(() => isCompatibilityWorkspaceTab(this.activeTab())); + + readonly headerTitle = computed(() => + this.mode() === 'edit' ? 'Editar Produto' : 'Visualizar Produto', + ); + + readonly breadcrumbs = computed(() => [ + { label: 'Catálogo', route: '/catalog/products' }, + { label: 'Produtos', route: '/catalog/products' }, + { label: this.headerTitle() }, + ]); + + private queries: GeneralOptionQuery = { + search: null, + active: null, + per_page: null, + page: null, + manufacturer_id: null, + }; + + readonly availableTags = []; + + ngOnInit(): void { + const url = this.router.url; + const id = this.route.snapshot.paramMap.get('id'); + const state = this.location.getState() as any; + + this.productCreatedSuccessBanner.set(!!state?.['productJustCreated']); + + setTimeout(() => { + this.queries.per_page = 10; + this.queries.page = 1; + this.getOemCodeByManufacturer(this.queries); + }, 100); + + if (id) { + this.mode.set(url.includes('/view') ? 'view' : 'edit'); + this.productById(id); + } + } + + getOemCodeByManufacturer(query: GeneralOptionQuery) { + this.oemCodeService.getByManufacturer(query).subscribe({ + next: (response) => { + const codes = response.data || []; + this.manufacturersByProduct.set(codes); + }, + error: (err) => { + this.manufacturersByProduct.set([]); + this.toastService.error('Erro', err.error.data.messsage); + }, + }); + } + + productById(productId: string): void { + this.productByIdService.getProduct(productId).subscribe({ + next: (prodData) => { + this.oemCodeIds.set(prodData.oem_codes.map((o) => o.id)); + this.product.set(prodData); + this.productForm.set(mapResponseToPayload(prodData)); + }, + error: (error) => { + console.error('Error loading product:', error); + this.toastService.error('Erro', 'Não foi possível carregar os dados do produto.'); + }, + }); + } + + setActiveTab(tab: FormTab): void { + this.activeTab.set(tab); + } + + closeSuccessBanner(): void { + this.productCreatedSuccessBanner.set(false); + } + + hasTabError(tab: FormTab): boolean { + const errs = this.errors(); + if (tab === 'geral') { + return !!( + errs['name'] || + errs['internal_code'] || + errs['category_id'] || + errs['manufacturer_id'] || + errs['unit_id'] + ); + } + if (tab === 'comercial') { + return !!( + errs['price'] || + errs['promotional_price'] || + errs['promotion_start_date'] || + errs['promotion_end_date'] + ); + } + if (tab === 'inventario') { + return !!( + errs['warehouse_id'] || + errs['quantity'] || + errs['min_quantity'] || + errs['max_quantity'] + ); + } + return false; + } + + private markCurrentTabAsModified(): void { + this.isFormDirty.set(true); + this.modifiedTabs.update((tabs) => { + const newTabs = new Set(tabs); + newTabs.add(this.activeTab()); + return newTabs; + }); + } + + updateField(field: string, val: string): void { + this.markCurrentTabAsModified(); + const fieldMapping: Record = { + name: 'name', + internal_code: 'internal_code', + barcode: 'barcode', + category_id: 'category_id', + manufacturer_id: 'manufacturer_id', + part_origin_id: 'part_origin_id', + unit_id: 'unit_id', + status_id: 'status_id', + slug: 'slug', + short_description: 'short_description', + }; + const key = fieldMapping[field]; + if (key) { + this.productForm.update((p) => ({ ...p, [key]: val })); + } + + if (this.errors()[field]) { + this.errors.update((e) => { + const copy = { ...e }; + delete copy[field]; + return copy; + }); + } + } + + updateBooleanField(field: string, val: boolean): void { + this.markCurrentTabAsModified(); + if (field === 'active') { + this.productForm.update((p) => ({ ...p, active: val })); + } else if (field === 'featured') { + this.productForm.update((p) => ({ ...p, featured: val })); + } + } + + updateNumberField(field: string, valStr: string): void { + this.markCurrentTabAsModified(); + const num = parseFloat(valStr) || 0; + const fieldMapping: Record = { + weight: 'weight', + height: 'height', + width: 'width', + length: 'length', + }; + 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]) { + this.errors.update((e) => { + const copy = { ...e }; + delete copy[field]; + return copy; + }); + } + } + + updateNullableNumberField(field: string, valStr: string): void { + this.markCurrentTabAsModified(); + const val = valStr.trim() === '' ? null : parseFloat(valStr); + if (field === 'promotional_price') { + this.productForm.update((p) => ({ + ...p, + price: { + ...(p.price || {}), + promotional_price: val, + }, + })); + } else if (field === 'max_quantity') { + 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, + }, + ], + }; + }); + console.log('[this.productForm()] ', this.productForm()); + } + + updateDescription(val: string): void { + this.markCurrentTabAsModified(); + this.productForm.update((p) => ({ ...p, description: val })); + } + + toggleIsInvoiced(): void { + // Não suportado diretamente no payload, mas mantido se necessário localmente. + } + + onInventoriesChange(inventories: UpdateProductInventoryPayload[]): void { + this.markCurrentTabAsModified(); + this.productForm.update((p) => ({ ...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.markCurrentTabAsModified(); + 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 newId = 'oem-' + Date.now(); + + this.product.update((p) => ({ + ...p, + oem_codes: p.oem_codes, + })); + + this.productForm.update((pf) => ({ + ...pf, + oem_code_ids: [newId, ...(pf.oem_code_ids || this.product().oem_codes.map((o) => o.id))], + })); + + this.markCurrentTabAsModified(); + } + + setPrimaryOem(id: string): void { + this.product.update((p) => ({ + ...p, + oem_codes: p.oem_codes.map((o) => ({ ...o, is_primary: o.id === id })), + })); + this.markCurrentTabAsModified(); + } + + removeOemCode(id: string): void { + this.product.update((p) => ({ + ...p, + 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.markCurrentTabAsModified(); + } + + toggleOemStatus(id: string): void { + 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'; + + const newId = 'pc-' + Date.now(); + const newCode = { id: newId, type, code, active: true }; + + this.product.update((p) => ({ + ...p, + 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.markCurrentTabAsModified(); + } + + removeProductCode(id: string): void { + this.product.update((p) => ({ + ...p, + 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.markCurrentTabAsModified(); + } + + toggleProductCodeStatus(id: string): void { + 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') || ''; + + 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, + 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.markCurrentTabAsModified(); + } + + removeEquivalent(id: string): void { + this.product.update((p) => ({ + ...p, + 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.markCurrentTabAsModified(); + } + + toggleEquivalentStatus(id: string): void { + 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'; + + 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, + applications: [...p.applications, newApplication], + })); + + this.productForm.update((pf) => ({ + ...pf, + application_ids: [ + ...(pf.application_ids || this.product().applications.map((a) => a.id)), + newId, + ], + })); + + this.markCurrentTabAsModified(); + } + + removeVehicleApplication(id: string): void { + this.product.update((p) => ({ + ...p, + 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.markCurrentTabAsModified(); + } + + toggleVehicleStatus(id: string): void { + console.log(id); + // No backend + } + + onDragOver(e: DragEvent): void { + e.preventDefault(); + this.isDragging.set(true); + } + + onDragLeave(e: DragEvent): void { + e.preventDefault(); + this.isDragging.set(false); + } + + onFileDrop(e: DragEvent): void { + e.preventDefault(); + this.isDragging.set(false); + if (e.dataTransfer?.files) { + this.handleFiles(Array.from(e.dataTransfer.files)); + } + } + + onFileSelected(e: Event): void { + const input = e.target as HTMLInputElement; + if (input.files) { + this.handleFiles(Array.from(input.files)); + } + } + + handleFiles(files: File[]): void { + console.log(files); + // Media handling + } + + confirmDeleteImage(img: MediaImageItem): void { + this.selectedImageForDelete.set(img); + this.deleteImageDialogOpen.set(true); + } + + executeDeleteImage(): void { + this.deleteImageDialogOpen.set(false); + } + + openAddSupplierModal(): void { + const name = prompt('Nome do Fornecedor:'); + if (!name) return; + const code = prompt('Código no Fornecedor:', 'SUP-01') || 'SUP-01'; + + 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, newSupplier], + })); + + this.productForm.update((pf) => ({ + ...pf, + supplier_ids: [...(pf.supplier_ids || this.product().suppliers.map((s) => s.id)), newId], + })); + + this.markCurrentTabAsModified(); + } + + removeSupplier(id: string): void { + 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.markCurrentTabAsModified(); + } + + setPreferentialSupplier(id: string): void { + this.product.update((p) => ({ + ...p, + suppliers: p.suppliers.map((s) => ({ + ...s, + preferred: s.id === id, + })), + })); + this.markCurrentTabAsModified(); + } + + toggleSupplierStatus(id: string): void { + console.log(id); + // No backend + } + + toggleTag(tagId: string): void { + if (this.isReadOnly()) return; + this.markCurrentTabAsModified(); + + this.product.update((p) => { + const exists = p.tags.some((t) => t.id === tagId); + return { + ...p, + tags: exists + ? p.tags.filter((t) => t.id !== tagId) + : [...p.tags, { id: tagId, name: '' } as any], + }; + }); + + 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], + }; + }); + } + + // openAddNoteModal(): void {} + + // removeNote(id: string): void {} + + // toggleNoteStatus(id: string): void {} + + onCategorySearch(query: GeneralOptionQuery): void { + this.categoryStore.loadOptions(query); + } + + onManufacturerSearch(query: GeneralOptionQuery): void { + this.manufacturerStore.loadOptions(query); + } + + // onCategorySearch(query: GeneralOptionQuery): void { + // this.categoryLoading.set(true); + // this.categoryStore.loadOptions(query).subscribe({ + // next: (res) => { + // const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ + // label: c.label, + // value: c.id, + // sublabel: c.description, + // icon: c.icon || 'folder', + // })); + // this.fetchedCategories.set(opts); + // this.categoryLoading.set(false); + // }, + // error: () => this.categoryLoading.set(false), + // }); + // } + + // onManufacturerSearch(query: GeneralOptionQuery): void { + // this.manufacturerLoading.set(true); + // this.manufacturerService.getOptions(query).subscribe({ + // next: (res) => { + // const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ + // label: m.label, + // value: m.id, + // sublabel: m.description, + // })); + // this.fetchedManufacturers.set(opts); + // this.manufacturerLoading.set(false); + // }, + // error: () => this.manufacturerLoading.set(false), + // }); + // } + + validateForm(): boolean { + const errs: Record = {}; + const p = this.productForm(); + + if (!p.name?.trim()) { + errs['name'] = 'O nome do produto é obrigatório.'; + } + if (!p.internal_code?.trim()) { + errs['internal_code'] = 'O código interno é obrigatório.'; + } + if (!p.category_id) { + errs['category_id'] = 'Selecione uma categoria.'; + } + if (!p.manufacturer_id) { + errs['manufacturer_id'] = 'Selecione um fabricante.'; + } + if (!p.unit_id) { + errs['unit_id'] = 'Selecione a unidade de medida.'; + } + + this.errors.set(errs); + + if (Object.keys(errs).length > 0) { + if (this.hasTabError('geral')) { + this.activeTab.set('geral'); + } + return false; + } + + return true; + } + + saveProduct(continueEditing: boolean): void { + if (!this.validateForm()) { + this.toastService.error( + 'Não foi possível salvar o produto.', + 'Verifique os campos obrigatórios destacados.', + ); + return; + } + + this.isSaving.set(true); + const id = this.route.snapshot.paramMap.get('id'); + const fullPayload = this.productForm(); + const modified = this.modifiedTabs(); + + const payload: Partial = {}; + + if ( + modified.has('geral') || + modified.has('classification') || + modified.has('logistics') || + modified.has('visibility') + ) { + if (fullPayload.name !== undefined) payload.name = fullPayload.name; + if (fullPayload.slug !== undefined) payload.slug = fullPayload.slug; + if (fullPayload.short_description !== undefined) + payload.short_description = fullPayload.short_description; + if (fullPayload.description !== undefined) payload.description = fullPayload.description; + if (fullPayload.category_id !== undefined) payload.category_id = fullPayload.category_id; + if (fullPayload.manufacturer_id !== undefined) + payload.manufacturer_id = fullPayload.manufacturer_id; + if (fullPayload.part_origin_id !== undefined) + payload.part_origin_id = fullPayload.part_origin_id; + if (fullPayload.status_id !== undefined) payload.status_id = fullPayload.status_id; + if (fullPayload.internal_code !== undefined) + payload.internal_code = fullPayload.internal_code; + if (fullPayload.barcode !== undefined) payload.barcode = fullPayload.barcode; + if (fullPayload.unit_id !== undefined) payload.unit_id = fullPayload.unit_id; + if (fullPayload.weight !== undefined) payload.weight = fullPayload.weight; + if (fullPayload.height !== undefined) payload.height = fullPayload.height; + if (fullPayload.width !== undefined) payload.width = fullPayload.width; + if (fullPayload.length !== undefined) payload.length = fullPayload.length; + if (fullPayload.featured !== undefined) payload.featured = fullPayload.featured; + if (fullPayload.active !== undefined) payload.active = fullPayload.active; + } + + if (modified.has('comercial')) { + if (fullPayload.price !== undefined) payload.price = fullPayload.price; + } + + if (modified.has('inventario')) { + if (fullPayload.inventories !== undefined) payload.inventories = fullPayload.inventories; + } + + if (modified.has('oem')) { + if (fullPayload.oem_code_ids !== undefined) payload.oem_code_ids = fullPayload.oem_code_ids; + } + + if (modified.has('codigos')) { + if (fullPayload.product_code_ids !== undefined) + payload.product_code_ids = fullPayload.product_code_ids; + } + + if (modified.has('equivalentes')) { + if (fullPayload.equivalent_product_ids !== undefined) + payload.equivalent_product_ids = fullPayload.equivalent_product_ids; + } + + if (modified.has('compatibilidade')) { + // Veículos/Aplicações + if (fullPayload.application_ids !== undefined) + payload.application_ids = fullPayload.application_ids; + } + + if (modified.has('fornecimento')) { + if (fullPayload.supplier_ids !== undefined) payload.supplier_ids = fullPayload.supplier_ids; + } + + if (modified.has('tags')) { + if (fullPayload.tag_ids !== undefined) payload.tag_ids = fullPayload.tag_ids; + } + + // console.log('[SAVE PRODUCT UPDATE]', payload); + // return; + + this.productService.update(id!, payload as UpdateProductPayload).subscribe({ + next: (response) => { + this.isSaving.set(false); + this.isFormDirty.set(false); + this.modifiedTabs.set(new Set()); + this.toastService.success(response.message, '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']); + } + }, + error: (err) => { + this.isSaving.set(false); + console.error('Error saving product:', err); + this.toastService.error( + 'Erro ao salvar o produto', + err.error?.message || err.message || 'Ocorreu um erro inesperado no servidor.', + ); + }, + }); + } + + onCancel(): void { + if (this.isFormDirty()) { + this.unsavedChangesDialogOpen.set(true); + } else { + this.navigateBack(); + } + } + + navigateBack(): void { + this.router.navigate(['/catalog/products']); + } + + executeExitWithoutSave(): void { + this.unsavedChangesDialogOpen.set(false); + this.navigateBack(); + } + + switchToEdit(): void { + const id = this.route.snapshot.paramMap.get('id') || '1'; + this.router.navigate(['/catalog/products', id, 'edit']); + this.mode.set('edit'); + } + + confirmDeleteProduct(): void { + this.deleteProductDialogOpen.set(true); + } + + onOemFiltersChange(filters: GeneralOptionQuery): void { + console.log('filters', filters); + this.getOemCodeByManufacturer(filters); + } + + executeDeleteProduct(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.productService.delete(id).subscribe({ + next: () => { + this.deleteProductDialogOpen.set(false); + this.toastService.success( + 'Produto Excluído', + 'O produto foi removido do catálogo com sucesso.', + ); + this.router.navigate(['/catalog/products']); + }, + error: () => { + this.deleteProductDialogOpen.set(false); + this.toastService.error('Erro', 'Não foi possível excluir o produto.'); + }, + }); + } else { + this.deleteProductDialogOpen.set(false); + this.navigateBack(); + } + } +} diff --git a/src/app/features/catalog/products/products.html b/src/app/features/catalog/products/products.html index dce4c1a..3a0aa84 100644 --- a/src/app/features/catalog/products/products.html +++ b/src/app/features/catalog/products/products.html @@ -88,25 +88,14 @@

184

diff --git a/src/app/utils/pagination-initial-data.ts b/src/app/utils/pagination-initial-data.ts new file mode 100644 index 0000000..c754742 --- /dev/null +++ b/src/app/utils/pagination-initial-data.ts @@ -0,0 +1,23 @@ +import { PaginatedResponse } from '../core/models/pagination/pagination.model'; + +export const paginationInitialData = (): PaginatedResponse => ({ + success: true, + message: '', + data: [], + meta: { + current_page: 1, + from: 1, + last_page: 1, + links: [], + path: '', + per_page: 20, + to: 1, + total: 1, + }, + links: { + first: '', + last: '', + prev: null, + next: null, + }, +}); 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',