diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..dea8def --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +package.json +package-lock.json +dist +node_modules \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3a97a77 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "auto", + "htmlWhitespaceSensitivity": "ignore", + "embeddedLanguageFormatting": "auto" +} diff --git a/README.md b/README.md index 856245c..b04519d 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ View your app in AI Studio: https://ai.studio/apps/c9f95229-05dc-4fcb-bfac-ba83f ## Run Locally -**Prerequisites:** Node.js - +**Prerequisites:** Node.js 1. Install dependencies: `npm install` diff --git a/angular.json b/angular.json index add9d39..665d126 100644 --- a/angular.json +++ b/angular.json @@ -3,9 +3,7 @@ "version": 1, "cli": { "packageManager": "npm", - "schematicCollections": [ - "angular-eslint" - ], + "schematicCollections": ["angular-eslint"], "analytics": "169ed763-2250-4e49-a165-52c8e18688e7" }, "newProjectRoot": "projects", @@ -28,9 +26,7 @@ "input": "public" } ], - "styles": [ - "src/styles.css" - ], + "styles": ["src/styles.css"], "server": "src/main.server.ts", "outputMode": "server", "ssr": { @@ -39,6 +35,13 @@ }, "configurations": { "production": { + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.production.ts" + } + ], "budgets": [ { "type": "initial", @@ -50,13 +53,18 @@ "maximumWarning": "4kB", "maximumError": "8kB" } - ], - "outputHashing": "all" + ] }, "development": { "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] } }, "defaultConfiguration": "production" @@ -79,10 +87,7 @@ "lint": { "builder": "@angular-eslint/builder:lint", "options": { - "lintFilePatterns": [ - "src/**/*.ts", - "src/**/*.html" - ] + "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] } } } diff --git a/eslint.config.js b/eslint.config.js index 25955c0..38f180b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,18 +1,23 @@ -// @ts-check +// @ts-nocheck const eslint = require('@eslint/js'); -const {defineConfig} = require('eslint/config'); const tseslint = require('typescript-eslint'); const angular = require('angular-eslint'); +const prettierPlugin = require('eslint-plugin-prettier'); +const prettierConfig = require('eslint-config-prettier'); -module.exports = defineConfig([ +module.exports = tseslint.config( { files: ['**/*.ts'], extends: [ eslint.configs.recommended, - tseslint.configs.recommended, - tseslint.configs.stylistic, - angular.configs.tsRecommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + ...angular.configs.tsRecommended, + prettierConfig, ], + plugins: { + prettier: prettierPlugin, + }, processor: angular.processInlineTemplates, rules: { '@angular-eslint/directive-selector': [ @@ -31,14 +36,18 @@ module.exports = defineConfig([ style: 'kebab-case', }, ], + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], + '@typescript-eslint/no-explicit-any': 'error', // Forbid usage of 'any' }, }, { files: ['**/*.html'], - extends: [ - angular.configs.templateRecommended, - angular.configs.templateAccessibility, - ], + extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: {}, - } -]); + }, +); diff --git a/package-lock.json b/package-lock.json index fc596bd..25da62f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,8 +35,12 @@ "angular-eslint": "21.1.0", "cross-env": "^10.1.0", "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", "jsdom": "^27.0.0", "postcss": "^8.5.3", + "prettier": "^3.9.6", + "pretty-quick": "^4.2.2", "tailwindcss": "^4.1.12", "typescript": "~5.9.2", "typescript-eslint": "8.47.0", @@ -925,6 +929,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1329,31 +1334,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -3739,6 +3719,19 @@ "license": "MIT", "optional": true }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -5149,6 +5142,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -6701,7 +6695,6 @@ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -7162,6 +7155,54 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -7509,6 +7550,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8892,6 +8940,7 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -9478,6 +9527,16 @@ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -10330,6 +10389,84 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-quick": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-4.2.2.tgz", + "integrity": "sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.7", + "ignore": "^7.0.5", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.2", + "tinyexec": "^0.3.2", + "tslib": "^2.8.1" + }, + "bin": { + "pretty-quick": "lib/cli.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://opencollective.com/pretty-quick" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/pretty-quick/node_modules/@pkgr/core": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.10.tgz", + "integrity": "sha512-x6fFWCeak8aCGfqZfe6CXYt5xVjxe9Os1cIPmVRcToInKLjhJkRVXvJ/L3/1KxFkjDQdbZV/YsuLKqa8t/xKpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/pretty-quick/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -11202,6 +11339,22 @@ "dev": true, "license": "MIT" }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", diff --git a/package.json b/package.json index cc0e38a..a4cf0d9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "watch": "ng build --watch --configuration development", "test": "ng test", "lint": "ng lint", + "pretty-quick": "pretty-quick --staged", + "lint:fix": "ng lint --fix", + "format": "prettier --write .", + "format:staged": "pretty-quick --staged", "serve:ssr:app": "node dist/app/server/server.mjs" }, "private": true, @@ -40,8 +44,12 @@ "angular-eslint": "21.1.0", "cross-env": "^10.1.0", "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", "jsdom": "^27.0.0", "postcss": "^8.5.3", + "prettier": "^3.9.6", + "pretty-quick": "^4.2.2", "tailwindcss": "^4.1.12", "typescript": "~5.9.2", "typescript-eslint": "8.47.0", diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index 37c9504..35926e4 100644 --- a/src/app/app.config.server.ts +++ b/src/app/app.config.server.ts @@ -1,7 +1,7 @@ -import {ApplicationConfig, mergeApplicationConfig} from '@angular/core'; -import {provideServerRendering, withRoutes} from '@angular/ssr'; -import {appConfig} from './app.config'; -import {serverRoutes} from './app.routes.server'; +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; const serverConfig: ApplicationConfig = { providers: [provideServerRendering(withRoutes(serverRoutes))], diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 7d47338..8c88b3b 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,11 +1,12 @@ -import { - ApplicationConfig, - provideBrowserGlobalErrorListeners, -} from '@angular/core'; -import {provideRouter} from '@angular/router'; - -import {routes} from './app.routes'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withFetch } from '@angular/common/http'; +import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideHttpClient(withFetch()), + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + ], }; diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 0c6a7ef..15a5c55 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -9,10 +9,6 @@ export const serverRoutes: ServerRoute[] = [ path: 'catalog/products/:id/view', renderMode: RenderMode.Server, }, - { - path: 'catalog/products/:id/duplicate', - renderMode: RenderMode.Server, - }, { path: 'catalog/products/:id', renderMode: RenderMode.Server, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 500edd7..e7d549a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,74 +4,82 @@ export const routes: Routes = [ { path: '', redirectTo: 'dashboard', - pathMatch: 'full' + pathMatch: 'full', }, { path: 'dashboard', - loadComponent: () => import('./features/dashboard/dashboard').then(m => m.DashboardPageComponent) + loadComponent: () => + import('./features/dashboard/dashboard').then((m) => m.DashboardPageComponent), }, { path: 'catalog/products', - loadComponent: () => import('./features/catalog/products').then(m => m.ProductsPageComponent) + loadComponent: () => + import('./features/catalog/products/products').then((m) => m.ProductsPageComponent), }, { path: 'catalog/products/new', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) + loadComponent: () => + import('./features/catalog/product-create/product-create').then((m) => m.ProductCreateComponent), }, { path: 'catalog/products/:id/edit', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) + loadComponent: () => + import('./features/catalog/product-workspace/product-workspace').then( + (m) => m.ProductWorkspaceComponent, + ), }, { path: 'catalog/products/:id/view', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) - }, - { - path: 'catalog/products/:id/duplicate', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) + loadComponent: () => + import('./features/catalog/product-workspace/product-workspace').then( + (m) => m.ProductWorkspaceComponent, + ), }, { path: 'catalog/products/:id', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) + loadComponent: () => + import('./features/catalog/product-workspace/product-workspace').then( + (m) => m.ProductWorkspaceComponent, + ), }, { path: 'catalog/:sub', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'compatibility/:sub', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'vehicles/:sub', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'commercial/:sub', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'imports', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'reports', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'notifications', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'admin', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: 'help', - loadComponent: () => import('./features/placeholder').then(m => m.PlaceholderPageComponent) + loadComponent: () => import('./features/placeholder').then((m) => m.PlaceholderPageComponent), }, { path: '**', - redirectTo: 'dashboard' - } + redirectTo: 'dashboard', + }, ]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index e69ce7d..75753d6 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,5 +1,5 @@ -import {TestBed} from '@angular/core/testing'; -import {App} from './app'; +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; describe('App', () => { beforeEach(async () => { diff --git a/src/app/app.ts b/src/app/app.ts index 8f63dbd..a00c3a7 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { AppShellComponent } from './layout/app-shell'; +import { AppShellComponent } from './components/app-shell/app-shell'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/layout/app-shell.css b/src/app/components/app-shell/app-shell.css similarity index 100% rename from src/app/layout/app-shell.css rename to src/app/components/app-shell/app-shell.css diff --git a/src/app/layout/app-shell.html b/src/app/components/app-shell/app-shell.html similarity index 75% rename from src/app/layout/app-shell.html rename to src/app/components/app-shell/app-shell.html index ac5a356..d228f20 100644 --- a/src/app/layout/app-shell.html +++ b/src/app/components/app-shell/app-shell.html @@ -1,4 +1,6 @@ -
+
diff --git a/src/app/components/app-shell/app-shell.ts b/src/app/components/app-shell/app-shell.ts new file mode 100644 index 0000000..b7db8ca --- /dev/null +++ b/src/app/components/app-shell/app-shell.ts @@ -0,0 +1,23 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { HeaderComponent } from '../../layout/header/header'; +import { SidebarComponent } from '../../layout/sidebar/sidebar'; +import { ContentComponent } from '../../layout/content/content'; +import { FooterComponent } from '../../layout/footer/footer'; +import { NotificationsDrawerComponent } from '../notifications-drawer/notifications-drawer'; +import { ToastContainerComponent } from '../../design-system/toast/toast'; +@Component({ + selector: 'app-shell', + standalone: true, + imports: [ + HeaderComponent, + SidebarComponent, + ContentComponent, + FooterComponent, + NotificationsDrawerComponent, + ToastContainerComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './app-shell.html', + styleUrl: './app-shell.css', +}) +export class AppShellComponent {} diff --git a/src/app/layout/notifications-drawer.css b/src/app/components/notifications-drawer/notifications-drawer.css similarity index 100% rename from src/app/layout/notifications-drawer.css rename to src/app/components/notifications-drawer/notifications-drawer.css diff --git a/src/app/components/notifications-drawer/notifications-drawer.html b/src/app/components/notifications-drawer/notifications-drawer.html new file mode 100644 index 0000000..9146a2b --- /dev/null +++ b/src/app/components/notifications-drawer/notifications-drawer.html @@ -0,0 +1,77 @@ + +
+ +
+ + Você possui + + {{ shellState.unreadNotificationsCount() }} + + não lidas + + + @if (shellState.unreadNotificationsCount() > 0) { + + } +
+ + +
+ @for (item of shellState.notifications(); track item.id) { + + } +
+
+ +
+ + Fechar + + + Ver Todas as Notificações + +
+
diff --git a/src/app/components/notifications-drawer/notifications-drawer.ts b/src/app/components/notifications-drawer/notifications-drawer.ts new file mode 100644 index 0000000..25e1476 --- /dev/null +++ b/src/app/components/notifications-drawer/notifications-drawer.ts @@ -0,0 +1,45 @@ +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { DrawerComponent } from '../../design-system/drawer/drawer'; +import { AppIconComponent } from '../../design-system/icon/app-icon'; +import { ButtonComponent } from '../../design-system/button/button'; +import { ShellStateService } from '../../core/services/shell-state'; + +@Component({ + selector: 'app-notifications-drawer', + standalone: true, + imports: [DrawerComponent, AppIconComponent, ButtonComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './notifications-drawer.html', + styleUrl: './notifications-drawer.css', +}) +export class NotificationsDrawerComponent { + readonly shellState = inject(ShellStateService); + + getIcon(type: string): string { + switch (type) { + case 'warning': + return 'alert-circle'; + case 'success': + return 'check'; + case 'error': + return 'alert-circle'; + case 'info': + default: + return 'info'; + } + } + + getBadgeBg(type: string): string { + switch (type) { + case 'warning': + return 'bg-amber-100 dark:bg-amber-950/60 text-[#E9B949]'; + case 'success': + return 'bg-emerald-100 dark:bg-emerald-950/60 text-[#5BAE6A]'; + case 'error': + return 'bg-rose-100 dark:bg-rose-950/60 text-[#D66A6A]'; + case 'info': + default: + return 'bg-blue-100 dark:bg-blue-950/60 text-[#5A8DEE]'; + } + } +} diff --git a/src/app/core/config/compatibility-modules.config.ts b/src/app/core/config/compatibility-modules.config.ts new file mode 100644 index 0000000..de8ab4f --- /dev/null +++ b/src/app/core/config/compatibility-modules.config.ts @@ -0,0 +1,83 @@ +import { FormTab } from '../../features/catalog/models/product-workspace.model'; +import { SubMenuItem } from '../models/menu-item'; + +export interface CompatibilityModuleConfig { + menuId: string; + workspaceTab: FormTab; + label: string; + route: string; + icon: string; + headerIcon: string; + headerIconClass: string; + description: string; + countKey: + 'oemCodesCount' | 'productCodesCount' | 'equivalentProductsCount' | 'vehicleApplicationsCount'; +} + +/** Módulos de compatibilidade — fonte única alinhada ao menu do shell-state */ +export const COMPATIBILITY_MODULES: CompatibilityModuleConfig[] = [ + { + menuId: 'oem', + workspaceTab: 'oem', + label: 'OEM Codes', + route: '/compatibility/oem-codes', + icon: 'hash', + headerIcon: 'box', + headerIconClass: 'text-[#4F8A6B]', + description: 'Códigos originais de montadoras de referência cadastrados para esta peça', + countKey: 'oemCodesCount', + }, + { + menuId: 'product-codes', + workspaceTab: 'codigos', + label: 'Product Codes', + route: '/compatibility/product-codes', + icon: 'barcode', + headerIcon: 'tag', + headerIconClass: 'text-[#5A8DEE]', + description: 'EAN, DUN, Código do fabricante, Código paralelo e Código interno auxiliar', + countKey: 'productCodesCount', + }, + { + menuId: 'equivalents', + workspaceTab: 'equivalentes', + label: 'Produtos Equivalentes', + route: '/compatibility/equivalent-products', + icon: 'copy', + headerIcon: 'copy', + headerIconClass: 'text-purple-500', + description: 'Relacionamento entre produtos equivalentes e marcas cruzadas', + countKey: 'equivalentProductsCount', + }, + { + menuId: 'applications', + workspaceTab: 'compatibilidade', + label: 'Aplicações', + route: '/compatibility/applications', + icon: 'layers', + headerIcon: 'car', + headerIconClass: 'text-amber-500', + description: 'Relacionamento entre o produto e suas aplicações veiculares', + countKey: 'vehicleApplicationsCount', + }, +]; + +export function compatibilityMenuChildren(): SubMenuItem[] { + return COMPATIBILITY_MODULES.map(({ menuId, label, route }) => ({ + id: menuId, + label, + route, + })); +} + +export const COMPATIBILITY_WORKSPACE_TABS: FormTab[] = COMPATIBILITY_MODULES.map( + (module) => module.workspaceTab, +); + +export function isCompatibilityWorkspaceTab(tab: FormTab): boolean { + return COMPATIBILITY_WORKSPACE_TABS.includes(tab); +} + +export function getCompatibilityModuleByTab(tab: FormTab): CompatibilityModuleConfig | undefined { + return COMPATIBILITY_MODULES.find((module) => module.workspaceTab === tab); +} diff --git a/src/app/core/models/PartOriginResponse.model.ts b/src/app/core/models/PartOriginResponse.model.ts new file mode 100644 index 0000000..f07b405 --- /dev/null +++ b/src/app/core/models/PartOriginResponse.model.ts @@ -0,0 +1,8 @@ +export interface PartOriginResponse { + id: string; + name: string; + abbreviation: string; + description: string; + display_order: number; + active: boolean; +} diff --git a/src/app/core/models/catetories/categories.model.ts b/src/app/core/models/catetories/categories.model.ts new file mode 100644 index 0000000..d37f33c --- /dev/null +++ b/src/app/core/models/catetories/categories.model.ts @@ -0,0 +1,13 @@ +export interface CategoryResponse { + id: string; + parent_id: null; + name: string; + slug: string; + description: string; + display_order: number; + icon: string; + image: string; + active: boolean; + created_at: string; + updated_at: string; +} diff --git a/src/app/core/models/catetories/category-options.model.ts b/src/app/core/models/catetories/category-options.model.ts new file mode 100644 index 0000000..016ddda --- /dev/null +++ b/src/app/core/models/catetories/category-options.model.ts @@ -0,0 +1,12 @@ +export interface CategoryOptionsResponse { + success: boolean; + message: string; + data: CategoryOption[]; +} + +export interface CategoryOption { + id: string; + label: string; + description: string; + icon: 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 new file mode 100644 index 0000000..f9e6634 --- /dev/null +++ b/src/app/core/models/design-system/select-option.model.ts @@ -0,0 +1,5 @@ +export interface DSelectOption { + label: string; + value: string | number; + disabled?: boolean; +} diff --git a/src/app/core/models/generals/general-option-query.model.ts b/src/app/core/models/generals/general-option-query.model.ts new file mode 100644 index 0000000..4a47c78 --- /dev/null +++ b/src/app/core/models/generals/general-option-query.model.ts @@ -0,0 +1,13 @@ +export interface GeneralOptionQuery { + search: string | null; + active: boolean | null; + limit: number | null; +} + +// ALTERNATIVA PRA PAGINAÇÃO +// { +// page?: number; +// limit?: number; +// query?: string; +// active?: boolean; +// } diff --git a/src/app/core/models/generals/general-responses-list.model.ts b/src/app/core/models/generals/general-responses-list.model.ts new file mode 100644 index 0000000..e56941d --- /dev/null +++ b/src/app/core/models/generals/general-responses-list.model.ts @@ -0,0 +1,5 @@ +export interface getAllResponse { + success: boolean; + message: string; + data: T; +} diff --git a/src/app/core/models/list-table/list-table.model.ts b/src/app/core/models/list-table/list-table.model.ts new file mode 100644 index 0000000..c77884f --- /dev/null +++ b/src/app/core/models/list-table/list-table.model.ts @@ -0,0 +1,40 @@ +export type ColumnAlign = 'left' | 'center' | 'right'; +export type ColumnType = 'text' | 'badge' | 'currency' | 'date' | 'boolean' | 'custom' | 'icon'; + +export interface TableBadgeConfig { + text: string; + variant?: 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'draft'; + color?: string; + icon?: string; +} + +export interface TableColumn> { + key: string; + header: string; + width?: string; + align?: ColumnAlign; + type?: ColumnType; + sortable?: boolean; + valueGetter?: (row: T) => unknown; + cellFormatter?: (row: T) => string | number | boolean | null | undefined; + badgeConfig?: (row: T) => TableBadgeConfig; + iconGetter?: (row: T) => string; +} + +export interface TableAction> { + id: string; + label: string; + icon: string; + colorClass?: string; + title?: string; + visible?: (row: T) => boolean; + handler: (row: T) => void; +} + +export interface TablePaginationConfig { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + pageSizeOptions?: number[]; +} diff --git a/src/app/core/models/manufactureres/manufacturer-response.model.ts b/src/app/core/models/manufactureres/manufacturer-response.model.ts new file mode 100644 index 0000000..7698c91 --- /dev/null +++ b/src/app/core/models/manufactureres/manufacturer-response.model.ts @@ -0,0 +1,10 @@ +export interface ManufacturerResponse { + id: string; + name: string; + slug: string; + website: string; + active: boolean; + image: null; + created_at: string; + updated_at: string; +} diff --git a/src/app/core/models/manufactureres/manufaturer-options.model.ts b/src/app/core/models/manufactureres/manufaturer-options.model.ts new file mode 100644 index 0000000..13dda21 --- /dev/null +++ b/src/app/core/models/manufactureres/manufaturer-options.model.ts @@ -0,0 +1,11 @@ +export interface ManufacturerOptionsResponse { + success: boolean; + message: string; + data: ManufacturerOption[]; +} + +export interface ManufacturerOption { + id: string; + label: string; + description: string; +} diff --git a/src/app/core/models/pagination/pagination.model.ts b/src/app/core/models/pagination/pagination.model.ts new file mode 100644 index 0000000..a7d0ec8 --- /dev/null +++ b/src/app/core/models/pagination/pagination.model.ts @@ -0,0 +1,32 @@ +export interface PaginationLink { + url: string | null; + label: string; + page: number | null; + active: boolean; +} + +export interface PaginationMeta { + current_page: number; + from: number | null; + last_page: number; + links: PaginationLink[]; + path: string; + per_page: number; + to: number | null; + total: number; +} + +export interface PaginationLinks { + first: string; + last: string; + prev: string | null; + next: string | null; +} + +export interface PaginatedResponse { + success: boolean; + message: string; + data: T[]; + meta: PaginationMeta; + links: PaginationLinks; +} diff --git a/src/app/core/models/products/product-create.model.ts b/src/app/core/models/products/product-create.model.ts new file mode 100644 index 0000000..5c41cbb --- /dev/null +++ b/src/app/core/models/products/product-create.model.ts @@ -0,0 +1,159 @@ +import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; +import { DSelectOption } from '../design-system/select-option.model'; + +/** Estado interno do formulário de criação (campos do POST) */ +export interface ProductCreateFormState { + category_id: string; + manufacturer_id: string; + part_origin_id: string; + unit_id: string; + internal_code: string; + barcode: string; + name: string; + short_description: string; + description: string; + weight: number; + height: number; + width: number; + length: number; + featured: boolean; +} + +export function defaultProductCreateFormState(): ProductCreateFormState { + return { + category_id: '', + manufacturer_id: '', + part_origin_id: '', + unit_id: '', + internal_code: '', + barcode: '', + name: '', + short_description: '', + description: '', + weight: 0, + height: 0, + width: 0, + length: 0, + featured: false, + }; +} + +export function createFormToGeneralSource(state: ProductCreateFormState): ProductGeneralFormSource { + return { + ...state, + slug: '', + status_id: '', + active: true, + }; +} + +/** Payload esperado pelo backend no POST /product */ +export interface CreateProductPayload { + category_id: string; + manufacturer_id: string; + part_origin_id: string; + unit_id: string; + internal_code: string; + barcode: string; + name: string; + short_description: string; + description: string; + weight: number; + height: number; + width: number; + length: number; + featured: boolean; +} + +/** Campos da aba Geral compartilhados entre create e workspace */ +export interface ProductGeneralFormData { + name: string; + slug: string; + short_description: string; + description: string; + category_id: string; + manufacturer_id: string; + part_origin_id: string; + status_id: string; + internal_code: string; + barcode: string; + unit_id: string; + weight: number; + height: number; + width: number; + length: number; + featured: boolean; + active: boolean; +} + +export interface ProductGeneralFormOptions { + categoryOptions: AutocompleteOption[]; + categoryLoading: boolean; + manufacturerOptions: AutocompleteOption[]; + manufacturerLoading: boolean; + partOriginOptions: DSelectOption[]; + unitOptions: DSelectOption[]; + statusOptions: DSelectOption[]; + warehouseOptions: DSelectOption[]; +} + +export interface ProductGeneralFormSource { + name: string; + slug: string; + short_description: string; + description: string; + category_id: string; + manufacturer_id: string; + part_origin_id: string; + status_id: string; + internal_code: string; + barcode: string; + unit_id: string; + weight: number; + height: number; + width: number; + length: number; + featured: boolean; + active: boolean; +} + +export function toProductGeneralFormData(source: ProductGeneralFormSource): ProductGeneralFormData { + return { + name: source.name, + slug: source.slug, + short_description: source.short_description, + description: source.description, + category_id: source.category_id, + manufacturer_id: source.manufacturer_id, + part_origin_id: source.part_origin_id, + status_id: source.status_id, + internal_code: source.internal_code, + barcode: source.barcode, + unit_id: source.unit_id, + weight: source.weight, + height: source.height, + width: source.width, + length: source.length, + featured: source.featured, + active: source.active, + }; +} + +export function toCreateProductPayload(source: ProductGeneralFormSource): CreateProductPayload { + return { + category_id: source.category_id, + manufacturer_id: source.manufacturer_id, + part_origin_id: source.part_origin_id, + unit_id: source.unit_id, + internal_code: source.internal_code, + barcode: source.barcode, + name: source.name, + short_description: source.short_description, + description: source.description, + weight: source.weight, + height: source.height, + width: source.width, + length: source.length, + featured: source.featured, + }; +} diff --git a/src/app/core/models/products/product-form-state.model.ts b/src/app/core/models/products/product-form-state.model.ts new file mode 100644 index 0000000..798c97c --- /dev/null +++ b/src/app/core/models/products/product-form-state.model.ts @@ -0,0 +1,26 @@ +export interface ProductFormState { + name: string; + internal_code: string; + category_id: string; + manufacturer_id: string; + unit: string; + unit_id: string; + slug: string; + description: string; + + weight: number; + height: number; + width: number; + length: number; + + price: number; + promotional_price: number | null; + promotion_start_date: string; + promotion_end_date: string; + + warehouse_id: string; + quantity: number; + minimum_quantity: number; + maximum_quantity: number | null; + allow_backorder: boolean; +} diff --git a/src/app/core/models/products/product-request.model.ts b/src/app/core/models/products/product-request.model.ts new file mode 100644 index 0000000..61625fb --- /dev/null +++ b/src/app/core/models/products/product-request.model.ts @@ -0,0 +1,123 @@ +export interface CreateProductPayload { + category_id: string; + manufacturer_id: string; + part_origin_id: string; + unit_id: string; + + internal_code: string; + barcode: string; + + name: string; + short_description: string; + description: string; + + weight: number; + height: number; + width: number; + length: number; + + featured: boolean; +} + +export interface ProductCreateResponse { + success: boolean; + message: string; + data: ProductCreate; +} + +export interface ProductCreate { + id: string; + status: ProductStatus; + internal_code: string; + name: string; + slug: string; + icon: string | null; + image: string | null; + short_description: string; + description: string; + weight: string; + height: string; + width: string; + length: string; + featured: boolean; + is_sellable: boolean; + commercial_status: CommercialStatus; +} + +export interface ProductStatus { + id: string; + module: string; + code: string; + name: string; + color: string; + icon: string; +} + +export type CommercialStatus = 'pending' | 'approved' | 'rejected'; + +// PARA REMOVER FUTURAMENTE APÓS UPDATE + +export interface ProductPricePayload { + price: number; + promotional_price?: number | null; + promotion_start?: string | null; + promotion_end?: string | null; +} + +export interface ProductInventoryPayload { + warehouse_id: string | null; + + quantity: number; + + minimum_quantity?: number; + maximum_quantity?: number | null; + + allow_backorder?: boolean; +} + +export interface UpdateProductPayload { + category_id?: string | null; + manufacturer_id?: string | null; + part_origin_id?: string | null; + unit_id?: string | null; + + internal_code?: string | null; + barcode?: string | null; + name?: string | null; + slug?: string | null; + + icon?: string | null; + image?: string | null; + + short_description?: string | null; + description?: string | null; + + weight?: number | null; + height?: number | null; + width?: number | null; + length?: number | null; + + unit?: string | null; + + active?: boolean; + featured?: boolean; + + price?: UpdateProductPricePayload; + + inventory?: UpdateProductInventoryPayload; +} + +export interface UpdateProductPricePayload { + price?: number | null; + promotional_price?: number | null; + promotion_start?: string | null; + promotion_end?: string | null; +} + +export interface UpdateProductInventoryPayload { + warehouse_id?: string | null; + quantity?: number | null; + minimum_quantity?: number | null; + maximum_quantity?: number | null; + allow_backorder?: boolean; +} diff --git a/src/app/core/models/products/product-response.model.ts b/src/app/core/models/products/product-response.model.ts new file mode 100644 index 0000000..dd206cf --- /dev/null +++ b/src/app/core/models/products/product-response.model.ts @@ -0,0 +1,154 @@ +import { + OemCodeItem, + SupplierItem, +} from '../../../features/catalog/models/product-workspace.model'; + +export interface ProductResponse extends Record { + id: string; + + status: Status; + category: Category; + manufacturer: Category; + part_origin: PartOrigin | null; + unit: Unit | null; + + internal_code: string; + barcode: string | null; + + name: string; + slug: string; + + icon: string | null; + image: string | null; + + short_description: string | null; + description: string; + + weight: string; + height: string; + width: string; + length: string; + + featured: boolean; + active: boolean; + + is_sellable: boolean; + commercial_status: string; + + pricing: Pricing | null; + + inventory: Inventory | null; + + oem_codes: OemCodeItem[]; + equivalents: SupplierItem[]; + applications: VehicleApplication[]; + suppliers: SupplierItem[]; + + created_at: string; + updated_at: string; +} + +export interface Inventory { + quantity: number; + reserved_quantity: number; + available_quantity: number; + minimum_quantity: number; + maximum_quantity: number; + allow_backorder: boolean; + active: boolean; +} + +export interface Pricing { + regular_price: number; + promotional_price: number; + current_price: number; + is_on_promotion: boolean; + promotion_start: string; + promotion_end: string; + active: boolean; + display: Display; +} + +export interface Display { + from: number; + to: number; + label: string; +} + +export interface Category { + id: string; + name: string; + slug: string; +} + +export interface Status { + id: string; + module: string; + code: string; + name: string; + color: string; + icon: string; +} + +export interface OemCode { + id: string; + code: string; + is_main: boolean; + manufacturer: Category; + manufacturer_code: string; + created_at: string; + updated_at: string; +} + +export interface EquivalentProduct { + id: string; + type: string; + reference_code: string; + manufacturer: Category; + manufacturer_code: string; + is_main: boolean; + created_at: string; + updated_at: string; +} + +export interface VehicleApplication { + id: string; + manufacturer: string; + model: string; + year_from: number; + year_to: number | null; + engine: string | null; + details: string | null; + created_at: string; + updated_at: string; +} + +// export interface Supplier { +// id: string; +// name: string; +// code: string | null; +// cost_price: number; +// delivery_time_days: number | null; +// minimum_order_quantity: number; +// active: boolean; +// created_at: string; +// updated_at: string; +// } + +export interface Unit { + id: string; + code: string; + name: string; + abbreviation: string; + is_base: boolean; + created_at: string; + updated_at: string; +} + +export interface PartOrigin { + id: string; + name: string; + code: string; + created_at: string; + updated_at: string; +} diff --git a/src/app/core/models/status/status-options.model.ts b/src/app/core/models/status/status-options.model.ts new file mode 100644 index 0000000..29f165b --- /dev/null +++ b/src/app/core/models/status/status-options.model.ts @@ -0,0 +1,12 @@ +export interface StatusOptionsResponse { + success: boolean; + message: string; + data: StatusOption[]; +} + +export interface StatusOption { + id: string; + label: string; + description: string; + icon: string; +} diff --git a/src/app/core/models/unit-sale.model.ts b/src/app/core/models/unit-sale.model.ts new file mode 100644 index 0000000..ad4c285 --- /dev/null +++ b/src/app/core/models/unit-sale.model.ts @@ -0,0 +1,12 @@ +export interface UnitSaleOptionsResponse { + success: boolean; + message: string; + data: UnitSaleOption[]; +} + +export interface UnitSaleOption { + id: string; + label: string; + description: string; + icon: string; +} diff --git a/src/app/core/models/warehouses/warehouse-options.model.ts b/src/app/core/models/warehouses/warehouse-options.model.ts new file mode 100644 index 0000000..927f29f --- /dev/null +++ b/src/app/core/models/warehouses/warehouse-options.model.ts @@ -0,0 +1,12 @@ +export interface WarehouseOptionsResponse { + success: boolean; + message: string; + data: WarehouseOption[]; +} + +export interface WarehouseOption { + id: string; + label: string; + description: string; + icon: string; +} diff --git a/src/app/core/models/warehouses/warehouses.interface.ts b/src/app/core/models/warehouses/warehouses.interface.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/services/api-service.ts b/src/app/core/services/api-service.ts new file mode 100644 index 0000000..d20aac3 --- /dev/null +++ b/src/app/core/services/api-service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { MenuItem } from '../models/menu-item'; + +export interface MenuGroup { + id: string; + title: string; + items: MenuItem[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class ApiService { + private readonly API_URL = environment.apiUrl; +} diff --git a/src/app/core/services/build-http-params.ts b/src/app/core/services/build-http-params.ts new file mode 100644 index 0000000..620e78f --- /dev/null +++ b/src/app/core/services/build-http-params.ts @@ -0,0 +1,13 @@ +import { HttpParams } from '@angular/common/http'; + +export function buildHttpParams(params: Record): HttpParams { + let httpParams = new HttpParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + httpParams = httpParams.set(key, String(value)); + } + }); + + return httpParams; +} diff --git a/src/app/core/services/category-service.ts b/src/app/core/services/category-service.ts new file mode 100644 index 0000000..13414a1 --- /dev/null +++ b/src/app/core/services/category-service.ts @@ -0,0 +1,30 @@ +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 { CategoryResponse } from '../models/catetories/categories.model'; +import { buildHttpParams } from './build-http-params'; +import { CategoryOptionsResponse } from '../models/catetories/category-options.model'; +import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; + +@Injectable({ + providedIn: 'root', +}) +export class CategoryService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'category'; + + 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 }); + } + + getById(id: string) { + return this.http.get(`${this.api}/${this.flag}/${id}`); + } +} diff --git a/src/app/core/services/manufacture-service.ts b/src/app/core/services/manufacture-service.ts new file mode 100644 index 0000000..6c2a82b --- /dev/null +++ b/src/app/core/services/manufacture-service.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { PaginatedResponse } from '../models/pagination/pagination.model'; +import { environment } from '../../../environments/environment'; +import { 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'; + +@Injectable({ + providedIn: 'root', +}) +export class ManufacturerService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'manufacturer'; + + 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, + }); + } + + getById(id: string) { + return this.http.get(`${this.api}/${this.flag}/${id}`); + } +} diff --git a/src/app/core/services/part-origins-service.ts b/src/app/core/services/part-origins-service.ts new file mode 100644 index 0000000..bb2da6c --- /dev/null +++ b/src/app/core/services/part-origins-service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { PaginatedResponse } from '../models/pagination/pagination.model'; +import { PartOriginResponse } from '../models/PartOriginResponse.model'; +import { buildHttpParams } from './build-http-params'; +import { GeneralOptionQuery } from '../models/generals/general-option-query.model'; + +@Injectable({ + providedIn: 'root', +}) +export class PartOriginService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'part-origins'; + + getAll(filters: GeneralOptionQuery) { + const params = buildHttpParams(filters); + return this.http.get>(`${this.api}/${this.flag}`, { + params, + }); + } +} diff --git a/src/app/core/services/product-service.ts b/src/app/core/services/product-service.ts new file mode 100644 index 0000000..dd0a232 --- /dev/null +++ b/src/app/core/services/product-service.ts @@ -0,0 +1,37 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { PaginatedResponse } from '../models/pagination/pagination.model'; +import { CreateProductPayload } from '../models/products/product-create.model'; +import { UpdateProductPayload } from '../models/products/product-request.model'; +import { ProductResponse } from '../models/products/product-response.model'; +import { environment } from '../../../environments/environment'; +import { getAllResponse } from '../models/generals/general-responses-list.model'; + +@Injectable({ + providedIn: 'root', +}) +export class ProductService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'product'; + + getAll() { + return this.http.get>(`${this.api}/${this.flag}`); + } + + getById(id: string) { + return this.http.get>(`${this.api}/${this.flag}/${id}`); + } + + create(payload: CreateProductPayload) { + return this.http.post(`${this.api}/${this.flag}`, payload); + } + + update(id: string, payload: UpdateProductPayload) { + return this.http.put(`${this.api}/${this.flag}/${id}`, payload); + } + + delete(id: string) { + return this.http.delete(`${this.api}/${this.flag}/${id}`); + } +} diff --git a/src/app/core/services/shell-state.ts b/src/app/core/services/shell-state.ts index b3db041..9ec8a6e 100644 --- a/src/app/core/services/shell-state.ts +++ b/src/app/core/services/shell-state.ts @@ -1,4 +1,5 @@ import { Injectable, signal, computed } from '@angular/core'; +import { compatibilityMenuChildren } from '../config/compatibility-modules.config'; import { MenuItem, NotificationItem, ShortcutItem } from '../models/menu-item'; export interface MenuGroup { @@ -8,7 +9,7 @@ export interface MenuGroup { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ShellStateService { // Sidebar state @@ -31,8 +32,9 @@ export class ShellStateService { name: 'Carlos Eduardo', role: 'Administrador de Catálogo', email: 'carlos.eduardo@reccos.com.br', - avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&auto=format&fit=crop&q=80', - unit: 'Matriz - São Paulo' + avatar: + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&auto=format&fit=crop&q=80', + unit: 'Matriz - São Paulo', }); // Navigation Items according to Reccos Shop Specification @@ -41,7 +43,7 @@ export class ShellStateService { id: 'dashboard', label: 'Dashboard', icon: 'layout-dashboard', - route: '/dashboard' + route: '/dashboard', }, { id: 'catalog', @@ -53,19 +55,14 @@ export class ShellStateService { { id: 'products', label: 'Produtos', route: '/catalog/products', badge: '12.4k' }, { id: 'tags', label: 'Tags', route: '/catalog/tags' }, { id: 'origins', label: 'Origem da Peça', route: '/catalog/origins' }, - { id: 'units', label: 'Unidades', route: '/catalog/units' } - ] + { id: 'units', label: 'Unidades', route: '/catalog/units' }, + ], }, { id: 'compatibility', label: 'Compatibilidade', icon: 'layers', - children: [ - { id: 'oem', label: 'OEM Codes', route: '/compatibility/oem-codes' }, - { id: 'product-codes', label: 'Product Codes', route: '/compatibility/product-codes' }, - { id: 'equivalents', label: 'Produtos Equivalentes', route: '/compatibility/equivalent-products' }, - { id: 'applications', label: 'Aplicações', route: '/compatibility/applications' } - ] + children: compatibilityMenuChildren(), }, { id: 'vehicles', @@ -75,8 +72,8 @@ export class ShellStateService { { id: 'brands', label: 'Montadoras', route: '/vehicles/brands' }, { id: 'models', label: 'Modelos', route: '/vehicles/models' }, { id: 'versions', label: 'Versões', route: '/vehicles/versions' }, - { id: 'engines', label: 'Motores', route: '/vehicles/engines' } - ] + { id: 'engines', label: 'Motores', route: '/vehicles/engines' }, + ], }, { id: 'commercial', @@ -86,41 +83,41 @@ export class ShellStateService { { id: 'inventory', label: 'Estoque', route: '/commercial/inventory', badge: 'Atenção' }, { id: 'pricing', label: 'Preços', route: '/commercial/pricing' }, { id: 'warehouses', label: 'Depósitos', route: '/commercial/warehouses' }, - { id: 'suppliers', label: 'Fornecedores', route: '/commercial/suppliers' } - ] + { id: 'suppliers', label: 'Fornecedores', route: '/commercial/suppliers' }, + ], }, { id: 'imports', label: 'Importações', icon: 'upload-cloud', route: '/imports', - badge: 'Novo' + badge: 'Novo', }, { id: 'reports', label: 'Relatórios', icon: 'bar-chart-3', - route: '/reports' + route: '/reports', }, { id: 'notifications', label: 'Notificações', icon: 'bell', route: '/notifications', - badge: '3' + badge: '3', }, { id: 'admin', label: 'Administração', icon: 'settings', - route: '/admin' + route: '/admin', }, { id: 'help', label: 'Ajuda', icon: 'help-circle', - route: '/help' - } + route: '/help', + }, ]); // Grouped Menu Items for Section Headers in Sidebar @@ -128,18 +125,18 @@ export class ShellStateService { { id: 'main', title: 'Módulos Principais', - items: this.menuItems().slice(0, 5) + items: this.menuItems().slice(0, 5), }, { id: 'operations', title: 'Ferramentas & Integração', - items: this.menuItems().slice(5, 8) + items: this.menuItems().slice(5, 8), }, { id: 'system', title: 'Administrativo', - items: this.menuItems().slice(8) - } + items: this.menuItems().slice(8), + }, ]); // Notifications mock data @@ -151,7 +148,7 @@ export class ShellStateService { timestamp: 'Há 12 min', read: false, type: 'warning', - link: '/commercial/inventory' + link: '/commercial/inventory', }, { id: '2', @@ -160,7 +157,7 @@ export class ShellStateService { timestamp: 'Há 45 min', read: false, type: 'success', - link: '/imports' + link: '/imports', }, { id: '3', @@ -169,7 +166,7 @@ export class ShellStateService { timestamp: 'Há 2 horas', read: false, type: 'info', - link: '/catalog/categories' + link: '/catalog/categories', }, { id: '4', @@ -178,27 +175,57 @@ export class ShellStateService { timestamp: 'Ontem', read: true, type: 'error', - link: '/compatibility/oem-codes' - } + link: '/compatibility/oem-codes', + }, ]); // Shortcuts mock readonly shortcuts = signal([ - { id: 'new-product', label: 'Novo Produto', icon: 'plus-circle', route: '/catalog/products/new', category: 'Catálogo' }, - { id: 'new-category', label: 'Nova Categoria', icon: 'folder-plus', route: '/catalog/categories/new', category: 'Catálogo' }, - { id: 'new-manufacturer', label: 'Novo Fabricante', icon: 'building', route: '/catalog/manufacturers/new', category: 'Catálogo' }, - { id: 'inventory-adj', label: 'Ajuste de Estoque', icon: 'package-check', route: '/commercial/inventory/adjust', category: 'Comercial' }, - { id: 'import-data', label: 'Nova Importação', icon: 'upload', route: '/imports/new', category: 'Importação' } + { + id: 'new-product', + label: 'Novo Produto', + icon: 'plus-circle', + route: '/catalog/products/new', + category: 'Catálogo', + }, + { + id: 'new-category', + label: 'Nova Categoria', + icon: 'folder-plus', + route: '/catalog/categories/new', + category: 'Catálogo', + }, + { + id: 'new-manufacturer', + label: 'Novo Fabricante', + icon: 'building', + route: '/catalog/manufacturers/new', + category: 'Catálogo', + }, + { + id: 'inventory-adj', + label: 'Ajuste de Estoque', + icon: 'package-check', + route: '/commercial/inventory/adjust', + category: 'Comercial', + }, + { + id: 'import-data', + label: 'Nova Importação', + icon: 'upload', + route: '/imports/new', + category: 'Importação', + }, ]); // Computed unread notifications count readonly unreadNotificationsCount = computed(() => { - return this.notifications().filter(n => !n.read).length; + return this.notifications().filter((n) => !n.read).length; }); // Toggles and setters toggleSidebar(): void { - this.sidebarExpanded.update(v => !v); + this.sidebarExpanded.update((v) => !v); } toggleDesktopSidebar(): void { @@ -206,7 +233,7 @@ export class ShellStateService { } toggleMobileDrawer(): void { - this.mobileDrawerOpen.update(v => !v); + this.mobileDrawerOpen.update((v) => !v); } closeMobileDrawer(): void { @@ -218,7 +245,7 @@ export class ShellStateService { } toggleNotificationsDrawer(): void { - this.notificationsDrawerOpen.update(v => !v); + this.notificationsDrawerOpen.update((v) => !v); } closeNotificationsDrawer(): void { @@ -230,12 +257,12 @@ export class ShellStateService { } markAllNotificationsAsRead(): void { - this.notifications.update(items => items.map(i => ({ ...i, read: true }))); + this.notifications.update((items) => items.map((i) => ({ ...i, read: true }))); } markNotificationAsRead(id: string): void { - this.notifications.update(items => - items.map(i => (i.id === id ? { ...i, read: true } : i)) + this.notifications.update((items) => + items.map((i) => (i.id === id ? { ...i, read: true } : i)), ); } @@ -250,8 +277,8 @@ export class ShellStateService { setActiveRoute(route: string): void { this.activeRoute.set(route); // Automatically expand parent group if route belongs to a child - const parent = this.menuItems().find(item => - item.children?.some(child => child.route === route) + const parent = this.menuItems().find((item) => + item.children?.some((child) => child.route === route), ); if (parent) { this.openGroup.set(parent.id); diff --git a/src/app/core/services/status-service.ts b/src/app/core/services/status-service.ts new file mode 100644 index 0000000..e2e9c6f --- /dev/null +++ b/src/app/core/services/status-service.ts @@ -0,0 +1,29 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +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 { GeneralOptionQuery } from '../models/generals/general-option-query.model'; +import { StatusOptionsResponse } from '../models/status/status-options.model'; + +@Injectable({ + providedIn: 'root', +}) +export class StatusService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'status'; + + getAll() { + return this.http.get>(`${this.api}/${this.flag}`); + } + + getOptions(module: string, limit: string | null = null) { + const params = buildHttpParams({ + module: module, + limit: limit, + }); + return this.http.get(`${this.api}/${this.flag}/options`, { params }); + } +} diff --git a/src/app/core/services/theme.ts b/src/app/core/services/theme.ts index 6c311fc..62acc2e 100644 --- a/src/app/core/services/theme.ts +++ b/src/app/core/services/theme.ts @@ -2,11 +2,11 @@ import { Injectable, signal, effect, PLATFORM_ID, inject } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ThemeService { private platformId = inject(PLATFORM_ID); - + // Signal for theme mode readonly isDarkMode = signal(false); @@ -35,7 +35,7 @@ export class ThemeService { } toggleTheme(): void { - this.isDarkMode.update(dark => !dark); + this.isDarkMode.update((dark) => !dark); } setDarkMode(enabled: boolean): void { diff --git a/src/app/core/services/toast.ts b/src/app/core/services/toast.ts index 46d1ffd..44b854d 100644 --- a/src/app/core/services/toast.ts +++ b/src/app/core/services/toast.ts @@ -9,7 +9,7 @@ export interface ToastMessage { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ToastService { readonly toasts = signal([]); @@ -19,10 +19,10 @@ export class ToastService { const newToast: ToastMessage = { id, duration: 4000, - ...toast + ...toast, }; - this.toasts.update(list => [...list, newToast]); + this.toasts.update((list) => [...list, newToast]); if (newToast.duration && newToast.duration > 0) { setTimeout(() => { @@ -50,7 +50,7 @@ export class ToastService { } remove(id: string): void { - this.toasts.update(list => list.filter(t => t.id !== id)); + this.toasts.update((list) => list.filter((t) => t.id !== id)); } clear(): void { diff --git a/src/app/core/services/unit-sale-service.ts b/src/app/core/services/unit-sale-service.ts new file mode 100644 index 0000000..808bc24 --- /dev/null +++ b/src/app/core/services/unit-sale-service.ts @@ -0,0 +1,23 @@ +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 { GeneralOptionQuery } from '../models/generals/general-option-query.model'; +import { buildHttpParams } from './build-http-params'; +import { UnitSaleOptionsResponse } from '../models/unit-sale.model'; + +@Injectable({ providedIn: 'root' }) +export class UnitSaleService { + private http = inject(HttpClient); + private api = environment.apiUrl; + private flag = 'units'; + + 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/services/warehouses-service.ts b/src/app/core/services/warehouses-service.ts new file mode 100644 index 0000000..93638cc --- /dev/null +++ b/src/app/core/services/warehouses-service.ts @@ -0,0 +1,25 @@ +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/utils/product-payload.transformer.ts b/src/app/core/utils/product-payload.transformer.ts new file mode 100644 index 0000000..939a137 --- /dev/null +++ b/src/app/core/utils/product-payload.transformer.ts @@ -0,0 +1,258 @@ +import { ProductFormState } from '../models/products/product-form-state.model'; +import { + CreateProductPayload, + UpdateProductPayload, +} from '../models/products/product-request.model'; + +export function toBoolean(val: unknown): boolean { + if (val === true || val === 1 || val === 'true' || val === '1') { + return true; + } + if (val === false || val === 0 || val === 'false' || val === '0') { + return false; + } + return Boolean(val); +} + +export function toNumber(val: unknown): number { + const parsed = parseFloat(String(val)); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function toInteger(val: unknown): number { + const parsed = parseInt(String(val), 10); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function toNullableNumber(val: unknown): number | null { + if (val === null || val === undefined || String(val).trim() === '') { + return null; + } + const parsed = parseFloat(String(val)); + return Number.isNaN(parsed) ? null : parsed; +} + +export function toNullableString(val: unknown): string | null { + if (val === null || val === undefined || String(val).trim() === '') { + return null; + } + return String(val).trim(); +} + +export function toNullableDate(val: unknown): string | null { + const normalized = toNullableString(val); + if (!normalized) { + return null; + } + + if (/^\d{4}-\d{2}-\d{2}T/.test(normalized)) { + return normalized.replace('T', ' ').slice(0, 19); + } + + return normalized; +} + +function hasPromotionalPrice(promotionalPrice: number | null): boolean { + return ( + promotionalPrice !== null && + promotionalPrice !== undefined && + String(promotionalPrice).trim() !== '' + ); +} + +function buildPriceBlock(state: ProductFormState) { + const hasPromotion = hasPromotionalPrice(state.promotional_price); + + return { + price: toNumber(state.price), + promotional_price: hasPromotion ? toNullableNumber(state.promotional_price) : null, + promotion_start: hasPromotion ? toNullableDate(state.promotion_start_date) : null, + promotion_end: hasPromotion ? toNullableDate(state.promotion_end_date) : null, + }; +} + +function buildInventoryBlock(state: ProductFormState) { + return { + warehouse_id: toNullableString(state.warehouse_id), + quantity: toInteger(state.quantity), + minimum_quantity: toInteger(state.minimum_quantity), + maximum_quantity: toNullableNumber(state.maximum_quantity), + allow_backorder: toBoolean(state.allow_backorder), + }; +} + +function buildProductRoot(state: ProductFormState) { + return { + category_id: toNullableString(state.category_id), + manufacturer_id: toNullableString(state.manufacturer_id), + unit_id: toNullableString(state.unit_id), + internal_code: toNullableString(state.internal_code), + name: toNullableString(state.name), + slug: toNullableString(state.slug), + icon: null, + image: null, + short_description: null, + description: toNullableString(state.description), + weight: toNumber(state.weight), + height: toNumber(state.height), + width: toNumber(state.width), + length: toNumber(state.length), + active: true, + featured: false, + unit: toNullableString(state.unit), + }; +} + +export function transformToCreatePayload(state: ProductFormState): CreateProductPayload { + const extended = state as ProductFormState & { + part_origin_id?: string; + barcode?: string; + short_description?: string; + featured?: boolean; + }; + + return { + category_id: toNullableString(state.category_id) ?? '', + manufacturer_id: toNullableString(state.manufacturer_id) ?? '', + part_origin_id: toNullableString(extended.part_origin_id) ?? '', + unit_id: toNullableString(state.unit) ?? '', + internal_code: toNullableString(state.internal_code) ?? '', + barcode: toNullableString(extended.barcode) ?? '', + name: toNullableString(state.name) ?? '', + short_description: toNullableString(extended.short_description) ?? '', + description: toNullableString(state.description) ?? '', + weight: toNumber(state.weight), + height: toNumber(state.height), + width: toNumber(state.width), + length: toNumber(state.length), + featured: toBoolean(extended.featured), + }; +} + +export function transformToUpdatePayload(state: ProductFormState): UpdateProductPayload { + return buildProductRoot(state); +} + +export function validateProductForm( + state: ProductFormState, + mode: 'create' | 'edit' | 'view', +): Record { + const errs: Record = {}; + const isCreateFlow = mode === 'create'; + + if (!state.name.trim()) { + errs['name'] = 'O nome do produto é obrigatório.'; + } + if (!state.internal_code.trim()) { + errs['internal_code'] = 'O código interno é obrigatório.'; + } + if (!state.category_id) { + errs['category_id'] = 'Selecione uma categoria.'; + } + if (!state.manufacturer_id) { + errs['manufacturer_id'] = 'Selecione um fabricante.'; + } + if (!state.unit) { + errs['unit_id'] = 'Selecione a unidade de medida.'; + } + if (!state.unit_id) { + errs['unit_id'] = 'Selecione o status do produto.'; + } + + if (isCreateFlow) { + const priceVal = state.price; + if (priceVal <= 0) { + errs['price'] = 'Informe um preço base válido superior a zero.'; + } + + const hasPromoPrice = hasPromotionalPrice(state.promotional_price); + const hasStartDate = !!state.promotion_start_date?.trim(); + const hasEndDate = !!state.promotion_end_date?.trim(); + + if (hasPromoPrice) { + const parsedPromoPrice = Number(state.promotional_price); + if (Number.isNaN(parsedPromoPrice) || parsedPromoPrice <= 0) { + errs['promotional_price'] = 'Informe um preço promocional válido.'; + } else if (parsedPromoPrice >= priceVal) { + errs['promotional_price'] = 'O preço promocional deve ser menor que o preço base.'; + } + + if (!hasStartDate) { + errs['promotion_start_date'] = + 'A data de início da promoção é obrigatória quando há preço promocional.'; + } + if (!hasEndDate) { + errs['promotion_end_date'] = + 'A data de término da promoção é obrigatória quando há preço promocional.'; + } + } + + if (hasStartDate || hasEndDate) { + if (!hasPromoPrice) { + errs['promotional_price'] = + 'O preço promocional é obrigatório quando as datas da promoção são informadas.'; + } + } + + if (!state.warehouse_id) { + errs['warehouse_id'] = 'Selecione o depósito.'; + } + + if (state.quantity === null || state.quantity === undefined || Number.isNaN(state.quantity)) { + errs['quantity'] = 'A quantidade inicial é obrigatória.'; + } + + const minQty = state.minimum_quantity; + if (minQty < 0) { + errs['min_quantity'] = 'A quantidade mínima deve ser maior ou igual a zero.'; + } + + const maxQty = state.maximum_quantity; + if (maxQty !== null && maxQty !== undefined && String(maxQty).trim() !== '') { + const parsedMaxQty = Number(maxQty); + if (Number.isNaN(parsedMaxQty)) { + errs['max_quantity'] = 'A quantidade máxima deve ser um número válido.'; + } else if (parsedMaxQty < minQty) { + errs['max_quantity'] = 'A quantidade máxima deve ser maior ou igual à quantidade mínima.'; + } + } + } + + return errs; +} + +export function mapProductBackendErrors( + backendErrors: Record, +): Record { + const mapped: Record = {}; + + for (const key of Object.keys(backendErrors)) { + const messages = backendErrors[key]; + const message = messages && messages.length > 0 ? messages[0] : ''; + if (!message) continue; + + if (key === 'price.price') { + mapped['price'] = message; + } else if (key === 'price.promotional_price') { + mapped['promotional_price'] = message; + } else if (key === 'price.promotion_start') { + mapped['promotion_start_date'] = message; + } else if (key === 'price.promotion_end') { + mapped['promotion_end_date'] = message; + } else if (key === 'inventory.warehouse_id') { + mapped['warehouse_id'] = message; + } else if (key === 'inventory.quantity') { + mapped['quantity'] = message; + } else if (key === 'inventory.minimum_quantity') { + mapped['min_quantity'] = message; + } else if (key === 'inventory.maximum_quantity') { + mapped['max_quantity'] = message; + } else if (key === 'inventory.allow_backorder') { + mapped['allow_backorder'] = message; + } else { + mapped[key] = message; + } + } + + return mapped; +} diff --git a/src/app/design-system/autocomplete-select/autocomplete-select.css b/src/app/design-system/autocomplete-select/autocomplete-select.css new file mode 100644 index 0000000..fc7af19 --- /dev/null +++ b/src/app/design-system/autocomplete-select/autocomplete-select.css @@ -0,0 +1 @@ +/* AutocompleteSelect styles */ diff --git a/src/app/design-system/autocomplete-select/autocomplete-select.html b/src/app/design-system/autocomplete-select/autocomplete-select.html new file mode 100644 index 0000000..4c41b11 --- /dev/null +++ b/src/app/design-system/autocomplete-select/autocomplete-select.html @@ -0,0 +1,158 @@ +
+ @if (label()) { + + } + +
+
+ + + +
+ +
+ + +
+ @if (loading()) { +
+ +
+ } + + @if (value() && !disabled()) { + + } + + +
+
+ + + @if (isOpen()) { +
+ + +
+ @if (loading()) { + + + Pesquisando no banco de dados... + + } @else { + {{ filteredOptions().length }} {{ filteredOptions().length === 1 ? 'item encontrado' : 'itens encontrados' }} + } + @if (filterQuery()) { + "{{ filterQuery() }}" + } +
+ + +
+ @if (loading() && filteredOptions().length === 0) { +
+ Carregando dados... +
+ } @else if (filteredOptions().length === 0) { +
+
+ +
+

+ {{ emptyText() }} +

+ @if (filterQuery()) { +

+ Nenhum registro corresponde a "{{ filterQuery() }}" +

+ } +
+ } @else { + @for (opt of filteredOptions(); track opt.value; let idx = $index) { + @let isSelected = isOptionSelected(opt.value); + @let isHighlighted = idx === highlightedIndex(); + + + } + } +
+
+ } +
+ + @if (helperText() && !error()) { +

+ {{ helperText() }} +

+ } + + @if (error()) { +

+ + {{ error() }} +

+ } +
diff --git a/src/app/design-system/autocomplete-select/autocomplete-select.ts b/src/app/design-system/autocomplete-select/autocomplete-select.ts new file mode 100644 index 0000000..4b8737d --- /dev/null +++ b/src/app/design-system/autocomplete-select/autocomplete-select.ts @@ -0,0 +1,227 @@ +import { + Component, + ChangeDetectionStrategy, + input, + output, + signal, + computed, + ElementRef, + inject, + HostListener, + OnDestroy, +} 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; +} + +@Component({ + selector: 'app-autocomplete-select', + standalone: true, + imports: [AppIconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './autocomplete-select.html', + styleUrl: './autocomplete-select.css', +}) +export class AutocompleteSelectComponent implements OnDestroy { + private elementRef = inject(ElementRef); + + readonly label = input(undefined); + readonly value = input(''); + readonly placeholder = input('Selecione ou digite para buscar...'); + readonly options = input.required(); + readonly disabled = input(false); + readonly required = input(false); + readonly error = input(undefined); + readonly helperText = input(undefined); + 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 valueChange = output(); + readonly optionSelect = output(); + readonly searchQueryChange = output(); + + readonly isOpen = signal(false); + readonly filterQuery = signal(''); + readonly highlightedIndex = signal(-1); + + private debounceTimer: ReturnType | null = null; + + // Selected Option Object for rendering + 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)); + if (found) return found; + + // Fallback: search by label if value was populated as label + const foundByLabel = this.options().find( + (opt) => opt.label.toLowerCase() === String(val).toLowerCase(), + ); + if (foundByLabel) return foundByLabel; + + return { + label: String(val), + value: val, + }; + }); + + // Display Text shown in input + readonly displayText = computed(() => { + if (this.isOpen()) { + return this.filterQuery(); + } + const sel = this.selectedOption(); + return sel ? sel.label : ''; + }); + + // Filtered options for local search or remote options + readonly filteredOptions = computed(() => { + const opts = this.options(); + const q = this.filterQuery().toLowerCase().trim(); + + if (this.remoteSearch() || !q) { + return opts; + } + + return opts.filter( + (opt) => + opt.label.toLowerCase().includes(q) || + String(opt.value).toLowerCase().includes(q) || + (opt.sublabel && opt.sublabel.toLowerCase().includes(q)), + ); + }); + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.elementRef.nativeElement.contains(event.target)) { + this.closeDropdown(); + } + } + + toggleDropdown(): void { + if (this.disabled()) return; + if (this.isOpen()) { + this.closeDropdown(); + } else { + this.openDropdown(); + } + } + + openDropdown(): void { + if (this.disabled()) return; + this.isOpen.set(true); + // When opening, reset query or keep selected label for quick editing + const sel = this.selectedOption(); + this.filterQuery.set(sel ? sel.label : ''); + this.highlightedIndex.set(-1); + } + + closeDropdown(): void { + this.isOpen.set(false); + this.filterQuery.set(''); + this.highlightedIndex.set(-1); + } + + onInputTextChange(event: Event): void { + const target = event.target as HTMLInputElement; + const text = target.value; + this.filterQuery.set(text); + + const result: GeneralOptionQuery = { + active: null, + search: text, + limit: 10, + }; + + if (!this.isOpen()) { + this.isOpen.set(true); + } + + // Handle debounce emit for remote database search + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.searchQueryChange.emit(result); + }, 250); + } + + selectOption(opt: AutocompleteOption, event?: MouseEvent): void { + if (event) { + event.stopPropagation(); + } + if (opt.disabled) return; + + this.valueChange.emit(String(opt.value)); + this.optionSelect.emit(opt); + this.closeDropdown(); + } + + isOptionSelected(optValue: string | number): boolean { + return String(optValue) === String(this.value()); + } + + clearSelection(event: MouseEvent): void { + event.stopPropagation(); + this.valueChange.emit(''); + this.filterQuery.set(''); + const result: GeneralOptionQuery = { + active: null, + search: null, + limit: 10, + }; + this.searchQueryChange.emit(result); + if (this.isOpen()) { + this.closeDropdown(); + } + } + + onKeyDown(event: KeyboardEvent): void { + if (this.disabled()) return; + + if (!this.isOpen() && (event.key === 'ArrowDown' || event.key === 'Enter')) { + this.openDropdown(); + event.preventDefault(); + return; + } + + if (!this.isOpen()) return; + + const list = this.filteredOptions(); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.highlightedIndex.update((i) => (i < list.length - 1 ? i + 1 : 0)); + break; + case 'ArrowUp': + event.preventDefault(); + this.highlightedIndex.update((i) => (i > 0 ? i - 1 : list.length - 1)); + break; + case 'Enter': + event.preventDefault(); + if (this.highlightedIndex() >= 0 && this.highlightedIndex() < list.length) { + this.selectOption(list[this.highlightedIndex()]); + } + break; + case 'Escape': + event.preventDefault(); + this.closeDropdown(); + break; + } + } + + ngOnDestroy(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + } +} diff --git a/src/app/design-system/badge/badge.ts b/src/app/design-system/badge/badge.ts index e9dc61f..692a30d 100644 --- a/src/app/design-system/badge/badge.ts +++ b/src/app/design-system/badge/badge.ts @@ -7,7 +7,7 @@ export type BadgeVariant = 'primary' | 'success' | 'warning' | 'error' | 'info' standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './badge.html', - styleUrl: './badge.css' + styleUrl: './badge.css', }) export class BadgeComponent { readonly variant = input('neutral'); @@ -15,28 +15,35 @@ export class BadgeComponent { get badgeClasses(): () => string { return () => { - const base = 'inline-flex items-center font-medium leading-none whitespace-nowrap rounded-md tracking-wide'; + const base = + 'inline-flex items-center font-medium leading-none whitespace-nowrap rounded-md tracking-wide'; const sz = this.size() === 'sm' ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs'; let colors = ''; switch (this.variant()) { case 'primary': - colors = 'bg-[#EAF4EE] text-[#4F8A6B] dark:bg-[#4F8A6B]/20 dark:text-[#5BAE6A] border border-[#4F8A6B]/30'; + colors = + 'bg-[#EAF4EE] text-[#4F8A6B] dark:bg-[#4F8A6B]/20 dark:text-[#5BAE6A] border border-[#4F8A6B]/30'; break; case 'success': - colors = 'bg-emerald-50 text-[#5BAE6A] dark:bg-emerald-950/50 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/50'; + colors = + 'bg-emerald-50 text-[#5BAE6A] dark:bg-emerald-950/50 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/50'; break; case 'warning': - colors = 'bg-amber-50 text-amber-700 dark:bg-amber-950/50 dark:text-[#E9B949] border border-amber-200 dark:border-amber-800/50'; + colors = + 'bg-amber-50 text-amber-700 dark:bg-amber-950/50 dark:text-[#E9B949] border border-amber-200 dark:border-amber-800/50'; break; case 'error': - colors = 'bg-rose-50 text-[#D66A6A] dark:bg-rose-950/50 dark:text-rose-400 border border-rose-200 dark:border-rose-800/50'; + colors = + 'bg-rose-50 text-[#D66A6A] dark:bg-rose-950/50 dark:text-rose-400 border border-rose-200 dark:border-rose-800/50'; break; case 'info': - colors = 'bg-blue-50 text-[#5A8DEE] dark:bg-blue-950/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800/50'; + colors = + 'bg-blue-50 text-[#5A8DEE] dark:bg-blue-950/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800/50'; break; case 'neutral': - colors = 'bg-gray-100 text-gray-700 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700'; + colors = + 'bg-gray-100 text-gray-700 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700'; break; } diff --git a/src/app/design-system/breadcrumb/breadcrumb.html b/src/app/design-system/breadcrumb/breadcrumb.html index 0f11a0b..052304d 100644 --- a/src/app/design-system/breadcrumb/breadcrumb.html +++ b/src/app/design-system/breadcrumb/breadcrumb.html @@ -1,21 +1,19 @@ diff --git a/src/app/design-system/breadcrumb/breadcrumb.ts b/src/app/design-system/breadcrumb/breadcrumb.ts index e574051..3f15b73 100644 --- a/src/app/design-system/breadcrumb/breadcrumb.ts +++ b/src/app/design-system/breadcrumb/breadcrumb.ts @@ -12,7 +12,7 @@ export interface BreadcrumbItem { imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './breadcrumb.html', - styleUrl: './breadcrumb.css' + styleUrl: './breadcrumb.css', }) export class BreadcrumbComponent { readonly items = input([]); diff --git a/src/app/design-system/button/button.html b/src/app/design-system/button/button.html index f3dd6f1..f64058c 100644 --- a/src/app/design-system/button/button.html +++ b/src/app/design-system/button/button.html @@ -5,17 +5,28 @@ [class]="buttonClasses()" > @if (loading()) { - - - - + + + + } @else if (iconStart()) { - + } @if (!loading() && iconEnd()) { - + } diff --git a/src/app/design-system/button/button.ts b/src/app/design-system/button/button.ts index f01fe48..1c542d1 100644 --- a/src/app/design-system/button/button.ts +++ b/src/app/design-system/button/button.ts @@ -10,7 +10,7 @@ export type ButtonSize = 'sm' | 'md' | 'lg'; imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './button.html', - styleUrl: './button.css' + styleUrl: './button.css', }) export class ButtonComponent { readonly variant = input('primary'); @@ -32,7 +32,8 @@ export class ButtonComponent { get buttonClasses(): () => string { return () => { - const base = 'inline-flex items-center justify-center font-medium transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed select-none rounded-[8px]'; + const base = + 'inline-flex items-center justify-center font-medium transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed select-none rounded-[8px]'; const width = this.fullWidth() ? 'w-full' : ''; let sz = 'px-4 py-2 text-sm'; @@ -42,13 +43,16 @@ export class ButtonComponent { let varClass = ''; switch (this.variant()) { case 'primary': - varClass = 'bg-[#4F8A6B] hover:bg-[#43765C] text-white shadow-sm focus:ring-[#4F8A6B] active:bg-[#38624C]'; + varClass = + 'bg-[#4F8A6B] hover:bg-[#43765C] text-white shadow-sm focus:ring-[#4F8A6B] active:bg-[#38624C]'; break; case 'secondary': - varClass = 'bg-[#EAF4EE] hover:bg-[#d8eadd] text-[#4F8A6B] dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-[#5BAE6A]'; + varClass = + 'bg-[#EAF4EE] hover:bg-[#d8eadd] text-[#4F8A6B] dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-[#5BAE6A]'; break; case 'outline': - varClass = 'border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-200 bg-white dark:bg-slate-800 hover:bg-gray-50 dark:hover:bg-slate-700'; + varClass = + 'border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-200 bg-white dark:bg-slate-800 hover:bg-gray-50 dark:hover:bg-slate-700'; break; case 'ghost': varClass = 'text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-800'; diff --git a/src/app/design-system/data-table/data-table.html b/src/app/design-system/data-table/data-table.html index 1544f08..eece1e7 100644 --- a/src/app/design-system/data-table/data-table.html +++ b/src/app/design-system/data-table/data-table.html @@ -2,99 +2,95 @@
- + @for (col of columns(); track col.key) { - - } - @if (hasActions()) { - + + } @if (hasActions()) { + } - - @if (loading()) { - @for (i of [1, 2, 3, 4, 5]; track i) { - - @for (col of columns(); track col.key) { - - } - @if (hasActions()) { - - } - + + @if (loading()) { @for (i of [1, 2, 3, 4, 5]; track i) { + + @for (col of columns(); track col.key) { + + } @if (hasActions()) { + } - } @else if (data().length === 0) { - - - - } @else { - @for (row of data(); track row) { - + } } @else if (data().length === 0) { + + + + } @else { @for (row of data(); track row) { + + @for (col of columns(); track col.key) { + + + + } @else { {{ getFormattedValue(row, col) }} } + + } @if (hasActions()) { + - } - + + } - } + + } }
-
- {{ col.header }} -
-
Ações +
+ {{ col.header }} +
+
Ações
- - - -
+ + + +
- -
+ +
+ @if (col.type === 'badge' || col.badgeConfig) { @let bg = getBadgeConfig(row, col); + + @if (bg.icon) { + + } {{ bg.text }} + + } @else if (col.type === 'icon' || col.iconGetter) { @let iconName = col.iconGetter ? + col.iconGetter(row) : 'box'; +
- @for (col of columns(); track col.key) { -
- @if (col.cell) { - {{ col.cell(row) }} - } @else { - {{ row[col.key] }} - } - +
+ @for (act of getVisibleActions(row); track act.id) { + } - - @if (hasActions()) { -
-
- - -
-
diff --git a/src/app/design-system/data-table/data-table.ts b/src/app/design-system/data-table/data-table.ts index 97c0366..2607101 100644 --- a/src/app/design-system/data-table/data-table.ts +++ b/src/app/design-system/data-table/data-table.ts @@ -2,6 +2,12 @@ import { Component, ChangeDetectionStrategy, input, output, computed } from '@an import { AppIconComponent } from '../icon/app-icon'; import { SkeletonComponent } from '../skeleton/skeleton'; import { EmptyStateComponent } from '../empty-state/empty-state'; +import { + TableAction, + TableBadgeConfig, + TableColumn, +} from '../../core/models/list-table/list-table.model'; +import { BadgeVariant, BadgeComponent } from '../badge/badge'; export interface ColumnDef { key: string; @@ -15,40 +21,160 @@ export interface ColumnDef { @Component({ selector: 'app-data-table', standalone: true, - imports: [AppIconComponent, SkeletonComponent, EmptyStateComponent], + imports: [AppIconComponent, SkeletonComponent, EmptyStateComponent, BadgeComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './data-table.html', - styleUrl: './data-table.css' + styleUrl: './data-table.css', }) export class DataTableComponent> { - readonly columns = input.required[]>(); + readonly columns = input.required[]>(); readonly data = input.required(); readonly loading = input(false); + readonly actions = input[]>([]); readonly hasActions = input(true); readonly roundedBottom = input(true); readonly emptyTitle = input('Nenhum dado encontrado'); readonly emptyDescription = input('Não existem registros cadastrados para exibição.'); readonly containerClasses = computed(() => { - const base = 'w-full bg-white dark:bg-slate-800 border border-gray-200/80 dark:border-slate-700/80 shadow-2xs overflow-hidden'; - return this.roundedBottom() - ? `${base} rounded-[12px]` - : `${base} rounded-t-[12px] border-b-0`; + const base = + 'w-full bg-white dark:bg-slate-800 border border-gray-200/80 dark:border-slate-700/80 shadow-2xs overflow-hidden'; + return this.roundedBottom() ? `${base} rounded-[12px]` : `${base} rounded-t-[12px] border-b-0`; }); readonly rowClick = output(); + readonly actionClick = output<{ actionId: string; row: T }>(); readonly editRow = output(); readonly deleteRow = output(); - onRowClick(row: T): void { - this.rowClick.emit(row); + resolveValue(row: T, col: TableColumn): unknown { + if (col.valueGetter) { + return col.valueGetter(row); + } + const key = col.key; + if (key.includes('.')) { + const parts = key.split('.'); + let current: unknown = row; + for (const p of parts) { + if (current && typeof current === 'object' && p in (current as Record)) { + current = (current as Record)[p]; + } else { + return undefined; + } + } + return current; + } + return row[key]; + } + + getFormattedValue(row: T, col: TableColumn): string { + if (col.cellFormatter) { + const res = col.cellFormatter(row); + return res !== null && res !== undefined ? String(res) : ''; + } + + const val = this.resolveValue(row, col); + + if (col.type === 'currency' && typeof val === 'number') { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(val); + } + + if (col.type === 'date' && typeof val === 'string' && val) { + const d = new Date(val); + if (!isNaN(d.getTime())) { + return d.toLocaleDateString('pt-BR'); + } + } + + if (val === null || val === undefined) { + return '-'; + } + + return String(val); } - onEdit(row: T): void { - this.editRow.emit(row); + getBadgeConfig(row: T, col: TableColumn): TableBadgeConfig { + if (col.badgeConfig) { + return col.badgeConfig(row); + } + const val = this.resolveValue(row, col); + if (typeof val === 'boolean') { + return { + text: val ? 'Ativo' : 'Inativo', + variant: val ? 'success' : 'neutral', + }; + } + return { + text: String(val ?? 'N/A'), + variant: 'neutral', + }; + } + + mapVariant(v?: string): BadgeVariant { + switch (v) { + case 'success': + return 'success'; + case 'warning': + return 'warning'; + case 'danger': + case 'error': + return 'error'; + case 'info': + return 'info'; + case 'primary': + return 'primary'; + default: + return 'neutral'; + } + } + + getVisibleActions(row: T): TableAction[] { + const list = this.actions(); + if (list.length > 0) { + return list.filter((act) => (act.visible ? act.visible(row) : true)); + } + // Default fallback actions if no explicit actions array passed + if (this.hasActions()) { + return [ + { + id: 'edit', + label: 'Editar', + icon: 'edit', + colorClass: 'text-gray-400 hover:text-[#4F8A6B]', + title: 'Editar', + handler: (r) => this.editRow.emit(r), + }, + { + id: 'delete', + label: 'Excluir', + icon: 'trash-2', + colorClass: 'text-gray-400 hover:text-[#D66A6A]', + title: 'Excluir', + handler: (r) => this.deleteRow.emit(r), + }, + ]; + } + return []; + } + + onRowClick(row: T): void { + this.rowClick.emit(row); } - onDelete(row: T): void { - this.deleteRow.emit(row); + onActionClick(action: TableAction, row: T, event: MouseEvent): void { + event.stopPropagation(); + if (action.id) { + action.handler(row); + } + console.log('[ON ACTION CLICK]', action, action.id); + this.actionClick.emit({ actionId: action.id, row }); + // if (action.id === 'edit') { + // this.editRow.emit(row); + // } else if (action.id === 'delete') { + // this.deleteRow.emit(row); + // } } } diff --git a/src/app/design-system/dialog/confirm-dialog.html b/src/app/design-system/dialog/confirm-dialog.html index 9f70fa0..3e0aa63 100644 --- a/src/app/design-system/dialog/confirm-dialog.html +++ b/src/app/design-system/dialog/confirm-dialog.html @@ -1,33 +1,37 @@ @if (isOpen()) { -
- -
+
+ +
- -
-
-
- -
- -
-

- {{ title() }} -

-

- {{ message() }} -

-
+ +
+
+
+
-
- - {{ cancelText() }} - - - {{ confirmText() }} - +
+

+ {{ title() }} +

+

{{ message() }}

+ +
+ + {{ cancelText() }} + + + {{ confirmText() }} + +
+
} diff --git a/src/app/design-system/dialog/confirm-dialog.ts b/src/app/design-system/dialog/confirm-dialog.ts index 2e60317..b081138 100644 --- a/src/app/design-system/dialog/confirm-dialog.ts +++ b/src/app/design-system/dialog/confirm-dialog.ts @@ -8,7 +8,7 @@ import { ButtonComponent } from '../button/button'; imports: [AppIconComponent, ButtonComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './confirm-dialog.html', - styleUrl: './confirm-dialog.css' + styleUrl: './confirm-dialog.css', }) export class ConfirmDialogComponent { readonly isOpen = input.required(); @@ -30,15 +30,19 @@ export class ConfirmDialogComponent { } get confirmVariant(): () => 'danger' | 'primary' { - return () => this.type() === 'danger' ? 'danger' : 'primary'; + return () => (this.type() === 'danger' ? 'danger' : 'primary'); } get typeIcon(): () => string { return () => { switch (this.type()) { - case 'danger': return 'trash-2'; - case 'warning': return 'alert-circle'; - case 'info': default: return 'info'; + case 'danger': + return 'trash-2'; + case 'warning': + return 'alert-circle'; + case 'info': + default: + return 'info'; } }; } @@ -46,9 +50,13 @@ export class ConfirmDialogComponent { get iconBgClass(): () => string { return () => { switch (this.type()) { - case 'danger': return 'bg-rose-50 dark:bg-rose-950/40 text-[#D66A6A]'; - case 'warning': return 'bg-amber-50 dark:bg-amber-950/40 text-[#E9B949]'; - case 'info': default: return 'bg-blue-50 dark:bg-blue-950/40 text-[#5A8DEE]'; + case 'danger': + return 'bg-rose-50 dark:bg-rose-950/40 text-[#D66A6A]'; + case 'warning': + return 'bg-amber-50 dark:bg-amber-950/40 text-[#E9B949]'; + case 'info': + default: + return 'bg-blue-50 dark:bg-blue-950/40 text-[#5A8DEE]'; } }; } diff --git a/src/app/design-system/dialog/dialog.html b/src/app/design-system/dialog/dialog.html index 4f1e76d..6a380cf 100644 --- a/src/app/design-system/dialog/dialog.html +++ b/src/app/design-system/dialog/dialog.html @@ -1,44 +1,46 @@ @if (isOpen()) { -
- -
+
+ +
- + +
+
- -
-

- {{ title() }} -

+

{{ title() }}

- -
+ +
- -
- -
+ +
+ +
- -
- -
+ +
+
+
} diff --git a/src/app/design-system/dialog/dialog.ts b/src/app/design-system/dialog/dialog.ts index d3f6ebb..c46ec24 100644 --- a/src/app/design-system/dialog/dialog.ts +++ b/src/app/design-system/dialog/dialog.ts @@ -7,7 +7,7 @@ import { AppIconComponent } from '../icon/app-icon'; imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './dialog.html', - styleUrl: './dialog.css' + styleUrl: './dialog.css', }) export class DialogComponent { readonly isOpen = input.required(); @@ -27,10 +27,15 @@ export class DialogComponent { get sizeClass(): () => string { return () => { switch (this.size()) { - case 'sm': return 'max-w-md'; - case 'lg': return 'max-w-2xl'; - case 'xl': return 'max-w-4xl'; - case 'md': default: return 'max-w-lg'; + case 'sm': + return 'max-w-md'; + case 'lg': + return 'max-w-2xl'; + case 'xl': + return 'max-w-4xl'; + case 'md': + default: + return 'max-w-lg'; } }; } diff --git a/src/app/design-system/drawer/drawer.html b/src/app/design-system/drawer/drawer.html index 7584528..551d40d 100644 --- a/src/app/design-system/drawer/drawer.html +++ b/src/app/design-system/drawer/drawer.html @@ -1,52 +1,54 @@ @if (isOpen()) { -
- -
+
+ +
-
- +
+ +
+
- -
-
- @if (icon()) { - - } -

- {{ title() }} -

-
- - +
+ @if (icon()) { + + } +

{{ title() }}

- -
- -
+ +
- -
- -
+ +
+ +
+ + +
+
+
} diff --git a/src/app/design-system/drawer/drawer.ts b/src/app/design-system/drawer/drawer.ts index 2544b46..2732347 100644 --- a/src/app/design-system/drawer/drawer.ts +++ b/src/app/design-system/drawer/drawer.ts @@ -7,7 +7,7 @@ import { AppIconComponent } from '../icon/app-icon'; imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './drawer.html', - styleUrl: './drawer.css' + styleUrl: './drawer.css', }) export class DrawerComponent { readonly isOpen = input.required(); @@ -28,9 +28,13 @@ export class DrawerComponent { get widthClass(): () => string { return () => { switch (this.width()) { - case 'sm': return 'max-w-sm'; - case 'lg': return 'max-w-xl'; - case 'md': default: return 'max-w-md'; + case 'sm': + return 'max-w-sm'; + case 'lg': + return 'max-w-xl'; + case 'md': + default: + return 'max-w-md'; } }; } diff --git a/src/app/design-system/dropdown/dropdown.html b/src/app/design-system/dropdown/dropdown.html index a9843fc..3f67912 100644 --- a/src/app/design-system/dropdown/dropdown.html +++ b/src/app/design-system/dropdown/dropdown.html @@ -6,41 +6,41 @@ @if (isOpen()) { -
- +
+ - @for (item of items(); track item.id) { - @if (item.divider) { -
- } @else { -
+ } @else { + + class="w-full px-3.5 py-2 text-left text-xs font-medium flex items-center justify-between transition-colors" + > +
+ @if (item.icon) { + } + {{ item.label }} +
+ + @if (item.badge) { + + {{ item.badge }} + } + + } } - -
+ +
}
diff --git a/src/app/design-system/dropdown/dropdown.ts b/src/app/design-system/dropdown/dropdown.ts index bbb2598..0d7edcb 100644 --- a/src/app/design-system/dropdown/dropdown.ts +++ b/src/app/design-system/dropdown/dropdown.ts @@ -1,4 +1,12 @@ -import { Component, ChangeDetectionStrategy, input, signal, ElementRef, inject, HostListener } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + input, + signal, + ElementRef, + inject, + HostListener, +} from '@angular/core'; import { AppIconComponent } from '../icon/app-icon'; export interface DropdownItem { @@ -16,7 +24,7 @@ export interface DropdownItem { imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './dropdown.html', - styleUrl: './dropdown.css' + styleUrl: './dropdown.css', }) export class DropdownComponent { private elementRef = inject(ElementRef); @@ -28,7 +36,7 @@ export class DropdownComponent { toggleOpen(event: Event): void { event.stopPropagation(); - this.isOpen.update(o => !o); + this.isOpen.update((o) => !o); } close(): void { diff --git a/src/app/design-system/empty-state/empty-state.html b/src/app/design-system/empty-state/empty-state.html index 4547807..4fa8f73 100644 --- a/src/app/design-system/empty-state/empty-state.html +++ b/src/app/design-system/empty-state/empty-state.html @@ -1,19 +1,21 @@ -
-
+
+
-

- {{ title() }} -

+

{{ title() }}

{{ description() }}

@if (actionLabel()) { - - {{ actionLabel() }} - + + {{ actionLabel() }} + }
diff --git a/src/app/design-system/empty-state/empty-state.ts b/src/app/design-system/empty-state/empty-state.ts index 1ed6459..ec763cf 100644 --- a/src/app/design-system/empty-state/empty-state.ts +++ b/src/app/design-system/empty-state/empty-state.ts @@ -8,12 +8,14 @@ import { ButtonComponent } from '../button/button'; imports: [AppIconComponent, ButtonComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './empty-state.html', - styleUrl: './empty-state.css' + styleUrl: './empty-state.css', }) export class EmptyStateComponent { readonly icon = input('box'); readonly title = input('Nenhum registro encontrado'); - readonly description = input('Não há dados disponíveis para exibição neste módulo ou filtro.'); + readonly description = input( + 'Não há dados disponíveis para exibição neste módulo ou filtro.', + ); readonly actionLabel = input(undefined); readonly actionClick = output(); diff --git a/src/app/design-system/error-state/error-state.html b/src/app/design-system/error-state/error-state.html index 2816e11..1495169 100644 --- a/src/app/design-system/error-state/error-state.html +++ b/src/app/design-system/error-state/error-state.html @@ -1,11 +1,11 @@ -
+
-

- {{ title() }} -

+

{{ title() }}

{{ message() }} diff --git a/src/app/design-system/error-state/error-state.ts b/src/app/design-system/error-state/error-state.ts index ba942cd..dd2269b 100644 --- a/src/app/design-system/error-state/error-state.ts +++ b/src/app/design-system/error-state/error-state.ts @@ -8,11 +8,13 @@ import { ButtonComponent } from '../button/button'; imports: [AppIconComponent, ButtonComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './error-state.html', - styleUrl: './error-state.css' + styleUrl: './error-state.css', }) export class ErrorStateComponent { readonly title = input('Erro ao carregar dados'); - readonly message = input('Ocorreu uma falha na comunicação com o servidor. Por favor, tente novamente.'); + readonly message = input( + 'Ocorreu uma falha na comunicação com o servidor. Por favor, tente novamente.', + ); readonly retry = output(); diff --git a/src/app/design-system/icon/app-icon.html b/src/app/design-system/icon/app-icon.html index 6aa8f63..d3e37ab 100644 --- a/src/app/design-system/icon/app-icon.html +++ b/src/app/design-system/icon/app-icon.html @@ -1,289 +1,387 @@ - - @switch (name()) { - @case ('box') { - - - - } - @case ('layout-dashboard') { - - - - - - - } - @case ('layers') { - - - - - - } - @case ('car') { - - - - - - } - @case ('shopping-bag') { - - - - - - } - @case ('upload-cloud') { - - - - - - } - @case ('bar-chart-3') { - - - - - - } - @case ('bell') { - - - - - } - @case ('settings') { - - - - - } - @case ('help-circle') { - - - - - - } - @case ('search') { - - - - - } - @case ('chevron-down') { - - - - } - @case ('chevron-right') { - - - - } - @case ('chevron-left') { - - - - } - @case ('chevrons-left') { - - - - - } - @case ('chevrons-right') { - - - - - } - @case ('menu') { - - - - - - } - @case ('x') { - - - - - } - @case ('plus') { - - - - - } - @case ('plus-circle') { - - - - - - } - @case ('sun') { - - - - - - - - - - - - } - @case ('moon') { - - - - } - @case ('user') { - - - - - } - @case ('filter') { - - - - } - @case ('refresh-cw') { - - - - - - } - @case ('trash-2') { - - - - - - - } - @case ('edit') { - - - - - } - @case ('check') { - - - - } - @case ('alert-circle') { - - - - - - } - @case ('info') { - - - - - - } - @case ('external-link') { - - - - - - } - @case ('download') { - - - - - - } - @case ('upload') { - - - - - - } - @case ('folder-plus') { - - - - - - } - @case ('building') { - - - - - - - - - - - } - @case ('package-check') { - - - - - - - } - @case ('log-out') { - - - - - - } - @case ('star') { - - - - } - @case ('copy') { - - - - - } - @case ('eye') { - - - - - } - @case ('arrow-left') { - - - - - } - @case ('image') { - - - - - - } - @default { - - - - } - } + + @switch (name()) { @case ('box') { + + + + } @case ('layout-dashboard') { + + + + + + + } @case ('layers') { + + + + + + } @case ('car') { + + + + + + } @case ('shopping-bag') { + + + + + + } @case ('upload-cloud') { + + + + + + } @case ('bar-chart-3') { + + + + + + } @case ('bell') { + + + + + } @case ('settings') { + + + + + } @case ('help-circle') { + + + + + + } @case ('search') { + + + + + } @case ('chevron-down') { + + + + } @case ('chevron-right') { + + + + } @case ('chevron-left') { + + + + } @case ('chevrons-left') { + + + + + } @case ('chevrons-right') { + + + + + } @case ('menu') { + + + + + + } @case ('x') { + + + + + } @case ('plus') { + + + + + } @case ('plus-circle') { + + + + + + } @case ('sun') { + + + + + + + + + + + + } @case ('moon') { + + + + } @case ('user') { + + + + + } @case ('filter') { + + + + } @case ('refresh-cw') { + + + + + + } @case ('trash-2') { + + + + + + + } @case ('edit') { + + + + + } @case ('check') { + + + + } @case ('alert-circle') { + + + + + + } @case ('info') { + + + + + + } @case ('external-link') { + + + + + + } @case ('download') { + + + + + + } @case ('upload') { + + + + + + } @case ('folder-plus') { + + + + + + } @case ('building') { + + + + + + + + + + + } @case ('package-check') { + + + + + + + } @case ('log-out') { + + + + + + } @case ('star') { + + + + } @case ('copy') { + + + + + } @case ('eye') { + + + + + } @case ('arrow-left') { + + + + + } @case ('image') { + + + + + + } @case ('archive') { + + + + + + } @case ('receipt') { + + + + + + + } @default { + + + + } } diff --git a/src/app/design-system/icon/app-icon.ts b/src/app/design-system/icon/app-icon.ts index ba6a9de..e226ac4 100644 --- a/src/app/design-system/icon/app-icon.ts +++ b/src/app/design-system/icon/app-icon.ts @@ -5,7 +5,7 @@ import { Component, ChangeDetectionStrategy, input, computed } from '@angular/co standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './app-icon.html', - styleUrl: './app-icon.css' + styleUrl: './app-icon.css', }) export class AppIconComponent { readonly name = input.required(); diff --git a/src/app/design-system/input/input.html b/src/app/design-system/input/input.html index 3eedde1..84bd856 100644 --- a/src/app/design-system/input/input.html +++ b/src/app/design-system/input/input.html @@ -1,18 +1,19 @@

@if (label()) { - + }
@if (iconPrefix()) { -
- -
+
+ +
}
@if (error()) { -

- - {{ error() }} -

+

+ + {{ error() }} +

} @else if (helperText()) { -

- {{ helperText() }} -

+

{{ helperText() }}

}
diff --git a/src/app/design-system/input/input.ts b/src/app/design-system/input/input.ts index f1b0fa9..9c68e5c 100644 --- a/src/app/design-system/input/input.ts +++ b/src/app/design-system/input/input.ts @@ -7,7 +7,7 @@ import { AppIconComponent } from '../icon/app-icon'; imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './input.html', - styleUrl: './input.css' + styleUrl: './input.css', }) export class InputComponent { readonly label = input(undefined); @@ -20,6 +20,9 @@ export class InputComponent { readonly error = input(undefined); readonly helperText = input(undefined); readonly iconPrefix = input(undefined); + readonly step = input(undefined); + readonly min = input(undefined); + readonly max = input(undefined); readonly inputId = input('input-' + Math.random().toString(36).substring(2, 7)); readonly valueChange = output(); @@ -31,7 +34,8 @@ export class InputComponent { get inputClasses(): () => string { return () => { - const base = 'w-full px-3 py-2 text-sm rounded-[8px] bg-white dark:bg-slate-800 border transition-colors focus:outline-none focus:ring-2 disabled:bg-gray-100 dark:disabled:bg-slate-900 disabled:cursor-not-allowed text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-500'; + const base = + 'w-full px-3 py-2 text-sm rounded-[8px] bg-white dark:bg-slate-800 border transition-colors focus:outline-none focus:ring-2 disabled:bg-gray-100 dark:disabled:bg-slate-900 disabled:cursor-not-allowed text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-500'; const pl = this.iconPrefix() ? 'pl-9' : 'pl-3'; const border = this.error() ? 'border-[#D66A6A] focus:border-[#D66A6A] focus:ring-[#D66A6A]/20' diff --git a/src/app/design-system/input/search-input.html b/src/app/design-system/input/search-input.html index bfe8414..2648e0f 100644 --- a/src/app/design-system/input/search-input.html +++ b/src/app/design-system/input/search-input.html @@ -1,5 +1,7 @@
-
+
@@ -13,13 +15,13 @@ /> @if (value()) { - + }
diff --git a/src/app/design-system/input/search-input.ts b/src/app/design-system/input/search-input.ts index d58676f..93358c1 100644 --- a/src/app/design-system/input/search-input.ts +++ b/src/app/design-system/input/search-input.ts @@ -7,7 +7,7 @@ import { AppIconComponent } from '../icon/app-icon'; imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './search-input.html', - styleUrl: './search-input.css' + styleUrl: './search-input.css', }) export class SearchInputComponent { readonly value = input(''); diff --git a/src/app/design-system/page-header/page-header.html b/src/app/design-system/page-header/page-header.html index 35e1460..47a8f08 100644 --- a/src/app/design-system/page-header/page-header.html +++ b/src/app/design-system/page-header/page-header.html @@ -1,17 +1,17 @@
@if (breadcrumbs() && breadcrumbs()!.length > 0) { - + }
-

+

{{ title() }}

@if (subtitle()) { -

- {{ subtitle() }} -

+

{{ subtitle() }}

}
diff --git a/src/app/design-system/page-header/page-header.ts b/src/app/design-system/page-header/page-header.ts index 5eb9063..e69e27b 100644 --- a/src/app/design-system/page-header/page-header.ts +++ b/src/app/design-system/page-header/page-header.ts @@ -7,7 +7,7 @@ import { BreadcrumbComponent, BreadcrumbItem } from '../breadcrumb/breadcrumb'; imports: [BreadcrumbComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './page-header.html', - styleUrl: './page-header.css' + styleUrl: './page-header.css', }) export class PageHeaderComponent { readonly title = input.required(); diff --git a/src/app/design-system/pagination/pagination.html b/src/app/design-system/pagination/pagination.html index 21b2673..9e22872 100644 --- a/src/app/design-system/pagination/pagination.html +++ b/src/app/design-system/pagination/pagination.html @@ -1,32 +1,43 @@ -
- +
@if (showItemRange()) { -
- Mostrando {{ startItemFormatted() }} a {{ endItemFormatted() }} de {{ totalItemsFormatted() }} registros -
- } - - @if (showPageSize()) { -
- -
- -
- -
+
+ Mostrando + + {{ startItemFormatted() }} + + a + {{ endItemFormatted() }} + de + + {{ totalItemsFormatted() }} + + registros +
+ } @if (showPageSize()) { +
+ +
+ +
+
+
}
@@ -55,28 +66,28 @@ -
+
{{ currentPage() }} / {{ totalPages() }}
@@ -103,20 +114,21 @@ @if (totalPages() > 5) { - + }
-
diff --git a/src/app/design-system/pagination/pagination.ts b/src/app/design-system/pagination/pagination.ts index c6d550d..6adc295 100644 --- a/src/app/design-system/pagination/pagination.ts +++ b/src/app/design-system/pagination/pagination.ts @@ -12,7 +12,7 @@ export interface PageItem { imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './pagination.html', - styleUrl: './pagination.css' + styleUrl: './pagination.css', }) export class PaginationComponent { readonly currentPage = input(1); diff --git a/src/app/design-system/select/select.html b/src/app/design-system/select/select.html index c11d0c0..a241a4f 100644 --- a/src/app/design-system/select/select.html +++ b/src/app/design-system/select/select.html @@ -1,11 +1,13 @@
@if (label()) { - + }
@@ -17,24 +19,29 @@ class="w-full pl-3 pr-9 py-2 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-[8px] text-gray-900 dark:text-slate-100 appearance-none focus:outline-none focus:border-[#4F8A6B] focus:ring-2 focus:ring-[#4F8A6B]/20 disabled:bg-gray-100 dark:disabled:bg-slate-900 disabled:cursor-not-allowed transition-colors" > @if (placeholder()) { - - } - @for (option of options(); track option.value) { - + + } @for (option of options(); track option.value) { + } -
+
@if (error()) { -

- - {{ error() }} -

+

+ + {{ error() }} +

}
diff --git a/src/app/design-system/select/select.ts b/src/app/design-system/select/select.ts index a5c4732..563a2f1 100644 --- a/src/app/design-system/select/select.ts +++ b/src/app/design-system/select/select.ts @@ -1,11 +1,6 @@ import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; import { AppIconComponent } from '../icon/app-icon'; - -export interface SelectOption { - label: string; - value: string | number; - disabled?: boolean; -} +import { DSelectOption } from '../../core/models/design-system/select-option.model'; @Component({ selector: 'app-select', @@ -13,13 +8,13 @@ export interface SelectOption { imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './select.html', - styleUrl: './select.css' + styleUrl: './select.css', }) export class SelectComponent { readonly label = input(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/design-system/skeleton/skeleton.ts b/src/app/design-system/skeleton/skeleton.ts index 331b0de..b675372 100644 --- a/src/app/design-system/skeleton/skeleton.ts +++ b/src/app/design-system/skeleton/skeleton.ts @@ -5,7 +5,7 @@ import { Component, ChangeDetectionStrategy, input } from '@angular/core'; standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './skeleton.html', - styleUrl: './skeleton.css' + styleUrl: './skeleton.css', }) export class SkeletonComponent { readonly width = input('w-full'); diff --git a/src/app/design-system/toast/toast.html b/src/app/design-system/toast/toast.html index 0036ac4..cfca2c4 100644 --- a/src/app/design-system/toast/toast.html +++ b/src/app/design-system/toast/toast.html @@ -1,31 +1,29 @@
@for (toast of toastService.toasts(); track toast.id) { -
-
- -
- -
-

- {{ toast.title }} -

- @if (toast.message) { -

- {{ toast.message }} -

- } -
+
+
+ +
- +
+

{{ toast.title }}

+ @if (toast.message) { +

+ {{ toast.message }} +

+ }
+ + +
}
diff --git a/src/app/design-system/toast/toast.ts b/src/app/design-system/toast/toast.ts index 8da780a..c3dec2a 100644 --- a/src/app/design-system/toast/toast.ts +++ b/src/app/design-system/toast/toast.ts @@ -8,36 +8,51 @@ import { AppIconComponent } from '../icon/app-icon'; imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './toast.html', - styleUrl: './toast.css' + styleUrl: './toast.css', }) export class ToastContainerComponent { readonly toastService = inject(ToastService); getIcon(type: ToastMessage['type']): string { switch (type) { - case 'success': return 'check'; - case 'error': return 'alert-circle'; - case 'warning': return 'alert-circle'; - case 'info': default: return 'info'; + case 'success': + return 'check'; + case 'error': + return 'alert-circle'; + case 'warning': + return 'alert-circle'; + case 'info': + default: + return 'info'; } } iconColor(type: ToastMessage['type']): string { switch (type) { - case 'success': return 'text-[#5BAE6A]'; - case 'error': return 'text-[#D66A6A]'; - case 'warning': return 'text-[#E9B949]'; - case 'info': default: return 'text-[#5A8DEE]'; + case 'success': + return 'text-[#5BAE6A]'; + case 'error': + return 'text-[#D66A6A]'; + case 'warning': + return 'text-[#E9B949]'; + case 'info': + default: + return 'text-[#5A8DEE]'; } } toastClasses(type: ToastMessage['type']): string { const base = 'bg-white dark:bg-slate-800'; switch (type) { - case 'success': return `${base} border-emerald-200 dark:border-emerald-800/60`; - case 'error': return `${base} border-rose-200 dark:border-rose-800/60`; - case 'warning': return `${base} border-amber-200 dark:border-amber-800/60`; - case 'info': default: return `${base} border-blue-200 dark:border-blue-800/60`; + case 'success': + return `${base} border-emerald-200 dark:border-emerald-800/60`; + case 'error': + return `${base} border-rose-200 dark:border-rose-800/60`; + case 'warning': + return `${base} border-amber-200 dark:border-amber-800/60`; + case 'info': + default: + return `${base} border-blue-200 dark:border-blue-800/60`; } } } diff --git a/src/app/design-system/toolbar/toolbar.html b/src/app/design-system/toolbar/toolbar.html index 72b00a3..7db03c1 100644 --- a/src/app/design-system/toolbar/toolbar.html +++ b/src/app/design-system/toolbar/toolbar.html @@ -1,4 +1,6 @@ -
+
diff --git a/src/app/design-system/toolbar/toolbar.ts b/src/app/design-system/toolbar/toolbar.ts index beb5925..499dbef 100644 --- a/src/app/design-system/toolbar/toolbar.ts +++ b/src/app/design-system/toolbar/toolbar.ts @@ -5,6 +5,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './toolbar.html', - styleUrl: './toolbar.css' + styleUrl: './toolbar.css', }) export class ToolbarComponent {} diff --git a/src/app/design-system/tooltip/tooltip.ts b/src/app/design-system/tooltip/tooltip.ts index 8d2f2e9..ab4db7a 100644 --- a/src/app/design-system/tooltip/tooltip.ts +++ b/src/app/design-system/tooltip/tooltip.ts @@ -2,7 +2,7 @@ import { Directive, ElementRef, HostListener, input, inject, Renderer2 } from '@ @Directive({ selector: '[appTooltip]', - standalone: true + standalone: true, }) export class TooltipDirective { private el = inject(ElementRef); @@ -28,18 +28,27 @@ export class TooltipDirective { if (this.tooltipElement) return; this.tooltipElement = this.renderer.createElement('div'); - this.renderer.appendChild( - this.tooltipElement, - this.renderer.createText(this.tooltipText()) - ); + this.renderer.appendChild(this.tooltipElement, this.renderer.createText(this.tooltipText())); // Apply styles const classes = [ - 'fixed', 'z-50', 'px-2.5', 'py-1', 'text-xs', 'font-medium', 'text-white', - 'bg-gray-900', 'dark:bg-slate-700', 'rounded-md', 'shadow-md', - 'pointer-events-none', 'whitespace-nowrap', 'transition-opacity', 'duration-150' + 'fixed', + 'z-50', + 'px-2.5', + 'py-1', + 'text-xs', + 'font-medium', + 'text-white', + 'bg-gray-900', + 'dark:bg-slate-700', + 'rounded-md', + 'shadow-md', + 'pointer-events-none', + 'whitespace-nowrap', + 'transition-opacity', + 'duration-150', ]; - classes.forEach(cls => this.renderer.addClass(this.tooltipElement, cls)); + classes.forEach((cls) => this.renderer.addClass(this.tooltipElement, cls)); this.renderer.appendChild(document.body, this.tooltipElement); this.positionTooltip(); diff --git a/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html new file mode 100644 index 0000000..75be4bd --- /dev/null +++ b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.html @@ -0,0 +1,265 @@ +
+ + @if (activeTab() === 'fornecimento') { +
+
+
+

+ + Fornecimento & Fornecedores Homologados +

+

+ Fornecedor, código, preço de compra, prazo de entrega (lead time), quantidade mínima, + preferencial, observações e situação +

+
+ + @if (!isReadOnly()) { + + Adicionar Fornecedor + + } +
+ +
+ + + + + + + + + + + + + + + + @for (sup of suppliersPaginated(); track sup.id) { + + + + + + + + + + + + } @empty { + + + + } + +
FornecedorCódigo no FornecedorPreço CompraLead TimeQtd. MínimaPreferencialObservaçõesSituaçãoAções
+ {{ sup.supplierName }} + + {{ sup.supplierCode }} + + R$ {{ sup.purchasePrice.toFixed(2) }} + {{ sup.leadTimeDays }} dias{{ sup.minQuantity || 1 }} un + @if (sup.isPreferential) { + + Sim (Preferencial) + + } @else if (!isReadOnly()) { + + } @else { + Não + } + + {{ sup.notes || '-' }} + + + + @if (!isReadOnly()) { + + } +
+ Nenhum fornecedor cadastrado. +
+
+ + @if (suppliers().length > 0) { +
+ +
+ } +
+ } + + + @if (activeTab() === 'tags') { +
+

+ + Tags do Produto (Situação Ativa) +

+

+ Marque as tags ativas para agrupamento e indexação de busca +

+ +
+ @for (tag of availableTags(); track tag.id) { + + } +
+
+ } + + + @if (activeTab() === 'observacoes') { +
+
+
+

+ + Observações & Notas Internas +

+

+ Tipo de observação, descrição completa e situação da nota +

+
+ + @if (!isReadOnly()) { + + Nova Observação + + } +
+ +
+ + + + + + + + + + + + + @for (note of notesPaginated(); track note.id) { + + + + + + + + + } @empty { + + + + } + +
Tipo da ObservaçãoDescriçãoData RegistroAutorSituaçãoAções
+ + {{ note.type }} + + {{ note.description }}{{ note.date }} + {{ note.author }} + + + + @if (!isReadOnly()) { + + } +
+ Nenhuma observação cadastrada. +
+
+ + @if (productNotes().length > 0) { +
+ +
+ } +
+ } +
diff --git a/src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts new file mode 100644 index 0000000..e72f54f --- /dev/null +++ b/src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts @@ -0,0 +1,87 @@ +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 { FormTab, ProductNoteItem, SupplierItem } from '../../../models/product-workspace.model'; + +export interface TagItem { + id: string; + label: string; +} + +@Component({ + selector: 'app-product-administration-tab', + standalone: true, + imports: [ButtonComponent, AppIconComponent, PaginationComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './administration-tab.html', +}) +export class ProductAdministrationTabComponent { + readonly activeTab = input.required(); + readonly suppliers = input([]); + readonly availableTags = input([]); + readonly selectedTagIds = input([]); + readonly productNotes = input([]); + readonly isReadOnly = input(false); + + readonly addSupplier = output(); + readonly removeSupplier = output(); + readonly setPreferentialSupplier = output(); + readonly toggleSupplierStatus = output(); + readonly toggleTag = output(); + readonly addNote = output(); + readonly removeNote = output(); + readonly toggleNoteStatus = output(); + + readonly suppliersPage = signal(1); + readonly suppliersPageSize = signal(10); + readonly notesPage = signal(1); + readonly notesPageSize = signal(10); + + readonly suppliersPaginated = computed(() => + this.paginate(this.suppliers(), this.suppliersPage(), this.suppliersPageSize()), + ); + readonly notesPaginated = computed(() => + this.paginate(this.productNotes(), this.notesPage(), this.notesPageSize()), + ); + + onAddSupplier(): void { + this.addSupplier.emit(); + } + onRemoveSupplier(id: string): void { + this.removeSupplier.emit(id); + } + onSetPreferentialSupplier(id: string): void { + if (this.isReadOnly()) return; + this.setPreferentialSupplier.emit(id); + } + onToggleSupplierStatus(id: string): void { + if (this.isReadOnly()) return; + this.toggleSupplierStatus.emit(id); + } + + isTagSelected(tagId: string): boolean { + return this.selectedTagIds().includes(tagId); + } + + onToggleTag(tagId: string): void { + if (this.isReadOnly()) return; + this.toggleTag.emit(tagId); + } + + onAddNote(): void { + this.addNote.emit(); + } + onRemoveNote(id: string): void { + this.removeNote.emit(id); + } + onToggleNoteStatus(id: string): void { + if (this.isReadOnly()) return; + this.toggleNoteStatus.emit(id); + } + + private paginate(items: T[], page: number, pageSize: number): T[] { + const start = (page - 1) * pageSize; + return items.slice(start, start + pageSize); + } +} diff --git a/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.html b/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.html new file mode 100644 index 0000000..b718042 --- /dev/null +++ b/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.html @@ -0,0 +1,76 @@ +
+
+ + + + + +
+ +
+ + + + + +
+ + +
+
+
+

Produto Faturado (Emissão de NFe)

+ + {{ formIsInvoiced() ? 'Faturado (Sim)' : 'Não Faturado (Não)' }} + +
+

+ Indica se as saídas deste produto geram faturamento comercial e emissão de nota fiscal de venda. +

+
+ + +
+
diff --git a/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.ts b/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.ts new file mode 100644 index 0000000..75d962c --- /dev/null +++ b/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.ts @@ -0,0 +1,41 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { InputComponent } from '../../../../../design-system/input/input'; + +@Component({ + selector: 'app-product-commercial-tab', + standalone: true, + imports: [InputComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './commercial-tab.html', +}) +export class ProductCommercialTabComponent { + readonly formPrice = input(0); + readonly formPromotionalPrice = input(null); + readonly formPromotionStartDate = input(null); + readonly formPromotionEndDate = input(null); + readonly formIsInvoiced = input(true); + + readonly isReadOnly = input(false); + readonly errors = input>({}); + + readonly fieldChange = output<{ field: string; value: string }>(); + readonly numberFieldChange = output<{ field: string; value: string }>(); + readonly nullableNumberFieldChange = output<{ field: string; value: string }>(); + readonly toggleIsInvoiced = output(); + + onFieldChange(field: string, value: string): void { + this.fieldChange.emit({ field, value }); + } + + onNumberFieldChange(field: string, value: string): void { + this.numberFieldChange.emit({ field, value }); + } + + onNullableNumberFieldChange(field: string, value: string): void { + this.nullableNumberFieldChange.emit({ field, value }); + } + + onToggleIsInvoiced(): void { + this.toggleIsInvoiced.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 new file mode 100644 index 0000000..d147c85 --- /dev/null +++ b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html @@ -0,0 +1,414 @@ +
+ @switch (activeTab()) { @case ('oem') { + +
+
+
+

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

+

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

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

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

+

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

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

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

+

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

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

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

+

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

+
+ + @if (!isReadOnly()) { + + Adicionar Aplicação Veicular + + } +
+ +
+ + + + + + + + + + + + + @for (veh of vehiclePaginated(); track veh.id) { + + + + + + + + + } @empty { + + + + } + +
Aplicação Veicular (Marca/Modelo/Versão)MotorAnosObservaçõesSituaçãoAções
+ {{ veh.brand }} {{ veh.model }} + ({{ veh.version }}) + {{ veh.engine }} + {{ veh.startYear }} - {{ veh.endYear || 'Atual' }} + {{ veh.notes || '-' }} + + + @if (!isReadOnly()) { + + } +
+ Nenhuma aplicação veicular vinculada. +
+
+ + @if (vehicleApplications().length > 0) { +
+ +
+ } +
+ } } +
diff --git a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts new file mode 100644 index 0000000..bc0a9f2 --- /dev/null +++ b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts @@ -0,0 +1,123 @@ +import { Component, ChangeDetectionStrategy, computed, input, output, signal } from '@angular/core'; +import { ButtonComponent } from '../../../../../design-system/button/button'; +import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; +import { PaginationComponent } from '../../../../../design-system/pagination/pagination'; +import { getCompatibilityModuleByTab } from '../../../../../core/config/compatibility-modules.config'; +import { + EquivalentProductItem, + FormTab, + OemCodeItem, + ProductCodeItem, + VehicleApplicationItem, +} from '../../../models/product-workspace.model'; + +@Component({ + selector: 'app-product-compatibility-tab', + standalone: true, + imports: [ButtonComponent, AppIconComponent, PaginationComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './compatibility-tab.html', +}) +export class ProductCompatibilityTabComponent { + readonly activeTab = input.required(); + readonly oemCodes = input([]); + readonly productCodes = input([]); + readonly equivalentProducts = input([]); + readonly vehicleApplications = input([]); + readonly isReadOnly = input(false); + + readonly addOemCode = output(); + readonly setPrimaryOem = output(); + readonly removeOemCode = output(); + readonly toggleOemStatus = output(); + + readonly addProductCode = output(); + readonly removeProductCode = output(); + readonly toggleProductCodeStatus = output(); + + readonly addEquivalent = output(); + readonly removeEquivalent = output(); + readonly toggleEquivalentStatus = output(); + + readonly addVehicleApplication = output(); + readonly removeVehicleApplication = output(); + readonly toggleVehicleStatus = output(); + + readonly oemPage = signal(1); + readonly oemPageSize = signal(10); + readonly productCodesPage = signal(1); + readonly productCodesPageSize = signal(10); + readonly equivalentPage = signal(1); + readonly equivalentPageSize = signal(10); + readonly vehiclePage = signal(1); + readonly vehiclePageSize = signal(10); + + readonly oemPaginated = computed(() => + this.paginate(this.oemCodes(), this.oemPage(), this.oemPageSize()), + ); + readonly productCodesPaginated = computed(() => + this.paginate(this.productCodes(), this.productCodesPage(), this.productCodesPageSize()), + ); + readonly equivalentPaginated = computed(() => + this.paginate(this.equivalentProducts(), this.equivalentPage(), this.equivalentPageSize()), + ); + readonly vehiclePaginated = computed(() => + this.paginate(this.vehicleApplications(), this.vehiclePage(), this.vehiclePageSize()), + ); + + getModule(tab: FormTab) { + return getCompatibilityModuleByTab(tab)!; + } + + onAddOemCode(): void { + this.addOemCode.emit(); + } + onSetPrimaryOem(id: string): void { + this.setPrimaryOem.emit(id); + } + onRemoveOemCode(id: string): void { + this.removeOemCode.emit(id); + } + onToggleOemStatus(id: string): void { + if (this.isReadOnly()) return; + this.toggleOemStatus.emit(id); + } + + onAddProductCode(): void { + this.addProductCode.emit(); + } + onRemoveProductCode(id: string): void { + this.removeProductCode.emit(id); + } + onToggleProductCodeStatus(id: string): void { + if (this.isReadOnly()) return; + this.toggleProductCodeStatus.emit(id); + } + + onAddEquivalent(): void { + this.addEquivalent.emit(); + } + onRemoveEquivalent(id: string): void { + this.removeEquivalent.emit(id); + } + onToggleEquivalentStatus(id: string): void { + if (this.isReadOnly()) return; + this.toggleEquivalentStatus.emit(id); + } + + onAddVehicleApplication(): void { + this.addVehicleApplication.emit(); + } + onRemoveVehicleApplication(id: string): void { + this.removeVehicleApplication.emit(id); + } + onToggleVehicleStatus(id: string): void { + if (this.isReadOnly()) return; + this.toggleVehicleStatus.emit(id); + } + + private paginate(items: T[], page: number, pageSize: number): T[] { + const start = (page - 1) * pageSize; + return items.slice(start, start + pageSize); + } +} diff --git a/src/app/features/catalog/components/product-form/general-tab/general-tab.html b/src/app/features/catalog/components/product-form/general-tab/general-tab.html new file mode 100644 index 0000000..6afa355 --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/general-tab.html @@ -0,0 +1,64 @@ +
+ + @if ((currentStep() === 0 && activeTab() === 'geral') || currentStep() === 1) { + + } + + + @if ((currentStep() === 0 && activeTab() === 'classification') || currentStep() === 2) { + + } + + + @if ((currentStep() === 0 && activeTab() === 'logistics') || currentStep() === 3) { + + + + } +
diff --git a/src/app/features/catalog/components/product-form/general-tab/general-tab.ts b/src/app/features/catalog/components/product-form/general-tab/general-tab.ts new file mode 100644 index 0000000..b8822b8 --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/general-tab.ts @@ -0,0 +1,55 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; + +import { ProductBasicInfoSectionComponent } from './sections/basic-info-section'; +import { ProductIdentificationSectionComponent } from './sections/identification-section'; +import { ProductDimensionsSectionComponent } from './sections/dimensions-section'; +import { ProductVisibilitySectionComponent } from './sections/visibility-section'; +import { GeneralOptionQuery } from '../../../../../core/models/generals/general-option-query.model'; +import { + ProductGeneralFormData, + ProductGeneralFormOptions, +} from '../../../../../core/models/products/product-create.model'; + +@Component({ + selector: 'app-product-general-tab', + standalone: true, + imports: [ + ProductBasicInfoSectionComponent, + ProductIdentificationSectionComponent, + ProductDimensionsSectionComponent, + ProductVisibilitySectionComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './general-tab.html', +}) +export class ProductGeneralTabComponent { + readonly form = input.required(); + readonly options = input.required(); + + readonly isReadOnly = input(false); + readonly isCreateMode = input(false); + readonly currentStep = input(0); + readonly errors = input>({}); + readonly activeTab = input('geral'); + + // Outputs + readonly fieldChange = output<{ field: string; value: string }>(); + readonly numberFieldChange = output<{ field: string; value: string }>(); + readonly booleanFieldChange = output<{ field: string; value: boolean }>(); + readonly categorySearch = output(); + readonly manufacturerSearch = output(); + readonly descriptionChange = output(); + + onFieldChange(field: string, value: string): void { + this.fieldChange.emit({ field, value }); + } + + onNumberFieldChange(field: string, value: string): void { + this.numberFieldChange.emit({ field, value }); + } + + onBooleanFieldToggle(field: string, currentValue: boolean): void { + if (this.isReadOnly()) return; + this.booleanFieldChange.emit({ field, value: !currentValue }); + } +} diff --git a/src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts b/src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts new file mode 100644 index 0000000..d271cc0 --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts @@ -0,0 +1,93 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { InputComponent } from '../../../../../../design-system/input/input'; + +@Component({ + selector: 'app-product-basic-info-section', + standalone: true, + imports: [InputComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

+ Informações Básicas & Descrições +

+

+ Título, identificador URL e descrições técnicas e comerciais +

+
+ +
+ + + + + @if (!isCreateMode()) { + + } +
+ + + + + +
+ + +
+
+ `, +}) +export class ProductBasicInfoSectionComponent { + readonly formName = input(''); + readonly formSlug = input(''); + readonly formShortDescription = input(''); + readonly formDescription = input(''); + readonly isReadOnly = input(false); + readonly isCreateMode = input(false); + readonly errors = input>({}); + + readonly fieldChange = output<{ field: string; value: string }>(); + readonly descriptionChange = output(); + + onTextareaInput(event: Event): void { + const val = (event.target as HTMLTextAreaElement).value; + this.descriptionChange.emit(val); + } +} diff --git a/src/app/features/catalog/components/product-form/general-tab/sections/dimensions-section.ts b/src/app/features/catalog/components/product-form/general-tab/sections/dimensions-section.ts new file mode 100644 index 0000000..27f1c18 --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/sections/dimensions-section.ts @@ -0,0 +1,99 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { InputComponent } from '../../../../../../design-system/input/input'; +import { SelectComponent } from '../../../../../../design-system/select/select'; +import { DSelectOption } from '../../../../../../core/models/design-system/select-option.model'; + +@Component({ + selector: 'app-product-dimensions-section', + standalone: true, + imports: [InputComponent, SelectComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

+ Informações Físicas & Logísticas +

+

+ Unidade de medida, peso bruto e dimensões da embalagem para cálculo de frete +

+
+ +
+ + + + + + + + + + + + + + +
+
+ `, +}) +export class ProductDimensionsSectionComponent { + readonly formUnitId = input(''); + readonly formWeight = input(0); + readonly formHeight = input(0); + readonly formWidth = input(0); + readonly formLength = input(0); + + readonly isReadOnly = input(false); + readonly unitOptions = input([]); + readonly warehouseOptions = input([]); + + readonly fieldChange = output<{ field: string; value: string }>(); + readonly numberFieldChange = output<{ field: string; value: string }>(); +} diff --git a/src/app/features/catalog/components/product-form/general-tab/sections/identification-section.ts b/src/app/features/catalog/components/product-form/general-tab/sections/identification-section.ts new file mode 100644 index 0000000..539efbf --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/sections/identification-section.ts @@ -0,0 +1,123 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { InputComponent } from '../../../../../../design-system/input/input'; +import { SelectComponent } from '../../../../../../design-system/select/select'; +import { + AutocompleteSelectComponent, + 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'; + +@Component({ + selector: 'app-product-identification-section', + standalone: true, + imports: [InputComponent, SelectComponent, AutocompleteSelectComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

+ Identificação & Classificação +

+

+ Códigos de controle e classificações principais do produto no sistema +

+
+ +
+ + + + + + + + + + + @if (!isCreateMode()) { + + } + + + + + + +
+
+ `, +}) +export class ProductIdentificationSectionComponent { + readonly formCategoryId = input(''); + readonly formManufacturerId = input(''); + readonly formPartOriginId = input(''); + readonly formStatusId = input(''); + readonly formInternalCode = input(''); + readonly formBarcode = input(''); + + readonly isReadOnly = input(false); + readonly isCreateMode = input(false); + readonly errors = input>({}); + + readonly categoryOptions = input([]); + readonly categoryLoading = input(false); + readonly manufacturerOptions = input([]); + readonly manufacturerLoading = input(false); + readonly partOriginOptions = input([]); + readonly statusOptions = input([]); + + readonly fieldChange = output<{ field: string; value: string }>(); + readonly categorySearch = output(); + readonly manufacturerSearch = output(); +} diff --git a/src/app/features/catalog/components/product-form/general-tab/sections/visibility-section.ts b/src/app/features/catalog/components/product-form/general-tab/sections/visibility-section.ts new file mode 100644 index 0000000..36ea4bc --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/sections/visibility-section.ts @@ -0,0 +1,70 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; + +@Component({ + selector: 'app-product-visibility-section', + standalone: true, + imports: [], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

+ Visibilidade & Destaque +

+

Configurações de exibição do produto nas vitrines e canais de venda

+
+ +
+ + @if (!isCreateMode()) { +
+
+ Produto Ativo no Catálogo + Se desativado, o produto fica oculto nas buscas e vitrines +
+ +
+ } + + +
+
+ Produto em Destaque + Exibir em banners e seções prioritárias da loja +
+ +
+
+
+ ` +}) +export class ProductVisibilitySectionComponent { + readonly formActive = input(true); + readonly formFeatured = input(false); + readonly isReadOnly = input(false); + readonly isCreateMode = input(false); + + readonly booleanFieldToggle = output<{ field: string; currentValue: boolean }>(); +} diff --git a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html new file mode 100644 index 0000000..212d41a --- /dev/null +++ b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html @@ -0,0 +1,117 @@ +
+
+

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

+

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

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

Permitir Backorder (Venda sem Estoque)

+

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

+
+ + +
+ + +
+
+ + Campos Gerenciados Automaticamente pelo Sistema +
+ +
+
+ ID (id) + + Gerado Auto (UUID) + +
+ +
+ Produto (product_id) + Vínculo Automático +
+ +
+ Qtd. Reservada (reserved) + 0 (Inicial) +
+ +
+ Status (active) + + Ativo (true) + +
+
+
+
diff --git a/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts new file mode 100644 index 0000000..f227d43 --- /dev/null +++ b/src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts @@ -0,0 +1,45 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { InputComponent } from '../../../../../design-system/input/input'; +import { SelectComponent } from '../../../../../design-system/select/select'; +import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; +import { DSelectOption } from '../../../../../core/models/design-system/select-option.model'; + +@Component({ + selector: 'app-product-inventory-tab', + standalone: true, + imports: [InputComponent, SelectComponent, AppIconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './inventory-tab.html', +}) +export class ProductInventoryTabComponent { + readonly formWarehouseId = input(''); + readonly formQuantity = input(0); + readonly formMinQuantity = input(0); + readonly formMaxQuantity = input(null); + readonly formAllowBackorder = input(false); + + readonly warehouseOptions = input([]); + readonly isReadOnly = input(false); + readonly errors = input>({}); + + readonly fieldChange = output<{ field: string; value: string }>(); + readonly numberFieldChange = output<{ field: string; value: string }>(); + readonly nullableNumberFieldChange = output<{ field: string; value: string }>(); + readonly toggleBackorder = output(); + + onFieldChange(field: string, value: string): void { + this.fieldChange.emit({ field, value }); + } + + onNumberFieldChange(field: string, value: string): void { + this.numberFieldChange.emit({ field, value }); + } + + onNullableNumberFieldChange(field: string, value: string): void { + this.nullableNumberFieldChange.emit({ field, value }); + } + + onToggleBackorder(): void { + this.toggleBackorder.emit(); + } +} diff --git a/src/app/features/catalog/components/product-form/media-tab/media-tab.html b/src/app/features/catalog/components/product-form/media-tab/media-tab.html new file mode 100644 index 0000000..afc2466 --- /dev/null +++ b/src/app/features/catalog/components/product-form/media-tab/media-tab.html @@ -0,0 +1,106 @@ +
+ + @if (!isReadOnly()) { +
+ + + +
+ } + + +
+

+ Galeria de Fotos ({{ mediaImages().length }}) + Uploads processados assincronamente +

+ +
+ @for (img of mediaImages(); track img.id) { +
+
+ + + @if (img.isPrimary) { +
+ Principal +
+ } + + @if (img.status === 'uploading') { +
+
+
+
+ Enviando... {{ img.progress }}% +
+ } @else if (img.status === 'error') { +
+ + Erro no Upload +
+ } +
+ +
+

+ {{ img.name }} +

+

{{ img.size }}

+
+ + @if (!isReadOnly()) { +
+ @if (!img.isPrimary) { + + } @else { + Imagem Principal + } + + +
+ } +
+ } @empty { +
+ +

Nenhuma imagem enviada para este produto.

+
+ } +
+
+
diff --git a/src/app/features/catalog/components/product-form/media-tab/media-tab.ts b/src/app/features/catalog/components/product-form/media-tab/media-tab.ts new file mode 100644 index 0000000..bf27a89 --- /dev/null +++ b/src/app/features/catalog/components/product-form/media-tab/media-tab.ts @@ -0,0 +1,42 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; +import { MediaImageItem } from '../../../models/product-workspace.model'; + +@Component({ + selector: 'app-product-media-tab', + standalone: true, + imports: [AppIconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './media-tab.html', +}) +export class ProductMediaTabComponent { + readonly mediaImages = input([]); + readonly isReadOnly = input(false); + readonly isDragging = input(false); + + readonly dragOver = output(); + readonly dragLeave = output(); + readonly fileDrop = output(); + readonly fileSelected = output(); + readonly setPrimaryImage = output(); + readonly confirmDeleteImage = output(); + + onDragOver(e: DragEvent): void { + this.dragOver.emit(e); + } + onDragLeave(e: DragEvent): void { + this.dragLeave.emit(e); + } + onFileDrop(e: DragEvent): void { + this.fileDrop.emit(e); + } + onFileSelected(e: Event): void { + this.fileSelected.emit(e); + } + onSetPrimaryImage(id: string): void { + this.setPrimaryImage.emit(id); + } + onConfirmDeleteImage(img: MediaImageItem): void { + this.confirmDeleteImage.emit(img); + } +} diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.css b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.css new file mode 100644 index 0000000..85ffadb --- /dev/null +++ b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.css @@ -0,0 +1,54 @@ +/* Scroll discreto na navegação interna do workspace sidebar */ +.workspace-sidebar-nav { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.35) transparent; +} + +.workspace-sidebar-nav::-webkit-scrollbar { + width: 4px; +} + +.workspace-sidebar-nav::-webkit-scrollbar-track { + background: transparent; +} + +.workspace-sidebar-nav::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.35); + border-radius: 9999px; +} + +.workspace-sidebar-nav::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.55); +} + +html.dark .workspace-sidebar-nav { + scrollbar-color: rgba(148, 163, 184, 0.3) transparent; +} + +html.dark .workspace-sidebar-nav::-webkit-scrollbar-thumb { + background-color: rgba(148, 163, 184, 0.3); +} + +html.dark .workspace-sidebar-nav::-webkit-scrollbar-thumb:hover { + background-color: rgba(148, 163, 184, 0.5); +} + +/* Indica conteúdo rolável sem poluir visualmente */ +.workspace-sidebar-nav-fade { + position: relative; +} + +.workspace-sidebar-nav-fade::after { + content: ''; + position: sticky; + bottom: 0; + display: block; + height: 1.25rem; + margin-top: -1.25rem; + pointer-events: none; + background: linear-gradient(to bottom, transparent, rgb(255 255 255 / 0.95)); +} + +html.dark .workspace-sidebar-nav-fade::after { + background: linear-gradient(to bottom, transparent, rgb(30 41 59 / 0.95)); +} diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html new file mode 100644 index 0000000..25e6f3d --- /dev/null +++ b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html @@ -0,0 +1,292 @@ +
+ +
+
+
+ + Abas do Catálogo + + + {{ status()?.name || 'Sem Status' }} + +
+

+ {{ productName() || 'Produto sem nome' }} @if (internal_code()) { + + (SKU: {{ internal_code() }}) + + } +

+
+ + +
+ +
+ +
+
+
+ + +
+ + @if (canScrollLeft()) { +
+ +
+ } + + +
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + @if (canScrollRight()) { +
+ +
+ } +
+
diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts new file mode 100644 index 0000000..cc805bb --- /dev/null +++ b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts @@ -0,0 +1,134 @@ +import { + Component, + ChangeDetectionStrategy, + input, + output, + signal, + ElementRef, + ViewChild, + AfterViewInit, +} from '@angular/core'; +import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; +import { FormTab } from '../../../models/product-workspace.model'; + +@Component({ + selector: 'app-product-workspace-sidebar', + standalone: true, + imports: [AppIconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './product-workspace-sidebar.html', +}) +export class ProductWorkspaceSidebarComponent implements AfterViewInit { + @ViewChild('navContainer') navContainer?: ElementRef; + + readonly productName = input(''); + readonly internal_code = input(''); + readonly status = input<{ name?: string; color?: string; code?: string } | null>(null); + readonly activeTab = input.required(); + + // Item counts for enrichment badges + readonly mediaImagesCount = input(0); + readonly oemCodesCount = input(0); + readonly productCodesCount = input(0); + readonly vehicleApplicationsCount = input(0); + readonly equivalentProductsCount = input(0); + readonly suppliersCount = input(0); + readonly tagsCount = input(0); + readonly notesCount = input(0); + + readonly errors = input>({}); + + readonly tabChange = output(); + + readonly canScrollLeft = signal(false); + readonly canScrollRight = signal(true); + + ngAfterViewInit(): void { + setTimeout(() => this.checkScrollState(), 50); + } + + checkScrollState(): void { + const el = this.navContainer?.nativeElement; + if (!el) return; + this.canScrollLeft.set(el.scrollLeft > 4); + this.canScrollRight.set(el.scrollLeft < el.scrollWidth - el.clientWidth - 4); + } + + scrollTabs(direction: 'left' | 'right'): void { + const el = this.navContainer?.nativeElement; + if (!el) return; + const distance = Math.min(el.clientWidth * 0.7, 280); + el.scrollBy({ + left: direction === 'left' ? -distance : distance, + behavior: 'smooth', + }); + setTimeout(() => this.checkScrollState(), 300); + } + + setActiveTab(tab: FormTab, event?: MouseEvent): void { + console.log('[setActiveTab]', tab); + this.tabChange.emit(tab); + + if (event?.currentTarget) { + (event.currentTarget as HTMLElement).scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + setTimeout(() => this.checkScrollState(), 350); + } + } + + onSelectTab(event: Event): void { + const target = event.target as HTMLSelectElement; + if (target && target.value) { + this.setActiveTab(target.value as FormTab); + } + } + + hasTabError(tab: FormTab): boolean { + const errs = this.errors(); + if (!errs) return false; + if (tab === 'geral') { + return !!(errs['name'] || errs['category_id'] || errs['manufacturer_id']); + } + if (tab === 'comercial') { + return !!errs['price']; + } + if (tab === 'inventario') { + return !!errs['quantity']; + } + return false; + } + + tabClasses(tab: FormTab): string { + const isActive = this.activeTab() === tab; + const base = + 'group relative inline-flex items-center gap-2 px-3.5 sm:px-4 py-3 text-xs sm:text-sm font-medium transition-all whitespace-nowrap cursor-pointer shrink-0 border-b-2 focus:outline-none '; + if (isActive) { + return ( + base + + 'border-[#4F8A6B] text-[#4F8A6B] dark:text-[#5BAE6A] font-semibold bg-[#4F8A6B]/5 dark:bg-[#4F8A6B]/10 rounded-t-lg' + ); + } + return ( + base + + 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:border-slate-600' + ); + } + + badgeClasses(tab: FormTab): string { + const isActive = this.activeTab() === tab; + if (isActive) { + return 'px-2 py-0.5 text-[10px] font-bold rounded-full bg-[#4F8A6B]/15 text-[#4F8A6B] dark:bg-[#4F8A6B]/30 dark:text-[#5BAE6A]'; + } + return 'px-2 py-0.5 text-[10px] font-bold rounded-full bg-gray-100 text-gray-500 dark:bg-slate-700/60 dark:text-slate-400 group-hover:bg-gray-200 dark:group-hover:bg-slate-700'; + } + + mobileTabClasses(tab: FormTab): string { + return this.tabClasses(tab); + } + sidebarTabClasses(tab: FormTab): string { + return this.tabClasses(tab); + } +} diff --git a/src/app/features/catalog/components/product-form/stepper/product-create-stepper.html b/src/app/features/catalog/components/product-form/stepper/product-create-stepper.html new file mode 100644 index 0000000..6ac0f08 --- /dev/null +++ b/src/app/features/catalog/components/product-form/stepper/product-create-stepper.html @@ -0,0 +1,99 @@ +
+
+ + + + + + + + + + +
+ + +
+
+
+
diff --git a/src/app/features/catalog/components/product-form/stepper/product-create-stepper.ts b/src/app/features/catalog/components/product-form/stepper/product-create-stepper.ts new file mode 100644 index 0000000..79a61a2 --- /dev/null +++ b/src/app/features/catalog/components/product-form/stepper/product-create-stepper.ts @@ -0,0 +1,18 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; + +@Component({ + selector: 'app-product-create-stepper', + standalone: true, + imports: [AppIconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './product-create-stepper.html' +}) +export class ProductCreateStepperComponent { + readonly currentStep = input.required(); + readonly stepChange = output(); + + setStep(step: number): void { + this.stepChange.emit(step); + } +} diff --git a/src/app/features/catalog/models/product-workspace.model.ts b/src/app/features/catalog/models/product-workspace.model.ts new file mode 100644 index 0000000..625b22a --- /dev/null +++ b/src/app/features/catalog/models/product-workspace.model.ts @@ -0,0 +1,189 @@ +export type ProductWorkspaceMode = 'edit' | 'view' | 'create'; + +export type FormTab = + | 'geral' + | 'comercial' + | 'inventario' + | 'midia' + | 'oem' + | 'codigos' + | 'fornecimento' + | 'equivalentes' + | 'compatibilidade' + | 'tags' + | 'observacoes' + | 'classification' + | 'logistics' + | 'visibility'; + +export interface OemCodeItem { + id: string; + manufacturer: string; + oemCode: string; + isPrimary: boolean; + status?: string; +} + +export interface ProductCodeItem { + id: string; + type: string; + code: string; + status?: string; +} + +export interface EquivalentProductItem { + id: string; + productName: string; + notes: string; + status?: string; +} + +export interface VehicleApplicationItem { + id: string; + brand: string; + model: string; + version: string; + engine: string; + startYear: number; + endYear: number; + notes?: string; + status?: string; +} + +export interface MediaImageItem { + id: string; + url: string; + name: string; + size: string; + isPrimary: boolean; + order: number; + status: 'uploading' | 'completed' | 'error'; + progress: number; +} + +export interface SupplierItem { + id: string; + supplierName: string; + supplierCode: string; + purchasePrice: number; + leadTimeDays: number; + isPreferential: boolean; + minQuantity?: number; + notes?: string; + status?: string; +} + +export interface ProductNoteItem { + id: string; + type: 'Geral' | 'Técnica' | 'Comercial' | 'Fiscal'; + description: string; + date: string; + author: string; + status?: string; +} + +export interface ProductFormData { + category_id: string; + manufacturer_id: string; + part_origin_id: string; + status_id: string; + + internal_code: string; + barcode: string; + + name: string; + slug: string; + + icon: string; + image: string; + + short_description: string; + description: string; + + weight: number; + height: number; + width: number; + length: number; + + unit_id: string; + + featured: boolean; + active: boolean; + + price: number; + promotionalPrice: number | null; + promotionStartDate: string | null; + promotionEndDate: string | null; + isInvoiced: boolean; + + warehouseId: string; + quantity: number; + minQuantity: number; + maxQuantity: number | null; + allowBackorder: boolean; + + oemCodes: OemCodeItem[]; + productCodes: ProductCodeItem[]; + equivalentProducts: EquivalentProductItem[]; + vehicleApplications: VehicleApplicationItem[]; + mediaImages: MediaImageItem[]; + suppliers: SupplierItem[]; + + selectedTagIds: string[]; + + productNotes: ProductNoteItem[]; +} + +export function defaultProductFormData(): ProductFormData { + return { + category_id: '', + manufacturer_id: '', + part_origin_id: '', + status_id: 'st-01', + + internal_code: '', + barcode: '', + + name: '', + slug: '', + + icon: '', + image: '', + + short_description: '', + description: '', + + weight: 0, + height: 0, + width: 0, + length: 0, + + unit_id: 'un', + + featured: false, + active: true, + + price: 0, + promotionalPrice: null, + promotionStartDate: null, + promotionEndDate: null, + isInvoiced: true, + + warehouseId: 'wh-01', + quantity: 0, + minQuantity: 0, + maxQuantity: null, + allowBackorder: false, + + oemCodes: [], + productCodes: [], + equivalentProducts: [], + vehicleApplications: [], + mediaImages: [], + suppliers: [], + + selectedTagIds: [], + + productNotes: [], + }; +} diff --git a/src/app/features/catalog/product-create/product-create.html b/src/app/features/catalog/product-create/product-create.html new file mode 100644 index 0000000..bbe970c --- /dev/null +++ b/src/app/features/catalog/product-create/product-create.html @@ -0,0 +1,88 @@ +
+ + +
+ Cancelar + + Salvar Produto + +
+
+ + + + + +
+ + + +
+
+ @if (createStep() > 1) { + + Anterior + + } +
+ +
+ @if (createStep() < 3) { + + Próximo Passo + + } + + Salvar Produto + +
+
+
+ + + +
diff --git a/src/app/features/catalog/product-create/product-create.ts b/src/app/features/catalog/product-create/product-create.ts new file mode 100644 index 0000000..af6b9db --- /dev/null +++ b/src/app/features/catalog/product-create/product-create.ts @@ -0,0 +1,332 @@ +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 { ButtonComponent } from '../../../design-system/button/button'; +import { ConfirmDialogComponent } from '../../../design-system/dialog/confirm-dialog'; +import { ProductGeneralTabComponent } from '../components/product-form/general-tab/general-tab'; +import { ProductCreateStepperComponent } from '../components/product-form/stepper/product-create-stepper'; +import { CategoryService } from '../../../core/services/category-service'; +import { ManufacturerService } from '../../../core/services/manufacture-service'; +import { PartOriginService } from '../../../core/services/part-origins-service'; +import { ProductService } from '../../../core/services/product-service'; +import { ToastService } from '../../../core/services/toast'; +import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; +import { DSelectOption } from '../../../core/models/design-system/select-option.model'; +import { GeneralOptionQuery } from '../../../core/models/generals/general-option-query.model'; +import { + createFormToGeneralSource, + defaultProductCreateFormState, + ProductCreateFormState, + toCreateProductPayload, + toProductGeneralFormData, +} from '../../../core/models/products/product-create.model'; + +@Component({ + selector: 'app-product-create', + standalone: true, + imports: [ + PageHeaderComponent, + ButtonComponent, + ConfirmDialogComponent, + ProductGeneralTabComponent, + ProductCreateStepperComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './product-create.html', +}) +export class ProductCreateComponent implements OnInit { + private router = inject(Router); + private productService = inject(ProductService); + private categoryService = inject(CategoryService); + private manufacturerService = inject(ManufacturerService); + private partOriginService = inject(PartOriginService); + private toastService = inject(ToastService); + + readonly createStep = signal(1); + readonly isSaving = signal(false); + readonly isFormDirty = signal(false); + readonly unsavedChangesDialogOpen = signal(false); + readonly errors = signal>({}); + + readonly createForm = signal(defaultProductCreateFormState()); + + readonly categoryLoading = signal(false); + readonly fetchedCategories = signal([]); + readonly manufacturerLoading = signal(false); + readonly fetchedManufacturers = signal([]); + readonly fetchedPartOrigins = signal([]); + + readonly generalForm = computed(() => + toProductGeneralFormData(createFormToGeneralSource(this.createForm())), + ); + + readonly generalFormOptions = computed(() => ({ + categoryOptions: this.fetchedCategories(), + categoryLoading: this.categoryLoading(), + manufacturerOptions: this.fetchedManufacturers(), + manufacturerLoading: this.manufacturerLoading(), + partOriginOptions: this.fetchedPartOrigins(), + unitOptions: this.unitOptions, + statusOptions: [], + })); + + readonly breadcrumbs = [ + { label: 'Catálogo', route: '/catalog/products' }, + { label: 'Produtos', route: '/catalog/products' }, + { label: 'Novo Produto' }, + ]; + + private queries: GeneralOptionQuery = { + search: null, + active: null, + limit: null, + }; + + readonly unitOptions: DSelectOption[] = [ + { label: 'Jogo', value: 'jogo' }, + { label: 'Peça', value: 'peça' }, + { label: 'Par', value: 'par' }, + { label: 'Kit', value: 'kit' }, + { label: 'Litro', value: 'litro' }, + { label: 'Metro', value: 'metro' }, + { label: 'Rolo', value: 'rolo' }, + { label: 'Caixa', value: 'caixa' }, + { label: 'Conjunto', value: 'conjunto' }, + ]; + + ngOnInit(): void { + this.loadInitialOptions(this.queries); + } + + 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.partOriginService.getAll(this.queries).subscribe({ + next: (res) => { + const opts: DSelectOption[] = (res.data || []).map((p) => ({ + label: p.name, + value: p.id, + })); + this.fetchedPartOrigins.set(opts); + }, + }); + } + + setCreateStep(step: number): void { + if (step < 1 || step > 3) return; + this.createStep.set(step); + } + + nextCreateStep(): void { + if (this.createStep() < 3) { + this.createStep.update((s) => s + 1); + } + } + + prevCreateStep(): void { + if (this.createStep() > 1) { + this.createStep.update((s) => s - 1); + } + } + + updateField(field: string, val: string): void { + this.isFormDirty.set(true); + const fieldMapping: Record = { + name: 'name', + internal_code: 'internal_code', + barcode: 'barcode', + category_id: 'category_id', + manufacturer_id: 'manufacturer_id', + part_origin_id: 'part_origin_id', + unit_id: 'unit_id', + short_description: 'short_description', + }; + const key = fieldMapping[field]; + if (key) { + this.createForm.update((p) => ({ ...p, [key]: val })); + } + + if (this.errors()[field]) { + this.errors.update((e) => { + const copy = { ...e }; + delete copy[field]; + return copy; + }); + } + } + + updateBooleanField(field: string, val: boolean): void { + if (field === 'featured') { + this.isFormDirty.set(true); + this.createForm.update((p) => ({ ...p, featured: val })); + } + } + + updateNumberField(field: string, valStr: string): void { + this.isFormDirty.set(true); + const num = parseFloat(valStr) || 0; + const fieldMapping: Record = { + weight: 'weight', + height: 'height', + width: 'width', + length: 'length', + }; + const key = fieldMapping[field]; + if (key) { + this.createForm.update((p) => ({ ...p, [key]: num })); + } + } + + updateDescription(val: string): void { + this.isFormDirty.set(true); + this.createForm.update((p) => ({ ...p, description: val })); + } + + onCategorySearch(query: GeneralOptionQuery): void { + this.categoryLoading.set(true); + this.categoryService.getOptions(query).subscribe({ + next: (res) => { + const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ + label: c.label, + value: c.id, + sublabel: c.description, + icon: c.icon || 'folder', + })); + this.fetchedCategories.set(opts); + this.categoryLoading.set(false); + }, + error: () => this.categoryLoading.set(false), + }); + } + + onManufacturerSearch(query: GeneralOptionQuery): void { + this.manufacturerLoading.set(true); + this.manufacturerService.getOptions(query).subscribe({ + next: (res) => { + const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ + label: m.label, + value: m.id, + sublabel: m.description, + })); + this.fetchedManufacturers.set(opts); + this.manufacturerLoading.set(false); + }, + error: () => this.manufacturerLoading.set(false), + }); + } + + validateForm(): boolean { + const errs: Record = {}; + const p = this.createForm(); + + if (!p.name.trim()) { + errs['name'] = 'O nome do produto é obrigatório.'; + } + if (!p.internal_code.trim()) { + errs['internal_code'] = 'O código interno é obrigatório.'; + } + if (!p.category_id) { + errs['category_id'] = 'Selecione uma categoria.'; + } + if (!p.manufacturer_id) { + errs['manufacturer_id'] = 'Selecione um fabricante.'; + } + if (!p.unit_id) { + errs['unit_id'] = 'Selecione a unidade de medida.'; + } + if (!p.part_origin_id) { + errs['part_origin_id'] = 'Selecione a origem da peça.'; + } + + this.errors.set(errs); + return Object.keys(errs).length === 0; + } + + saveProduct(): void { + if (!this.validateForm()) { + this.toastService.error( + 'Não foi possível salvar o produto.', + 'Verifique os campos obrigatórios destacados.', + ); + return; + } + + this.isSaving.set(true); + const payload = toCreateProductPayload(createFormToGeneralSource(this.createForm())); + + this.productService.create(payload).subscribe({ + next: (response) => { + this.isSaving.set(false); + this.isFormDirty.set(false); + this.toastService.success( + '✔ Produto criado com sucesso.', + 'Entidade principal cadastrada. Você já pode enriquecer as informações complementares.', + ); + const newId = response.id || '1'; + this.router.navigate(['/catalog/products', newId, 'edit'], { + replaceUrl: true, + state: { productJustCreated: true }, + }); + }, + error: (err) => { + this.isSaving.set(false); + console.error('Error saving product:', err); + this.toastService.error( + 'Erro ao salvar o produto', + err.error?.message || err.message || 'Ocorreu um erro inesperado no servidor.', + ); + }, + }); + } + + onCancel(): void { + if (this.isFormDirty()) { + this.unsavedChangesDialogOpen.set(true); + } else { + this.navigateBack(); + } + } + + navigateBack(): void { + this.router.navigate(['/catalog/products']); + } + + executeExitWithoutSave(): void { + this.unsavedChangesDialogOpen.set(false); + this.navigateBack(); + } +} diff --git a/src/app/features/catalog/product-form.css b/src/app/features/catalog/product-form.css deleted file mode 100644 index 8269a07..0000000 --- a/src/app/features/catalog/product-form.css +++ /dev/null @@ -1 +0,0 @@ -/* Product Form Component Styles */ diff --git a/src/app/features/catalog/product-form.html b/src/app/features/catalog/product-form.html deleted file mode 100644 index b7a2224..0000000 --- a/src/app/features/catalog/product-form.html +++ /dev/null @@ -1,918 +0,0 @@ - - -
- @if (mode() === 'view') { - - Voltar - - - Editar - - } @else { - - Cancelar - - @if (mode() === 'edit') { - - Duplicar - - } - } -
-
- - -
- -
- - -
- - @if (activeTab() === 'geral') { -
-
- - - - - -
- -
- - - - - - - - - - - -
- -
- - -
- - -
- - -
- - -
-

- Dimensões e Peso -

- -
- - - - - - - -
-
-
- } - - - @if (activeTab() === 'comercial') { -
-
- - - - - -
- -
- - - - - -
- -
- - - - - - - - -
- - -
-
-

Permitir Backorder (Venda sem Estoque)

-

Permite que o produto continue recebendo pedidos mesmo sem saldo físico em estoque

-
- - -
-
- } - - - @if (activeTab() === 'compatibilidade') { -
- -
-
-
-

- - Códigos OEM -

-

Códigos originais de montadoras cadastrados para esta peça

-
- - @if (!isReadOnly()) { - - Adicionar Código OEM - - } -
- -
- - - - - - - - - - - @for (oem of oemCodes(); track oem.id) { - - - - - - - } @empty { - - - - } - -
Fabricante / MontadoraCódigo OEMPrincipalAções
{{ oem.manufacturer }}{{ oem.oemCode }} - @if (oem.isPrimary) { - - Principal - - } @else if (!isReadOnly()) { - - } @else { - - - } - - @if (!isReadOnly()) { - - } -
Nenhum código OEM cadastrado.
-
-
- - -
-
-
-

- - Códigos de Barras & Referência -

-

EAN-13, DUN-14 e códigos de fabricante comercial

-
- - @if (!isReadOnly()) { - - Adicionar Código - - } -
- -
- - - - - - - - - - @for (codeItem of productCodes(); track codeItem.id) { - - - - - - } @empty { - - - - } - -
TipoCódigoAções
{{ codeItem.type }}{{ codeItem.code }} - @if (!isReadOnly()) { - - } -
Nenhum código cadastrado.
-
-
- - -
-
-
-

- - Produtos Equivalentes -

-

Peças similares de concorrentes ou marcas cruzadas

-
- - @if (!isReadOnly()) { - - Adicionar Equivalente - - } -
- -
- - - - - - - - - - @for (eq of equivalentProducts(); track eq.id) { - - - - - - } @empty { - - - - } - -
Produto EquivalenteObservaçãoAções
{{ eq.productName }}{{ eq.notes }} - @if (!isReadOnly()) { - - } -
Nenhum produto equivalente vinculado.
-
-
- - -
-
-
-

- - Aplicações Veiculares -

-

Compatibilidade com marca, modelo, versão, motorização e anos do veículo

-
- - @if (!isReadOnly()) { - - Adicionar Aplicação - - } -
- -
- - - - - - - - - - - - - - @for (veh of vehicleApplications(); track veh.id) { - - - - - - - - - - } @empty { - - - - } - -
MarcaModeloVersãoMotorAno InicialAno FinalAções
{{ veh.brand }}{{ veh.model }}{{ veh.version }}{{ veh.engine }}{{ veh.startYear }}{{ veh.endYear || 'Atual' }} - @if (!isReadOnly()) { - - } -
Nenhuma aplicação veicular vinculada.
-
-
-
- } - - - @if (activeTab() === 'midia') { -
- - @if (!isReadOnly()) { -
- - - -
- } - - -
-

- Galeria de Fotos ({{ mediaImages().length }}) - Uploads processados assincronamente -

- -
- @for (img of mediaImages(); track img.id) { -
-
- - - @if (img.isPrimary) { -
- Principal -
- } - - @if (img.status === 'uploading') { -
-
-
-
- Enviando... {{ img.progress }}% -
- } @else if (img.status === 'error') { -
- - Erro no Upload -
- } -
- -
-

- {{ img.name }} -

-

{{ img.size }}

-
- - @if (!isReadOnly()) { -
- @if (!img.isPrimary) { - - } @else { - Imagem Principal - } - - -
- } -
- } @empty { -
- -

Nenhuma imagem enviada para este produto.

-
- } -
-
-
- } - - - @if (activeTab() === 'administracao') { -
- -
-
-
-

- - Fornecedores Homologados -

-

Origens de fornecimento, prazos de entrega e preços de compra

-
- - @if (!isReadOnly()) { - - Adicionar Fornecedor - - } -
- -
- - - - - - - - - - - - - @for (sup of suppliers(); track sup.id) { - - - - - - - - - } @empty { - - - - } - -
FornecedorCódigo no FornecedorPreço Compra (R$)Lead Time (Dias)PreferencialAções
{{ sup.supplierName }}{{ sup.supplierCode }}R$ {{ sup.purchasePrice.toFixed(2) }}{{ sup.leadTimeDays }} dias - @if (sup.isPreferential) { - Sim - } @else { - Não - } - - @if (!isReadOnly()) { - - } -
Nenhum fornecedor cadastrado.
-
-
- - -
-

- - Tags do Produto -

-

Marque marcadores de pesquisa e agrupamento rápido

- -
- @for (tag of availableTags; track tag.id) { - - } -
-
- - -
-
-
-

- - Notas e Observações Internas -

-

Anotações administrativas, técnicas ou comerciais registradas pela equipe

-
- - @if (!isReadOnly()) { - - Nova Nota - - } -
- -
- - - - - - - - - - - - @for (note of productNotes(); track note.id) { - - - - - - - - } @empty { - - - - } - -
TipoDescriçãoDataAutorAções
- - {{ note.type }} - - {{ note.description }}{{ note.date }}{{ note.author }} - @if (!isReadOnly()) { - - } -
Nenhuma nota cadastrada.
-
-
-
- } -
- - -
-
-
- @if (mode() === 'edit') { - - Excluir - - } -
- -
- @if (mode() === 'view') { - - Voltar - - - Editar Produto - - } @else { - - Cancelar - - - Salvar - - - Salvar e Continuar - - } -
-
-
- - - - - - - - - \ No newline at end of file diff --git a/src/app/features/catalog/product-form.ts b/src/app/features/catalog/product-form.ts deleted file mode 100644 index 70e8c1f..0000000 --- a/src/app/features/catalog/product-form.ts +++ /dev/null @@ -1,726 +0,0 @@ -import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { PageHeaderComponent } from '../../design-system/page-header/page-header'; -import { InputComponent } from '../../design-system/input/input'; -import { SelectComponent, SelectOption } from '../../design-system/select/select'; -import { ButtonComponent } from '../../design-system/button/button'; -import { ConfirmDialogComponent } from '../../design-system/dialog/confirm-dialog'; -import { AppIconComponent } from '../../design-system/icon/app-icon'; -import { ToastService } from '../../core/services/toast'; -import { ShellStateService } from '../../core/services/shell-state'; - -export type ProductFormMode = 'create' | 'edit' | 'view' | 'duplicate'; -export type FormTab = 'geral' | 'comercial' | 'compatibilidade' | 'midia' | 'administracao'; - -export interface OemCodeItem { - id: string; - manufacturer: string; - oemCode: string; - isPrimary: boolean; -} - -export interface ProductCodeItem { - id: string; - type: string; - code: string; -} - -export interface EquivalentProductItem { - id: string; - productName: string; - notes: string; -} - -export interface VehicleApplicationItem { - id: string; - brand: string; - model: string; - version: string; - engine: string; - startYear: number; - endYear: number; -} - -export interface MediaImageItem { - id: string; - url: string; - name: string; - size: string; - isPrimary: boolean; - order: number; - status: 'uploading' | 'completed' | 'error'; - progress: number; -} - -export interface SupplierItem { - id: string; - supplierName: string; - supplierCode: string; - purchasePrice: number; - leadTimeDays: number; - isPreferential: boolean; -} - -export interface ProductNoteItem { - id: string; - type: 'Geral' | 'Técnica' | 'Comercial' | 'Fiscal'; - description: string; - date: string; - author: string; -} - -@Component({ - selector: 'app-product-form', - standalone: true, - imports: [ - PageHeaderComponent, - InputComponent, - SelectComponent, - ButtonComponent, - ConfirmDialogComponent, - AppIconComponent - ], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: "./product-form.html", - styleUrl: "./product-form.css" -}) -export class ProductFormComponent implements OnInit { - readonly shellState = inject(ShellStateService); - private route = inject(ActivatedRoute); - private router = inject(Router); - private toastService = inject(ToastService); - - readonly mode = signal('create'); - readonly activeTab = signal('geral'); - readonly isSaving = signal(false); - readonly isDragging = signal(false); - readonly isFormDirty = signal(false); - - // Dialog States - readonly deleteProductDialogOpen = signal(false); - readonly unsavedChangesDialogOpen = signal(false); - readonly deleteImageDialogOpen = signal(false); - readonly selectedImageForDelete = signal(null); - - // Validation errors map - readonly errors = signal>({}); - - // Form Fields State (Signals) - readonly formName = signal(''); - readonly formInternalCode = signal(''); - readonly formCategoryId = signal(''); - readonly formManufacturerId = signal(''); - readonly formPartOriginId = signal(''); - readonly formUnitId = signal(''); - readonly formStatusId = signal('st-01'); - readonly formDescription = signal(''); - - readonly formWeight = signal(0); - readonly formHeight = signal(0); - readonly formWidth = signal(0); - readonly formLength = signal(0); - - readonly formPrice = signal(0); - readonly formPromotionalPrice = signal(null); - readonly formPromotionStartDate = signal(''); - readonly formPromotionEndDate = signal(''); - readonly formWarehouseId = signal('wh-01'); - readonly formQuantity = signal(0); - readonly formMinQuantity = signal(0); - readonly formAllowBackorder = signal(false); - - // Collection Signals - readonly oemCodes = signal([]); - readonly productCodes = signal([]); - readonly equivalentProducts = signal([]); - readonly vehicleApplications = signal([]); - readonly mediaImages = signal([]); - readonly suppliers = signal([]); - readonly selectedTagIds = signal([]); - readonly productNotes = signal([]); - - // Static Dropdown Options - readonly categoryOptions: SelectOption[] = [ - { label: 'Sistemas de Freios', value: 'cat-01' }, - { label: 'Motor & Transmissão', value: 'cat-02' }, - { label: 'Suspensão & Direção', value: 'cat-03' }, - { label: 'Sistema Elétrico', value: 'cat-04' }, - { label: 'Arrefecimento', value: 'cat-05' } - ]; - - readonly manufacturerOptions: SelectOption[] = [ - { label: 'Bosch', value: 'm-01' }, - { label: 'Continental', value: 'm-02' }, - { label: 'Monroe', value: 'm-03' }, - { label: 'NGK', value: 'm-04' }, - { label: 'Fremax', value: 'm-05' }, - { label: 'Magneti Marelli', value: 'm-06' }, - { label: 'Sachs', value: 'm-07' } - ]; - - readonly partOriginOptions: SelectOption[] = [ - { label: 'Nacional', value: 'po-01' }, - { label: 'Importado Direto', value: 'po-02' }, - { label: 'Original OEM', value: 'po-03' }, - { label: 'Aftermarket Premium', value: 'po-04' } - ]; - - readonly unitOptions: SelectOption[] = [ - { label: 'UN - Unidade', value: 'u-01' }, - { label: 'JG - Jogo', value: 'u-02' }, - { label: 'PC - Peça', value: 'u-03' }, - { label: 'PAR - Par', value: 'u-04' }, - { label: 'KG - Quilograma', value: 'u-05' } - ]; - - readonly statusOptions: SelectOption[] = [ - { label: 'Ativo', value: 'st-01' }, - { label: 'Inativo', value: 'st-02' }, - { label: 'Em Homologação', value: 'st-03' } - ]; - - readonly warehouseOptions: SelectOption[] = [ - { label: 'Depósito Central SP', value: 'wh-01' }, - { label: 'Depósito Filial PR', value: 'wh-02' }, - { label: 'Depósito Distribuição RJ', value: 'wh-03' } - ]; - - readonly availableTags = [ - { id: 'tag-1', label: 'Garantia 12 Meses' }, - { id: 'tag-2', label: 'Linha Leve' }, - { id: 'tag-3', label: 'Alto Giro' }, - { id: 'tag-4', label: 'Primeira Linha' }, - { id: 'tag-5', label: 'Importado' }, - { id: 'tag-6', label: 'Oferta Especial' } - ]; - - readonly isReadOnly = computed(() => this.mode() === 'view'); - - readonly headerTitle = computed(() => { - switch (this.mode()) { - case 'edit': return 'Editar Produto'; - case 'view': return 'Visualizar Produto'; - case 'duplicate': return 'Novo Produto'; - default: return 'Novo Produto'; - } - }); - - readonly breadcrumbs = computed(() => { - const actionLabel = this.headerTitle(); - return [ - { label: 'Catálogo', route: '/catalog/products' }, - { label: 'Produtos', route: '/catalog/products' }, - { label: actionLabel } - ]; - }); - - ngOnInit(): void { - const url = this.router.url; - const id = this.route.snapshot.paramMap.get('id'); - - if (url.includes('/new')) { - this.mode.set('create'); - } else if (url.includes('/edit')) { - this.mode.set('edit'); - this.loadMockProduct(id); - } else if (url.includes('/view') || (id && !url.includes('/duplicate'))) { - this.mode.set('view'); - this.loadMockProduct(id); - } else if (url.includes('/duplicate')) { - this.mode.set('duplicate'); - this.loadMockProduct(id); - // Backend mandate rule for duplicate: clean internal_code automatically - this.formInternalCode.set(''); - } - } - - loadMockProduct(id: string | null): void { - if (id) { - console.log('Loading product data for ID:', id); - } - // Populate form with realistic data matching specification contract - this.formName.set('Jogo de Pastilhas de Freio Dianteira'); - this.formInternalCode.set('REC-10029'); - this.formCategoryId.set('cat-01'); - this.formManufacturerId.set('m-01'); - this.formPartOriginId.set('po-01'); - this.formUnitId.set('u-02'); - this.formStatusId.set('st-01'); - this.formDescription.set('Jogo de pastilhas de freio dianteiras de alta performance, compostas por massa cerâmica de baixo ruído e menor liberação de poeira nas rodas. Homologado para veículos de passeio leves.'); - - this.formWeight.set(1.45); - this.formHeight.set(8.5); - this.formWidth.set(12.0); - this.formLength.set(18.0); - - this.formPrice.set(189.90); - this.formPromotionalPrice.set(169.90); - this.formPromotionStartDate.set('2026-07-01'); - this.formPromotionEndDate.set('2026-08-31'); - this.formWarehouseId.set('wh-01'); - this.formQuantity.set(45); - this.formMinQuantity.set(10); - this.formAllowBackorder.set(true); - - this.oemCodes.set([ - { id: 'oem-1', manufacturer: 'Volkswagen / Audi', oemCode: '5U0698151A', isPrimary: true }, - { id: 'oem-2', manufacturer: 'Bosch Global', oemCode: '0986BB0781', isPrimary: false } - ]); - - this.productCodes.set([ - { id: 'pc-1', type: 'EAN-13', code: '7891234567890' }, - { id: 'pc-2', type: 'Código Fábrica', code: 'BOSCH-PAD-409' } - ]); - - this.equivalentProducts.set([ - { id: 'eq-1', productName: 'Cobreq N-238 Pastilha Dianteira', notes: 'Equivalência direta 100%' }, - { id: 'eq-2', productName: 'Fras-le PD/102', notes: 'Linha alternativa mercado' } - ]); - - this.vehicleApplications.set([ - { id: 'veh-1', brand: 'Volkswagen', model: 'Gol G5 / G6 / G7', version: '1.0 / 1.6 8V', engine: 'EA111 Flex', startYear: 2008, endYear: 2022 }, - { id: 'veh-2', brand: 'Volkswagen', model: 'Voyage', version: '1.6 8V Trend', engine: 'EA111 Flex', startYear: 2009, endYear: 2021 } - ]); - - this.mediaImages.set([ - { id: 'img-1', url: 'https://picsum.photos/seed/brake_pads_1/400/300', name: 'pastilha_freio_frente.jpg', size: '1.2 MB', isPrimary: true, order: 1, status: 'completed', progress: 100 }, - { id: 'img-2', url: 'https://picsum.photos/seed/brake_pads_2/400/300', name: 'pastilha_freio_verso.jpg', size: '980 KB', isPrimary: false, order: 2, status: 'completed', progress: 100 } - ]); - - this.suppliers.set([ - { id: 'sup-1', supplierName: 'Distribuidora Automotiva SP', supplierCode: 'SUP-BOSCH-882', purchasePrice: 112.50, leadTimeDays: 3, isPreferential: true } - ]); - - this.selectedTagIds.set(['tag-1', 'tag-2', 'tag-4']); - - this.productNotes.set([ - { id: 'note-1', type: 'Técnica', description: 'Pastilhas com mola antirruído inclusa no kit.', date: '27/07/2026', author: 'Engenharia de Produto' } - ]); - } - - setActiveTab(tab: FormTab): void { - this.activeTab.set(tab); - } - - tabClasses(tab: FormTab): string { - const base = 'pb-3 px-1 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors cursor-pointer whitespace-nowrap'; - if (this.activeTab() === tab) { - return `${base} border-[#4F8A6B] text-[#4F8A6B] font-semibold dark:text-[#5BAE6A] dark:border-[#5BAE6A]`; - } - return `${base} border-transparent text-gray-500 hover:text-gray-700 dark:text-slate-400 dark:hover:text-slate-200`; - } - - hasTabError(tab: FormTab): boolean { - const errs = this.errors(); - if (tab === 'geral') { - return !!(errs['name'] || errs['internal_code'] || errs['category_id'] || errs['manufacturer_id'] || errs['unit_id'] || errs['status_id']); - } - if (tab === 'comercial') { - return !!errs['price']; - } - return false; - } - - updateField(field: string, val: string): void { - this.isFormDirty.set(true); - switch (field) { - case 'name': this.formName.set(val); break; - case 'internal_code': this.formInternalCode.set(val); break; - case 'category_id': this.formCategoryId.set(val); break; - case 'manufacturer_id': this.formManufacturerId.set(val); break; - case 'part_origin_id': this.formPartOriginId.set(val); break; - case 'unit_id': this.formUnitId.set(val); break; - case 'status_id': this.formStatusId.set(val); break; - case 'promotion_start_date': this.formPromotionStartDate.set(val); break; - case 'promotion_end_date': this.formPromotionEndDate.set(val); break; - case 'warehouse_id': this.formWarehouseId.set(val); break; - } - - if (this.errors()[field]) { - this.errors.update(e => { - const copy = { ...e }; - delete copy[field]; - return copy; - }); - } - } - - updateNumberField(field: string, valStr: string): void { - this.isFormDirty.set(true); - const num = parseFloat(valStr) || 0; - switch (field) { - case 'weight': this.formWeight.set(num); break; - case 'height': this.formHeight.set(num); break; - case 'width': this.formWidth.set(num); break; - case 'length': this.formLength.set(num); break; - case 'price': this.formPrice.set(num); break; - case 'quantity': this.formQuantity.set(num); break; - case 'min_quantity': this.formMinQuantity.set(num); break; - } - - if (this.errors()[field]) { - this.errors.update(e => { - const copy = { ...e }; - delete copy[field]; - return copy; - }); - } - } - - updateNullableNumberField(field: string, valStr: string): void { - this.isFormDirty.set(true); - const val = valStr.trim() === '' ? null : parseFloat(valStr); - if (field === 'promotional_price') { - this.formPromotionalPrice.set(val); - } - } - - onTextareaInput(event: Event): void { - this.isFormDirty.set(true); - const val = (event.target as HTMLTextAreaElement).value; - this.formDescription.set(val); - } - - toggleBackorder(): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.formAllowBackorder.update(v => !v); - } - - // --- COMPATIBILIDADE COLLECTIONS --- - openAddOemModal(): void { - const code = prompt('Digite o Código OEM:'); - if (!code) return; - const manufacturer = prompt('Digite o Fabricante/Montadora:', 'Volkswagen') || 'Montadora'; - this.oemCodes.update(list => [ - ...list, - { id: 'oem-' + Date.now(), manufacturer, oemCode: code.toUpperCase(), isPrimary: list.length === 0 } - ]); - this.isFormDirty.set(true); - } - - setPrimaryOem(id: string): void { - this.oemCodes.update(list => list.map(o => ({ ...o, isPrimary: o.id === id }))); - this.isFormDirty.set(true); - } - - removeOemCode(id: string): void { - this.oemCodes.update(list => list.filter(o => o.id !== id)); - this.isFormDirty.set(true); - } - - openAddProductCodeModal(): void { - const code = prompt('Digite o Código:'); - if (!code) return; - const type = prompt('Digite o Tipo (Ex.: EAN-13, Código Fábrica):', 'EAN-13') || 'Geral'; - this.productCodes.update(list => [ - ...list, - { id: 'pc-' + Date.now(), type, code } - ]); - this.isFormDirty.set(true); - } - - removeProductCode(id: string): void { - this.productCodes.update(list => list.filter(p => p.id !== id)); - this.isFormDirty.set(true); - } - - openAddEquivalentModal(): void { - const name = prompt('Digite o Nome do Produto Equivalente:'); - if (!name) return; - const notes = prompt('Observação de equivalência:', 'Compatibilidade direta') || ''; - this.equivalentProducts.update(list => [ - ...list, - { id: 'eq-' + Date.now(), productName: name, notes } - ]); - this.isFormDirty.set(true); - } - - removeEquivalent(id: string): void { - this.equivalentProducts.update(list => list.filter(e => e.id !== id)); - this.isFormDirty.set(true); - } - - openAddVehicleModal(): void { - const brand = prompt('Marca do Veículo:', 'Volkswagen') || 'Volkswagen'; - const model = prompt('Modelo:', 'Gol') || 'Modelo'; - const engine = prompt('Motorização:', '1.6 8V') || '1.0'; - this.vehicleApplications.update(list => [ - ...list, - { id: 'veh-' + Date.now(), brand, model, version: 'Flex', engine, startYear: 2015, endYear: 2022 } - ]); - this.isFormDirty.set(true); - } - - removeVehicleApplication(id: string): void { - this.vehicleApplications.update(list => list.filter(v => v.id !== id)); - this.isFormDirty.set(true); - } - - // --- MÍDIA UPLOAD --- - onDragOver(e: DragEvent): void { - e.preventDefault(); - this.isDragging.set(true); - } - - onDragLeave(e: DragEvent): void { - e.preventDefault(); - this.isDragging.set(false); - } - - onFileDrop(e: DragEvent): void { - e.preventDefault(); - this.isDragging.set(false); - if (e.dataTransfer?.files) { - this.handleFiles(Array.from(e.dataTransfer.files)); - } - } - - onFileSelected(e: Event): void { - const input = e.target as HTMLInputElement; - if (input.files) { - this.handleFiles(Array.from(input.files)); - } - } - - handleFiles(files: File[]): void { - files.forEach(file => { - if (file.size > 2 * 1024 * 1024) { - this.toastService.error('Arquivo Excede Limite', `O arquivo ${file.name} possui mais de 2 MB.`); - return; - } - - const newImg: MediaImageItem = { - id: 'img-' + Date.now() + Math.random().toString(36).substring(2, 5), - url: URL.createObjectURL(file), - name: file.name, - size: (file.size / (1024 * 1024)).toFixed(1) + ' MB', - isPrimary: this.mediaImages().length === 0, - order: this.mediaImages().length + 1, - status: 'uploading', - progress: 0 - }; - - this.mediaImages.update(list => [...list, newImg]); - this.simulateAsyncUpload(newImg.id); - }); - this.isFormDirty.set(true); - } - - simulateAsyncUpload(imgId: string): void { - let prog = 0; - const interval = setInterval(() => { - prog += 25; - this.mediaImages.update(list => - list.map(img => img.id === imgId ? { ...img, progress: prog } : img) - ); - - if (prog >= 100) { - clearInterval(interval); - this.mediaImages.update(list => - list.map(img => img.id === imgId ? { ...img, status: 'completed' } : img) - ); - } - }, 200); - } - - setPrimaryImage(id: string): void { - this.mediaImages.update(list => list.map(img => ({ ...img, isPrimary: img.id === id }))); - this.isFormDirty.set(true); - } - - confirmDeleteImage(img: MediaImageItem): void { - this.selectedImageForDelete.set(img); - this.deleteImageDialogOpen.set(true); - } - - executeDeleteImage(): void { - const img = this.selectedImageForDelete(); - if (img) { - this.mediaImages.update(list => { - const filtered = list.filter(i => i.id !== img.id); - if (img.isPrimary && filtered.length > 0) { - filtered[0].isPrimary = true; - } - return filtered; - }); - this.toastService.success('Imagem Removida', `${img.name} foi removida da galeria.`); - } - this.deleteImageDialogOpen.set(false); - } - - // --- ADMINISTRAÇÃO COLLECTIONS --- - openAddSupplierModal(): void { - const name = prompt('Nome do Fornecedor:'); - if (!name) return; - const code = prompt('Código no Fornecedor:', 'SUP-01') || 'SUP-01'; - this.suppliers.update(list => [ - ...list, - { id: 'sup-' + Date.now(), supplierName: name, supplierCode: code, purchasePrice: 100.0, leadTimeDays: 5, isPreferential: list.length === 0 } - ]); - this.isFormDirty.set(true); - } - - removeSupplier(id: string): void { - this.suppliers.update(list => list.filter(s => s.id !== id)); - this.isFormDirty.set(true); - } - - isTagSelected(tagId: string): boolean { - return this.selectedTagIds().includes(tagId); - } - - toggleTag(tagId: string): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - if (this.isTagSelected(tagId)) { - this.selectedTagIds.update(ids => ids.filter(id => id !== tagId)); - } else { - this.selectedTagIds.update(ids => [...ids, tagId]); - } - } - - openAddNoteModal(): void { - const desc = prompt('Descrição da Nota/Observação:'); - if (!desc) return; - this.productNotes.update(list => [ - ...list, - { id: 'note-' + Date.now(), type: 'Geral', description: desc, date: new Date().toLocaleDateString('pt-BR'), author: 'Usuário do Sistema' } - ]); - this.isFormDirty.set(true); - } - - removeNote(id: string): void { - this.productNotes.update(list => list.filter(n => n.id !== id)); - this.isFormDirty.set(true); - } - - // --- SAVE & VALIDATE --- - validateForm(): boolean { - const errs: Record = {}; - - if (!this.formName().trim()) { - errs['name'] = 'O nome do produto é obrigatório.'; - } - if (!this.formInternalCode().trim()) { - errs['internal_code'] = 'O código interno é obrigatório.'; - } - if (!this.formCategoryId()) { - errs['category_id'] = 'Selecione uma categoria.'; - } - if (!this.formManufacturerId()) { - errs['manufacturer_id'] = 'Selecione um fabricante.'; - } - if (!this.formUnitId()) { - errs['unit_id'] = 'Selecione a unidade de medida.'; - } - if (!this.formStatusId()) { - errs['status_id'] = 'Selecione o status do produto.'; - } - if (this.formPrice() <= 0) { - errs['price'] = 'Informe um preço base válido superior a zero.'; - } - - this.errors.set(errs); - - if (Object.keys(errs).length > 0) { - if (this.hasTabError('geral')) { - this.activeTab.set('geral'); - } else if (this.hasTabError('comercial')) { - this.activeTab.set('comercial'); - } - return false; - } - - return true; - } - - saveProduct(continueEditing: boolean): void { - if (!this.validateForm()) { - this.toastService.error('Não foi possível salvar o produto.', 'Verifique os campos obrigatórios destacados.'); - return; - } - - this.isSaving.set(true); - - // Build backend JSON payload matching specification contract - const payload = { - name: this.formName(), - internal_code: this.formInternalCode(), - category_id: this.formCategoryId(), - manufacturer_id: this.formManufacturerId(), - part_origin_id: this.formPartOriginId() || null, - unit_id: this.formUnitId(), - status_id: this.formStatusId(), - description: this.formDescription(), - weight: this.formWeight(), - height: this.formHeight(), - width: this.formWidth(), - length: this.formLength(), - price: this.formPrice(), - promotional_price: this.formPromotionalPrice(), - promotion_start_date: this.formPromotionStartDate() || null, - promotion_end_date: this.formPromotionEndDate() || null, - warehouse_id: this.formWarehouseId(), - quantity: this.formQuantity(), - min_quantity: this.formMinQuantity(), - allow_backorder: this.formAllowBackorder(), - tag_ids: this.selectedTagIds() - }; - - console.log('Sending Product Payload to Backend API:', payload); - - setTimeout(() => { - this.isSaving.set(false); - this.isFormDirty.set(false); - this.toastService.success('Produto salvo com sucesso.', 'Todas as informações comerciais e técnicas foram salvas.'); - - if (!continueEditing) { - this.router.navigate(['/catalog/products']); - } else if (this.mode() === 'create' || this.mode() === 'duplicate') { - this.mode.set('edit'); - } - }, 600); - } - - onCancel(): void { - if (this.isFormDirty()) { - this.unsavedChangesDialogOpen.set(true); - } else { - this.navigateBack(); - } - } - - navigateBack(): void { - this.router.navigate(['/catalog/products']); - } - - executeExitWithoutSave(): void { - this.unsavedChangesDialogOpen.set(false); - this.navigateBack(); - } - - switchToEdit(): void { - const id = this.route.snapshot.paramMap.get('id') || '1'; - this.router.navigate(['/catalog/products', id, 'edit']); - this.mode.set('edit'); - } - - switchToDuplicate(): void { - const id = this.route.snapshot.paramMap.get('id') || '1'; - this.router.navigate(['/catalog/products', id, 'duplicate']); - this.mode.set('duplicate'); - this.formInternalCode.set(''); - this.toastService.info('Modo Duplicar', 'Código interno limpo automaticamente conforme regra do sistema.'); - } - - confirmDeleteProduct(): void { - this.deleteProductDialogOpen.set(true); - } - - executeDeleteProduct(): void { - this.deleteProductDialogOpen.set(false); - this.toastService.success('Produto Excluído', 'O produto foi removido do catálogo com sucesso.'); - this.router.navigate(['/catalog/products']); - } -} diff --git a/src/app/features/catalog/product-workspace/product-workspace.css b/src/app/features/catalog/product-workspace/product-workspace.css new file mode 100644 index 0000000..fd4f787 --- /dev/null +++ b/src/app/features/catalog/product-workspace/product-workspace.css @@ -0,0 +1,40 @@ +/* Container raiz: ocupa viewport disponível sem rolar a página */ +.product-workspace-root { + height: calc(100dvh - 11rem); + max-height: calc(100dvh - 11rem); +} + +/* Scroll discreto no painel de conteúdo das abas */ +.workspace-panel-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.35) transparent; +} + +.workspace-panel-scroll::-webkit-scrollbar { + width: 5px; +} + +.workspace-panel-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.workspace-panel-scroll::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.35); + border-radius: 9999px; +} + +.workspace-panel-scroll::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.55); +} + +html.dark .workspace-panel-scroll { + scrollbar-color: rgba(148, 163, 184, 0.3) transparent; +} + +html.dark .workspace-panel-scroll::-webkit-scrollbar-thumb { + background-color: rgba(148, 163, 184, 0.3); +} + +html.dark .workspace-panel-scroll::-webkit-scrollbar-thumb:hover { + background-color: rgba(148, 163, 184, 0.5); +} diff --git a/src/app/features/catalog/product-workspace/product-workspace.html b/src/app/features/catalog/product-workspace/product-workspace.html new file mode 100644 index 0000000..5cba9fe --- /dev/null +++ b/src/app/features/catalog/product-workspace/product-workspace.html @@ -0,0 +1,310 @@ +
+ +
+ +
+ @if (mode() === 'view') { + + Voltar + + + Editar Produto + + } @else { + Cancelar + + Salvar Alterações + + } +
+
+ + + @if (productCreatedSuccessBanner()) { +
+
+
+ +
+
+

+ ✔ Produto criado com sucesso! +

+

+ O cadastro principal já foi registrado. Navegue pelos módulos no + Menu Lateral + para enriquecer este produto com imagens, preços, estoque, OEM codes e aplicações. +

+
+
+ +
+ } +
+ + +
+ + + + +
+ @if (activeTab() === 'geral' || activeTab() === 'classification' || activeTab() === + 'logistics' || activeTab() === 'visibility') { + + } @if (activeTab() === 'comercial') { + + } @if (activeTab() === 'inventario') { + + } @if (isCompatibilityTab()) { + + } + +
+
+ + +
+
+
+ @if (mode() === 'edit') { + + + + } @else if (mode() === 'create') { + + } +
+ +
+ @if (mode() === 'view') { + + + + + + Editar + + } @else { + + + + + + + + + + Salvar + + } +
+
+
+ + + + + + + + + +
diff --git a/src/app/features/catalog/product-workspace/product-workspace.ts b/src/app/features/catalog/product-workspace/product-workspace.ts new file mode 100644 index 0000000..60c9985 --- /dev/null +++ b/src/app/features/catalog/product-workspace/product-workspace.ts @@ -0,0 +1,1019 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { Location } from '@angular/common'; +import { PageHeaderComponent } from '../../../design-system/page-header/page-header'; +import { ButtonComponent } from '../../../design-system/button/button'; +import { ConfirmDialogComponent } from '../../../design-system/dialog/confirm-dialog'; +import { AppIconComponent } from '../../../design-system/icon/app-icon'; +import { ProductGeneralTabComponent } from '../components/product-form/general-tab/general-tab'; +import { ProductCommercialTabComponent } from '../components/product-form/commercial-tab/commercial-tab'; +import { ProductInventoryTabComponent } from '../components/product-form/inventory-tab/inventory-tab'; +import { ProductCompatibilityTabComponent } from '../components/product-form/compatibility-tab/compatibility-tab'; +// import { ProductMediaTabComponent } from '../components/product-form/media-tab/media-tab'; +// import { ProductAdministrationTabComponent } from '../components/product-form/administration-tab/administration-tab'; +import { ProductWorkspaceSidebarComponent } from '../components/product-form/sidebar/product-workspace-sidebar'; +import { ShellStateService } from '../../../core/services/shell-state'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ProductService } from '../../../core/services/product-service'; +import { CategoryService } from '../../../core/services/category-service'; +import { ManufacturerService } from '../../../core/services/manufacture-service'; +import { StatusService } from '../../../core/services/status-service'; +import { ToastService } from '../../../core/services/toast'; +import { PartOriginService } from '../../../core/services/part-origins-service'; +import { AutocompleteOption } from '../../../design-system/autocomplete-select/autocomplete-select'; +import { DSelectOption } from '../../../core/models/design-system/select-option.model'; +import { GeneralOptionQuery } from '../../../core/models/generals/general-option-query.model'; +import { ProductResponse } from '../../../core/models/products/product-response.model'; +import { UpdateProductPayload } from '../../../core/models/products/product-request.model'; +import { isCompatibilityWorkspaceTab } from '../../../core/config/compatibility-modules.config'; +import { toProductGeneralFormData } from '../../../core/models/products/product-create.model'; +import { + defaultProductFormData, + FormTab, + MediaImageItem, + ProductFormData, + ProductWorkspaceMode, +} from '../models/product-workspace.model'; +import { WarehousesService } from '../../../core/services/warehouses-service'; + +@Component({ + selector: 'app-product-workspace', + standalone: true, + imports: [ + PageHeaderComponent, + ButtonComponent, + ConfirmDialogComponent, + AppIconComponent, + ProductGeneralTabComponent, + ProductCommercialTabComponent, + ProductInventoryTabComponent, + ProductCompatibilityTabComponent, + ProductWorkspaceSidebarComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './product-workspace.html', + styleUrl: './product-workspace.css', +}) +export class ProductWorkspaceComponent implements OnInit { + private router = inject(Router); + private location = inject(Location); + private route = inject(ActivatedRoute); + private toastService = inject(ToastService); + readonly shellState = inject(ShellStateService); + private productService = inject(ProductService); + private categoryService = inject(CategoryService); + private statusService = inject(StatusService); + private partOriginService = inject(PartOriginService); + private warehouseService = inject(WarehousesService); + private manufacturerService = inject(ManufacturerService); + + readonly activeTab = signal('geral'); + readonly mode = signal('edit'); + + readonly isSaving = signal(false); + readonly isDragging = signal(false); + readonly isFormDirty = signal(false); + readonly categoryLoading = signal(false); + readonly manufacturerLoading = signal(false); + readonly productCreatedSuccessBanner = signal(false); + + readonly fetchedStatuses = signal([]); + readonly fetchedWarehouses = signal([]); + readonly fetchedPartOrigins = signal([]); + readonly fetchedCategories = signal([]); + readonly fetchedManufacturers = signal([]); + + readonly deleteProductDialogOpen = signal(false); + readonly unsavedChangesDialogOpen = signal(false); + readonly deleteImageDialogOpen = signal(false); + readonly selectedImageForDelete = signal(null); + readonly errors = signal>({}); + + readonly productForm = signal(defaultProductFormData()); + readonly currentStatus = signal(null); + + readonly categoryOptions = computed(() => this.fetchedCategories()); + readonly manufacturerOptions = computed(() => this.fetchedManufacturers()); + readonly statusOptions = computed(() => this.fetchedStatuses()); + + readonly generalForm = computed(() => toProductGeneralFormData(this.productForm())); + + readonly generalFormOptions = computed(() => ({ + categoryOptions: this.categoryOptions(), + categoryLoading: this.categoryLoading(), + manufacturerOptions: this.manufacturerOptions(), + manufacturerLoading: this.manufacturerLoading(), + partOriginOptions: this.fetchedPartOrigins(), + unitOptions: this.unitOptions, + statusOptions: this.statusOptions(), + warehouseOptions: this.fetchedWarehouses(), + })); + + readonly isReadOnly = computed(() => this.mode() === 'view'); + + readonly isCompatibilityTab = computed(() => isCompatibilityWorkspaceTab(this.activeTab())); + + readonly headerTitle = computed(() => + this.mode() === 'edit' ? 'Editar Produto' : 'Visualizar Produto', + ); + + readonly breadcrumbs = computed(() => [ + { label: 'Catálogo', route: '/catalog/products' }, + { label: 'Produtos', route: '/catalog/products' }, + { label: this.headerTitle() }, + ]); + + private queries: GeneralOptionQuery = { + search: null, + active: null, + limit: null, + }; + + readonly unitOptions: DSelectOption[] = [ + { label: 'Jogo', value: 'jogo' }, + { label: 'Peça', value: 'peça' }, + { label: 'Par', value: 'par' }, + { label: 'Kit', value: 'kit' }, + { label: 'Litro', value: 'litro' }, + { label: 'Metro', value: 'metro' }, + { label: 'Rolo', value: 'rolo' }, + { label: 'Caixa', value: 'caixa' }, + { label: 'Conjunto', value: 'conjunto' }, + ]; + + readonly availableTags = [ + { id: 'tag-1', label: 'Garantia 12 Meses' }, + { id: 'tag-2', label: 'Linha Leve' }, + { id: 'tag-3', label: 'Alto Giro' }, + { id: 'tag-4', label: 'Primeira Linha' }, + { id: 'tag-5', label: 'Importado' }, + { id: 'tag-6', label: 'Oferta Especial' }, + ]; + + ngOnInit(): void { + const url = this.router.url; + const id = this.route.snapshot.paramMap.get('id'); + const state = this.location.getState() as any; + + this.productCreatedSuccessBanner.set(!!state?.['productJustCreated']); + + this.loadInitialOptions(this.queries); + + if (id) { + this.mode.set(url.includes('/view') ? 'view' : 'edit'); + this.productById(id); + } + } + + loadInitialOptions(query: GeneralOptionQuery): void { + this.categoryLoading.set(true); + this.categoryService.getOptions(query).subscribe({ + next: (res) => { + const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ + label: c.label, + value: c.id, + sublabel: c.description, + icon: c.icon || 'folder', + })); + this.fetchedCategories.set(opts); + this.categoryLoading.set(false); + }, + error: () => this.categoryLoading.set(false), + }); + + this.manufacturerLoading.set(true); + this.manufacturerService.getOptions(query).subscribe({ + next: (res) => { + const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ + label: m.label, + value: m.id, + sublabel: m.description, + })); + this.fetchedManufacturers.set(opts); + this.manufacturerLoading.set(false); + }, + error: () => this.manufacturerLoading.set(false), + }); + + this.statusService.getOptions('PRODUCT').subscribe({ + next: (res) => { + const opts: DSelectOption[] = (res.data || []).map((s) => ({ + label: s.label, + value: s.id, + })); + this.fetchedStatuses.set(opts); + }, + }); + + this.partOriginService.getAll(this.queries).subscribe({ + next: (res) => { + const opts: DSelectOption[] = (res.data || []).map((p) => ({ + label: p.name, + value: p.id, + })); + this.fetchedPartOrigins.set(opts); + }, + }); + + this.warehouseService.getOptions(query).subscribe({ + next: (res) => { + const opts: DSelectOption[] = (res.data || []).map((w) => ({ + label: w.label, + value: w.id, + })); + this.fetchedWarehouses.set(opts); + }, + }); + } + + productById(productId: string): void { + this.productService.getById(productId).subscribe({ + next: (response) => { + const prodData = response.data || response; + this.populateForm(prodData); + }, + error: (error) => { + console.error('Error loading product:', error); + this.toastService.error('Erro', 'Não foi possível carregar os dados do produto.'); + }, + }); + } + + populateForm(prod: ProductResponse): void { + this.currentStatus.set(prod.status || null); + const pricingObj = prod.pricing as unknown as Record | undefined; + const inventoryObj = prod.inventory as unknown as Record | undefined; + const rawPrice = prod['price'] as unknown as + | { + price?: number; + promotional_price?: number; + promotion_start?: string; + promotion_end?: string; + } + | undefined; + + this.productForm.set({ + category_id: prod.category?.id || (prod['category_id'] as string) || '', + manufacturer_id: prod.manufacturer?.id || (prod['manufacturer_id'] as string) || '', + part_origin_id: prod.part_origin?.id || (prod['part_origin_id'] as string) || '', + status_id: prod.status?.id || (prod['status_id'] as string) || 'st-01', + unit_id: prod.unit?.id || (prod['unit_id'] as string) || 'un', + + internal_code: prod.internal_code || '', + barcode: (prod['barcode'] as string) || '', + + name: prod.name || '', + slug: prod.slug || '', + + icon: prod.icon || '', + image: prod.image || '', + + short_description: (prod['short_description'] as string) || '', + description: prod.description || '', + + weight: parseFloat(prod.weight as string) || 0, + height: parseFloat(prod.height as string) || 0, + width: parseFloat(prod.width as string) || 0, + length: parseFloat(prod.length as string) || 0, + + featured: !!prod['featured'], + active: prod['active'] !== undefined ? !!prod['active'] : true, + + price: prod.pricing?.regular_price || rawPrice?.price || 0, + + promotionalPrice: prod.pricing?.promotional_price || rawPrice?.promotional_price || null, + + promotionStartDate: prod.pricing?.promotion_start || rawPrice?.promotion_start || null, + + promotionEndDate: prod.pricing?.promotion_end || rawPrice?.promotion_end || null, + + isInvoiced: + typeof pricingObj?.['is_invoiced'] === 'boolean' ? pricingObj['is_invoiced'] : true, + + warehouseId: (inventoryObj?.['warehouse_id'] as string) || 'wh-01', + + quantity: prod.inventory?.quantity || 0, + + minQuantity: prod.inventory?.minimum_quantity || 0, + + maxQuantity: prod.inventory?.maximum_quantity || null, + + allowBackorder: !!prod.inventory?.allow_backorder, + + oemCodes: (prod['oem_codes'] as ProductFormData['oemCodes']) || [], + + productCodes: (prod['product_codes'] as ProductFormData['productCodes']) || [], + + equivalentProducts: + (prod['equivalent_products'] as ProductFormData['equivalentProducts']) || [], + + vehicleApplications: + (prod['vehicle_applications'] as ProductFormData['vehicleApplications']) || [], + + mediaImages: (prod['media_images'] as ProductFormData['mediaImages']) || [], + + suppliers: (prod['suppliers'] as ProductFormData['suppliers']) || [], + + selectedTagIds: (prod['tags'] as string[]) || [], + + productNotes: (prod['notes'] as ProductFormData['productNotes']) || [], + }); + } + + setActiveTab(tab: FormTab): void { + console.log('tab', tab); + this.activeTab.set(tab); + } + + closeSuccessBanner(): void { + this.productCreatedSuccessBanner.set(false); + } + + hasTabError(tab: FormTab): boolean { + const errs = this.errors(); + if (tab === 'geral') { + return !!( + errs['name'] || + errs['internal_code'] || + errs['category_id'] || + errs['manufacturer_id'] || + errs['unit_id'] || + errs['unit_id'] + ); + } + if (tab === 'comercial') { + return !!( + errs['price'] || + errs['promotional_price'] || + errs['promotion_start_date'] || + errs['promotion_end_date'] + ); + } + if (tab === 'inventario') { + return !!( + errs['warehouse_id'] || + errs['quantity'] || + errs['min_quantity'] || + errs['max_quantity'] + ); + } + return false; + } + + updateField(field: string, val: string): void { + this.isFormDirty.set(true); + const fieldMapping: Record = { + name: 'name', + internal_code: 'internal_code', + barcode: 'barcode', + category_id: 'category_id', + manufacturer_id: 'manufacturer_id', + part_origin_id: 'part_origin_id', + unit_id: 'unit_id', + status_id: 'status_id', + slug: 'slug', + short_description: 'short_description', + promotion_start_date: 'promotionStartDate', + promotion_end_date: 'promotionEndDate', + warehouse_id: 'warehouseId', + }; + const key = fieldMapping[field]; + if (key) { + this.productForm.update((p) => ({ ...p, [key]: val })); + } + + if (this.errors()[field]) { + this.errors.update((e) => { + const copy = { ...e }; + delete copy[field]; + return copy; + }); + } + } + + updateBooleanField(field: string, val: boolean): void { + this.isFormDirty.set(true); + if (field === 'active') { + this.productForm.update((p) => ({ ...p, active: val })); + } else if (field === 'featured') { + this.productForm.update((p) => ({ ...p, featured: val })); + } + } + + updateNumberField(field: string, valStr: string): void { + this.isFormDirty.set(true); + const num = parseFloat(valStr) || 0; + const fieldMapping: Record = { + weight: 'weight', + height: 'height', + width: 'width', + length: 'length', + price: 'price', + quantity: 'quantity', + min_quantity: 'minQuantity', + }; + const key = fieldMapping[field]; + if (key) { + this.productForm.update((p) => ({ ...p, [key]: num })); + } + + if (this.errors()[field]) { + this.errors.update((e) => { + const copy = { ...e }; + delete copy[field]; + return copy; + }); + } + } + + updateNullableNumberField(field: string, valStr: string): void { + this.isFormDirty.set(true); + const val = valStr.trim() === '' ? null : parseFloat(valStr); + if (field === 'promotional_price') { + this.productForm.update((p) => ({ ...p, promotionalPrice: val })); + } else if (field === 'max_quantity') { + this.productForm.update((p) => ({ ...p, maxQuantity: val })); + } + } + + updateDescription(val: string): void { + this.isFormDirty.set(true); + this.productForm.update((p) => ({ ...p, description: val })); + } + + toggleIsInvoiced(): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ ...p, isInvoiced: !p.isInvoiced })); + } + + toggleBackorder(): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ ...p, allowBackorder: !p.allowBackorder })); + } + + openAddOemModal(): void { + const code = prompt('Digite o Código OEM:'); + if (!code) return; + const manufacturer = prompt('Digite o Fabricante/Montadora:', 'Volkswagen') || 'Montadora'; + this.productForm.update((p) => ({ + ...p, + oemCodes: [ + ...p.oemCodes, + { + id: 'oem-' + Date.now(), + manufacturer, + oemCode: code.toUpperCase(), + isPrimary: p.oemCodes.length === 0, + }, + ], + })); + this.isFormDirty.set(true); + } + + setPrimaryOem(id: string): void { + this.productForm.update((p) => ({ + ...p, + oemCodes: p.oemCodes.map((o) => ({ ...o, isPrimary: o.id === id })), + })); + this.isFormDirty.set(true); + } + + removeOemCode(id: string): void { + this.productForm.update((p) => ({ + ...p, + oemCodes: p.oemCodes.filter((o) => o.id !== id), + })); + this.isFormDirty.set(true); + } + + toggleOemStatus(id: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ + ...p, + oemCodes: p.oemCodes.map((o) => + o.id === id ? { ...o, status: o.status === 'Ativo' ? 'Inativo' : 'Ativo' } : o, + ), + })); + } + + openAddProductCodeModal(): void { + const code = prompt('Digite o Código:'); + if (!code) return; + const type = prompt('Digite o Tipo (Ex.: EAN-13, Código Fábrica):', 'EAN-13') || 'Geral'; + this.productForm.update((p) => ({ + ...p, + productCodes: [...p.productCodes, { id: 'pc-' + Date.now(), type, code }], + })); + this.isFormDirty.set(true); + } + + removeProductCode(id: string): void { + this.productForm.update((p) => ({ + ...p, + productCodes: p.productCodes.filter((pc) => pc.id !== id), + })); + this.isFormDirty.set(true); + } + + toggleProductCodeStatus(id: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ + ...p, + productCodes: p.productCodes.map((pc) => + pc.id === id ? { ...pc, status: pc.status === 'Ativo' ? 'Inativo' : 'Ativo' } : pc, + ), + })); + } + + openAddEquivalentModal(): void { + const name = prompt('Digite o Nome do Produto Equivalente:'); + if (!name) return; + const notes = prompt('Observação de equivalência:', 'Compatibilidade direta') || ''; + this.productForm.update((p) => ({ + ...p, + equivalentProducts: [ + ...p.equivalentProducts, + { id: 'eq-' + Date.now(), productName: name, notes }, + ], + })); + this.isFormDirty.set(true); + } + + removeEquivalent(id: string): void { + this.productForm.update((p) => ({ + ...p, + equivalentProducts: p.equivalentProducts.filter((e) => e.id !== id), + })); + this.isFormDirty.set(true); + } + + toggleEquivalentStatus(id: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ + ...p, + equivalentProducts: p.equivalentProducts.map((e) => + e.id === id ? { ...e, status: e.status === 'Ativo' ? 'Inativo' : 'Ativo' } : e, + ), + })); + } + + openAddVehicleModal(): void { + const brand = prompt('Marca do Veículo:', 'Volkswagen') || 'Volkswagen'; + const model = prompt('Modelo:', 'Gol') || 'Modelo'; + const engine = prompt('Motorização:', '1.6 8V') || '1.0'; + this.productForm.update((p) => ({ + ...p, + vehicleApplications: [ + ...p.vehicleApplications, + { + id: 'veh-' + Date.now(), + brand, + model, + version: 'Flex', + engine, + startYear: 2015, + endYear: 2022, + }, + ], + })); + this.isFormDirty.set(true); + } + + removeVehicleApplication(id: string): void { + this.productForm.update((p) => ({ + ...p, + vehicleApplications: p.vehicleApplications.filter((v) => v.id !== id), + })); + this.isFormDirty.set(true); + } + + toggleVehicleStatus(id: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ + ...p, + vehicleApplications: p.vehicleApplications.map((v) => + v.id === id ? { ...v, status: v.status === 'Ativo' ? 'Inativo' : 'Ativo' } : v, + ), + })); + } + + onDragOver(e: DragEvent): void { + e.preventDefault(); + this.isDragging.set(true); + } + + onDragLeave(e: DragEvent): void { + e.preventDefault(); + this.isDragging.set(false); + } + + onFileDrop(e: DragEvent): void { + e.preventDefault(); + this.isDragging.set(false); + if (e.dataTransfer?.files) { + this.handleFiles(Array.from(e.dataTransfer.files)); + } + } + + onFileSelected(e: Event): void { + const input = e.target as HTMLInputElement; + if (input.files) { + this.handleFiles(Array.from(input.files)); + } + } + + handleFiles(files: File[]): void { + files.forEach((file) => { + if (file.size > 2 * 1024 * 1024) { + this.toastService.error( + 'Arquivo Excede Limite', + `O arquivo ${file.name} possui mais de 2 MB.`, + ); + return; + } + + const currentImages = this.productForm().mediaImages; + const newImg: MediaImageItem = { + id: 'img-' + Date.now() + Math.random().toString(36).substring(2, 5), + url: URL.createObjectURL(file), + name: file.name, + size: (file.size / (1024 * 1024)).toFixed(1) + ' MB', + isPrimary: currentImages.length === 0, + order: currentImages.length + 1, + status: 'uploading', + progress: 0, + }; + + this.productForm.update((p) => ({ + ...p, + mediaImages: [...p.mediaImages, newImg], + })); + this.simulateAsyncUpload(newImg.id); + }); + this.isFormDirty.set(true); + } + + simulateAsyncUpload(imgId: string): void { + let prog = 0; + const interval = setInterval(() => { + prog += 25; + this.productForm.update((p) => ({ + ...p, + mediaImages: p.mediaImages.map((img) => + img.id === imgId ? { ...img, progress: prog } : img, + ), + })); + + if (prog >= 100) { + clearInterval(interval); + this.productForm.update((p) => ({ + ...p, + mediaImages: p.mediaImages.map((img) => + img.id === imgId ? { ...img, status: 'completed' } : img, + ), + })); + } + }, 200); + } + + setPrimaryImage(id: string): void { + this.productForm.update((p) => ({ + ...p, + mediaImages: p.mediaImages.map((img) => ({ ...img, isPrimary: img.id === id })), + })); + this.isFormDirty.set(true); + } + + confirmDeleteImage(img: MediaImageItem): void { + this.selectedImageForDelete.set(img); + this.deleteImageDialogOpen.set(true); + } + + executeDeleteImage(): void { + const img = this.selectedImageForDelete(); + if (img) { + this.productForm.update((p) => { + const filtered = p.mediaImages.filter((i) => i.id !== img.id); + if (img.isPrimary && filtered.length > 0) { + filtered[0] = { ...filtered[0], isPrimary: true }; + } + return { ...p, mediaImages: filtered }; + }); + this.toastService.success('Imagem Removida', `${img.name} foi removida da galeria.`); + } + this.deleteImageDialogOpen.set(false); + } + + openAddSupplierModal(): void { + const name = prompt('Nome do Fornecedor:'); + if (!name) return; + const code = prompt('Código no Fornecedor:', 'SUP-01') || 'SUP-01'; + this.productForm.update((p) => ({ + ...p, + suppliers: [ + ...p.suppliers, + { + id: 'sup-' + Date.now(), + supplierName: name, + supplierCode: code, + purchasePrice: 100.0, + leadTimeDays: 5, + isPreferential: p.suppliers.length === 0, + }, + ], + })); + this.isFormDirty.set(true); + } + + removeSupplier(id: string): void { + this.productForm.update((p) => ({ + ...p, + suppliers: p.suppliers.filter((s) => s.id !== id), + })); + this.isFormDirty.set(true); + } + + setPreferentialSupplier(id: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ + ...p, + suppliers: p.suppliers.map((s) => ({ ...s, isPreferential: s.id === id })), + })); + } + + toggleSupplierStatus(id: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ + ...p, + suppliers: p.suppliers.map((s) => + s.id === id ? { ...s, status: s.status === 'Ativo' ? 'Inativo' : 'Ativo' } : s, + ), + })); + } + + toggleTag(tagId: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => { + const exists = p.selectedTagIds.includes(tagId); + return { + ...p, + selectedTagIds: exists + ? p.selectedTagIds.filter((id) => id !== tagId) + : [...p.selectedTagIds, tagId], + }; + }); + } + + openAddNoteModal(): void { + const desc = prompt('Descrição da Nota/Observação:'); + if (!desc) return; + this.productForm.update((p) => ({ + ...p, + productNotes: [ + ...p.productNotes, + { + id: 'note-' + Date.now(), + type: 'Geral', + description: desc, + date: new Date().toLocaleDateString('pt-BR'), + author: 'Usuário do Sistema', + }, + ], + })); + this.isFormDirty.set(true); + } + + removeNote(id: string): void { + this.productForm.update((p) => ({ + ...p, + productNotes: p.productNotes.filter((n) => n.id !== id), + })); + this.isFormDirty.set(true); + } + + toggleNoteStatus(id: string): void { + if (this.isReadOnly()) return; + this.isFormDirty.set(true); + this.productForm.update((p) => ({ + ...p, + productNotes: p.productNotes.map((n) => + n.id === id ? { ...n, status: n.status === 'Ativo' ? 'Inativo' : 'Ativo' } : n, + ), + })); + } + + onCategorySearch(query: GeneralOptionQuery): void { + this.categoryLoading.set(true); + this.categoryService.getOptions(query).subscribe({ + next: (res) => { + const opts: AutocompleteOption[] = (res.data || []).map((c) => ({ + label: c.label, + value: c.id, + sublabel: c.description, + icon: c.icon || 'folder', + })); + this.fetchedCategories.set(opts); + this.categoryLoading.set(false); + }, + error: () => this.categoryLoading.set(false), + }); + } + + onManufacturerSearch(query: GeneralOptionQuery): void { + this.manufacturerLoading.set(true); + this.manufacturerService.getOptions(query).subscribe({ + next: (res) => { + const opts: AutocompleteOption[] = (res.data || []).map((m) => ({ + label: m.label, + value: m.id, + sublabel: m.description, + })); + this.fetchedManufacturers.set(opts); + this.manufacturerLoading.set(false); + }, + error: () => this.manufacturerLoading.set(false), + }); + } + + validateForm(): boolean { + const errs: Record = {}; + const p = this.productForm(); + + if (!p.name.trim()) { + errs['name'] = 'O nome do produto é obrigatório.'; + } + if (!p.internal_code.trim()) { + errs['internal_code'] = 'O código interno é obrigatório.'; + } + if (!p.category_id) { + errs['category_id'] = 'Selecione uma categoria.'; + } + if (!p.manufacturer_id) { + errs['manufacturer_id'] = 'Selecione um fabricante.'; + } + if (!p.unit_id) { + errs['unit_id'] = 'Selecione a unidade de medida.'; + } + if (p.price <= 0) { + errs['price'] = 'O preço base deve ser maior que zero.'; + } + + this.errors.set(errs); + + if (Object.keys(errs).length > 0) { + if (this.hasTabError('geral')) { + this.activeTab.set('geral'); + } else if (this.hasTabError('comercial')) { + this.activeTab.set('comercial'); + } else if (this.hasTabError('inventario')) { + this.activeTab.set('inventario'); + } + return false; + } + + return true; + } + + saveProduct(continueEditing: boolean): void { + if (!this.validateForm()) { + this.toastService.error( + 'Não foi possível salvar o produto.', + 'Verifique os campos obrigatórios destacados.', + ); + return; + } + + this.isSaving.set(true); + + const id = this.route.snapshot.paramMap.get('id'); + const p = this.productForm(); + + const payload: UpdateProductPayload = { + category_id: p.category_id, + manufacturer_id: p.manufacturer_id, + part_origin_id: p.part_origin_id, + unit_id: p.status_id, + + internal_code: p.internal_code, + barcode: p.barcode, + + name: p.name, + slug: p.slug, + + short_description: p.short_description, + description: p.description, + + weight: p.weight, + height: p.height, + width: p.width, + length: p.length, + + unit: p.unit_id, + + featured: p.featured, + + price: { + price: p.price, + promotional_price: p.promotionalPrice, + promotion_start: p.promotionStartDate, + promotion_end: p.promotionEndDate, + }, + + inventory: { + warehouse_id: p.warehouseId, + quantity: p.quantity, + minimum_quantity: p.minQuantity, + maximum_quantity: p.maxQuantity, + allow_backorder: p.allowBackorder, + }, + }; + + this.productService.update(id!, payload as UpdateProductPayload).subscribe({ + next: () => { + this.isSaving.set(false); + this.isFormDirty.set(false); + this.toastService.success( + 'Produto salvo com sucesso.', + 'Informações atualizadas no sistema.', + ); + + if (!continueEditing) { + this.router.navigate(['/catalog/products']); + } + }, + error: (err) => { + this.isSaving.set(false); + console.error('Error saving product:', err); + this.toastService.error( + 'Erro ao salvar o produto', + err.error?.message || err.message || 'Ocorreu um erro inesperado no servidor.', + ); + }, + }); + } + + onCancel(): void { + if (this.isFormDirty()) { + this.unsavedChangesDialogOpen.set(true); + } else { + this.navigateBack(); + } + } + + navigateBack(): void { + this.router.navigate(['/catalog/products']); + } + + executeExitWithoutSave(): void { + this.unsavedChangesDialogOpen.set(false); + this.navigateBack(); + } + + switchToEdit(): void { + const id = this.route.snapshot.paramMap.get('id') || '1'; + this.router.navigate(['/catalog/products', id, 'edit']); + this.mode.set('edit'); + } + + confirmDeleteProduct(): void { + this.deleteProductDialogOpen.set(true); + } + + executeDeleteProduct(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.productService.delete(id).subscribe({ + next: () => { + this.deleteProductDialogOpen.set(false); + this.toastService.success( + 'Produto Excluído', + 'O produto foi removido do catálogo com sucesso.', + ); + this.router.navigate(['/catalog/products']); + }, + error: () => { + this.deleteProductDialogOpen.set(false); + this.toastService.error('Erro', 'Não foi possível excluir o produto.'); + }, + }); + } else { + this.deleteProductDialogOpen.set(false); + this.navigateBack(); + } + } +} diff --git a/src/app/features/catalog/products.ts b/src/app/features/catalog/products.ts deleted file mode 100644 index 0a102f1..0000000 --- a/src/app/features/catalog/products.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Component, ChangeDetectionStrategy, signal, computed, inject } 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, ColumnDef } from '../../design-system/data-table/data-table'; -import { PaginationComponent } from '../../design-system/pagination/pagination'; -import { ConfirmDialogComponent } from '../../design-system/dialog/confirm-dialog'; -import { AppIconComponent } from '../../design-system/icon/app-icon'; -import { ToastService } from '../../core/services/toast'; - -export interface ProductItem extends Record { - id: string; - code: string; - name: string; - category: string; - manufacturer: string; - oemCode: string; - price: string; - stock: number; - status: 'Ativo' | 'Inativo' | 'Baixo Estoque'; -} - -@Component({ - selector: 'app-products-page', - standalone: true, - imports: [ - PageHeaderComponent, - ToolbarComponent, - SearchInputComponent, - SelectComponent, - ButtonComponent, - DataTableComponent, - PaginationComponent, - ConfirmDialogComponent, - AppIconComponent - ], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './products.html', - styleUrl: './products.css' -}) -export class ProductsPageComponent { - private router = inject(Router); - private toastService = inject(ToastService); - - readonly searchQuery = signal(''); - readonly selectedCategory = signal(''); - readonly selectedStatus = signal(''); - readonly currentPage = signal(1); - readonly pageSize = signal(10); - - readonly deleteDialogOpen = signal(false); - readonly selectedProductForDelete = signal(null); - - readonly allProducts = signal([ - { id: '1', code: 'REC-10029', name: 'Jogo de Pastilhas de Freio Dianteira', category: 'Sistemas de Freios', manufacturer: 'Bosch', oemCode: '5U0698151A', price: 'R$ 189,90', stock: 45, status: 'Ativo' }, - { id: '2', code: 'REC-20491', name: 'Correia Dentada com Tensor Synchrobelt', category: 'Motor & Transmissão', manufacturer: 'Continental', oemCode: '030109119AB', price: 'R$ 245,00', stock: 3, status: 'Baixo Estoque' }, - { id: '3', code: 'REC-30112', name: 'Amortecedor Dianteiro Pressurizado HG', category: 'Suspensão & Direção', manufacturer: 'Monroe', oemCode: '5U0413031A', price: 'R$ 380,00', stock: 18, status: 'Ativo' }, - { id: '4', code: 'REC-40918', name: 'Vela de Ignição Iridium IX', category: 'Sistema Elétrico', manufacturer: 'NGK', oemCode: '04E905601B', price: 'R$ 68,50', stock: 120, status: 'Ativo' }, - { id: '5', code: 'REC-10882', name: 'Disco de Freio Ventilado Par', category: 'Sistemas de Freios', manufacturer: 'Fremax', oemCode: '5U0615301A', price: 'R$ 290,00', stock: 0, status: 'Inativo' }, - { id: '6', code: 'REC-50192', name: 'Filtro de Óleo Lubrificante Motor EA111', category: 'Motor & Transmissão', manufacturer: 'Mann Filter', oemCode: '030115561AN', price: 'R$ 38,90', stock: 85, status: 'Ativo' }, - { id: '7', code: 'REC-60183', name: 'Bomba de Combustível Elétrica Flex 4.2 Bar', category: 'Motor & Transmissão', manufacturer: 'Magneti Marelli', oemCode: '5U0919051A', price: 'R$ 310,00', stock: 12, status: 'Ativo' }, - { id: '8', code: 'REC-70812', name: 'Kit Embreagem Cobre e Aço 200mm', category: 'Motor & Transmissão', manufacturer: 'Sachs', oemCode: '030141025E', price: 'R$ 540,00', stock: 2, status: 'Baixo Estoque' }, - { id: '9', code: 'REC-10044', name: 'Cilindro Mestre de Freio Duplo', category: 'Sistemas de Freios', manufacturer: 'TRW', oemCode: '5U0611019B', price: 'R$ 215,00', stock: 14, status: 'Ativo' }, - { id: '10', code: 'REC-20381', name: 'Filtro de Ar do Motor Flex', category: 'Motor & Transmissão', manufacturer: 'Fram', oemCode: '030129620A', price: 'R$ 42,00', stock: 92, status: 'Ativo' }, - { id: '11', code: 'REC-30819', name: 'Terminal de Direção Lado Direito', category: 'Suspensão & Direção', manufacturer: 'Nakata', oemCode: '5U0423812A', price: 'R$ 78,00', stock: 33, status: 'Ativo' }, - { id: '12', code: 'REC-40112', name: 'Bobina de Ignição Eletrônica 4 Pinos', category: 'Sistema Elétrico', manufacturer: 'Bosch', oemCode: '036905715F', price: 'R$ 310,00', stock: 24, status: 'Ativo' }, - { id: '13', code: 'REC-50821', name: 'Radiador de Água do Motor com Ar', category: 'Motor & Transmissão', manufacturer: 'Valeo', oemCode: '5U0121253', price: 'R$ 495,00', stock: 8, status: 'Ativo' }, - { id: '14', code: 'REC-10992', name: 'Tambor de Freio Traseiro Unitário', category: 'Sistemas de Freios', manufacturer: 'Hipper Freios', oemCode: '5U0609617', price: 'R$ 135,00', stock: 21, status: 'Ativo' }, - { id: '15', code: 'REC-30441', name: 'Bieleta de Suspensão Dianteira', category: 'Suspensão & Direção', manufacturer: 'Cofap', oemCode: '5U0411315', price: 'R$ 58,00', stock: 4, status: 'Baixo Estoque' }, - { id: '16', code: 'REC-40788', name: 'Alternador de Energia 90A 12V', category: 'Sistema Elétrico', manufacturer: 'Denso', oemCode: '030903023J', price: 'R$ 890,00', stock: 6, status: 'Ativo' }, - { id: '17', code: 'REC-20998', name: 'Junta do Cabeçote de Aço Multilâminas', category: 'Motor & Transmissão', manufacturer: 'Taranto', oemCode: '030103383AL', price: 'R$ 110,00', stock: 19, status: 'Ativo' }, - { id: '18', code: 'REC-30221', name: 'Pivô de Suspensão Inferior Esquerdo', category: 'Suspensão & Direção', manufacturer: 'Viemar', oemCode: '5U0407365A', price: 'R$ 64,00', stock: 40, status: 'Ativo' }, - { id: '19', code: 'REC-10334', name: 'Sapatas de Freio Traseiro com Lona', category: 'Sistemas de Freios', manufacturer: 'Fras-le', oemCode: '5U0698525', price: 'R$ 115,00', stock: 16, status: 'Ativo' }, - { id: '20', code: 'REC-50319', name: 'Sensor de Oxigênio Sonda Lambda', category: 'Sistema Elétrico', manufacturer: 'NTK', oemCode: '030906262R', price: 'R$ 225,00', stock: 11, status: 'Ativo' }, - { id: '21', code: 'REC-20119', name: 'Válvula Termostática com Carcaça', category: 'Motor & Transmissão', manufacturer: 'MTE-Thomson', oemCode: '032121111CD', price: 'R$ 145,00', stock: 27, status: 'Ativo' }, - { id: '22', code: 'REC-30991', name: 'Kits Coxim do Amortecedor com Batente', category: 'Suspensão & Direção', manufacturer: 'Sampel', oemCode: '5U0412331', price: 'R$ 98,00', stock: 1, status: 'Baixo Estoque' }, - { id: '23', code: 'REC-40442', name: 'Motor de Partida Arrancada 1.0/1.6', category: 'Sistema Elétrico', manufacturer: 'Valeo', oemCode: '02T911023D', price: 'R$ 680,00', stock: 9, status: 'Ativo' }, - { id: '24', code: 'REC-20551', name: 'Filtro de Combustível Injeção Eletrônica', category: 'Motor & Transmissão', manufacturer: 'Tecfil', oemCode: '6Q0201051J', price: 'R$ 28,50', stock: 150, status: 'Ativo' }, - { id: '25', code: 'REC-10771', name: 'Servo Freio Hidrovácuo ATE', category: 'Sistemas de Freios', manufacturer: 'ATE', oemCode: '5U0612105A', price: 'R$ 410,00', stock: 7, status: 'Ativo' }, - { id: '26', code: 'REC-30661', name: 'Junta Homocinética Lado Roda', category: 'Suspensão & Direção', manufacturer: 'Spicer', oemCode: '5U0498099', price: 'R$ 230,00', stock: 15, status: 'Ativo' }, - { id: '27', code: 'REC-40889', name: 'Jogo de Cabos de Vela de Ignição Silicone', category: 'Sistema Elétrico', manufacturer: 'NGK', oemCode: '030905409AC', price: 'R$ 105,00', stock: 38, status: 'Ativo' }, - { id: '28', code: 'REC-50990', name: 'Eletroventilador do Radiador Completo', category: 'Motor & Transmissão', manufacturer: 'Notus', oemCode: '5U0959455A', price: 'R$ 320,00', stock: 0, status: 'Inativo' } - ]); - - readonly columns: ColumnDef[] = [ - { key: 'code', header: 'Código Reccos', width: '130px' }, - { key: 'name', header: 'Nome do Produto' }, - { key: 'category', header: 'Categoria', width: '170px' }, - { key: 'manufacturer', header: 'Fabricante', width: '140px' }, - { key: 'oemCode', header: 'Código OEM', width: '130px' }, - { key: 'price', header: 'Preço Venda', width: '120px', align: 'right' }, - { key: 'stock', header: 'Estoque', width: '90px', align: 'center' }, - { - key: 'status', - header: 'Status', - width: '120px', - align: 'center', - cell: (row) => row['status'] as string - } - ]; - - readonly filteredProducts = computed(() => { - const q = this.searchQuery().toLowerCase().trim(); - const cat = this.selectedCategory().toLowerCase(); - const st = this.selectedStatus(); - - return this.allProducts().filter(p => { - const matchesQ = !q || p.name.toLowerCase().includes(q) || p.code.toLowerCase().includes(q) || p.oemCode.toLowerCase().includes(q); - const matchesCat = !cat || p.category.toLowerCase().includes(cat); - const matchesSt = !st || p.status === st; - return matchesQ && matchesCat && matchesSt; - }); - }); - - readonly paginatedProducts = computed(() => { - const all = this.filteredProducts(); - 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); - } - - onCategoryChange(category: string): void { - this.selectedCategory.set(category); - 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.selectedCategory.set(''); - this.selectedStatus.set(''); - this.currentPage.set(1); - this.toastService.info('Filtros limpos', 'Todos os parâmetros de busca foram resetados.'); - } - - exportProducts(): void { - this.toastService.success('Exportação Iniciada', 'Gerando arquivo Excel com os produtos do catálogo...'); - } - - newProduct(): void { - this.router.navigate(['/catalog/products/new']); - } - - editProduct(prod: ProductItem): void { - this.router.navigate(['/catalog/products', prod.id, 'edit']); - } - - confirmDelete(prod: ProductItem): void { - this.selectedProductForDelete.set(prod); - this.deleteDialogOpen.set(true); - } - - executeDelete(): void { - const prod = this.selectedProductForDelete(); - if (prod) { - this.allProducts.update(list => list.filter(p => p.id !== prod.id)); - this.toastService.success('Produto Excluído', `${prod.name} foi removido com sucesso.`); - } - this.deleteDialogOpen.set(false); - } -} diff --git a/src/app/features/catalog/products.css b/src/app/features/catalog/products/products.css similarity index 100% rename from src/app/features/catalog/products.css rename to src/app/features/catalog/products/products.css diff --git a/src/app/features/catalog/products.html b/src/app/features/catalog/products/products.html similarity index 84% rename from src/app/features/catalog/products.html rename to src/app/features/catalog/products/products.html index 3565c09..dce4c1a 100644 --- a/src/app/features/catalog/products.html +++ b/src/app/features/catalog/products/products.html @@ -19,7 +19,9 @@
-
+

Total de Produtos

12.480

@@ -27,12 +29,16 @@

12.480

+142 este mês
-
+
-
+

Produtos Ativos

11.920

@@ -43,7 +49,9 @@

11.920

-
+

Estoque Crítico

42

@@ -54,7 +62,9 @@

42

-
+

Fabricantes Homologados

184

@@ -113,9 +123,10 @@

184

{ + id: string; + code: string; + name: string; + category: string; + manufacturer: string; + oemCode: string; + price: string; + stock: number; + status: 'Ativo' | 'Inativo' | 'Baixo Estoque'; +} + +@Component({ + selector: 'app-products-page', + standalone: true, + imports: [ + PageHeaderComponent, + ToolbarComponent, + SearchInputComponent, + SelectComponent, + ButtonComponent, + DataTableComponent, + PaginationComponent, + ConfirmDialogComponent, + AppIconComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './products.html', + styleUrl: './products.css', +}) +export class ProductsPageComponent implements OnInit { + private router = inject(Router); + private toastService = inject(ToastService); + private productService = inject(ProductService); + + readonly searchQuery = signal(''); + readonly selectedStatus = signal(''); + readonly selectedCategory = signal(''); + readonly pageSize = signal(10); + readonly currentPage = signal(1); + readonly totalItems = signal(0); + + readonly loading = signal(false); + readonly deleteDialogOpen = signal(false); + readonly selectedProductForDelete = signal(null); + + readonly allProducts = signal([]); + + readonly columns: TableColumn[] = productTableColumns; + readonly actions: TableAction[] = productTableActions; + + readonly filteredProducts = computed(() => { + const q = this.searchQuery().toLowerCase().trim(); + const cat = this.selectedCategory().toLowerCase(); + // const st = this.selectedStatus(); + + return this.allProducts().filter((p) => { + // const matchesQ = !q || p.name.toLowerCase().includes(q) || p.code.toLowerCase().includes(q) || p.oemCode.toLowerCase().includes(q); + const matchesQ = !q || p.name.toLowerCase().includes(q); + const matchesCat = !cat || p.category.name.toLowerCase().includes(cat); + const matchesSt = 'active'; + return matchesQ && matchesCat && matchesSt; + }); + }); + + readonly paginatedProducts = computed(() => { + const all = this.filteredProducts(); + const page = this.currentPage(); + const size = this.pageSize(); + const start = (page - 1) * size; + return all.slice(start, start + size); + }); + + ngOnInit(): void { + this.getPaginationAllProducts(); + } + + getPaginationAllProducts(): void { + this.loading.set(true); + this.productService.getAll().subscribe({ + next: (response) => { + this.allProducts.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 produtos', error.message || 'Falha na requisição.'); + }, + }); + } + + onSearchChange(query: string): void { + this.searchQuery.set(query); + this.currentPage.set(1); + } + + onCategoryChange(category: string): void { + this.selectedCategory.set(category); + 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.selectedCategory.set(''); + this.selectedStatus.set(''); + this.currentPage.set(1); + this.toastService.info('Filtros limpos', 'Todos os parâmetros de busca foram resetados.'); + } + + exportProducts(): void { + this.toastService.success( + 'Exportação Iniciada', + 'Gerando arquivo Excel com os produtos do catálogo...', + ); + } + + newProduct(): void { + this.router.navigate(['/catalog/products/new']); + } + + editProduct(prod: ProductResponse): void { + this.router.navigate(['/catalog/products', prod.id, 'edit']); + } + + confirmDelete(prod: ProductResponse): void { + this.selectedProductForDelete.set(prod); + this.deleteDialogOpen.set(true); + } + + executeDelete(): void { + const prod = this.selectedProductForDelete(); + if (prod) { + this.allProducts.update((list) => list.filter((p) => p.id !== prod.id)); + this.toastService.success('Produto Excluído', `${prod.name} foi removido com sucesso.`); + } + this.deleteDialogOpen.set(false); + } + + handleProductAction(event: { actionId: string; row: ProductResponse }): void { + console.log('[HANDLE PRODUCT ACTION]', event); + switch (event.actionId) { + case 'edit': + this.editProduct(event.row); + break; + case 'delete': + this.confirmDelete(event.row); + break; + } + } +} diff --git a/src/app/features/dashboard/dashboard.html b/src/app/features/dashboard/dashboard.html index 7f6457e..a0e1628 100644 --- a/src/app/features/dashboard/dashboard.html +++ b/src/app/features/dashboard/dashboard.html @@ -8,28 +8,43 @@ Atualizar Dados - + Novo Produto
-
+
- + Bem-vindo ao Reccos Shop

Plataforma de Autopeças e Gestão de Catálogo

- Centralize códigos OEM, compatibilidade veicular, gestão de fabricantes e estoque integrado em uma única interface padronizada. + Centralize códigos OEM, compatibilidade veicular, gestão de fabricantes e estoque integrado em + uma única interface padronizada.

- + Explorar Catálogo
@@ -37,7 +52,9 @@

-
+

Total de Produtos

12.480

@@ -45,12 +62,16 @@

12.480

+142 cadastrados este mês
-
+
-
+

Aplicações Veiculares

48.920

@@ -61,7 +82,9 @@

48.920

-
+

Mapeamentos OEM

32.110

@@ -72,7 +95,9 @@

32.110

-
+

Alertas de Estoque

42

@@ -101,7 +126,9 @@

Catálogo
-

+

Catálogo de Peças

@@ -120,7 +147,9 @@

Compatibilidade

-

+

OEM & Equivalência

@@ -139,7 +168,9 @@

Comercial

-

+

Estoque & Preços

diff --git a/src/app/features/dashboard/dashboard.ts b/src/app/features/dashboard/dashboard.ts index 113ca51..0704857 100644 --- a/src/app/features/dashboard/dashboard.ts +++ b/src/app/features/dashboard/dashboard.ts @@ -9,22 +9,20 @@ import { Router } from '@angular/router'; @Component({ selector: 'app-dashboard-page', standalone: true, - imports: [ - PageHeaderComponent, - ButtonComponent, - BadgeComponent, - AppIconComponent - ], + imports: [PageHeaderComponent, ButtonComponent, BadgeComponent, AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './dashboard.html', - styleUrl: './dashboard.css' + styleUrl: './dashboard.css', }) export class DashboardPageComponent { private toastService = inject(ToastService); private router = inject(Router); refreshStats(): void { - this.toastService.success('Dados Atualizados', 'Sincronização com a base do catálogo concluída.'); + this.toastService.success( + 'Dados Atualizados', + 'Sincronização com a base do catálogo concluída.', + ); } navTo(route: string): void { diff --git a/src/app/features/placeholder.ts b/src/app/features/placeholder.ts index ba02db9..cd7557c 100644 --- a/src/app/features/placeholder.ts +++ b/src/app/features/placeholder.ts @@ -25,11 +25,11 @@ export interface PlaceholderRowItem extends Record { SearchInputComponent, ButtonComponent, DataTableComponent, - PaginationComponent + PaginationComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './placeholder.html', - styleUrl: './placeholder.css' + styleUrl: './placeholder.css', }) export class PlaceholderPageComponent { private router = inject(Router); @@ -49,63 +49,63 @@ export class PlaceholderPageComponent { title: 'Categorias de Produtos', subtitle: 'Estruturação hierárquica do catálogo de autopeças Reccos Shop', buttonText: 'Nova Categoria', - breadcrumbs: [{ label: 'Catálogo', route: '/catalog/products' }, { label: 'Categorias' }] + breadcrumbs: [{ label: 'Catálogo', route: '/catalog/products' }, { label: 'Categorias' }], }; } else if (url.includes('manufacturers')) { return { title: 'Fabricantes Homologados', subtitle: 'Cadastro de indústrias, marcas e sistemistas automotivos', buttonText: 'Novo Fabricante', - breadcrumbs: [{ label: 'Catálogo', route: '/catalog/products' }, { label: 'Fabricantes' }] + breadcrumbs: [{ label: 'Catálogo', route: '/catalog/products' }, { label: 'Fabricantes' }], }; } else if (url.includes('oem')) { return { title: 'Códigos OEM & Equivalência', subtitle: 'Indexação de números originais de montadoras e conversão cruzada', buttonText: 'Novo Código OEM', - breadcrumbs: [{ label: 'Compatibilidade' }, { label: 'OEM Codes' }] + breadcrumbs: [{ label: 'Compatibilidade' }, { label: 'OEM Codes' }], }; } else if (url.includes('brands') || url.includes('vehicles')) { return { title: 'Montadoras & Veículos', subtitle: 'Base de dados de marcas, modelos, versões e motores', buttonText: 'Nova Montadora', - breadcrumbs: [{ label: 'Veículos' }, { label: 'Montadoras' }] + breadcrumbs: [{ label: 'Veículos' }, { label: 'Montadoras' }], }; } else if (url.includes('inventory') || url.includes('commercial')) { return { title: 'Gestão de Estoque & Depósitos', subtitle: 'Saldos físicos, locação em prateleiras e depósitos centralizados', buttonText: 'Ajustar Saldo', - breadcrumbs: [{ label: 'Comercial' }, { label: 'Estoque' }] + breadcrumbs: [{ label: 'Comercial' }, { label: 'Estoque' }], }; } else if (url.includes('imports')) { return { title: 'Central de Importações', subtitle: 'Processamento de planilhas Excel/CSV e atualização massiva de preços', buttonText: 'Nova Importação', - breadcrumbs: [{ label: 'Importações' }] + breadcrumbs: [{ label: 'Importações' }], }; } else if (url.includes('reports')) { return { title: 'Relatórios Gerenciais', subtitle: 'Análises de curva ABC, giro de peças e lacunas de catálogo', buttonText: 'Gerar Relatório', - breadcrumbs: [{ label: 'Relatórios' }] + breadcrumbs: [{ label: 'Relatórios' }], }; } else if (url.includes('admin')) { return { title: 'Administração do Sistema', subtitle: 'Permissões de acesso, logs de auditoria e configurações globais', buttonText: 'Novo Usuário', - breadcrumbs: [{ label: 'Administração' }] + breadcrumbs: [{ label: 'Administração' }], }; } else { return { title: 'Módulo Reccos Shop', subtitle: 'Estrutura padrão de módulo integrada ao Application Shell', buttonText: 'Novo Item', - breadcrumbs: [{ label: 'Sistema' }] + breadcrumbs: [{ label: 'Sistema' }], }; } }); @@ -115,31 +115,104 @@ export class PlaceholderPageComponent { { key: 'name', header: 'Descrição / Item' }, { key: 'category', header: 'Grupo', width: '180px' }, { key: 'updatedAt', header: 'Última Atualização', width: '160px' }, - { key: 'status', header: 'Status', width: '120px', align: 'center' } + { key: 'status', header: 'Status', width: '120px', align: 'center' }, ]; readonly mockData: PlaceholderRowItem[] = [ - { code: 'ITEM-001', name: 'Sistemas de Freios ABS / Disc / Pad', category: 'Sistemas Ativos', updatedAt: '27/07/2026 10:14', status: 'Ativo' }, - { code: 'ITEM-002', name: 'Kits de Distribuição e Correias Sincronizadoras', category: 'Transmissão', updatedAt: '27/07/2026 09:30', status: 'Ativo' }, - { code: 'ITEM-003', name: 'Conjunto de Amortecedores e Molas Helicoidais', category: 'Suspensão', updatedAt: '26/07/2026 18:22', status: 'Ativo' }, - { code: 'ITEM-004', name: 'Sistema de Ignição e Injeção Eletrônica Flex', category: 'Elétrica', updatedAt: '25/07/2026 14:10', status: 'Ativo' }, - { code: 'ITEM-005', name: 'Filtros de Lubrificantes, Ar e Combustível', category: 'Manutenção', updatedAt: '24/07/2026 11:05', status: 'Ativo' }, - { code: 'ITEM-006', name: 'Sensores de Rotação, Oxigênio e Pressão MAP', category: 'Injeção', updatedAt: '24/07/2026 08:12', status: 'Ativo' }, - { code: 'ITEM-007', name: 'Kits de Embreagem Cobre / Aço Orgânica', category: 'Transmissão', updatedAt: '23/07/2026 16:45', status: 'Ativo' }, - { code: 'ITEM-008', name: 'Bombas d\'Água e Termostatos de Arrefecimento', category: 'Motor', updatedAt: '23/07/2026 12:20', status: 'Ativo' }, - { code: 'ITEM-009', name: 'Radiadores e Ventoinhas do Ar-Condicionado', category: 'Climatização', updatedAt: '22/07/2026 17:00', status: 'Inativo' }, - { code: 'ITEM-010', name: 'Terminais de Direção e Pivôs de Suspensão', category: 'Direção', updatedAt: '22/07/2026 10:15', status: 'Ativo' }, - { code: 'ITEM-011', name: 'Bujões e Juntas de Vedação de Aço Inox', category: 'Motor', updatedAt: '21/07/2026 15:30', status: 'Ativo' }, - { code: 'ITEM-012', name: 'Faróis Principais e DRL em LED Alta Intensidade', category: 'Iluminação', updatedAt: '21/07/2026 09:10', status: 'Ativo' } + { + code: 'ITEM-001', + name: 'Sistemas de Freios ABS / Disc / Pad', + category: 'Sistemas Ativos', + updatedAt: '27/07/2026 10:14', + status: 'Ativo', + }, + { + code: 'ITEM-002', + name: 'Kits de Distribuição e Correias Sincronizadoras', + category: 'Transmissão', + updatedAt: '27/07/2026 09:30', + status: 'Ativo', + }, + { + code: 'ITEM-003', + name: 'Conjunto de Amortecedores e Molas Helicoidais', + category: 'Suspensão', + updatedAt: '26/07/2026 18:22', + status: 'Ativo', + }, + { + code: 'ITEM-004', + name: 'Sistema de Ignição e Injeção Eletrônica Flex', + category: 'Elétrica', + updatedAt: '25/07/2026 14:10', + status: 'Ativo', + }, + { + code: 'ITEM-005', + name: 'Filtros de Lubrificantes, Ar e Combustível', + category: 'Manutenção', + updatedAt: '24/07/2026 11:05', + status: 'Ativo', + }, + { + code: 'ITEM-006', + name: 'Sensores de Rotação, Oxigênio e Pressão MAP', + category: 'Injeção', + updatedAt: '24/07/2026 08:12', + status: 'Ativo', + }, + { + code: 'ITEM-007', + name: 'Kits de Embreagem Cobre / Aço Orgânica', + category: 'Transmissão', + updatedAt: '23/07/2026 16:45', + status: 'Ativo', + }, + { + code: 'ITEM-008', + name: "Bombas d'Água e Termostatos de Arrefecimento", + category: 'Motor', + updatedAt: '23/07/2026 12:20', + status: 'Ativo', + }, + { + code: 'ITEM-009', + name: 'Radiadores e Ventoinhas do Ar-Condicionado', + category: 'Climatização', + updatedAt: '22/07/2026 17:00', + status: 'Inativo', + }, + { + code: 'ITEM-010', + name: 'Terminais de Direção e Pivôs de Suspensão', + category: 'Direção', + updatedAt: '22/07/2026 10:15', + status: 'Ativo', + }, + { + code: 'ITEM-011', + name: 'Bujões e Juntas de Vedação de Aço Inox', + category: 'Motor', + updatedAt: '21/07/2026 15:30', + status: 'Ativo', + }, + { + code: 'ITEM-012', + name: 'Faróis Principais e DRL em LED Alta Intensidade', + category: 'Iluminação', + updatedAt: '21/07/2026 09:10', + status: 'Ativo', + }, ]; readonly filteredData = computed(() => { const q = this.searchQuery().toLowerCase().trim(); if (!q) return this.mockData; - return this.mockData.filter(item => - item.name.toLowerCase().includes(q) || - item.code.toLowerCase().includes(q) || - item.category.toLowerCase().includes(q) + return this.mockData.filter( + (item) => + item.name.toLowerCase().includes(q) || + item.code.toLowerCase().includes(q) || + item.category.toLowerCase().includes(q), ); }); diff --git a/src/app/layout/app-shell.ts b/src/app/layout/app-shell.ts deleted file mode 100644 index d9900e5..0000000 --- a/src/app/layout/app-shell.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { HeaderComponent } from './header/header'; -import { SidebarComponent } from './sidebar/sidebar'; -import { ContentComponent } from './content/content'; -import { FooterComponent } from './footer/footer'; -import { NotificationsDrawerComponent } from './notifications-drawer'; -import { ToastContainerComponent } from '../design-system/toast/toast'; - -@Component({ - selector: 'app-shell', - standalone: true, - imports: [ - HeaderComponent, - SidebarComponent, - ContentComponent, - FooterComponent, - NotificationsDrawerComponent, - ToastContainerComponent - ], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './app-shell.html', - styleUrl: './app-shell.css' -}) -export class AppShellComponent {} diff --git a/src/app/layout/content/content.ts b/src/app/layout/content/content.ts index 620155f..352aeb0 100644 --- a/src/app/layout/content/content.ts +++ b/src/app/layout/content/content.ts @@ -8,7 +8,7 @@ import { ShellStateService } from '../../core/services/shell-state'; imports: [RouterOutlet], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './content.html', - styleUrl: './content.css' + styleUrl: './content.css', }) export class ContentComponent { readonly shellState = inject(ShellStateService); diff --git a/src/app/layout/footer/footer.html b/src/app/layout/footer/footer.html index cbef11f..c5f6051 100644 --- a/src/app/layout/footer/footer.html +++ b/src/app/layout/footer/footer.html @@ -10,7 +10,9 @@

- + Produção v1.0.0 diff --git a/src/app/layout/footer/footer.ts b/src/app/layout/footer/footer.ts index e1c2cea..0041bb9 100644 --- a/src/app/layout/footer/footer.ts +++ b/src/app/layout/footer/footer.ts @@ -6,7 +6,7 @@ import { ShellStateService } from '../../core/services/shell-state'; standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './footer.html', - styleUrl: './footer.css' + styleUrl: './footer.css', }) export class FooterComponent { readonly shellState = inject(ShellStateService); diff --git a/src/app/layout/header/header.html b/src/app/layout/header/header.html index 0652da7..b5fbcdb 100644 --- a/src/app/layout/header/header.html +++ b/src/app/layout/header/header.html @@ -1,4 +1,6 @@ -
+
@@ -29,12 +31,17 @@ role="button" class="flex items-center gap-2.5 cursor-pointer group focus:outline-none" > -
+
R