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 index c01d660..644be4e 100644 --- a/src/app/core/models/additional-codes/additional-codes-product.model.ts +++ b/src/app/core/models/additional-codes/additional-codes-product.model.ts @@ -2,4 +2,5 @@ export interface ProductAdditionalCodeSummary { id: string; code: string; active: boolean; + type: string; // remover possivelmente } diff --git a/src/app/core/models/catetories/category-options.model.ts b/src/app/core/models/catetories/category-options.model.ts index b4d6616..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 index 58799a6..39d8c6f 100644 --- a/src/app/core/models/equivalent/equivalent-products.model.ts +++ b/src/app/core/models/equivalent/equivalent-products.model.ts @@ -5,4 +5,5 @@ export interface ProductEquivalent { observation: string | null; product: EntityReference; equivalent_product: EntityReference; + status?: boolean; // remover após produtos equivalentes } diff --git a/src/app/core/models/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/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/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/oem-codes/oem-codes-product.model.ts b/src/app/core/models/oem-codes/oem-codes-product.model.ts index f74ae8e..97a5ef4 100644 --- a/src/app/core/models/oem-codes/oem-codes-product.model.ts +++ b/src/app/core/models/oem-codes/oem-codes-product.model.ts @@ -3,9 +3,6 @@ import { EntityReference } from '../generals/entity-reference'; export interface ProductOemCode { id: string; oem_code: string; - manufacturer?: EntityReference; - product?: EntityReference; - created_at: string; - updated_at: string; - is_primary: boolean; + manufacturer: EntityReference; + product: EntityReference; } diff --git a/src/app/core/models/oem-codes/oem-codes-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-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/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-response.model.ts b/src/app/core/models/products/product-response.model.ts index 1e065c5..e71e142 100644 --- a/src/app/core/models/products/product-response.model.ts +++ b/src/app/core/models/products/product-response.model.ts @@ -1,7 +1,6 @@ import { ProductVehicleApplication } from '../vehicle-application/vehicle-application-product.model'; import { ProductEquivalent } from '../equivalent/equivalent-products.model'; import { EntityReference } from '../generals/entity-reference'; -import { ProductOemCode } from '../oem-codes/oem-codes-product.model'; import { ProductStatus } from '../status/status-product.model'; import { ProductSupplier } from '../suppliers/suppliers-product.model'; import { ProductPricing } from '../pricing/pricing-product-model'; @@ -10,6 +9,7 @@ import { ProductUnitSale } from '../units-sale/unit-sale-product.model'; import { ProductPartOrigin } from '../part-origin/part-origin-product.model'; import { ProductAdditionalCodeSummary } from '../additional-codes/additional-codes-product.model'; import { ProductTag } from '../tags/tags-product.model'; +import { OemCode } from '../oem-codes/oem-codes.model'; export interface ProductSummaryResponse { id: string; @@ -56,7 +56,7 @@ export interface ProductResponse extends ProductSummaryResponse { part_origin: ProductPartOrigin | null; tags: ProductTag[]; - oem_codes: ProductOemCode[]; + oem_codes: OemCode[]; suppliers: ProductSupplier[]; equivalents: ProductEquivalent[]; applications: ProductVehicleApplication[]; diff --git a/src/app/core/models/products/product-summary-response.ts b/src/app/core/models/products/product-summary-response.ts new file mode 100644 index 0000000..caad5e6 --- /dev/null +++ b/src/app/core/models/products/product-summary-response.ts @@ -0,0 +1,6 @@ +export interface ProductSummaryResponse { + id: string; + name: string; + slug: string; + internal_code: string; +} diff --git a/src/app/core/models/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-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/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 index 6c9a78f..4487b5c 100644 --- a/src/app/core/models/vehicle-application/vehicle-application-product.model.ts +++ b/src/app/core/models/vehicle-application/vehicle-application-product.model.ts @@ -10,4 +10,5 @@ export interface ProductVehicleApplication { details: string | null; created_at?: string; updated_at?: string; + status?: boolean; // remover possivelmente } diff --git a/src/app/core/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 18c6594..e92dac4 100644 --- a/src/app/core/services/category-service.ts +++ b/src/app/core/services/category-service.ts @@ -30,4 +30,16 @@ export class CategoryService { 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/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/warehouse-service.ts b/src/app/core/services/warehouse-service.ts index 4f1dd85..a1f14bd 100644 --- a/src/app/core/services/warehouse-service.ts +++ b/src/app/core/services/warehouse-service.ts @@ -4,7 +4,9 @@ import { environment } from '../../../environments/environment'; import { PaginatedResponse } from '../models/pagination/pagination.model'; import { buildHttpParams } from './build-http-params'; import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; -import { WarehouseOptionsResponse } from '../models/warehouses/warehouse-options.model'; +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', @@ -16,7 +18,14 @@ export class WarehouseService { getAll(filters: GeneralOptionQuery) { const params = buildHttpParams(filters); - return this.http.get>(`${this.api}/${this.flag}`, { + 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, }); } diff --git a/src/app/core/services/warehouses-service.ts b/src/app/core/services/warehouses-service.ts deleted file mode 100644 index 93638cc..0000000 --- a/src/app/core/services/warehouses-service.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 { buildHttpParams } from './build-http-params'; - -@Injectable({ - providedIn: 'root', -}) -export class WarehousesService { - private http = inject(HttpClient); - private api = environment.apiUrl; - private flag = 'warehouses'; - - 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 }); - } -} 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/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 80257ab..0000000 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html +++ /dev/null @@ -1,398 +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?.name }} - {{ oem.oem_code }} - - @if (oem.is_primary) { - - - Principal - - } @else if (!isReadOnly()) { - - } @else { - - - } - - - Ativo - - - @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.code }} - - {{ codeItem.code }} - - - Ativo - - - @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.equivalent_product.name }} - - {{ eq.observation || 'Sem observações' }} - - - Ativo - - - @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.manufacturer.name }} {{ veh.model }} - ({{ veh.details }}) - {{ veh.engine }} - {{ veh.year_from }} - {{ veh.year_to || 'Atual' }} - {{ veh.details || '-' }} - - Ativo - - - @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 82c7755..0000000 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts +++ /dev/null @@ -1,120 +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 { FormTab } from '../../../models/product-workspace.model'; -import { ProductOemCode } from '../../../../../core/models/oem-codes/oem-codes-product.model'; -import { ProductAdditionalCodeSummary } from '../../../../../core/models/additional-codes/additional-codes-product.model'; -import { ProductEquivalent } from '../../../../../core/models/equivalent/equivalent-products.model'; -import { ProductVehicleApplication } from '../../../../../core/models/vehicle-application/vehicle-application-product.model'; - -@Component({ - selector: 'app-product-compatibility-tab', - 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[] { - return items.slice((page - 1) * pageSize, page * pageSize); - } -} 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 67% rename from src/app/features/catalog/product-create/product-create.ts rename to src/app/features/catalog/products/product-create/product-create.ts index 7ac6db8..80eac71 100644 --- a/src/app/features/catalog/product-create/product-create.ts +++ b/src/app/features/catalog/products/product-create/product-create.ts @@ -7,28 +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 { WarehouseService } from '../../../core/services/warehouse-service'; -import { ProductService } from '../../../core/services/product-service'; -import { ToastService } from '../../../core/services/toast'; -import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; -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'; -import { PartOriginService } from '../../../core/services/part-origins-service'; -import { WarehouseOption } from '../../../core/models/warehouses/warehouse-options.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', @@ -46,6 +52,7 @@ import { WarehouseOption } from '../../../core/models/warehouses/warehouse-optio export class ProductCreateComponent implements OnInit { private router = inject(Router); private productService = inject(ProductService); + private unitSaleService = inject(UnitSaleService); private categoryService = inject(CategoryService); private warehouseService = inject(WarehouseService); private partOriginService = inject(PartOriginService); @@ -63,24 +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, - warehouseOptions: [], - 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' }, @@ -90,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); @@ -115,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); @@ -128,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); @@ -141,9 +156,11 @@ 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); }, @@ -157,6 +174,18 @@ export class ProductCreateComponent implements OnInit { 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 { @@ -235,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); @@ -252,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 100% 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 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 100% 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 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()) { +
- - -
+
{{ totalPhysical() }} un. @@ -54,6 +57,7 @@

Reservado

+
{{ totalReserved() }} un. @@ -68,6 +72,7 @@

Disponível

+
{{ totalAvailable() }} un. @@ -82,8 +87,10 @@

Depósitos

+
{{ activeWarehousesCount() }} + de {{ localInventories().length }} ativos @@ -91,7 +98,7 @@

@@ -99,6 +106,7 @@

Regra Comercial do Produto: + @if (anyBackorderAllowed()) { +
Depósitos & Saldo Físico Inline ({{ localInventories().length }}) + @@ -150,37 +160,95 @@

- @for (header of tableHeaders; track header.key) { @if (!isReadOnly() || - header.showInReadOnly) { + + + Depósito + + + + + Saldo Físico (un) + + + + + Reservado + + + + + Disponível + + + + + Est. Mínimo + + + + + Est. Máximo + + + + + Backorder + + + + + Status + + + + @if (!isReadOnly()) { - {{ header.label }} + Ações - } } + } + - @for (item of localInventories(); track item.id || $index; let idx = $index) { @let + @for ( item of localInventories(); track item.id || $index; let idx = $index ) { @let levelBadge = getStockLevelBadge(item); + - + + + @let itemWhOptions = getWarehouseOptionsForItem(item); @if (!isReadOnly() && itemWhOptions.length > 0) {
- +
+ - + + un.
@@ -262,33 +347,43 @@

- {{ $any(item).reservedQuantity || 0 }} + {{ $any(item).reservedQuantity || item.reserved_quantity || 0 }} - + + + - {{ (item.quantity || 0) - ($any(item).reservedQuantity || 0) }} + {{ (item.quantity || 0) - ($any(item).reservedQuantity || item.reserved_quantity || + 0) }} - - + + + + @if (!isReadOnly()) { -
+
- + + un.
@@ -299,19 +394,29 @@

+ + + + @if (!isReadOnly()) { -
+
- + + un.
@@ -323,36 +428,59 @@

- + + + - + + + @if (!isReadOnly()) {

+

Adicione um depósito para registrar o saldo físico inicial e definir os limites de segurança diretamente na tela. @@ -399,4 +528,63 @@

}

} + + + + + +
+
+ + + Regras e Estrutura de Estoque Multi-Depósito (tbl_inventory) +
+ +
+ +
+ + Chave Única + + + + UNIQUE(product_id, warehouse_id) + +
+ + +
+ + Cálculo de Saldo Comercial + + + + Soma de (quantidade - reservado) dos depósitos ativos + +
+ + +
+ + Status Comercial + + + + Avaliado globalmente para o produto + +
+
+

diff --git a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts b/src/app/features/catalog/products/product-form/inventory-tab/inventory-tab.ts similarity index 52% rename from src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts rename to src/app/features/catalog/products/product-form/inventory-tab/inventory-tab.ts index a3521e5..0ca7147 100644 --- a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts +++ b/src/app/features/catalog/products/product-form/inventory-tab/inventory-tab.ts @@ -8,11 +8,10 @@ import { effect, untracked, } from '@angular/core'; + import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; -import { DSelectOption } from '../../../../../core/models/design-system/select-option.model'; +import { SelectOption } from '../../../../../core/models/design-system/select-option.model'; import { ButtonComponent } from '../../../../../design-system/button/button'; -import { TableHeaderOption } from '../../../../../core/models/generals/table-inventory.model'; -import { tableHeaders } from './utils/table-values'; import { UpdateProductInventoryPayload } from '../../../../../core/models/products/product-request.model'; export type LocalInventoryItem = UpdateProductInventoryPayload & { @@ -27,24 +26,55 @@ export type LocalInventoryItem = UpdateProductInventoryPayload & { templateUrl: './inventory-tab.html', }) export class ProductInventoryTabComponent { - readonly fieldChange = output<{ field: string; value: string }>(); - readonly numberFieldChange = output<{ field: string; value: string }>(); - readonly nullableNumberFieldChange = output<{ field: string; value: string }>(); - readonly inventories = input([]); - readonly warehouseOptions = input([]); + readonly warehouseOptions = input([]); readonly isReadOnly = input(false); readonly errors = input>({}); readonly inventoriesChange = output(); - private removedItemsCache = new Map>(); - readonly tableHeaders: TableHeaderOption[] = tableHeaders; - - // Estado Local ÚNICO para a Interface + /** + * Mantém o estado anterior de um depósito removido. + * Quando ele for selecionado novamente, seus dados podem ser restaurados. + */ + private readonly removedItemsCache = new Map>(); + + /** + * Estado local usado pela interface. + * + * Mantido para preservar o comportamento da primeira versão, + * permitindo edição inline sem depender exclusivamente do ciclo + * de atualização do componente pai. + */ readonly localInventories = signal([]); - // Computeds apontando estritamente para o localInventories + readonly fieldChange = output<{ field: string; value: string }>(); + + readonly numberFieldChange = output<{ + field: string; + value: string; + }>(); + + readonly nullableNumberFieldChange = output<{ + field: string; + value: string; + }>(); + + constructor() { + effect(() => { + const incoming = this.inventories() || []; + untracked(() => { + this.localInventories.set(incoming); + }); + }); + } + + /** + * ============================== + * AGGREGATIONS + * ============================== + */ + readonly totalPhysical = computed(() => this.localInventories().reduce((sum, item) => sum + (Number(item.quantity) || 0), 0), ); @@ -69,202 +99,357 @@ export class ProductInventoryTabComponent { ); readonly anyBackorderAllowed = computed(() => - this.localInventories().some((item) => item.active !== false && item.allow_backorder), + this.localInventories().some((item) => item.active !== false && item.allow_backorder === true), ); + /** + * Depósitos que ainda podem ser adicionados. + */ readonly availableWarehouseOptions = computed(() => { - const currentItems = this.localInventories() || []; - const usedWhIds = new Set(currentItems.map((i) => String(i.warehouse_id))); - return this.warehouseOptions().filter((opt) => !usedWhIds.has(String(opt.value))); + const currentItems = this.localInventories(); + + const usedWhIds = new Set(currentItems.map((item) => String(item.warehouse_id))); + + return this.warehouseOptions().filter((option) => !usedWhIds.has(String(option.value))); }); - constructor() { - effect(() => { - const incoming = this.inventories() || []; - untracked(() => { - this.localInventories.set(incoming); - }); - }); - } + /** + * ============================== + * WAREHOUSE HELPERS + * ============================== + */ + + getWarehouseOptionsForItem(item: UpdateProductInventoryPayload): SelectOption[] { + const currentItems = this.localInventories(); - getWarehouseOptionsForItem(item: UpdateProductInventoryPayload): DSelectOption[] { - const currentItems = this.localInventories() || []; const otherUsedWhIds = new Set( currentItems - .filter((i) => (i.id && item.id ? i.id !== item.id : i !== item)) - .map((i) => String(i.warehouse_id)), + .filter((currentItem) => + currentItem.id && item.id ? currentItem.id !== item.id : currentItem !== item, + ) + .map((currentItem) => String(currentItem.warehouse_id)), ); - return this.warehouseOptions().filter((opt) => !otherUsedWhIds.has(String(opt.value))); + + return this.warehouseOptions().filter((option) => !otherUsedWhIds.has(String(option.value))); } - getWarehouseCode(warehouse_id: string): string { - if (!warehouse_id) return 'N/A'; - if (warehouse_id === 'wh-01') return 'DC-SP'; - if (warehouse_id === 'wh-02') return 'DF-PR'; - if (warehouse_id === 'wh-03') return 'DD-RJ'; - return String(warehouse_id).toUpperCase(); + getWarehouseName(warehouseId: string): string { + if (!warehouseId) { + return 'N/A'; + } + + const found = this.warehouseOptions().find( + (warehouse) => String(warehouse.value) === String(warehouseId), + ); + + return found?.label || warehouseId; } + getWarehouseCode(warehouseId: string): string { + if (!warehouseId) { + return 'N/A'; + } + + return String(warehouseId).toUpperCase(); + } + + /** + * ============================== + * ADD / REMOVE WAREHOUSE + * ============================== + */ + addWarehouseDirect(): void { - if (this.isReadOnly()) return; + if (this.isReadOnly()) { + return; + } const available = this.availableWarehouseOptions(); - // Garante um item selecionável mesmo que a lista esteja vazia - const firstWh = - available && available.length > 0 - ? available[0] - : this.warehouseOptions() && this.warehouseOptions().length > 0 - ? this.warehouseOptions()[0] - : null; + if (!available.length) { + return; + } - const whId = firstWh ? String(firstWh.value) : 'wh-01'; - const cached = this.removedItemsCache.get(whId); + const firstWarehouse = available[0]; + + const warehouseId = String(firstWarehouse.value); + + const cached = this.removedItemsCache.get(warehouseId); const newItem: LocalInventoryItem = { id: cached?.id || `draft-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, - warehouse_id: whId, + + warehouse_id: warehouseId, + quantity: cached?.quantity !== undefined ? cached.quantity : 0, + reserved_quantity: cached?.reserved_quantity !== undefined ? cached.reserved_quantity : 0, + minimum_quantity: cached?.minimum_quantity !== undefined ? cached.minimum_quantity : 0, + maximum_quantity: cached?.maximum_quantity !== undefined ? cached.maximum_quantity : null, + allow_backorder: cached?.allow_backorder !== undefined ? cached.allow_backorder : false, + active: cached?.active !== undefined ? cached.active : true, - }; + } as LocalInventoryItem; this.localInventories.update((current) => [...current, newItem]); + this.emitToParent(); } - private emitToParent(): void { - const activeItems = this.localInventories() - .filter((item) => !item.isDraft) // se usar a flag isDraft - .map(({ id, ...payload }) => { - // Checa se é um ID de rascunho - const isDraftId = typeof id === 'string' && id.startsWith('draft-'); - - // Se for draft, envia sem a chave id. Se for item existente, envia o id original - return isDraftId ? payload : { id, ...payload }; - }); + removeItem(index: number): void { + if (this.isReadOnly()) { + return; + } + + const currentList = this.localInventories(); + const target = currentList[index]; - this.inventoriesChange.emit(activeItems); + if (!target) { + return; + } + + if (target.warehouse_id) { + this.removedItemsCache.set(String(target.warehouse_id), { ...target }); + } + + this.localInventories.update((current) => + current.filter((_, currentIndex) => currentIndex !== index), + ); + + this.emitToParent(); } + /** + * ============================== + * INLINE EDITING + * ============================== + */ + updateQuantity(index: number, value: string | number): void { - if (this.isReadOnly()) return; + if (this.isReadOnly()) { + return; + } + const num = Math.max(0, Number(value) || 0); + const currentItem = this.localInventories()[index]; - if (!currentItem) return; + + if (!currentItem) { + return; + } this.localInventories.update((current) => - current.map((item, i) => (i === index ? { ...item, quantity: num } : item)), + current.map((item, itemIndex) => + itemIndex === index + ? { + ...item, + quantity: num, + } + : item, + ), ); + this.emitToParent(); } adjustQuantity(index: number, delta: number): void { - if (this.isReadOnly()) return; + if (this.isReadOnly()) { + return; + } + const current = Number(this.localInventories()[index]?.quantity) || 0; - const nextVal = Math.max(0, current + delta); - this.updateQuantity(index, nextVal); + + const nextValue = Math.max(0, current + delta); + + this.updateQuantity(index, nextValue); } updateMinQuantity(index: number, value: string | number): void { - if (this.isReadOnly()) return; + if (this.isReadOnly()) { + return; + } + const num = Math.max(0, Number(value) || 0); + const currentItem = this.localInventories()[index]; - if (!currentItem) return; + + if (!currentItem) { + return; + } this.localInventories.update((current) => - current.map((item, i) => (i === index ? { ...item, minimum_quantity: num } : item)), + current.map((item, itemIndex) => + itemIndex === index + ? { + ...item, + minimum_quantity: num, + } + : item, + ), ); + this.emitToParent(); } updateMaxQuantity(index: number, value: string): void { - if (this.isReadOnly()) return; + if (this.isReadOnly()) { + return; + } + const num = value.trim() === '' ? null : Math.max(0, Number(value) || 0); + const currentItem = this.localInventories()[index]; - if (!currentItem) return; + + if (!currentItem) { + return; + } this.localInventories.update((current) => - current.map((item, i) => (i === index ? { ...item, maximum_quantity: num } : item)), + current.map((item, itemIndex) => + itemIndex === index + ? { + ...item, + maximum_quantity: num, + } + : item, + ), ); + this.emitToParent(); } + /** + * ============================== + * WAREHOUSE CHANGE + * ============================== + */ + changeWarehouse(index: number, newWarehouseId: string): void { - if (this.isReadOnly()) return; - const oldItem = this.localInventories()[index]; - if (oldItem && oldItem.warehouse_id) { + if (this.isReadOnly()) { + return; + } + + const currentList = this.localInventories(); + + const oldItem = currentList[index]; + + if (!oldItem) { + return; + } + + /** + * Guarda o estado anterior do depósito. + */ + if (oldItem.warehouse_id) { this.removedItemsCache.set(String(oldItem.warehouse_id), { ...oldItem }); } + /** + * Se o depósito já foi utilizado anteriormente, + * restaura seus dados. + */ const cached = this.removedItemsCache.get(String(newWarehouseId)); const updatedItem: LocalInventoryItem = { ...oldItem, + warehouse_id: newWarehouseId, + quantity: cached?.quantity !== undefined ? cached.quantity : oldItem.quantity, + reserved_quantity: cached?.reserved_quantity !== undefined ? cached.reserved_quantity : oldItem.reserved_quantity, + minimum_quantity: cached?.minimum_quantity !== undefined ? cached.minimum_quantity : oldItem.minimum_quantity, + maximum_quantity: cached?.maximum_quantity !== undefined ? cached.maximum_quantity : oldItem.maximum_quantity, + allow_backorder: cached?.allow_backorder !== undefined ? cached.allow_backorder : oldItem.allow_backorder, + active: cached?.active !== undefined ? cached.active : oldItem.active, - }; + } as LocalInventoryItem; this.localInventories.update((current) => - current.map((item, i) => (i === index ? updatedItem : item)), + current.map((item, itemIndex) => (itemIndex === index ? updatedItem : item)), ); this.emitToParent(); } - removeItem(index: number): void { - if (this.isReadOnly()) return; + /** + * ============================== + * TOGGLES + * ============================== + */ - const currentList = this.localInventories(); - const target = currentList[index]; - if (!target) return; - - if (target.warehouse_id) { - this.removedItemsCache.set(String(target.warehouse_id), { ...target }); + toggleBackorder(index: number): void { + if (this.isReadOnly()) { + return; } - this.localInventories.update((current) => current.filter((_, i) => i !== index)); - this.emitToParent(); - } - - toggleBackorder(index: number): void { - if (this.isReadOnly()) return; const currentItem = this.localInventories()[index]; - if (!currentItem) return; + + if (!currentItem) { + return; + } this.localInventories.update((current) => - current.map((item, i) => - i === index ? { ...item, allow_backorder: !item.allow_backorder } : item, + current.map((item, itemIndex) => + itemIndex === index + ? { + ...item, + allow_backorder: !item.allow_backorder, + } + : item, ), ); + this.emitToParent(); } toggleActive(index: number): void { - if (this.isReadOnly()) return; + if (this.isReadOnly()) { + return; + } + const currentItem = this.localInventories()[index]; - if (!currentItem) return; + + if (!currentItem) { + return; + } this.localInventories.update((current) => - current.map((item, i) => (i === index ? { ...item, active: !item.active } : item)), + current.map((item, itemIndex) => + itemIndex === index + ? { + ...item, + active: !item.active, + } + : item, + ), ); + this.emitToParent(); } - getStockLevelBadge(item: UpdateProductInventoryPayload) { + /** + * ============================== + * STOCK STATUS + * ============================== + */ + + getStockLevelBadge(item: UpdateProductInventoryPayload): { + label: string; + class: string; + icon: string; + } { if (item.active === false) { return { label: 'Inativo', @@ -273,8 +458,10 @@ export class ProductInventoryTabComponent { icon: 'slash', }; } - const avail = (item.quantity || 0) - (item.reserved_quantity || 0); - if (avail <= 0) { + + const available = (Number(item.quantity) || 0) - (Number(item.reserved_quantity) || 0); + + if (available <= 0) { return { label: 'Sem Estoque', class: @@ -282,7 +469,10 @@ export class ProductInventoryTabComponent { icon: 'x-circle', }; } - if ((item.minimum_quantity || 0) > 0 && avail < (item.minimum_quantity || 0)) { + + const minimum = Number(item.minimum_quantity) || 0; + + if (minimum > 0 && available < minimum) { return { label: 'Abaixo do Mínimo', class: @@ -290,6 +480,7 @@ export class ProductInventoryTabComponent { icon: 'alert-triangle', }; } + return { label: 'Estoque OK', class: @@ -298,8 +489,24 @@ export class ProductInventoryTabComponent { }; } - getWarehouseName(warehouse_id: string): string { - const found = this.warehouseOptions().find((w) => String(w.value) === String(warehouse_id)); - return found?.label || warehouse_id; + /** + * ============================== + * PARENT EMISSION + * ============================== + */ + + private emitToParent(): void { + const items = this.localInventories().map((item) => { + const payload = { ...item }; + + if (item.isDraft || (typeof item.id === 'string' && item.id.startsWith('draft-'))) { + delete payload.id; + delete payload.isDraft; + } + + return payload; + }); + + this.inventoriesChange.emit(items as UpdateProductInventoryPayload[]); } } diff --git a/src/app/features/catalog/components/product-form/inventory-tab/utils/table-values.ts b/src/app/features/catalog/products/product-form/inventory-tab/utils/table-values.ts similarity index 100% rename from src/app/features/catalog/components/product-form/inventory-tab/utils/table-values.ts rename to src/app/features/catalog/products/product-form/inventory-tab/utils/table-values.ts diff --git a/src/app/features/catalog/components/product-form/media-tab/media-tab.html b/src/app/features/catalog/products/product-form/media-tab/media-tab.html similarity index 100% rename from src/app/features/catalog/components/product-form/media-tab/media-tab.html rename to src/app/features/catalog/products/product-form/media-tab/media-tab.html diff --git a/src/app/features/catalog/components/product-form/media-tab/media-tab.ts b/src/app/features/catalog/products/product-form/media-tab/media-tab.ts similarity index 100% rename from src/app/features/catalog/components/product-form/media-tab/media-tab.ts rename to src/app/features/catalog/products/product-form/media-tab/media-tab.ts diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.css b/src/app/features/catalog/products/product-form/sidebar/product-workspace-sidebar.css similarity index 100% rename from src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.css rename to src/app/features/catalog/products/product-form/sidebar/product-workspace-sidebar.css diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html b/src/app/features/catalog/products/product-form/sidebar/product-workspace-sidebar.html similarity index 100% rename from src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html rename to src/app/features/catalog/products/product-form/sidebar/product-workspace-sidebar.html 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 97% rename from src/app/features/catalog/product-workspace/product-workspace.html rename to src/app/features/catalog/products/product-workspace/product-workspace.html index 682a65d..9d8b1c7 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.html +++ b/src/app/features/catalog/products/product-workspace/product-workspace.html @@ -129,11 +129,15 @@

('geral'); readonly mode = signal('edit'); @@ -79,30 +88,23 @@ export class ProductWorkspaceComponent implements OnInit { readonly isSaving = signal(false); readonly isDragging = signal(false); readonly isFormDirty = signal(false); - readonly categoryLoading = signal(false); - readonly manufacturerLoading = signal(false); + readonly deleteImageDialogOpen = signal(false); + readonly deleteProductDialogOpen = signal(false); + readonly unsavedChangesDialogOpen = signal(false); readonly productCreatedSuccessBanner = signal(false); - readonly fetchedStatuses = signal([]); - readonly fetchedWarehouses = signal([]); - readonly fetchedPartOrigins = signal([]); - readonly fetchedCategories = signal([]); - readonly fetchedManufacturers = signal([]); + readonly oemCodeIds = signal([]); + readonly errors = signal>({}); - readonly deleteProductDialogOpen = signal(false); - readonly unsavedChangesDialogOpen = signal(false); - readonly deleteImageDialogOpen = signal(false); + readonly manufacturers = signal([]); + readonly manufacturersByProduct = signal([]); readonly selectedImageForDelete = signal(null); - readonly errors = signal>({}); - // Dual Signal: product armazena dados brutos da API; productForm armazena o payload mutável para envio. + readonly modifiedTabs = signal>(new Set()); + readonly product = signal(initialProductPayload); readonly productForm = signal(defaultUpdatePayload()); - readonly categoryOptions = computed(() => this.fetchedCategories()); - readonly manufacturerOptions = computed(() => this.fetchedManufacturers()); - readonly statusOptions = computed(() => this.fetchedStatuses()); - readonly currentStatus = computed(() => this.product()?.status ?? null); readonly generalForm = computed(() => { @@ -129,14 +131,12 @@ export class ProductWorkspaceComponent implements OnInit { }); readonly generalFormOptions = computed(() => ({ - unitOptions: this.unitOptions(), - statusOptions: this.statusOptions(), - categoryOptions: this.categoryOptions(), - categoryLoading: this.categoryLoading(), - warehouseOptions: this.fetchedWarehouses(), - partOriginOptions: this.fetchedPartOrigins(), - manufacturerOptions: this.manufacturerOptions(), - manufacturerLoading: this.manufacturerLoading(), + 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'); @@ -156,11 +156,11 @@ export class ProductWorkspaceComponent implements OnInit { private queries: GeneralOptionQuery = { search: null, active: null, - limit: null, + per_page: null, + page: null, + manufacturer_id: null, }; - readonly unitOptions = signal([]); - readonly availableTags = []; ngOnInit(): void { @@ -170,7 +170,11 @@ export class ProductWorkspaceComponent implements OnInit { this.productCreatedSuccessBanner.set(!!state?.['productJustCreated']); - this.loadInitialOptions(this.queries); + 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'); @@ -178,81 +182,23 @@ export class ProductWorkspaceComponent implements OnInit { } } - 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); + getOemCodeByManufacturer(query: GeneralOptionQuery) { + this.oemCodeService.getByManufacturer(query).subscribe({ + next: (response) => { + const codes = response.data || []; + this.manufacturersByProduct.set(codes); }, - }); - - this.unitSaleService.getOptions(query).subscribe({ - next: (res) => { - const opts: DSelectOption[] = (res.data || []).map((u) => ({ - label: u.label, - value: u.id, - })); - this.unitOptions.set(opts); + error: (err) => { + this.manufacturersByProduct.set([]); + this.toastService.error('Erro', err.error.data.messsage); }, }); } productById(productId: string): void { - this.productService.getById(productId).subscribe({ - next: (response) => { - const prodData = response.data || response; + 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)); }, @@ -264,7 +210,6 @@ export class ProductWorkspaceComponent implements OnInit { } setActiveTab(tab: FormTab): void { - console.log('tab', tab); this.activeTab.set(tab); } @@ -302,8 +247,17 @@ export class ProductWorkspaceComponent implements OnInit { return false; } - updateField(field: string, val: string): void { + 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', @@ -331,7 +285,7 @@ export class ProductWorkspaceComponent implements OnInit { } updateBooleanField(field: string, val: boolean): void { - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); if (field === 'active') { this.productForm.update((p) => ({ ...p, active: val })); } else if (field === 'featured') { @@ -340,7 +294,7 @@ export class ProductWorkspaceComponent implements OnInit { } updateNumberField(field: string, valStr: string): void { - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); const num = parseFloat(valStr) || 0; const fieldMapping: Record = { weight: 'weight', @@ -375,7 +329,7 @@ export class ProductWorkspaceComponent implements OnInit { } updateNullableNumberField(field: string, valStr: string): void { - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); const val = valStr.trim() === '' ? null : parseFloat(valStr); if (field === 'promotional_price') { this.productForm.update((p) => ({ @@ -408,7 +362,7 @@ export class ProductWorkspaceComponent implements OnInit { } updateDescription(val: string): void { - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); this.productForm.update((p) => ({ ...p, description: val })); } @@ -417,7 +371,7 @@ export class ProductWorkspaceComponent implements OnInit { } onInventoriesChange(inventories: UpdateProductInventoryPayload[]): void { - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); this.productForm.update((p) => ({ ...p, inventories })); if (this.errors()['inventories']) { this.errors.update((e) => { @@ -430,7 +384,7 @@ export class ProductWorkspaceComponent implements OnInit { toggleBackorder(): void { if (this.isReadOnly()) return; - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); this.productForm.update((p) => { const invs = p.inventories || []; const first = invs[0] || { warehouse_id: '', quantity: 0 }; @@ -447,32 +401,19 @@ export class ProductWorkspaceComponent implements OnInit { } openAddOemModal(): void { - const code = prompt('Digite o Código OEM:'); - if (!code) return; - const manufacturer = prompt('Digite o Fabricante/Montadora:', 'Volkswagen') || 'Montadora'; - - // Simula a adição relacional: adiciona ao sinal product (UI) e adiciona o ID ao productForm const newId = 'oem-' + Date.now(); - const newOem = { - id: newId, - oem_code: code.toUpperCase(), - manufacturer: { id: '', name: manufacturer, slug: '' }, - is_primary: this.product().oem_codes.length === 0, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; this.product.update((p) => ({ ...p, - oem_codes: [...p.oem_codes, newOem], + oem_codes: p.oem_codes, })); this.productForm.update((pf) => ({ ...pf, - oem_code_ids: [...(pf.oem_code_ids || this.product().oem_codes.map((o) => o.id)), newId], + oem_code_ids: [newId, ...(pf.oem_code_ids || this.product().oem_codes.map((o) => o.id))], })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } setPrimaryOem(id: string): void { @@ -480,7 +421,7 @@ export class ProductWorkspaceComponent implements OnInit { ...p, oem_codes: p.oem_codes.map((o) => ({ ...o, is_primary: o.id === id })), })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } removeOemCode(id: string): void { @@ -496,7 +437,7 @@ export class ProductWorkspaceComponent implements OnInit { ), })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } toggleOemStatus(id: string): void { @@ -522,7 +463,7 @@ export class ProductWorkspaceComponent implements OnInit { product_code_ids: [...(pf.product_code_ids || this.product().codes.map((c) => c.id)), newId], })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } removeProductCode(id: string): void { @@ -538,7 +479,7 @@ export class ProductWorkspaceComponent implements OnInit { ), })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } toggleProductCodeStatus(id: string): void { @@ -572,7 +513,7 @@ export class ProductWorkspaceComponent implements OnInit { ], })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } removeEquivalent(id: string): void { @@ -588,7 +529,7 @@ export class ProductWorkspaceComponent implements OnInit { ).filter((eqId) => eqId !== id), })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } toggleEquivalentStatus(id: string): void { @@ -627,7 +568,7 @@ export class ProductWorkspaceComponent implements OnInit { ], })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } removeVehicleApplication(id: string): void { @@ -643,7 +584,7 @@ export class ProductWorkspaceComponent implements OnInit { ), })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } toggleVehicleStatus(id: string): void { @@ -677,6 +618,7 @@ export class ProductWorkspaceComponent implements OnInit { } handleFiles(files: File[]): void { + console.log(files); // Media handling } @@ -716,7 +658,7 @@ export class ProductWorkspaceComponent implements OnInit { supplier_ids: [...(pf.supplier_ids || this.product().suppliers.map((s) => s.id)), newId], })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } removeSupplier(id: string): void { @@ -732,7 +674,7 @@ export class ProductWorkspaceComponent implements OnInit { ), })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } setPreferentialSupplier(id: string): void { @@ -743,7 +685,7 @@ export class ProductWorkspaceComponent implements OnInit { preferred: s.id === id, })), })); - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); } toggleSupplierStatus(id: string): void { @@ -753,7 +695,7 @@ export class ProductWorkspaceComponent implements OnInit { toggleTag(tagId: string): void { if (this.isReadOnly()) return; - this.isFormDirty.set(true); + this.markCurrentTabAsModified(); this.product.update((p) => { const exists = p.tags.some((t) => t.id === tagId); @@ -775,44 +717,52 @@ export class ProductWorkspaceComponent implements OnInit { }); } - openAddNoteModal(): void {} + // openAddNoteModal(): void {} - removeNote(id: string): void {} + // removeNote(id: string): void {} - toggleNoteStatus(id: string): void {} + // toggleNoteStatus(id: string): void {} 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), - }); + this.categoryStore.loadOptions(query); } 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), - }); - } + 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 = {}; @@ -857,15 +807,84 @@ export class ProductWorkspaceComponent implements OnInit { this.isSaving.set(true); const id = this.route.snapshot.paramMap.get('id'); - const payload = this.productForm(); + 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).subscribe({ + 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; @@ -914,6 +933,11 @@ export class ProductWorkspaceComponent implements OnInit { 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) { 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, + }, +});