From d86078e475ad1c687417eeee01de927b5897a58c Mon Sep 17 00:00:00 2001 From: aledguedes Date: Tue, 28 Jul 2026 16:32:38 -0300 Subject: [PATCH 1/8] feat install prietter, eslint and fortatation code --- .prettierignore | 4 + .prettierrc | 13 + README.md | 3 +- angular.json | 31 +- eslint.config.js | 33 +- package-lock.json | 205 +++- package.json | 8 + src/app/app.config.server.ts | 8 +- src/app/app.config.ts | 9 +- src/app/app.routes.ts | 45 +- src/app/app.spec.ts | 4 +- src/app/app.ts | 2 +- .../app-shell}/app-shell.css | 0 .../app-shell}/app-shell.html | 4 +- src/app/components/app-shell/app-shell.ts | 23 + .../notifications-drawer.css | 0 .../notifications-drawer.html | 77 ++ .../notifications-drawer.ts | 45 + .../models/list-table/list-table.model.ts | 40 + .../models/pagination/pagination.model.ts | 32 + .../models/products/product-request.model.ts | 0 .../models/products/product-response.model.ts | 65 + src/app/core/services/api-service.ts | 16 + src/app/core/services/product-service.ts | 17 + src/app/core/services/shell-state.ts | 119 +- src/app/core/services/theme.ts | 6 +- src/app/core/services/toast.ts | 8 +- src/app/design-system/badge/badge.ts | 23 +- .../design-system/breadcrumb/breadcrumb.html | 22 +- .../design-system/breadcrumb/breadcrumb.ts | 2 +- src/app/design-system/button/button.html | 23 +- src/app/design-system/button/button.ts | 14 +- .../design-system/data-table/data-table.html | 157 ++- .../design-system/data-table/data-table.ts | 149 ++- .../design-system/dialog/confirm-dialog.html | 54 +- .../design-system/dialog/confirm-dialog.ts | 24 +- src/app/design-system/dialog/dialog.html | 70 +- src/app/design-system/dialog/dialog.ts | 15 +- src/app/design-system/drawer/drawer.html | 84 +- src/app/design-system/drawer/drawer.ts | 12 +- src/app/design-system/dropdown/dropdown.html | 60 +- src/app/design-system/dropdown/dropdown.ts | 14 +- .../empty-state/empty-state.html | 18 +- .../design-system/empty-state/empty-state.ts | 6 +- .../error-state/error-state.html | 8 +- .../design-system/error-state/error-state.ts | 6 +- src/app/design-system/icon/app-icon.html | 657 +++++----- src/app/design-system/icon/app-icon.ts | 2 +- src/app/design-system/input/input.html | 31 +- src/app/design-system/input/input.ts | 5 +- src/app/design-system/input/search-input.html | 20 +- src/app/design-system/input/search-input.ts | 2 +- .../page-header/page-header.html | 10 +- .../design-system/page-header/page-header.ts | 2 +- .../design-system/pagination/pagination.html | 118 +- .../design-system/pagination/pagination.ts | 2 +- src/app/design-system/select/select.html | 41 +- src/app/design-system/select/select.ts | 2 +- src/app/design-system/skeleton/skeleton.ts | 2 +- src/app/design-system/toast/toast.html | 48 +- src/app/design-system/toast/toast.ts | 41 +- src/app/design-system/toolbar/toolbar.html | 4 +- src/app/design-system/toolbar/toolbar.ts | 2 +- src/app/design-system/tooltip/tooltip.ts | 27 +- src/app/features/catalog/product-form.html | 918 -------------- .../{ => product-form}/product-form.css | 0 .../catalog/product-form/product-form.html | 1087 +++++++++++++++++ .../{ => product-form}/product-form.ts | 340 ++++-- src/app/features/catalog/products.ts | 183 --- .../catalog/{ => products}/products.css | 0 .../catalog/{ => products}/products.html | 24 +- src/app/features/catalog/products/products.ts | 174 +++ src/app/features/dashboard/dashboard.html | 57 +- src/app/features/dashboard/dashboard.ts | 14 +- src/app/features/placeholder.ts | 129 +- src/app/layout/app-shell.ts | 24 - src/app/layout/content/content.ts | 2 +- src/app/layout/footer/footer.html | 4 +- src/app/layout/footer/footer.ts | 2 +- src/app/layout/header/header.html | 28 +- src/app/layout/header/header.ts | 4 +- src/app/layout/notifications-drawer.html | 71 -- src/app/layout/notifications-drawer.ts | 35 - src/app/layout/sidebar/sidebar.html | 175 ++- src/app/layout/sidebar/sidebar.ts | 5 +- src/app/utils/product-table-collum.ts | 81 ++ src/environment/environment.development.ts | 4 + src/environment/environment.ts | 4 + src/main.server.ts | 12 +- src/main.ts | 6 +- src/server.ts | 6 +- src/styles.css | 37 +- tsconfig.app.json | 4 +- 93 files changed, 3626 insertions(+), 2398 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc rename src/app/{layout => components/app-shell}/app-shell.css (100%) rename src/app/{layout => components/app-shell}/app-shell.html (75%) create mode 100644 src/app/components/app-shell/app-shell.ts rename src/app/{layout => components/notifications-drawer}/notifications-drawer.css (100%) create mode 100644 src/app/components/notifications-drawer/notifications-drawer.html create mode 100644 src/app/components/notifications-drawer/notifications-drawer.ts create mode 100644 src/app/core/models/list-table/list-table.model.ts create mode 100644 src/app/core/models/pagination/pagination.model.ts create mode 100644 src/app/core/models/products/product-request.model.ts create mode 100644 src/app/core/models/products/product-response.model.ts create mode 100644 src/app/core/services/api-service.ts create mode 100644 src/app/core/services/product-service.ts delete mode 100644 src/app/features/catalog/product-form.html rename src/app/features/catalog/{ => product-form}/product-form.css (100%) create mode 100644 src/app/features/catalog/product-form/product-form.html rename src/app/features/catalog/{ => product-form}/product-form.ts (68%) delete mode 100644 src/app/features/catalog/products.ts rename src/app/features/catalog/{ => products}/products.css (100%) rename src/app/features/catalog/{ => products}/products.html (84%) create mode 100644 src/app/features/catalog/products/products.ts delete mode 100644 src/app/layout/app-shell.ts delete mode 100644 src/app/layout/notifications-drawer.html delete mode 100644 src/app/layout/notifications-drawer.ts create mode 100644 src/app/utils/product-table-collum.ts create mode 100644 src/environment/environment.development.ts create mode 100644 src/environment/environment.ts 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..e75614a 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,10 +1,7 @@ -import { - ApplicationConfig, - provideBrowserGlobalErrorListeners, -} from '@angular/core'; -import {provideRouter} from '@angular/router'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; -import {routes} from './app.routes'; +import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 500edd7..52795ca 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,74 +4,81 @@ 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-form/product-form').then((m) => m.ProductFormComponent), }, { path: 'catalog/products/:id/edit', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) + loadComponent: () => + import('./features/catalog/product-form/product-form').then((m) => m.ProductFormComponent), }, { path: 'catalog/products/:id/view', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) + loadComponent: () => + import('./features/catalog/product-form/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-form/product-form').then((m) => m.ProductFormComponent), }, { path: 'catalog/products/:id', - loadComponent: () => import('./features/catalog/product-form').then(m => m.ProductFormComponent) + loadComponent: () => + import('./features/catalog/product-form/product-form').then((m) => m.ProductFormComponent), }, { 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/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/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-request.model.ts b/src/app/core/models/products/product-request.model.ts new file mode 100644 index 0000000..e69de29 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..1123b4f --- /dev/null +++ b/src/app/core/models/products/product-response.model.ts @@ -0,0 +1,65 @@ +export interface ProductResponse { + id: string; + status: Status; + category: Category; + manufacturer: Category; + internal_code: string; + name: string; + slug: string; + icon: null; + image: null; + short_description: string; + description: string; + weight: string; + height: string; + width: string; + length: string; + featured: boolean; + active: number; + is_sellable: boolean; + commercial_status: string; + pricing: Pricing; + inventory: Inventory; +} + +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; +} diff --git a/src/app/core/services/api-service.ts b/src/app/core/services/api-service.ts new file mode 100644 index 0000000..d9b12eb --- /dev/null +++ b/src/app/core/services/api-service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../environment/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/product-service.ts b/src/app/core/services/product-service.ts new file mode 100644 index 0000000..0a2df27 --- /dev/null +++ b/src/app/core/services/product-service.ts @@ -0,0 +1,17 @@ +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 '../../../environment/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class ProductService { + private http = inject(HttpClient); + private api = environment.apiUrl; + + getAll() { + return this.http.get>(`${this.api}/product`); + } +} diff --git a/src/app/core/services/shell-state.ts b/src/app/core/services/shell-state.ts index b3db041..e512c0b 100644 --- a/src/app/core/services/shell-state.ts +++ b/src/app/core/services/shell-state.ts @@ -8,7 +8,7 @@ export interface MenuGroup { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ShellStateService { // Sidebar state @@ -31,8 +31,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 +42,7 @@ export class ShellStateService { id: 'dashboard', label: 'Dashboard', icon: 'layout-dashboard', - route: '/dashboard' + route: '/dashboard', }, { id: 'catalog', @@ -53,8 +54,8 @@ 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', @@ -63,9 +64,13 @@ export class ShellStateService { 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' } - ] + { + id: 'equivalents', + label: 'Produtos Equivalentes', + route: '/compatibility/equivalent-products', + }, + { id: 'applications', label: 'Aplicações', route: '/compatibility/applications' }, + ], }, { id: 'vehicles', @@ -75,8 +80,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 +91,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 +133,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 +156,7 @@ export class ShellStateService { timestamp: 'Há 12 min', read: false, type: 'warning', - link: '/commercial/inventory' + link: '/commercial/inventory', }, { id: '2', @@ -160,7 +165,7 @@ export class ShellStateService { timestamp: 'Há 45 min', read: false, type: 'success', - link: '/imports' + link: '/imports', }, { id: '3', @@ -169,7 +174,7 @@ export class ShellStateService { timestamp: 'Há 2 horas', read: false, type: 'info', - link: '/catalog/categories' + link: '/catalog/categories', }, { id: '4', @@ -178,27 +183,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 +241,7 @@ export class ShellStateService { } toggleMobileDrawer(): void { - this.mobileDrawerOpen.update(v => !v); + this.mobileDrawerOpen.update((v) => !v); } closeMobileDrawer(): void { @@ -218,7 +253,7 @@ export class ShellStateService { } toggleNotificationsDrawer(): void { - this.notificationsDrawerOpen.update(v => !v); + this.notificationsDrawerOpen.update((v) => !v); } closeNotificationsDrawer(): void { @@ -230,12 +265,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 +285,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/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/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..cab6ba0 100644 --- a/src/app/design-system/data-table/data-table.html +++ b/src/app/design-system/data-table/data-table.html @@ -2,99 +2,88 @@
- + @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) { - - @for (col of columns(); track col.key) { - - } - - @if (hasActions()) { - - } - + + } } @else if (data().length === 0) { + + + + } @else { @for (row of data(); track row) { + + @for (col of columns(); track col.key) { + + } @if (hasActions()) { + } - } + + } }
-
- {{ col.header }} -
-
Ações +
+ {{ col.header }} +
+
Ações
- - - -
+ + + +
- -
- @if (col.cell) { - {{ col.cell(row) }} - } @else { - {{ row[col.key] }} - } - -
- - -
-
+ +
+ @if (col.cell) { {{ col.cell(row) }} } @else { {{ row[col.key] }} } + +
+ + +
+
diff --git a/src/app/design-system/data-table/data-table.ts b/src/app/design-system/data-table/data-table.ts index 97c0366..e0e8657 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 } from '../badge/badge'; export interface ColumnDef { key: string; @@ -18,37 +24,156 @@ export interface ColumnDef { imports: [AppIconComponent, SkeletonComponent, EmptyStateComponent], 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); + } + 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..79ab9db 100644 --- a/src/app/design-system/icon/app-icon.html +++ b/src/app/design-system/icon/app-icon.html @@ -1,289 +1,370 @@ - - @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') { + + + + + + } @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..ffa4c60 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..aa005c6 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); @@ -31,7 +31,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..2dc487a 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..7df96ec 100644 --- a/src/app/design-system/select/select.ts +++ b/src/app/design-system/select/select.ts @@ -13,7 +13,7 @@ export interface SelectOption { imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './select.html', - styleUrl: './select.css' + styleUrl: './select.css', }) export class SelectComponent { readonly label = 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/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.css b/src/app/features/catalog/product-form/product-form.css similarity index 100% rename from src/app/features/catalog/product-form.css rename to src/app/features/catalog/product-form/product-form.css diff --git a/src/app/features/catalog/product-form/product-form.html b/src/app/features/catalog/product-form/product-form.html new file mode 100644 index 0000000..43aa4c7 --- /dev/null +++ b/src/app/features/catalog/product-form/product-form.html @@ -0,0 +1,1087 @@ + + +
+ @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 + + } +
+
+
+ + + + + + + + + diff --git a/src/app/features/catalog/product-form.ts b/src/app/features/catalog/product-form/product-form.ts similarity index 68% rename from src/app/features/catalog/product-form.ts rename to src/app/features/catalog/product-form/product-form.ts index 70e8c1f..d0400f8 100644 --- a/src/app/features/catalog/product-form.ts +++ b/src/app/features/catalog/product-form/product-form.ts @@ -1,13 +1,20 @@ -import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; +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'; +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'; @@ -78,11 +85,11 @@ export interface ProductNoteItem { SelectComponent, ButtonComponent, ConfirmDialogComponent, - AppIconComponent + AppIconComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: "./product-form.html", - styleUrl: "./product-form.css" + templateUrl: './product-form.html', + styleUrl: './product-form.css', }) export class ProductFormComponent implements OnInit { readonly shellState = inject(ShellStateService); @@ -145,7 +152,7 @@ export class ProductFormComponent implements OnInit { { 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' } + { label: 'Arrefecimento', value: 'cat-05' }, ]; readonly manufacturerOptions: SelectOption[] = [ @@ -155,14 +162,14 @@ export class ProductFormComponent implements OnInit { { label: 'NGK', value: 'm-04' }, { label: 'Fremax', value: 'm-05' }, { label: 'Magneti Marelli', value: 'm-06' }, - { label: 'Sachs', value: 'm-07' } + { 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' } + { label: 'Aftermarket Premium', value: 'po-04' }, ]; readonly unitOptions: SelectOption[] = [ @@ -170,19 +177,19 @@ export class ProductFormComponent implements OnInit { { 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' } + { 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' } + { 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' } + { label: 'Depósito Distribuição RJ', value: 'wh-03' }, ]; readonly availableTags = [ @@ -191,17 +198,21 @@ export class ProductFormComponent implements OnInit { { id: 'tag-3', label: 'Alto Giro' }, { id: 'tag-4', label: 'Primeira Linha' }, { id: 'tag-5', label: 'Importado' }, - { id: 'tag-6', label: 'Oferta Especial' } + { 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'; + case 'edit': + return 'Editar Produto'; + case 'view': + return 'Visualizar Produto'; + case 'duplicate': + return 'Novo Produto'; + default: + return 'Novo Produto'; } }); @@ -210,7 +221,7 @@ export class ProductFormComponent implements OnInit { return [ { label: 'Catálogo', route: '/catalog/products' }, { label: 'Produtos', route: '/catalog/products' }, - { label: actionLabel } + { label: actionLabel }, ]; }); @@ -246,15 +257,17 @@ export class ProductFormComponent implements OnInit { 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.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.formPrice.set(189.9); + this.formPromotionalPrice.set(169.9); this.formPromotionStartDate.set('2026-07-01'); this.formPromotionEndDate.set('2026-08-31'); this.formWarehouseId.set('wh-01'); @@ -264,37 +277,88 @@ export class ProductFormComponent implements OnInit { this.oemCodes.set([ { id: 'oem-1', manufacturer: 'Volkswagen / Audi', oemCode: '5U0698151A', isPrimary: true }, - { id: 'oem-2', manufacturer: 'Bosch Global', oemCode: '0986BB0781', isPrimary: false } + { 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' } + { 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' } + { + 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 } + { + 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 } + { + 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 } + { + id: 'sup-1', + supplierName: 'Distribuidora Automotiva SP', + supplierCode: 'SUP-BOSCH-882', + purchasePrice: 112.5, + 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' } + { + id: 'note-1', + type: 'Técnica', + description: 'Pastilhas com mola antirruído inclusa no kit.', + date: '27/07/2026', + author: 'Engenharia de Produto', + }, ]); } @@ -303,7 +367,8 @@ export class ProductFormComponent implements OnInit { } 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'; + 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]`; } @@ -313,7 +378,14 @@ export class ProductFormComponent implements OnInit { 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']); + return !!( + errs['name'] || + errs['internal_code'] || + errs['category_id'] || + errs['manufacturer_id'] || + errs['unit_id'] || + errs['status_id'] + ); } if (tab === 'comercial') { return !!errs['price']; @@ -324,20 +396,40 @@ export class ProductFormComponent implements OnInit { 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; + 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 => { + this.errors.update((e) => { const copy = { ...e }; delete copy[field]; return copy; @@ -349,17 +441,31 @@ export class ProductFormComponent implements OnInit { 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; + 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 => { + this.errors.update((e) => { const copy = { ...e }; delete copy[field]; return copy; @@ -384,7 +490,7 @@ export class ProductFormComponent implements OnInit { toggleBackorder(): void { if (this.isReadOnly()) return; this.isFormDirty.set(true); - this.formAllowBackorder.update(v => !v); + this.formAllowBackorder.update((v) => !v); } // --- COMPATIBILIDADE COLLECTIONS --- @@ -392,20 +498,25 @@ export class ProductFormComponent implements OnInit { const code = prompt('Digite o Código OEM:'); if (!code) return; const manufacturer = prompt('Digite o Fabricante/Montadora:', 'Volkswagen') || 'Montadora'; - this.oemCodes.update(list => [ + this.oemCodes.update((list) => [ ...list, - { id: 'oem-' + Date.now(), manufacturer, oemCode: code.toUpperCase(), isPrimary: list.length === 0 } + { + 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.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.oemCodes.update((list) => list.filter((o) => o.id !== id)); this.isFormDirty.set(true); } @@ -413,15 +524,12 @@ export class ProductFormComponent implements OnInit { 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.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.productCodes.update((list) => list.filter((p) => p.id !== id)); this.isFormDirty.set(true); } @@ -429,15 +537,15 @@ export class ProductFormComponent implements OnInit { 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 => [ + this.equivalentProducts.update((list) => [ ...list, - { id: 'eq-' + Date.now(), productName: name, notes } + { 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.equivalentProducts.update((list) => list.filter((e) => e.id !== id)); this.isFormDirty.set(true); } @@ -445,15 +553,23 @@ export class ProductFormComponent implements OnInit { 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 => [ + this.vehicleApplications.update((list) => [ ...list, - { id: 'veh-' + Date.now(), brand, model, version: 'Flex', engine, startYear: 2015, endYear: 2022 } + { + 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.vehicleApplications.update((list) => list.filter((v) => v.id !== id)); this.isFormDirty.set(true); } @@ -484,9 +600,12 @@ export class ProductFormComponent implements OnInit { } handleFiles(files: File[]): void { - files.forEach(file => { + files.forEach((file) => { if (file.size > 2 * 1024 * 1024) { - this.toastService.error('Arquivo Excede Limite', `O arquivo ${file.name} possui mais de 2 MB.`); + this.toastService.error( + 'Arquivo Excede Limite', + `O arquivo ${file.name} possui mais de 2 MB.`, + ); return; } @@ -498,10 +617,10 @@ export class ProductFormComponent implements OnInit { isPrimary: this.mediaImages().length === 0, order: this.mediaImages().length + 1, status: 'uploading', - progress: 0 + progress: 0, }; - this.mediaImages.update(list => [...list, newImg]); + this.mediaImages.update((list) => [...list, newImg]); this.simulateAsyncUpload(newImg.id); }); this.isFormDirty.set(true); @@ -511,21 +630,21 @@ export class ProductFormComponent implements OnInit { let prog = 0; const interval = setInterval(() => { prog += 25; - this.mediaImages.update(list => - list.map(img => img.id === imgId ? { ...img, progress: prog } : img) + 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) + 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.mediaImages.update((list) => list.map((img) => ({ ...img, isPrimary: img.id === id }))); this.isFormDirty.set(true); } @@ -537,8 +656,8 @@ export class ProductFormComponent implements OnInit { executeDeleteImage(): void { const img = this.selectedImageForDelete(); if (img) { - this.mediaImages.update(list => { - const filtered = list.filter(i => i.id !== img.id); + this.mediaImages.update((list) => { + const filtered = list.filter((i) => i.id !== img.id); if (img.isPrimary && filtered.length > 0) { filtered[0].isPrimary = true; } @@ -554,15 +673,22 @@ export class ProductFormComponent implements OnInit { const name = prompt('Nome do Fornecedor:'); if (!name) return; const code = prompt('Código no Fornecedor:', 'SUP-01') || 'SUP-01'; - this.suppliers.update(list => [ + this.suppliers.update((list) => [ ...list, - { id: 'sup-' + Date.now(), supplierName: name, supplierCode: code, purchasePrice: 100.0, leadTimeDays: 5, isPreferential: list.length === 0 } + { + 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.suppliers.update((list) => list.filter((s) => s.id !== id)); this.isFormDirty.set(true); } @@ -574,24 +700,30 @@ export class ProductFormComponent implements OnInit { if (this.isReadOnly()) return; this.isFormDirty.set(true); if (this.isTagSelected(tagId)) { - this.selectedTagIds.update(ids => ids.filter(id => id !== tagId)); + this.selectedTagIds.update((ids) => ids.filter((id) => id !== tagId)); } else { - this.selectedTagIds.update(ids => [...ids, tagId]); + this.selectedTagIds.update((ids) => [...ids, tagId]); } } openAddNoteModal(): void { const desc = prompt('Descrição da Nota/Observação:'); if (!desc) return; - this.productNotes.update(list => [ + this.productNotes.update((list) => [ ...list, - { id: 'note-' + Date.now(), type: 'Geral', description: desc, date: new Date().toLocaleDateString('pt-BR'), author: 'Usuário do Sistema' } + { + 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.productNotes.update((list) => list.filter((n) => n.id !== id)); this.isFormDirty.set(true); } @@ -637,7 +769,10 @@ export class ProductFormComponent implements OnInit { saveProduct(continueEditing: boolean): void { if (!this.validateForm()) { - this.toastService.error('Não foi possível salvar o produto.', 'Verifique os campos obrigatórios destacados.'); + this.toastService.error( + 'Não foi possível salvar o produto.', + 'Verifique os campos obrigatórios destacados.', + ); return; } @@ -665,7 +800,7 @@ export class ProductFormComponent implements OnInit { quantity: this.formQuantity(), min_quantity: this.formMinQuantity(), allow_backorder: this.formAllowBackorder(), - tag_ids: this.selectedTagIds() + tag_ids: this.selectedTagIds(), }; console.log('Sending Product Payload to Backend API:', payload); @@ -673,7 +808,10 @@ export class ProductFormComponent implements OnInit { 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.'); + this.toastService.success( + 'Produto salvo com sucesso.', + 'Todas as informações comerciais e técnicas foram salvas.', + ); if (!continueEditing) { this.router.navigate(['/catalog/products']); @@ -711,7 +849,10 @@ export class ProductFormComponent implements OnInit { 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.'); + this.toastService.info( + 'Modo Duplicar', + 'Código interno limpo automaticamente conforme regra do sistema.', + ); } confirmDeleteProduct(): void { @@ -720,7 +861,10 @@ export class ProductFormComponent implements OnInit { executeDeleteProduct(): void { this.deleteProductDialogOpen.set(false); - this.toastService.success('Produto Excluído', 'O produto foi removido do catálogo com sucesso.'); + 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/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..91982a0 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,9 @@

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() { + this.productService.getAll().subscribe({ + next: (response) => { + this.allProducts.set(response.data); + console.log('[PRODUCT ALL LIST]', this.allProducts); + }, + error: (error) => { + this.toastService.error('Erro ao buscar produtos', error.message); + }, + }); + } + + 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); + } +} 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
} diff --git a/src/app/features/catalog/product-form/product-form.ts b/src/app/features/catalog/product-form/product-form.ts index 1b60325..7f8309f 100644 --- a/src/app/features/catalog/product-form/product-form.ts +++ b/src/app/features/catalog/product-form/product-form.ts @@ -9,7 +9,7 @@ import { 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 { SelectComponent } 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'; @@ -29,6 +29,11 @@ import { debounceTime, distinctUntilChanged, Subject, switchMap } from 'rxjs'; import { GeneralOptionQuery } from '../../../core/models/generals/general-option-query.model'; import { ManufacturerOption } from '../../../core/models/manufactureres/manufaturer-options.model'; import { StatusService } from '../../../core/services/status-service'; +import { + StatusOption, + StatusOptionsResponse, +} from '../../../core/models/status/status-options.model'; +import { DSelectOption } from '../../../core/models/design-system/select-option.model'; export type ProductFormMode = 'create' | 'edit' | 'view' | 'duplicate'; export type FormTab = @@ -176,21 +181,21 @@ export class ProductFormComponent implements OnInit { readonly productNotes = signal([]); readonly categoryLoading = signal(false); - readonly categoryQuery = signal(''); + readonly manufacturerLoading = signal(false); + + readonly fetchedStatuses = signal([]); readonly fetchedCategories = signal([]); readonly fetchedManufacturers = signal([]); - readonly manufacturerLoading = signal(false); + readonly statusQuery = signal(''); + readonly categoryQuery = signal(''); readonly manufacturerQuery = signal(''); - // readonly manufacturerQuery = signal({ - // active: null, - // search: '', - // limit: 10, - // }); private readonly categorySearch$ = new Subject(); private readonly manufacturerSearch$ = new Subject(); + private currentModule = signal('PRODUCT'); + readonly product = signal({ id: 'p0000000-0002-0000-0000-000000000002', status: { @@ -266,10 +271,10 @@ export class ProductFormComponent implements OnInit { }); readonly manufacturerOptions = computed(() => { - const manufacturers = this.fetchedManufacturers(); + const status = this.fetchedStatuses(); const query = this.manufacturerQuery().trim().toLowerCase() || ''; - return manufacturers + return status .filter((m) => !query || m.label.toLowerCase().includes(query)) .map((m) => ({ label: m.label, @@ -278,14 +283,16 @@ export class ProductFormComponent implements OnInit { })); }); - readonly partOriginOptions: SelectOption[] = [ + readonly statusOptions = signal([]); + + readonly partOriginOptions: DSelectOption[] = [ { 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[] = [ + readonly unitOptions: DSelectOption[] = [ { label: 'Jogo', value: 'jogo' }, { label: 'Peça', value: 'peça' }, { label: 'Par', value: 'par' }, @@ -297,13 +304,7 @@ export class ProductFormComponent implements OnInit { { label: 'Conjunto', value: 'conjunto' }, ]; - readonly statusOptions: SelectOption[] = [ - { label: 'Ativo', value: 'st-01' }, - { label: 'Inativo', value: 'st-02' }, - { label: 'Em Homologação', value: 'st-03' }, - ]; - - readonly warehouseOptions: SelectOption[] = [ + readonly warehouseOptions: DSelectOption[] = [ { 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' }, @@ -384,9 +385,9 @@ export class ProductFormComponent implements OnInit { }, }); + this.onStatusSearch(); this.onCategorySearch(this.queries()); this.onManufacturerSearch(this.queries()); - this.getStatus(); if (id) { this.productById(id); @@ -1068,10 +1069,15 @@ export class ProductFormComponent implements OnInit { this.manufacturerSearch$.next(query); } - getStatus() { - this.statusService.getAll().subscribe({ + onStatusSearch(limit = null) { + this.statusService.getOptions(this.currentModule(), limit).subscribe({ next: (response) => { - console.log('Fetched Product Status Options:', response.data); + const result = response.data.map((status) => ({ + label: status.label, + value: status.id, + description: status.description, + })); + this.statusOptions.set(result); }, error: (error) => { console.error('Error fetching product status options:', error); From bcfa3d9de4c7e34a426b299f6b552f251216a22a Mon Sep 17 00:00:00 2001 From: aledguedes Date: Mon, 3 Aug 2026 10:42:17 -0300 Subject: [PATCH 5/8] Add product payload transformer and CRUD support Introduce ProductFormState model and a product-payload.transformer with helpers (type conversions, transformToCreatePayload, transformToUpdatePayload, validateProductForm, mapProductBackendErrors). Make product request types nullable and add UpdateProductPayload. Add ProductService.create/update methods. Update product form template to surface field errors and product-form.ts to use computed formState, build create/update payloads, populate form from API, call create/update endpoints, and handle validation errors and tab switching. --- .../products/product-form-state.model.ts | 26 ++ .../models/products/product-request.model.ts | 58 +++-- src/app/core/services/product-service.ts | 12 + .../core/utils/product-payload.transformer.ts | 236 +++++++++++++++++ .../catalog/product-form/product-form.html | 5 + .../catalog/product-form/product-form.ts | 238 +++++++++++------- 6 files changed, 473 insertions(+), 102 deletions(-) create mode 100644 src/app/core/models/products/product-form-state.model.ts create mode 100644 src/app/core/utils/product-payload.transformer.ts 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..3ec697e --- /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; + status_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 index 9646013..fc00167 100644 --- a/src/app/core/models/products/product-request.model.ts +++ b/src/app/core/models/products/product-request.model.ts @@ -1,17 +1,17 @@ export interface CreateProductPayload { - category_id: string; - manufacturer_id: string; - status_id?: string; + category_id: string | null; + manufacturer_id: string | null; + status_id?: string | null; - internal_code: string; - name: string; + internal_code: string | null; + name: string | null; - slug?: string; - icon?: string; - image?: string; + slug?: string | null; + icon?: string | null; + image?: string | null; - short_description?: string; - description?: string; + short_description?: string | null; + description?: string | null; weight?: number; height?: number; @@ -21,7 +21,7 @@ export interface CreateProductPayload { active?: boolean; featured?: boolean; - unit?: string; + unit?: string | null; price: ProductPricePayload; @@ -30,18 +30,44 @@ export interface CreateProductPayload { export interface ProductPricePayload { price: number; - promotional_price?: number; - promotion_start?: string; // YYYY-MM-DD - promotion_end?: string; // YYYY-MM-DD + promotional_price?: number | null; + promotion_start?: string | null; + promotion_end?: string | null; } export interface ProductInventoryPayload { - warehouse_id: string; + warehouse_id: string | null; quantity: number; minimum_quantity?: number; - maximum_quantity?: number; + maximum_quantity?: number | null; allow_backorder?: boolean; } + +export interface UpdateProductPayload { + category_id?: string | null; + manufacturer_id?: string | null; + status_id?: string | null; + + internal_code?: string | null; + name?: string | null; + + slug?: string | null; + icon?: string | null; + image?: string | null; + + short_description?: string | null; + description?: string | null; + + weight?: number; + height?: number; + width?: number; + length?: number; + + active?: boolean; + featured?: boolean; + + unit?: string | null; +} diff --git a/src/app/core/services/product-service.ts b/src/app/core/services/product-service.ts index 76040ac..bc38c02 100644 --- a/src/app/core/services/product-service.ts +++ b/src/app/core/services/product-service.ts @@ -1,6 +1,10 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { PaginatedResponse } from '../models/pagination/pagination.model'; +import { + CreateProductPayload, + UpdateProductPayload, +} from '../models/products/product-request.model'; import { ProductResponse } from '../models/products/product-response.model'; import { environment } from '../../../environments/environment'; @@ -19,4 +23,12 @@ export class ProductService { 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); + } } 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..cd9d11b --- /dev/null +++ b/src/app/core/utils/product-payload.transformer.ts @@ -0,0 +1,236 @@ +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), + status_id: toNullableString(state.status_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 { + return { + ...buildProductRoot(state), + price: buildPriceBlock(state), + inventory: buildInventoryBlock(state), + }; +} + +export function transformToUpdatePayload(state: ProductFormState): UpdateProductPayload { + return buildProductRoot(state); +} + +export function validateProductForm( + state: ProductFormState, + mode: 'create' | 'edit' | 'view' | 'duplicate', +): Record { + const errs: Record = {}; + const isCreateFlow = mode === 'create' || mode === 'duplicate'; + + 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.status_id) { + errs['status_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/features/catalog/product-form/product-form.html b/src/app/features/catalog/product-form/product-form.html index a4c946c..80c32f3 100644 --- a/src/app/features/catalog/product-form/product-form.html +++ b/src/app/features/catalog/product-form/product-form.html @@ -264,6 +264,7 @@

@@ -276,6 +277,7 @@

@@ -285,6 +287,7 @@

@@ -378,6 +381,7 @@

@@ -388,6 +392,7 @@

diff --git a/src/app/features/catalog/product-form/product-form.ts b/src/app/features/catalog/product-form/product-form.ts index 7f8309f..d47b80b 100644 --- a/src/app/features/catalog/product-form/product-form.ts +++ b/src/app/features/catalog/product-form/product-form.ts @@ -34,6 +34,13 @@ import { StatusOptionsResponse, } from '../../../core/models/status/status-options.model'; import { DSelectOption } from '../../../core/models/design-system/select-option.model'; +import { ProductFormState } from '../../../core/models/products/product-form-state.model'; +import { + mapProductBackendErrors, + transformToCreatePayload, + transformToUpdatePayload, + validateProductForm, +} from '../../../core/utils/product-payload.transformer'; export type ProductFormMode = 'create' | 'edit' | 'view' | 'duplicate'; export type FormTab = @@ -271,10 +278,10 @@ export class ProductFormComponent implements OnInit { }); readonly manufacturerOptions = computed(() => { - const status = this.fetchedStatuses(); + const manufacturers = this.fetchedManufacturers(); const query = this.manufacturerQuery().trim().toLowerCase() || ''; - return status + return manufacturers .filter((m) => !query || m.label.toLowerCase().includes(query)) .map((m) => ({ label: m.label, @@ -283,6 +290,34 @@ export class ProductFormComponent implements OnInit { })); }); + readonly formState = computed(() => ({ + name: this.formName(), + internal_code: this.formInternalCode(), + category_id: this.formCategoryId(), + manufacturer_id: this.formManufacturerId(), + unit: this.formUnitId(), + status_id: this.formStatusId(), + slug: this.formSlug(), + 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(), + promotion_end_date: this.formPromotionEndDate(), + warehouse_id: this.formWarehouseId(), + quantity: this.formQuantity(), + minimum_quantity: this.formMinQuantity(), + maximum_quantity: this.formMaxQuantity(), + allow_backorder: this.formAllowBackorder(), + })); + + readonly createPayload = computed(() => transformToCreatePayload(this.formState())); + + readonly updatePayload = computed(() => transformToUpdatePayload(this.formState())); + readonly statusOptions = signal([]); readonly partOriginOptions: DSelectOption[] = [ @@ -390,34 +425,31 @@ export class ProductFormComponent implements OnInit { this.onManufacturerSearch(this.queries()); if (id) { + if (url.includes('/edit')) { + this.mode.set('edit'); + } else if (url.includes('/duplicate')) { + this.mode.set('duplicate'); + } else { + this.mode.set('view'); + } this.productById(id); - } - - if (url.includes('/new')) { + } else { 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(''); - // } } productById(productId: string) { this.productService.getById(productId).subscribe({ next: (response) => { - console.log(response); - // this.product = response; + console.log('Product fetched from API:', response); + this.populateForm(response); + if (this.mode() === 'duplicate') { + this.formInternalCode.set(''); + } }, error: (error) => { - console.log(error); + console.error('Error loading product:', error); + this.toastService.error('Erro', 'Não foi possível carregar os dados do produto.'); }, }); } @@ -567,7 +599,20 @@ export class ProductFormComponent implements OnInit { ); } if (tab === 'comercial') { - return !!errs['price']; + 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; } @@ -915,30 +960,39 @@ export class ProductFormComponent implements OnInit { } // --- 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.'; + populateForm(prod: ProductResponse): void { + this.formName.set(prod.name || ''); + this.formInternalCode.set(prod.internal_code || ''); + this.formCategoryId.set(prod.category?.id || ''); + this.formManufacturerId.set(prod.manufacturer?.id || ''); + this.formUnitId.set((prod['unit'] || prod['unit_id'] || '') as string); + this.formStatusId.set(prod.status?.id || ''); + this.formDescription.set(prod.description || ''); + this.formSlug.set(prod.slug || ''); + + this.formWeight.set(parseFloat(prod.weight) || 0); + this.formHeight.set(parseFloat(prod.height) || 0); + this.formWidth.set(parseFloat(prod.width) || 0); + this.formLength.set(parseFloat(prod.length) || 0); + + if (prod.pricing) { + this.formPrice.set(prod.pricing.regular_price || 0); + this.formPromotionalPrice.set(prod.pricing.promotional_price || null); + this.formPromotionStartDate.set(prod.pricing.promotion_start || ''); + this.formPromotionEndDate.set(prod.pricing.promotion_end || ''); } - 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.'; + + if (prod.inventory) { + this.formWarehouseId.set(((prod.inventory as any)['warehouse_id'] || 'wh-01') as string); + this.formQuantity.set(prod.inventory.quantity || 0); + this.formMinQuantity.set(prod.inventory.minimum_quantity || 0); + this.formMaxQuantity.set(prod.inventory.maximum_quantity || null); + this.formAllowBackorder.set(!!prod.inventory.allow_backorder); } + } + + validateForm(): boolean { + const errs = validateProductForm(this.formState(), this.mode()); this.errors.set(errs); @@ -947,6 +1001,8 @@ export class ProductFormComponent implements OnInit { this.activeTab.set('geral'); } else if (this.hasTabError('comercial')) { this.activeTab.set('comercial'); + } else if (this.hasTabError('inventario')) { + this.activeTab.set('inventario'); } return false; } @@ -965,53 +1021,63 @@ export class ProductFormComponent implements OnInit { 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, - is_invoiced: this.formIsInvoiced(), - inventory: { - warehouse_id: this.formWarehouseId(), - quantity: this.formQuantity(), - minimum_quantity: this.formMinQuantity(), - maximum_quantity: this.formMaxQuantity(), - allow_backorder: this.formAllowBackorder(), - reserved_quantity: 0, - active: true, - }, - tag_ids: this.selectedTagIds(), - }; + const id = this.route.snapshot.paramMap.get('id'); + const isEdit = this.mode() === 'edit' && id; + + console.log( + 'Sending Product Payload to Backend API:', + isEdit ? this.updatePayload() : this.createPayload(), + ); - console.log('Sending Product Payload to Backend API:', payload); + const request$ = isEdit + ? this.productService.update(id!, this.updatePayload()) + : this.productService.create(this.createPayload()); - 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.', - ); + request$.subscribe({ + next: (response) => { + 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'); + if (!continueEditing) { + this.router.navigate(['/catalog/products']); + } else if (this.mode() === 'create' || this.mode() === 'duplicate') { + this.mode.set('edit'); + if (response.id) { + this.router.navigate(['/catalog/products', response.id, 'edit']); + } + } + }, + error: (err) => { + this.isSaving.set(false); + console.error('Error saving product:', err); + if (err.status === 422 && err.error?.errors) { + const mapped = mapProductBackendErrors(err.error.errors); + this.errors.set(mapped); + + this.toastService.error( + 'Erro de validação', + err.error.message || 'Verifique as mensagens de erro nos campos.' + ); + + 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'); + } + } else { + this.toastService.error( + 'Erro ao salvar o produto', + err.error?.message || err.message || 'Ocorreu um erro inesperado no servidor.' + ); + } } - }, 600); + }); } onCancel(): void { From ae8e05c5d19e9dbc39fbdc4af6590cae0ca14c50 Mon Sep 17 00:00:00 2001 From: aledguedes Date: Wed, 5 Aug 2026 17:43:35 -0300 Subject: [PATCH 6/8] Add product workspace, create UI & services Introduce a new product creation/workspace feature: adds standalone product-create and product-workspace components with tabbed subcomponents (general, commercial, inventory, media, compatibility, administration) and UI pieces (sidebar, stepper). Adds models, utilities and compatibility-modules.config to centralize compatibility modules. New services: PartOriginService and UnitSaleService; ProductService gains delete(). Payload transformer and request models updated to match create API contract and validation rules. Routes updated to point to new components and appConfig registers provideHttpClient(withFetch()). Legacy product-form files removed and global scrollbar styles refined. --- src/app/app.config.ts | 8 +- src/app/app.routes.server.ts | 4 - src/app/app.routes.ts | 19 +- .../config/compatibility-modules.config.ts | 86 ++ .../core/models/PartOriginResponse.model.ts | 8 + .../catetories/category-options.model.ts | 1 - .../generals/general-responses-list.model.ts | 5 + .../models/products/product-create.model.ts | 158 +++ .../models/products/product-request.model.ts | 69 +- src/app/core/models/unit-sale.model.ts | 12 + src/app/core/services/part-origins-service.ts | 23 + src/app/core/services/product-service.ts | 10 +- src/app/core/services/shell-state.ts | 12 +- src/app/core/services/unit-sale-service.ts | 23 + .../core/utils/product-payload.transformer.ts | 28 +- .../autocomplete-select.ts | 17 +- .../administration-tab.html | 265 ++++ .../administration-tab/administration-tab.ts | 87 ++ .../commercial-tab/commercial-tab.html | 76 ++ .../commercial-tab/commercial-tab.ts | 43 + .../compatibility-tab/compatibility-tab.html | 405 ++++++ .../compatibility-tab/compatibility-tab.ts | 123 ++ .../product-form/general-tab/general-tab.html | 63 + .../product-form/general-tab/general-tab.ts | 54 + .../sections/basic-info-section.ts | 86 ++ .../sections/dimensions-section.ts | 90 ++ .../sections/identification-section.ts | 123 ++ .../sections/visibility-section.ts | 70 + .../inventory-tab/inventory-tab.html | 117 ++ .../inventory-tab/inventory-tab.ts | 45 + .../product-form/media-tab/media-tab.html | 106 ++ .../product-form/media-tab/media-tab.ts | 42 + .../sidebar/product-workspace-sidebar.css | 54 + .../sidebar/product-workspace-sidebar.html | 371 ++++++ .../sidebar/product-workspace-sidebar.ts | 97 ++ .../stepper/product-create-stepper.html | 99 ++ .../stepper/product-create-stepper.ts | 18 + .../catalog/models/product-workspace.model.ts | 170 +++ .../product-create/product-create.html | 88 ++ .../catalog/product-create/product-create.ts | 338 +++++ .../catalog/product-form/product-form.css | 1 - .../catalog/product-form/product-form.html | 1164 ----------------- .../catalog/product-form/product-form.ts | 1153 ---------------- .../product-workspace/product-workspace.css | 40 + .../product-workspace/product-workspace.html | 313 +++++ .../product-workspace/product-workspace.ts | 1001 ++++++++++++++ src/app/utils/product-table-collum.ts | 8 - src/styles.css | 30 +- 48 files changed, 4834 insertions(+), 2389 deletions(-) create mode 100644 src/app/core/config/compatibility-modules.config.ts create mode 100644 src/app/core/models/PartOriginResponse.model.ts create mode 100644 src/app/core/models/generals/general-responses-list.model.ts create mode 100644 src/app/core/models/products/product-create.model.ts create mode 100644 src/app/core/models/unit-sale.model.ts create mode 100644 src/app/core/services/part-origins-service.ts create mode 100644 src/app/core/services/unit-sale-service.ts create mode 100644 src/app/features/catalog/components/product-form/administration-tab/administration-tab.html create mode 100644 src/app/features/catalog/components/product-form/administration-tab/administration-tab.ts create mode 100644 src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.html create mode 100644 src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.ts create mode 100644 src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html create mode 100644 src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.ts create mode 100644 src/app/features/catalog/components/product-form/general-tab/general-tab.html create mode 100644 src/app/features/catalog/components/product-form/general-tab/general-tab.ts create mode 100644 src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts create mode 100644 src/app/features/catalog/components/product-form/general-tab/sections/dimensions-section.ts create mode 100644 src/app/features/catalog/components/product-form/general-tab/sections/identification-section.ts create mode 100644 src/app/features/catalog/components/product-form/general-tab/sections/visibility-section.ts create mode 100644 src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.html create mode 100644 src/app/features/catalog/components/product-form/inventory-tab/inventory-tab.ts create mode 100644 src/app/features/catalog/components/product-form/media-tab/media-tab.html create mode 100644 src/app/features/catalog/components/product-form/media-tab/media-tab.ts create mode 100644 src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.css create mode 100644 src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html create mode 100644 src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts create mode 100644 src/app/features/catalog/components/product-form/stepper/product-create-stepper.html create mode 100644 src/app/features/catalog/components/product-form/stepper/product-create-stepper.ts create mode 100644 src/app/features/catalog/models/product-workspace.model.ts create mode 100644 src/app/features/catalog/product-create/product-create.html create mode 100644 src/app/features/catalog/product-create/product-create.ts delete mode 100644 src/app/features/catalog/product-form/product-form.css delete mode 100644 src/app/features/catalog/product-form/product-form.html delete mode 100644 src/app/features/catalog/product-form/product-form.ts create mode 100644 src/app/features/catalog/product-workspace/product-workspace.css create mode 100644 src/app/features/catalog/product-workspace/product-workspace.html create mode 100644 src/app/features/catalog/product-workspace/product-workspace.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e75614a..8c88b3b 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,8 +1,12 @@ 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 52795ca..e7d549a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -19,27 +19,28 @@ export const routes: Routes = [ { path: 'catalog/products/new', loadComponent: () => - import('./features/catalog/product-form/product-form').then((m) => m.ProductFormComponent), + import('./features/catalog/product-create/product-create').then((m) => m.ProductCreateComponent), }, { path: 'catalog/products/:id/edit', loadComponent: () => - import('./features/catalog/product-form/product-form').then((m) => m.ProductFormComponent), + import('./features/catalog/product-workspace/product-workspace').then( + (m) => m.ProductWorkspaceComponent, + ), }, { path: 'catalog/products/:id/view', loadComponent: () => - import('./features/catalog/product-form/product-form').then((m) => m.ProductFormComponent), - }, - { - path: 'catalog/products/:id/duplicate', - loadComponent: () => - import('./features/catalog/product-form/product-form').then((m) => m.ProductFormComponent), + import('./features/catalog/product-workspace/product-workspace').then( + (m) => m.ProductWorkspaceComponent, + ), }, { path: 'catalog/products/:id', loadComponent: () => - import('./features/catalog/product-form/product-form').then((m) => m.ProductFormComponent), + import('./features/catalog/product-workspace/product-workspace').then( + (m) => m.ProductWorkspaceComponent, + ), }, { path: 'catalog/:sub', 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..320389b --- /dev/null +++ b/src/app/core/config/compatibility-modules.config.ts @@ -0,0 +1,86 @@ +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/category-options.model.ts b/src/app/core/models/catetories/category-options.model.ts index da15947..016ddda 100644 --- a/src/app/core/models/catetories/category-options.model.ts +++ b/src/app/core/models/catetories/category-options.model.ts @@ -10,4 +10,3 @@ export interface CategoryOption { description: string; icon: string; } -// 001400_ARARAS_PT1 => http://localhost:4200/#/my-farms/bagaco_03_30/72529 \ No newline at end of file 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/products/product-create.model.ts b/src/app/core/models/products/product-create.model.ts new file mode 100644 index 0000000..156a8c6 --- /dev/null +++ b/src/app/core/models/products/product-create.model.ts @@ -0,0 +1,158 @@ +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 { + categoryId: string; + manufacturerId: string; + partOriginId: string; + unitId: string; + internalCode: string; + barcode: string; + name: string; + shortDescription: string; + description: string; + weight: number; + height: number; + width: number; + length: number; + featured: boolean; +} + +export function defaultProductCreateFormState(): ProductCreateFormState { + return { + categoryId: '', + manufacturerId: '', + partOriginId: '', + unitId: '', + internalCode: '', + barcode: '', + name: '', + shortDescription: '', + description: '', + weight: 0, + height: 0, + width: 0, + length: 0, + featured: false, + }; +} + +export function createFormToGeneralSource(state: ProductCreateFormState): ProductGeneralFormSource { + return { + ...state, + slug: '', + statusId: '', + 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; + shortDescription: string; + description: string; + categoryId: string; + manufacturerId: string; + partOriginId: string; + statusId: string; + internalCode: string; + barcode: string; + unitId: 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[]; +} + +export interface ProductGeneralFormSource { + name: string; + slug: string; + shortDescription: string; + description: string; + categoryId: string; + manufacturerId: string; + partOriginId: string; + statusId: string; + internalCode: string; + barcode: string; + unitId: 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, + shortDescription: source.shortDescription, + description: source.description, + categoryId: source.categoryId, + manufacturerId: source.manufacturerId, + partOriginId: source.partOriginId, + statusId: source.statusId, + internalCode: source.internalCode, + barcode: source.barcode, + unitId: source.unitId, + 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.categoryId, + manufacturer_id: source.manufacturerId, + part_origin_id: source.partOriginId, + unit_id: source.unitId, + internal_code: source.internalCode, + barcode: source.barcode, + name: source.name, + short_description: source.shortDescription, + 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-request.model.ts b/src/app/core/models/products/product-request.model.ts index fc00167..6d0c396 100644 --- a/src/app/core/models/products/product-request.model.ts +++ b/src/app/core/models/products/product-request.model.ts @@ -1,33 +1,62 @@ export interface CreateProductPayload { - category_id: string | null; - manufacturer_id: string | null; - status_id?: string | null; + category_id: string; + manufacturer_id: string; + part_origin_id: string; + unit_id: string; - internal_code: string | null; - name: string | null; + internal_code: string; + barcode: string; - slug?: string | null; - icon?: string | null; - image?: string | null; + name: string; + short_description: string; + description: string; - short_description?: string | null; - description?: string | null; + weight: number; + height: number; + width: number; + length: number; - weight?: number; - height?: number; - width?: number; - length?: number; - - active?: boolean; - featured?: boolean; + featured: boolean; +} - unit?: string | null; +export interface ProductCreateResponse { + success: boolean; + message: string; + data: ProductCreate; +} - price: ProductPricePayload; +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; +} - inventory: ProductInventoryPayload; +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; 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/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 index bc38c02..a311d7d 100644 --- a/src/app/core/services/product-service.ts +++ b/src/app/core/services/product-service.ts @@ -1,10 +1,8 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { PaginatedResponse } from '../models/pagination/pagination.model'; -import { - CreateProductPayload, - UpdateProductPayload, -} from '../models/products/product-request.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'; @@ -31,4 +29,8 @@ export class ProductService { 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 e512c0b..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 { @@ -61,16 +62,7 @@ export class ShellStateService { 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', 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/utils/product-payload.transformer.ts b/src/app/core/utils/product-payload.transformer.ts index cd9d11b..ad301a0 100644 --- a/src/app/core/utils/product-payload.transformer.ts +++ b/src/app/core/utils/product-payload.transformer.ts @@ -100,10 +100,28 @@ function buildProductRoot(state: ProductFormState) { } export function transformToCreatePayload(state: ProductFormState): CreateProductPayload { + const extended = state as ProductFormState & { + part_origin_id?: string; + barcode?: string; + short_description?: string; + featured?: boolean; + }; + return { - ...buildProductRoot(state), - price: buildPriceBlock(state), - inventory: buildInventoryBlock(state), + 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), }; } @@ -113,10 +131,10 @@ export function transformToUpdatePayload(state: ProductFormState): UpdateProduct export function validateProductForm( state: ProductFormState, - mode: 'create' | 'edit' | 'view' | 'duplicate', + mode: 'create' | 'edit' | 'view', ): Record { const errs: Record = {}; - const isCreateFlow = mode === 'create' || mode === 'duplicate'; + const isCreateFlow = mode === 'create'; if (!state.name.trim()) { errs['name'] = 'O nome do produto é obrigatório.'; diff --git a/src/app/design-system/autocomplete-select/autocomplete-select.ts b/src/app/design-system/autocomplete-select/autocomplete-select.ts index f02b29c..4b8737d 100644 --- a/src/app/design-system/autocomplete-select/autocomplete-select.ts +++ b/src/app/design-system/autocomplete-select/autocomplete-select.ts @@ -58,12 +58,19 @@ export class AutocompleteSelectComponent implements OnDestroy { readonly selectedOption = computed(() => { const val = this.value(); if (val === undefined || val === null || val === '') return null; - return ( - this.options().find((opt) => String(opt.value) === String(val)) || { - label: String(val), - value: val, - } + 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 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..4ad732d --- /dev/null +++ b/src/app/features/catalog/components/product-form/commercial-tab/commercial-tab.ts @@ -0,0 +1,43 @@ +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(''); + readonly formPromotionEndDate = input(''); + 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..27a5f13 --- /dev/null +++ b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html @@ -0,0 +1,405 @@ +
+ @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..10811dd --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/general-tab.html @@ -0,0 +1,63 @@ +
+ + @if (currentStep() === 0 || currentStep() === 1) { + + } + + + @if (currentStep() === 0 || currentStep() === 2) { + + } + + + @if (currentStep() === 0 || 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..07d5a72 --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/general-tab.ts @@ -0,0 +1,54 @@ +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>({}); + + // 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..72e1293 --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/sections/basic-info-section.ts @@ -0,0 +1,86 @@ +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..5a744a8 --- /dev/null +++ b/src/app/features/catalog/components/product-form/general-tab/sections/dimensions-section.ts @@ -0,0 +1,90 @@ +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 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..46c5fb4 --- /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..c1c8d88 --- /dev/null +++ b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html @@ -0,0 +1,371 @@ + +
+ +
+
+
+ + Workspace + + + {{ active() ? 'Ativo' : 'Inativo' }} + +
+

+ {{ productName() || 'Produto sem nome' }} +

+
+ + +
+ +
+ +
+
+
+ + +
+ +
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + 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..e175d65 --- /dev/null +++ b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.ts @@ -0,0 +1,97 @@ +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; +import { + COMPATIBILITY_MODULES, + CompatibilityModuleConfig, +} from '../../../../../core/config/compatibility-modules.config'; +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', + styleUrl: './product-workspace-sidebar.css', +}) +export class ProductWorkspaceSidebarComponent { + readonly productName = input(''); + readonly internalCode = input(''); + readonly active = input(true); + 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 compatibilityModules = COMPATIBILITY_MODULES; + + readonly tabChange = output(); + + setActiveTab(tab: FormTab): void { + this.tabChange.emit(tab); + } + + onSelectTab(event: Event): void { + const target = event.target as HTMLSelectElement; + if (target?.value) { + this.setActiveTab(target.value as FormTab); + } + } + + getModuleCount(key: CompatibilityModuleConfig['countKey']): number { + switch (key) { + case 'oemCodesCount': + return this.oemCodesCount(); + case 'productCodesCount': + return this.productCodesCount(); + case 'equivalentProductsCount': + return this.equivalentProductsCount(); + case 'vehicleApplicationsCount': + return this.vehicleApplicationsCount(); + } + } + + mobileTabClasses(tab: FormTab): string { + const isActive = this.activeTab() === tab; + const base = + 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-semibold whitespace-nowrap shrink-0 transition-colors cursor-pointer '; + if (isActive) { + return base + 'bg-[#4F8A6B] text-white shadow-xs'; + } + return base + 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700/60'; + } + + hasTabError(tab: FormTab): boolean { + const errs = this.errors(); + if (!errs) return false; + if (tab === 'geral') { + return !!(errs['name'] || errs['categoryId'] || errs['manufacturerId']); + } + if (tab === 'comercial') { + return !!errs['price']; + } + if (tab === 'inventario') { + return !!errs['quantity']; + } + return false; + } + + sidebarTabClasses(tab: FormTab): string { + const isActive = this.activeTab() === tab; + const base = + 'w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-xs font-semibold transition-colors cursor-pointer '; + if (isActive) { + return base + 'bg-[#4F8A6B] text-white shadow-xs'; + } + return base + 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700/60'; + } +} 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..05f8e16 --- /dev/null +++ b/src/app/features/catalog/models/product-workspace.model.ts @@ -0,0 +1,170 @@ +export type ProductWorkspaceMode = 'edit' | 'view' | 'create'; + +export type FormTab = + | 'geral' + | 'comercial' + | 'inventario' + | 'midia' + | 'oem' + | 'codigos' + | 'fornecimento' + | 'equivalentes' + | 'compatibilidade' + | 'tags' + | 'observacoes'; + +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 { + categoryId: string; + manufacturerId: string; + partOriginId: string; + statusId: string; + internalCode: string; + barcode: string; + + name: string; + slug: string; + shortDescription: string; + description: string; + + weight: number; + height: number; + width: number; + length: number; + unitId: string; + + featured: boolean; + active: boolean; + + price: number; + promotionalPrice: number | null; + promotionStartDate: string; + promotionEndDate: string; + 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 { + categoryId: '', + manufacturerId: '', + partOriginId: '', + statusId: 'st-01', + internalCode: '', + barcode: '', + + name: '', + slug: '', + shortDescription: '', + description: '', + + weight: 0, + height: 0, + width: 0, + length: 0, + unitId: 'un', + + featured: false, + active: true, + + price: 0, + promotionalPrice: null, + promotionStartDate: '', + promotionEndDate: '', + 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..1d2be89 --- /dev/null +++ b/src/app/features/catalog/product-create/product-create.ts @@ -0,0 +1,338 @@ +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: 'internalCode', + internalCode: 'internalCode', + barcode: 'barcode', + category_id: 'categoryId', + categoryId: 'categoryId', + manufacturer_id: 'manufacturerId', + manufacturerId: 'manufacturerId', + part_origin_id: 'partOriginId', + partOriginId: 'partOriginId', + unit_id: 'unitId', + unitId: 'unitId', + short_description: 'shortDescription', + shortDescription: 'shortDescription', + }; + 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.internalCode.trim()) { + errs['internal_code'] = 'O código interno é obrigatório.'; + } + if (!p.categoryId) { + errs['category_id'] = 'Selecione uma categoria.'; + } + if (!p.manufacturerId) { + errs['manufacturer_id'] = 'Selecione um fabricante.'; + } + if (!p.unitId) { + errs['unit_id'] = 'Selecione a unidade de medida.'; + } + if (!p.partOriginId) { + 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/product-form.css b/src/app/features/catalog/product-form/product-form.css deleted file mode 100644 index 8269a07..0000000 --- a/src/app/features/catalog/product-form/product-form.css +++ /dev/null @@ -1 +0,0 @@ -/* Product Form Component Styles */ diff --git a/src/app/features/catalog/product-form/product-form.html b/src/app/features/catalog/product-form/product-form.html deleted file mode 100644 index 80c32f3..0000000 --- a/src/app/features/catalog/product-form/product-form.html +++ /dev/null @@ -1,1164 +0,0 @@ - - -
- @if (mode() === 'view') { - - Voltar - - - Editar - - } @else { - Cancelar - @if (mode() === 'edit') { - - Duplicar - - } } -
-
- - -
- -
- - -
- - @if (activeTab() === 'geral') { -
-
- - - - - -
- -
- - - - - - - - - - - -
- - -
- - -
- - -
-

- Dimensões e Peso -

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

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

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

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

-
- - -
-
- } - - - @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 - - } -
-
-
- - - - - - - - - diff --git a/src/app/features/catalog/product-form/product-form.ts b/src/app/features/catalog/product-form/product-form.ts deleted file mode 100644 index d47b80b..0000000 --- a/src/app/features/catalog/product-form/product-form.ts +++ /dev/null @@ -1,1153 +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 } 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'; -import { ProductService } from '../../../core/services/product-service'; -import { ProductResponse } from '../../../core/models/products/product-response.model'; -import { - AutocompleteOption, - AutocompleteSelectComponent, -} from '../../../design-system/autocomplete-select/autocomplete-select'; -import { CategoryResponse } from '../../../core/models/catetories/categories.model'; -import { CategoryService } from '../../../core/services/category-service'; -import { ManufacturerService } from '../../../core/services/manufacture-service'; -import { CategoryOption } from '../../../core/models/catetories/category-options.model'; -import { debounceTime, distinctUntilChanged, Subject, switchMap } from 'rxjs'; -import { GeneralOptionQuery } from '../../../core/models/generals/general-option-query.model'; -import { ManufacturerOption } from '../../../core/models/manufactureres/manufaturer-options.model'; -import { StatusService } from '../../../core/services/status-service'; -import { - StatusOption, - StatusOptionsResponse, -} from '../../../core/models/status/status-options.model'; -import { DSelectOption } from '../../../core/models/design-system/select-option.model'; -import { ProductFormState } from '../../../core/models/products/product-form-state.model'; -import { - mapProductBackendErrors, - transformToCreatePayload, - transformToUpdatePayload, - validateProductForm, -} from '../../../core/utils/product-payload.transformer'; - -export type ProductFormMode = 'create' | 'edit' | 'view' | 'duplicate'; -export type FormTab = - 'geral' | 'comercial' | 'inventario' | '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, - AutocompleteSelectComponent, - ], - 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 productService = inject(ProductService); - private toastService = inject(ToastService); - private categoryService = inject(CategoryService); - private manufacturerService = inject(ManufacturerService); - private statusService = inject(StatusService); - - readonly queries = signal({ - search: null, - active: null, - limit: 5, - }); - - 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 formSlug = 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 formIsInvoiced = signal(true); - readonly formMinQuantity = signal(0); - readonly formMaxQuantity = signal(null); - 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([]); - - readonly categoryLoading = signal(false); - readonly manufacturerLoading = signal(false); - - readonly fetchedStatuses = signal([]); - readonly fetchedCategories = signal([]); - readonly fetchedManufacturers = signal([]); - - readonly statusQuery = signal(''); - readonly categoryQuery = signal(''); - readonly manufacturerQuery = signal(''); - - private readonly categorySearch$ = new Subject(); - private readonly manufacturerSearch$ = new Subject(); - - private currentModule = signal('PRODUCT'); - - readonly product = signal({ - id: 'p0000000-0002-0000-0000-000000000002', - status: { - id: 's0000000-0001-0000-0000-000000000001', - module: 'PRODUCT', - code: 'DRAFT', - name: 'Rascunho', - color: '#64748B', - icon: 'file-text', - }, - category: { - id: 'c0000000-0013-0000-0000-000000000013', - name: 'Filtros de Óleo', - slug: 'filtros-de-oleo', - }, - manufacturer: { - id: 'm0000000-0001-0000-0000-000000000001', - name: 'Bosch', - slug: 'bosch', - }, - internal_code: 'OF-002', - name: 'Filtro de Óleo Bosch OF-002', - slug: 'filtro-de-oleo-bosch-of-002', - icon: null, - image: null, - short_description: 'Filtro de óleo paralelo Bosch', - description: 'Filtro de óleo paralelo de alta qualidade para linha VW/Fiat 1.0 e 1.6.', - weight: '0.300', - height: '10.00', - width: '8.00', - length: '8.00', - featured: false, - active: 0, - is_sellable: false, - commercial_status: 'ready', - pricing: { - regular_price: 35.9, - promotional_price: 29.9, - current_price: 29.9, - is_on_promotion: true, - promotion_start: '2026-07-01', - promotion_end: '2026-12-31', - active: true, - display: { - from: 35.9, - to: 29.9, - label: 'de R$ 35,90 por R$ 29,90', - }, - }, - inventory: { - quantity: 50, - reserved_quantity: 0, - available_quantity: 50, - minimum_quantity: 5, - maximum_quantity: 300, - allow_backorder: true, - active: true, - }, - }); - - readonly categoryOptions = computed(() => { - const categories = this.fetchedCategories(); - const query = this.categoryQuery().trim().toLowerCase(); - - return categories - .filter((cat) => !query || cat.label.toLowerCase().includes(query)) - .map((cat) => ({ - label: cat.label, - value: cat.id, - sublabel: cat.description, - icon: cat.icon || 'folder', - })); - }); - - readonly manufacturerOptions = computed(() => { - const manufacturers = this.fetchedManufacturers(); - const query = this.manufacturerQuery().trim().toLowerCase() || ''; - - return manufacturers - .filter((m) => !query || m.label.toLowerCase().includes(query)) - .map((m) => ({ - label: m.label, - value: m.id, - sublabel: m.description, - })); - }); - - readonly formState = computed(() => ({ - name: this.formName(), - internal_code: this.formInternalCode(), - category_id: this.formCategoryId(), - manufacturer_id: this.formManufacturerId(), - unit: this.formUnitId(), - status_id: this.formStatusId(), - slug: this.formSlug(), - 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(), - promotion_end_date: this.formPromotionEndDate(), - warehouse_id: this.formWarehouseId(), - quantity: this.formQuantity(), - minimum_quantity: this.formMinQuantity(), - maximum_quantity: this.formMaxQuantity(), - allow_backorder: this.formAllowBackorder(), - })); - - readonly createPayload = computed(() => transformToCreatePayload(this.formState())); - - readonly updatePayload = computed(() => transformToUpdatePayload(this.formState())); - - readonly statusOptions = signal([]); - - readonly partOriginOptions: DSelectOption[] = [ - { 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: 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 warehouseOptions: DSelectOption[] = [ - { 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'); - - this.categorySearch$ - .pipe( - debounceTime(600), - distinctUntilChanged(), - switchMap((query) => { - this.categoryLoading.set(true); - return this.categoryService.getOptions(query); - }), - ) - .subscribe({ - next: (response) => { - this.fetchedCategories.set(response.data); - this.categoryLoading.set(false); - }, - error: () => { - this.categoryLoading.set(false); - }, - }); - - this.manufacturerSearch$ - .pipe( - debounceTime(600), - distinctUntilChanged(), - switchMap((query) => { - this.manufacturerLoading.set(true); - return this.manufacturerService.getOptions(query); - }), - ) - .subscribe({ - next: (response) => { - this.fetchedManufacturers.set(response.data); - this.manufacturerLoading.set(false); - }, - error: () => { - this.manufacturerLoading.set(false); - }, - }); - - this.onStatusSearch(); - this.onCategorySearch(this.queries()); - this.onManufacturerSearch(this.queries()); - - if (id) { - if (url.includes('/edit')) { - this.mode.set('edit'); - } else if (url.includes('/duplicate')) { - this.mode.set('duplicate'); - } else { - this.mode.set('view'); - } - this.productById(id); - } else { - this.mode.set('create'); - } - } - - productById(productId: string) { - this.productService.getById(productId).subscribe({ - next: (response) => { - console.log('Product fetched from API:', response); - this.populateForm(response); - if (this.mode() === 'duplicate') { - this.formInternalCode.set(''); - } - }, - error: (error) => { - console.error('Error loading product:', error); - this.toastService.error('Erro', 'Não foi possível carregar os dados do produto.'); - }, - }); - } - - 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.9); - this.formPromotionalPrice.set(169.9); - this.formPromotionStartDate.set('2026-07-01'); - this.formPromotionEndDate.set('2026-08-31'); - this.formIsInvoiced.set(true); - this.formWarehouseId.set('wh-01'); - this.formQuantity.set(45); - this.formMinQuantity.set(10); - this.formMaxQuantity.set(250); - 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.5, - 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'] || - 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); - 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); - } else if (field === 'max_quantity') { - this.formMaxQuantity.set(val); - } - } - - toggleIsInvoiced(): void { - if (this.isReadOnly()) return; - this.isFormDirty.set(true); - this.formIsInvoiced.update((v) => !v); - } - - 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 --- - populateForm(prod: ProductResponse): void { - this.formName.set(prod.name || ''); - this.formInternalCode.set(prod.internal_code || ''); - this.formCategoryId.set(prod.category?.id || ''); - this.formManufacturerId.set(prod.manufacturer?.id || ''); - this.formUnitId.set((prod['unit'] || prod['unit_id'] || '') as string); - this.formStatusId.set(prod.status?.id || ''); - this.formDescription.set(prod.description || ''); - this.formSlug.set(prod.slug || ''); - - this.formWeight.set(parseFloat(prod.weight) || 0); - this.formHeight.set(parseFloat(prod.height) || 0); - this.formWidth.set(parseFloat(prod.width) || 0); - this.formLength.set(parseFloat(prod.length) || 0); - - if (prod.pricing) { - this.formPrice.set(prod.pricing.regular_price || 0); - this.formPromotionalPrice.set(prod.pricing.promotional_price || null); - this.formPromotionStartDate.set(prod.pricing.promotion_start || ''); - this.formPromotionEndDate.set(prod.pricing.promotion_end || ''); - } - - if (prod.inventory) { - this.formWarehouseId.set(((prod.inventory as any)['warehouse_id'] || 'wh-01') as string); - this.formQuantity.set(prod.inventory.quantity || 0); - this.formMinQuantity.set(prod.inventory.minimum_quantity || 0); - this.formMaxQuantity.set(prod.inventory.maximum_quantity || null); - this.formAllowBackorder.set(!!prod.inventory.allow_backorder); - } - } - - validateForm(): boolean { - const errs = validateProductForm(this.formState(), this.mode()); - - 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 isEdit = this.mode() === 'edit' && id; - - console.log( - 'Sending Product Payload to Backend API:', - isEdit ? this.updatePayload() : this.createPayload(), - ); - - const request$ = isEdit - ? this.productService.update(id!, this.updatePayload()) - : this.productService.create(this.createPayload()); - - request$.subscribe({ - next: (response) => { - 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'); - if (response.id) { - this.router.navigate(['/catalog/products', response.id, 'edit']); - } - } - }, - error: (err) => { - this.isSaving.set(false); - console.error('Error saving product:', err); - if (err.status === 422 && err.error?.errors) { - const mapped = mapProductBackendErrors(err.error.errors); - this.errors.set(mapped); - - this.toastService.error( - 'Erro de validação', - err.error.message || 'Verifique as mensagens de erro nos campos.' - ); - - 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'); - } - } else { - 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'); - } - - 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']); - } - - onCategorySearch(query: GeneralOptionQuery): void { - this.categorySearch$.next(query); - } - - onManufacturerSearch(query: GeneralOptionQuery): void { - this.manufacturerSearch$.next(query); - } - - onStatusSearch(limit = null) { - this.statusService.getOptions(this.currentModule(), limit).subscribe({ - next: (response) => { - const result = response.data.map((status) => ({ - label: status.label, - value: status.id, - description: status.description, - })); - this.statusOptions.set(result); - }, - error: (error) => { - console.error('Error fetching product status options:', error); - }, - }); - } -} 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..85cf13c --- /dev/null +++ b/src/app/features/catalog/product-workspace/product-workspace.html @@ -0,0 +1,313 @@ +
+ +
+ +
+ @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') { + + } @if (activeTab() === 'comercial') { + + } @if (activeTab() === 'inventario') { + + } @if (isCompatibilityTab()) { + + } @if (activeTab() === 'midia') { + + } @if (activeTab() === 'fornecimento' || activeTab() === 'tags' || activeTab() === + 'observacoes') { + + } +
+
+ + +
+
+
+ @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..5f16d00 --- /dev/null +++ b/src/app/features/catalog/product-workspace/product-workspace.ts @@ -0,0 +1,1001 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +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'; + +@Component({ + selector: 'app-product-workspace', + standalone: true, + imports: [ + PageHeaderComponent, + ButtonComponent, + ConfirmDialogComponent, + AppIconComponent, + ProductGeneralTabComponent, + ProductCommercialTabComponent, + ProductInventoryTabComponent, + ProductCompatibilityTabComponent, + ProductMediaTabComponent, + ProductAdministrationTabComponent, + ProductWorkspaceSidebarComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './product-workspace.html', + styleUrl: './product-workspace.css', +}) +export class ProductWorkspaceComponent implements OnInit { + readonly shellState = inject(ShellStateService); + private route = inject(ActivatedRoute); + private router = inject(Router); + private productService = inject(ProductService); + private categoryService = inject(CategoryService); + private manufacturerService = inject(ManufacturerService); + private statusService = inject(StatusService); + private toastService = inject(ToastService); + private partOriginService = inject(PartOriginService); + + readonly mode = signal('edit'); + readonly activeTab = signal('geral'); + readonly isSaving = signal(false); + readonly isDragging = signal(false); + readonly isFormDirty = signal(false); + readonly productCreatedSuccessBanner = signal(false); + + readonly categoryLoading = signal(false); + readonly fetchedCategories = signal([]); + readonly manufacturerLoading = signal(false); + readonly fetchedManufacturers = signal([]); + readonly fetchedStatuses = signal([]); + readonly fetchedPartOrigins = 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 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(), + })); + + 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 warehouseOptions: DSelectOption[] = [ + { 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' }, + ]; + + ngOnInit(): void { + const url = this.router.url; + const id = this.route.snapshot.paramMap.get('id'); + + this.productCreatedSuccessBanner.set(!!history.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); + }, + }); + } + + productById(productId: string): void { + this.productService.getById(productId).subscribe({ + next: (response: ProductResponse) => this.populateForm(response), + 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 { + 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({ + categoryId: prod.category?.id || (prod['category_id'] as string) || '', + manufacturerId: prod.manufacturer?.id || (prod['manufacturer_id'] as string) || '', + partOriginId: (prod['part_origin_id'] as string) || '', + statusId: prod.status?.id || (prod['status_id'] as string) || 'st-01', + internalCode: prod.internal_code || '', + barcode: (prod['barcode'] as string) || '', + + name: prod.name || '', + slug: prod.slug || '', + shortDescription: (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, + unitId: (prod['unit'] || prod['unit_id'] || 'un') as string, + + 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 || '', + promotionEndDate: prod.pricing?.promotion_end || rawPrice?.promotion_end || '', + 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 { + 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['status_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: 'internalCode', + internalCode: 'internalCode', + barcode: 'barcode', + category_id: 'categoryId', + categoryId: 'categoryId', + manufacturer_id: 'manufacturerId', + manufacturerId: 'manufacturerId', + part_origin_id: 'partOriginId', + partOriginId: 'partOriginId', + unit_id: 'unitId', + unitId: 'unitId', + status_id: 'statusId', + statusId: 'statusId', + slug: 'slug', + short_description: 'shortDescription', + shortDescription: 'shortDescription', + 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.internalCode.trim()) { + errs['internal_code'] = 'O código interno é obrigatório.'; + } + if (!p.categoryId) { + errs['category_id'] = 'Selecione uma categoria.'; + } + if (!p.manufacturerId) { + errs['manufacturer_id'] = 'Selecione um fabricante.'; + } + if (!p.unitId) { + 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 = { + category_id: p.categoryId, + manufacturer_id: p.manufacturerId, + part_origin_id: p.partOriginId, + status_id: p.statusId, + internal_code: p.internalCode, + barcode: p.barcode, + + name: p.name, + slug: p.slug, + short_description: p.shortDescription, + description: p.description, + + weight: p.weight, + height: p.height, + width: p.width, + length: p.length, + unit: p.unitId, + + featured: p.featured, + active: p.active, + + price: { + price: p.price, + promotional_price: p.promotionalPrice, + promotion_start: p.promotionStartDate, + promotion_end: p.promotionEndDate, + }, + pricing: { + regular_price: p.price, + promotional_price: p.promotionalPrice, + promotion_start: p.promotionStartDate, + promotion_end: p.promotionEndDate, + is_invoiced: p.isInvoiced, + }, + inventory: { + warehouse_id: p.warehouseId, + quantity: p.quantity, + minimum_quantity: p.minQuantity, + maximum_quantity: p.maxQuantity, + allow_backorder: p.allowBackorder, + }, + oem_codes: p.oemCodes, + product_codes: p.productCodes, + equivalent_products: p.equivalentProducts, + vehicle_applications: p.vehicleApplications, + suppliers: p.suppliers, + tags: p.selectedTagIds, + notes: p.productNotes, + }; + + 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/utils/product-table-collum.ts b/src/app/utils/product-table-collum.ts index ee1a9c1..4c552ec 100644 --- a/src/app/utils/product-table-collum.ts +++ b/src/app/utils/product-table-collum.ts @@ -62,14 +62,6 @@ export const productTableActions: TableAction[] = [ title: 'Editar Produto', handler: (p) => ['/catalog/products', p.id, 'edit'], }, - { - id: 'duplicate', - label: 'Duplicar', - icon: 'copy', - colorClass: 'text-gray-400 hover:text-amber-600 hover:bg-gray-100 dark:hover:bg-slate-700', - title: 'Duplicar Produto', - handler: (p) => ['/catalog/products', p.id, 'duplicate'], - }, { id: 'delete', label: 'Excluir', diff --git a/src/styles.css b/src/styles.css index 7f5240a..d58b032 100644 --- a/src/styles.css +++ b/src/styles.css @@ -40,17 +40,41 @@ body { } /* Custom Scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.35) transparent; +} + ::-webkit-scrollbar { - width: 6px; - height: 6px; + width: 5px; + height: 5px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background-color: rgba(156, 163, 175, 0.4); + background-color: rgba(156, 163, 175, 0.35); border-radius: 9999px; } ::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.6); +} + +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.4) transparent; +} +.custom-scrollbar::-webkit-scrollbar { + width: 5px; + height: 5px; +} +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.4); + border-radius: 9999px; +} +.custom-scrollbar::-webkit-scrollbar-thumb:hover { background-color: rgba(156, 163, 175, 0.7); } From b2285b4151bdcc1a0d0022d3bb12c6b61c2d751f Mon Sep 17 00:00:00 2001 From: aledguedes Date: Thu, 6 Aug 2026 15:53:10 -0300 Subject: [PATCH 7/8] Enhance product workspace UI and update product models Introduce model and API payload improvements and revamp the product workspace UI. Update UpdateProductPayload (nullable dimensions, barcode, part_origin_id) and add nested UpdateProductPricePayload and UpdateProductInventoryPayload. Change ProductService.getById to expect wrapped getAllResponse. Enhance design-system input to expose step/min/max attrs. Large refactor of product form/sidebar: new responsive, scrollable tab strip, left/right scroll controls, accessible tab roles, new tab keys (classification, logistics, visibility), status/SKU display, improved badges and layout. product-workspace now handles wrapped responses, sets current status, maps nested pricing/inventory, and comments out unused media/admin components. Add .no-scrollbar CSS. Misc whitespace/formatting tweaks. --- .../config/compatibility-modules.config.ts | 5 +- .../models/products/product-request.model.ts | 33 +- src/app/core/services/product-service.ts | 3 +- src/app/design-system/input/input.html | 3 + src/app/design-system/input/input.ts | 3 + .../compatibility-tab/compatibility-tab.html | 43 +- .../product-form/general-tab/general-tab.html | 104 ++-- .../product-form/general-tab/general-tab.ts | 1 + .../sections/basic-info-section.ts | 31 +- .../sections/dimensions-section.ts | 8 + .../sidebar/product-workspace-sidebar.html | 463 ++++++++---------- .../sidebar/product-workspace-sidebar.ts | 113 +++-- .../catalog/models/product-workspace.model.ts | 29 +- .../product-workspace/product-workspace.html | 25 +- .../product-workspace/product-workspace.ts | 66 ++- src/styles.css | 8 + 16 files changed, 494 insertions(+), 444 deletions(-) diff --git a/src/app/core/config/compatibility-modules.config.ts b/src/app/core/config/compatibility-modules.config.ts index 320389b..de8ab4f 100644 --- a/src/app/core/config/compatibility-modules.config.ts +++ b/src/app/core/config/compatibility-modules.config.ts @@ -11,10 +11,7 @@ export interface CompatibilityModuleConfig { headerIconClass: string; description: string; countKey: - | 'oemCodesCount' - | 'productCodesCount' - | 'equivalentProductsCount' - | 'vehicleApplicationsCount'; + 'oemCodesCount' | 'productCodesCount' | 'equivalentProductsCount' | 'vehicleApplicationsCount'; } /** Módulos de compatibilidade — fonte única alinhada ao menu do shell-state */ diff --git a/src/app/core/models/products/product-request.model.ts b/src/app/core/models/products/product-request.model.ts index 6d0c396..ca37418 100644 --- a/src/app/core/models/products/product-request.model.ts +++ b/src/app/core/models/products/product-request.model.ts @@ -78,25 +78,46 @@ export interface ProductInventoryPayload { export interface UpdateProductPayload { category_id?: string | null; manufacturer_id?: string | null; + part_origin_id?: string | null; status_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; - height?: number; - width?: number; - length?: number; + weight?: number | null; + height?: number | null; + width?: number | null; + length?: number | null; + + unit?: string | null; active?: boolean; featured?: boolean; - unit?: string | null; + 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/services/product-service.ts b/src/app/core/services/product-service.ts index a311d7d..dd0a232 100644 --- a/src/app/core/services/product-service.ts +++ b/src/app/core/services/product-service.ts @@ -5,6 +5,7 @@ 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', @@ -19,7 +20,7 @@ export class ProductService { } getById(id: string) { - return this.http.get(`${this.api}/${this.flag}/${id}`); + return this.http.get>(`${this.api}/${this.flag}/${id}`); } create(payload: CreateProductPayload) { diff --git a/src/app/design-system/input/input.html b/src/app/design-system/input/input.html index ffa4c60..84bd856 100644 --- a/src/app/design-system/input/input.html +++ b/src/app/design-system/input/input.html @@ -23,6 +23,9 @@ [placeholder]="placeholder()" [disabled]="disabled()" [readOnly]="readonly()" + [attr.step]="type() === 'number' ? step() : null" + [attr.min]="type() === 'number' ? min() : null" + [attr.max]="type() === 'number' ? max() : null" (input)="onInput($event)" [class]="inputClasses()" /> diff --git a/src/app/design-system/input/input.ts b/src/app/design-system/input/input.ts index aa005c6..9c68e5c 100644 --- a/src/app/design-system/input/input.ts +++ b/src/app/design-system/input/input.ts @@ -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(); diff --git a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html index 27a5f13..d147c85 100644 --- a/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html +++ b/src/app/features/catalog/components/product-form/compatibility-tab/compatibility-tab.html @@ -1,6 +1,5 @@
- @switch (activeTab()) { - @case ('oem') { + @switch (activeTab()) { @case ('oem') {

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

-

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

+

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

@if (!isReadOnly()) { @@ -111,8 +112,7 @@

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

@@ -204,8 +208,7 @@

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

@@ -297,8 +304,7 @@

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

@@ -400,6 +410,5 @@

- @if (currentStep() === 0 || currentStep() === 1) { - + @if ((currentStep() === 0 && activeTab() === 'geral') || currentStep() === 1) { + } - @if (currentStep() === 0 || currentStep() === 2) { - + @if ((currentStep() === 0 && activeTab() === 'classification') || currentStep() === 2) { + } - @if (currentStep() === 0 || currentStep() === 3) { - + @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 index 07d5a72..b8822b8 100644 --- 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 @@ -30,6 +30,7 @@ export class ProductGeneralTabComponent { readonly isCreateMode = input(false); readonly currentStep = input(0); readonly errors = input>({}); + readonly activeTab = input('geral'); // Outputs readonly fieldChange = output<{ field: string; value: string }>(); 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 index 72e1293..d271cc0 100644 --- 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 @@ -7,12 +7,16 @@ import { InputComponent } from '../../../../../../design-system/input/input'; imports: [InputComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+

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

-

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

+

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

@@ -29,14 +33,14 @@ import { InputComponent } from '../../../../../../design-system/input/input'; @if (!isCreateMode()) { - + }
@@ -51,7 +55,10 @@ import { InputComponent } from '../../../../../../design-system/input/input';
-
- ` + `, }) export class ProductBasicInfoSectionComponent { readonly formName = input(''); 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 index 5a744a8..578c15c 100644 --- 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 @@ -35,6 +35,8 @@ import { DSelectOption } from '../../../../../../core/models/design-system/selec
- +
-
+
- Workspace + Abas do Catálogo - {{ active() ? 'Ativo' : 'Inativo' }} + {{ status()?.name || 'Sem Status' }}

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

- -
+ +
- -
- -
- + +
+ + @if (canScrollLeft()) {
- - +
+ } + +
+ -
- - - - - - - + + + - - + -
-
-
- -
- +
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 index e175d65..4f163c8 100644 --- 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 @@ -1,9 +1,14 @@ -import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; -import { AppIconComponent } from '../../../../../design-system/icon/app-icon'; import { - COMPATIBILITY_MODULES, - CompatibilityModuleConfig, -} from '../../../../../core/config/compatibility-modules.config'; + 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({ @@ -12,12 +17,13 @@ import { FormTab } from '../../../models/product-workspace.model'; imports: [AppIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './product-workspace-sidebar.html', - styleUrl: './product-workspace-sidebar.css', }) -export class ProductWorkspaceSidebarComponent { +export class ProductWorkspaceSidebarComponent implements AfterViewInit { + @ViewChild('navContainer') navContainer?: ElementRef; + readonly productName = input(''); readonly internalCode = input(''); - readonly active = input(true); + readonly status = input<{ name?: string; color?: string; code?: string } | null>(null); readonly activeTab = input.required(); // Item counts for enrichment badges @@ -32,42 +38,52 @@ export class ProductWorkspaceSidebarComponent { readonly errors = input>({}); - readonly compatibilityModules = COMPATIBILITY_MODULES; - readonly tabChange = output(); - setActiveTab(tab: FormTab): void { - this.tabChange.emit(tab); + readonly canScrollLeft = signal(false); + readonly canScrollRight = signal(true); + + ngAfterViewInit(): void { + setTimeout(() => this.checkScrollState(), 50); } - onSelectTab(event: Event): void { - const target = event.target as HTMLSelectElement; - if (target?.value) { - this.setActiveTab(target.value as FormTab); - } + 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); } - getModuleCount(key: CompatibilityModuleConfig['countKey']): number { - switch (key) { - case 'oemCodesCount': - return this.oemCodesCount(); - case 'productCodesCount': - return this.productCodesCount(); - case 'equivalentProductsCount': - return this.equivalentProductsCount(); - case 'vehicleApplicationsCount': - return this.vehicleApplicationsCount(); + 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); } } - mobileTabClasses(tab: FormTab): string { - const isActive = this.activeTab() === tab; - const base = - 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-semibold whitespace-nowrap shrink-0 transition-colors cursor-pointer '; - if (isActive) { - return base + 'bg-[#4F8A6B] text-white shadow-xs'; + onSelectTab(event: Event): void { + const target = event.target as HTMLSelectElement; + if (target && target.value) { + this.setActiveTab(target.value as FormTab); } - return base + 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700/60'; } hasTabError(tab: FormTab): boolean { @@ -85,13 +101,34 @@ export class ProductWorkspaceSidebarComponent { return false; } - sidebarTabClasses(tab: FormTab): string { + tabClasses(tab: FormTab): string { const isActive = this.activeTab() === tab; const base = - 'w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-xs font-semibold transition-colors cursor-pointer '; + '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 base + 'bg-[#4F8A6B] text-white shadow-xs'; + 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 base + 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700/60'; + 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/models/product-workspace.model.ts b/src/app/features/catalog/models/product-workspace.model.ts index 05f8e16..b0d234b 100644 --- a/src/app/features/catalog/models/product-workspace.model.ts +++ b/src/app/features/catalog/models/product-workspace.model.ts @@ -11,7 +11,10 @@ export type FormTab = | 'equivalentes' | 'compatibilidade' | 'tags' - | 'observacoes'; + | 'observacoes' + | 'classification' + | 'logistics' + | 'visibility'; export interface OemCodeItem { id: string; @@ -84,11 +87,16 @@ export interface ProductFormData { manufacturerId: string; partOriginId: string; statusId: string; + internalCode: string; barcode: string; name: string; slug: string; + + icon: string; + image: string; + shortDescription: string; description: string; @@ -96,6 +104,7 @@ export interface ProductFormData { height: number; width: number; length: number; + unitId: string; featured: boolean; @@ -103,8 +112,8 @@ export interface ProductFormData { price: number; promotionalPrice: number | null; - promotionStartDate: string; - promotionEndDate: string; + promotionStartDate: string | null; + promotionEndDate: string | null; isInvoiced: boolean; warehouseId: string; @@ -119,7 +128,9 @@ export interface ProductFormData { vehicleApplications: VehicleApplicationItem[]; mediaImages: MediaImageItem[]; suppliers: SupplierItem[]; + selectedTagIds: string[]; + productNotes: ProductNoteItem[]; } @@ -129,11 +140,16 @@ export function defaultProductFormData(): ProductFormData { manufacturerId: '', partOriginId: '', statusId: 'st-01', + internalCode: '', barcode: '', name: '', slug: '', + + icon: '', + image: '', + shortDescription: '', description: '', @@ -141,6 +157,7 @@ export function defaultProductFormData(): ProductFormData { height: 0, width: 0, length: 0, + unitId: 'un', featured: false, @@ -148,8 +165,8 @@ export function defaultProductFormData(): ProductFormData { price: 0, promotionalPrice: null, - promotionStartDate: '', - promotionEndDate: '', + promotionStartDate: null, + promotionEndDate: null, isInvoiced: true, warehouseId: 'wh-01', @@ -164,7 +181,9 @@ export function defaultProductFormData(): ProductFormData { vehicleApplications: [], mediaImages: [], suppliers: [], + selectedTagIds: [], + productNotes: [], }; } diff --git a/src/app/features/catalog/product-workspace/product-workspace.html b/src/app/features/catalog/product-workspace/product-workspace.html index 85cf13c..08bc6ff 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.html +++ b/src/app/features/catalog/product-workspace/product-workspace.html @@ -1,6 +1,4 @@ -
+
+
- @if (activeTab() === 'geral') { +
+ @if (activeTab() === 'geral' || activeTab() === 'classification' || activeTab() === + 'logistics' || activeTab() === 'visibility') { - } @if (activeTab() === 'midia') { + } +
diff --git a/src/app/features/catalog/product-workspace/product-workspace.ts b/src/app/features/catalog/product-workspace/product-workspace.ts index 5f16d00..49e1d15 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.ts +++ b/src/app/features/catalog/product-workspace/product-workspace.ts @@ -6,6 +6,7 @@ import { 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'; @@ -14,8 +15,8 @@ import { ProductGeneralTabComponent } from '../components/product-form/general-t import { ProductCommercialTabComponent } from '../components/product-form/commercial-tab/commercial-tab'; import { ProductInventoryTabComponent } from '../components/product-form/inventory-tab/inventory-tab'; import { ProductCompatibilityTabComponent } from '../components/product-form/compatibility-tab/compatibility-tab'; -import { ProductMediaTabComponent } from '../components/product-form/media-tab/media-tab'; -import { ProductAdministrationTabComponent } from '../components/product-form/administration-tab/administration-tab'; +// import { 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'; @@ -52,8 +53,6 @@ import { ProductCommercialTabComponent, ProductInventoryTabComponent, ProductCompatibilityTabComponent, - ProductMediaTabComponent, - ProductAdministrationTabComponent, ProductWorkspaceSidebarComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -70,6 +69,7 @@ export class ProductWorkspaceComponent implements OnInit { private statusService = inject(StatusService); private toastService = inject(ToastService); private partOriginService = inject(PartOriginService); + private location = inject(Location); readonly mode = signal('edit'); readonly activeTab = signal('geral'); @@ -92,6 +92,7 @@ export class ProductWorkspaceComponent implements OnInit { readonly errors = signal>({}); readonly productForm = signal(defaultProductFormData()); + readonly currentStatus = signal(null); readonly categoryOptions = computed(() => this.fetchedCategories()); readonly manufacturerOptions = computed(() => this.fetchedManufacturers()); @@ -159,8 +160,9 @@ export class ProductWorkspaceComponent implements OnInit { 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(!!history.state?.['productJustCreated']); + this.productCreatedSuccessBanner.set(!!state?.['productJustCreated']); this.loadInitialOptions(this.queries); @@ -223,7 +225,10 @@ export class ProductWorkspaceComponent implements OnInit { productById(productId: string): void { this.productService.getById(productId).subscribe({ - next: (response: ProductResponse) => this.populateForm(response), + 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.'); @@ -232,6 +237,7 @@ export class ProductWorkspaceComponent implements OnInit { } populateForm(prod: ProductResponse): void { + this.currentStatus.set(prod.status || null); const pricingObj = prod.pricing as unknown as Record | undefined; const inventoryObj = prod.inventory as unknown as Record | undefined; const rawPrice = prod['price'] as unknown as @@ -248,11 +254,16 @@ export class ProductWorkspaceComponent implements OnInit { manufacturerId: prod.manufacturer?.id || (prod['manufacturer_id'] as string) || '', partOriginId: (prod['part_origin_id'] as string) || '', statusId: prod.status?.id || (prod['status_id'] as string) || 'st-01', + internalCode: prod.internal_code || '', barcode: (prod['barcode'] as string) || '', name: prod.name || '', slug: prod.slug || '', + + icon: prod.icon || '', + image: prod.image || '', + shortDescription: (prod['short_description'] as string) || '', description: prod.description || '', @@ -260,38 +271,55 @@ export class ProductWorkspaceComponent implements OnInit { height: parseFloat(prod.height as string) || 0, width: parseFloat(prod.width as string) || 0, length: parseFloat(prod.length as string) || 0, - unitId: (prod['unit'] || prod['unit_id'] || 'un') as string, + + unitId: (prod['unit']?.id || prod['unit_id'] || 'un') as string, 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 || '', - promotionEndDate: prod.pricing?.promotion_end || rawPrice?.promotion_end || '', + + 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); } @@ -874,16 +902,18 @@ export class ProductWorkspaceComponent implements OnInit { const id = this.route.snapshot.paramMap.get('id'); const p = this.productForm(); - const payload = { + const payload: UpdateProductPayload = { category_id: p.categoryId, manufacturer_id: p.manufacturerId, part_origin_id: p.partOriginId, status_id: p.statusId, + internal_code: p.internalCode, barcode: p.barcode, name: p.name, slug: p.slug, + short_description: p.shortDescription, description: p.description, @@ -891,6 +921,7 @@ export class ProductWorkspaceComponent implements OnInit { height: p.height, width: p.width, length: p.length, + unit: p.unitId, featured: p.featured, @@ -902,13 +933,7 @@ export class ProductWorkspaceComponent implements OnInit { promotion_start: p.promotionStartDate, promotion_end: p.promotionEndDate, }, - pricing: { - regular_price: p.price, - promotional_price: p.promotionalPrice, - promotion_start: p.promotionStartDate, - promotion_end: p.promotionEndDate, - is_invoiced: p.isInvoiced, - }, + inventory: { warehouse_id: p.warehouseId, quantity: p.quantity, @@ -916,13 +941,6 @@ export class ProductWorkspaceComponent implements OnInit { maximum_quantity: p.maxQuantity, allow_backorder: p.allowBackorder, }, - oem_codes: p.oemCodes, - product_codes: p.productCodes, - equivalent_products: p.equivalentProducts, - vehicle_applications: p.vehicleApplications, - suppliers: p.suppliers, - tags: p.selectedTagIds, - notes: p.productNotes, }; this.productService.update(id!, payload as UpdateProductPayload).subscribe({ diff --git a/src/styles.css b/src/styles.css index d58b032..e450dcd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -60,6 +60,14 @@ body { background-color: rgba(156, 163, 175, 0.6); } +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} + .custom-scrollbar { scrollbar-width: thin; scrollbar-color: rgba(156, 163, 175, 0.4) transparent; From 260409d64b2d3f06d939b6f9aa0cf293864e92ff Mon Sep 17 00:00:00 2001 From: aledguedes Date: Thu, 6 Aug 2026 17:53:48 -0300 Subject: [PATCH 8/8] Standardize product fields to snake_case Refactor: convert product-related fields from camelCase to snake_case across models, components and payload transformer to match API naming. Add warehouses models and WarehousesService and wire warehouseOptions into product workspace and general tab. Extend ProductResponse with richer types (units, part_origin, oem_codes, equivalents, applications, suppliers, created_at/updated_at) and adjust nullable/primitives (active, pricing, inventory). Also minor fixes: select equality check, promotional date nullability, and validation/payload mapping updates to use snake_case. --- .../models/products/product-create.model.ts | 81 +++++++------- .../products/product-form-state.model.ts | 2 +- .../models/products/product-request.model.ts | 2 +- .../models/products/product-response.model.ts | 101 +++++++++++++++-- .../warehouses/warehouse-options.model.ts | 12 +++ .../models/warehouses/warehouses.interface.ts | 0 src/app/core/services/warehouses-service.ts | 25 +++++ .../core/utils/product-payload.transformer.ts | 12 ++- src/app/design-system/select/select.html | 2 +- .../commercial-tab/commercial-tab.ts | 10 +- .../product-form/general-tab/general-tab.html | 15 +-- .../sections/dimensions-section.ts | 3 +- .../sections/identification-section.ts | 24 ++--- .../sidebar/product-workspace-sidebar.html | 4 +- .../sidebar/product-workspace-sidebar.ts | 4 +- .../catalog/models/product-workspace.model.ts | 28 ++--- .../catalog/product-create/product-create.ts | 28 ++--- .../product-workspace/product-workspace.html | 6 +- .../product-workspace/product-workspace.ts | 102 +++++++++--------- 19 files changed, 293 insertions(+), 168 deletions(-) create mode 100644 src/app/core/models/warehouses/warehouse-options.model.ts create mode 100644 src/app/core/models/warehouses/warehouses.interface.ts create mode 100644 src/app/core/services/warehouses-service.ts diff --git a/src/app/core/models/products/product-create.model.ts b/src/app/core/models/products/product-create.model.ts index 156a8c6..5c41cbb 100644 --- a/src/app/core/models/products/product-create.model.ts +++ b/src/app/core/models/products/product-create.model.ts @@ -3,14 +3,14 @@ import { DSelectOption } from '../design-system/select-option.model'; /** Estado interno do formulário de criação (campos do POST) */ export interface ProductCreateFormState { - categoryId: string; - manufacturerId: string; - partOriginId: string; - unitId: string; - internalCode: string; + category_id: string; + manufacturer_id: string; + part_origin_id: string; + unit_id: string; + internal_code: string; barcode: string; name: string; - shortDescription: string; + short_description: string; description: string; weight: number; height: number; @@ -21,14 +21,14 @@ export interface ProductCreateFormState { export function defaultProductCreateFormState(): ProductCreateFormState { return { - categoryId: '', - manufacturerId: '', - partOriginId: '', - unitId: '', - internalCode: '', + category_id: '', + manufacturer_id: '', + part_origin_id: '', + unit_id: '', + internal_code: '', barcode: '', name: '', - shortDescription: '', + short_description: '', description: '', weight: 0, height: 0, @@ -42,7 +42,7 @@ export function createFormToGeneralSource(state: ProductCreateFormState): Produc return { ...state, slug: '', - statusId: '', + status_id: '', active: true, }; } @@ -69,15 +69,15 @@ export interface CreateProductPayload { export interface ProductGeneralFormData { name: string; slug: string; - shortDescription: string; + short_description: string; description: string; - categoryId: string; - manufacturerId: string; - partOriginId: string; - statusId: string; - internalCode: string; + category_id: string; + manufacturer_id: string; + part_origin_id: string; + status_id: string; + internal_code: string; barcode: string; - unitId: string; + unit_id: string; weight: number; height: number; width: number; @@ -94,20 +94,21 @@ export interface ProductGeneralFormOptions { partOriginOptions: DSelectOption[]; unitOptions: DSelectOption[]; statusOptions: DSelectOption[]; + warehouseOptions: DSelectOption[]; } export interface ProductGeneralFormSource { name: string; slug: string; - shortDescription: string; + short_description: string; description: string; - categoryId: string; - manufacturerId: string; - partOriginId: string; - statusId: string; - internalCode: string; + category_id: string; + manufacturer_id: string; + part_origin_id: string; + status_id: string; + internal_code: string; barcode: string; - unitId: string; + unit_id: string; weight: number; height: number; width: number; @@ -120,15 +121,15 @@ export function toProductGeneralFormData(source: ProductGeneralFormSource): Prod return { name: source.name, slug: source.slug, - shortDescription: source.shortDescription, + short_description: source.short_description, description: source.description, - categoryId: source.categoryId, - manufacturerId: source.manufacturerId, - partOriginId: source.partOriginId, - statusId: source.statusId, - internalCode: source.internalCode, + 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, - unitId: source.unitId, + unit_id: source.unit_id, weight: source.weight, height: source.height, width: source.width, @@ -140,14 +141,14 @@ export function toProductGeneralFormData(source: ProductGeneralFormSource): Prod export function toCreateProductPayload(source: ProductGeneralFormSource): CreateProductPayload { return { - category_id: source.categoryId, - manufacturer_id: source.manufacturerId, - part_origin_id: source.partOriginId, - unit_id: source.unitId, - internal_code: source.internalCode, + 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.shortDescription, + short_description: source.short_description, description: source.description, weight: source.weight, height: source.height, diff --git a/src/app/core/models/products/product-form-state.model.ts b/src/app/core/models/products/product-form-state.model.ts index 3ec697e..798c97c 100644 --- a/src/app/core/models/products/product-form-state.model.ts +++ b/src/app/core/models/products/product-form-state.model.ts @@ -4,7 +4,7 @@ export interface ProductFormState { category_id: string; manufacturer_id: string; unit: string; - status_id: string; + unit_id: string; slug: string; description: string; diff --git a/src/app/core/models/products/product-request.model.ts b/src/app/core/models/products/product-request.model.ts index ca37418..61625fb 100644 --- a/src/app/core/models/products/product-request.model.ts +++ b/src/app/core/models/products/product-request.model.ts @@ -79,7 +79,7 @@ export interface UpdateProductPayload { category_id?: string | null; manufacturer_id?: string | null; part_origin_id?: string | null; - status_id?: string | null; + unit_id?: string | null; internal_code?: string | null; barcode?: string | null; diff --git a/src/app/core/models/products/product-response.model.ts b/src/app/core/models/products/product-response.model.ts index ad42969..dd206cf 100644 --- a/src/app/core/models/products/product-response.model.ts +++ b/src/app/core/models/products/product-response.model.ts @@ -1,25 +1,51 @@ +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: null; - image: null; - short_description: string; + + icon: string | null; + image: string | null; + + short_description: string | null; description: string; + weight: string; height: string; width: string; length: string; + featured: boolean; - active: number; + active: boolean; + is_sellable: boolean; commercial_status: string; - pricing: Pricing; - inventory: Inventory; + + pricing: Pricing | null; + + inventory: Inventory | null; + + oem_codes: OemCodeItem[]; + equivalents: SupplierItem[]; + applications: VehicleApplication[]; + suppliers: SupplierItem[]; + + created_at: string; + updated_at: string; } export interface Inventory { @@ -63,3 +89,66 @@ export interface Status { 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/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/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 index ad301a0..939a137 100644 --- a/src/app/core/utils/product-payload.transformer.ts +++ b/src/app/core/utils/product-payload.transformer.ts @@ -53,7 +53,11 @@ export function toNullableDate(val: unknown): string | null { } function hasPromotionalPrice(promotionalPrice: number | null): boolean { - return promotionalPrice !== null && promotionalPrice !== undefined && String(promotionalPrice).trim() !== ''; + return ( + promotionalPrice !== null && + promotionalPrice !== undefined && + String(promotionalPrice).trim() !== '' + ); } function buildPriceBlock(state: ProductFormState) { @@ -81,7 +85,7 @@ function buildProductRoot(state: ProductFormState) { return { category_id: toNullableString(state.category_id), manufacturer_id: toNullableString(state.manufacturer_id), - status_id: toNullableString(state.status_id), + unit_id: toNullableString(state.unit_id), internal_code: toNullableString(state.internal_code), name: toNullableString(state.name), slug: toNullableString(state.slug), @@ -151,8 +155,8 @@ export function validateProductForm( if (!state.unit) { errs['unit_id'] = 'Selecione a unidade de medida.'; } - if (!state.status_id) { - errs['status_id'] = 'Selecione o status do produto.'; + if (!state.unit_id) { + errs['unit_id'] = 'Selecione o status do produto.'; } if (isCreateFlow) { diff --git a/src/app/design-system/select/select.html b/src/app/design-system/select/select.html index 2dc487a..a241a4f 100644 --- a/src/app/design-system/select/select.html +++ b/src/app/design-system/select/select.html @@ -24,7 +24,7 @@ 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 index 4ad732d..75d962c 100644 --- 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 @@ -4,17 +4,15 @@ import { InputComponent } from '../../../../../design-system/input/input'; @Component({ selector: 'app-product-commercial-tab', standalone: true, - imports: [ - InputComponent - ], + imports: [InputComponent], changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './commercial-tab.html' + templateUrl: './commercial-tab.html', }) export class ProductCommercialTabComponent { readonly formPrice = input(0); readonly formPromotionalPrice = input(null); - readonly formPromotionStartDate = input(''); - readonly formPromotionEndDate = input(''); + readonly formPromotionStartDate = input(null); + readonly formPromotionEndDate = input(null); readonly formIsInvoiced = input(true); readonly isReadOnly = input(false); 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 index 761a710..6afa355 100644 --- 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 @@ -4,7 +4,7 @@ @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/sections/dimensions-section.ts b/src/app/features/catalog/components/product-form/general-tab/sections/dimensions-section.ts index 578c15c..27f1c18 100644 --- 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 @@ -29,7 +29,7 @@ import { DSelectOption } from '../../../../../../core/models/design-system/selec [options]="unitOptions()" [value]="formUnitId()" [disabled]="isReadOnly()" - (valueChange)="fieldChange.emit({ field: 'unitId', value: $event })" + (valueChange)="fieldChange.emit({ field: 'unit_id', value: $event })" /> @@ -92,6 +92,7 @@ export class ProductDimensionsSectionComponent { 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 index 46c5fb4..539efbf 100644 --- 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 @@ -37,7 +37,7 @@ import { GeneralOptionQuery } from '../../../../../../core/models/generals/gener [disabled]="isReadOnly()" [loading]="categoryLoading()" [error]="errors()['category_id']" - (valueChange)="fieldChange.emit({ field: 'categoryId', value: $event })" + (valueChange)="fieldChange.emit({ field: 'category_id', value: $event })" (searchQueryChange)="categorySearch.emit($event)" /> @@ -49,7 +49,7 @@ import { GeneralOptionQuery } from '../../../../../../core/models/generals/gener [value]="formManufacturerId()" [disabled]="isReadOnly()" [loading]="manufacturerLoading()" - (valueChange)="fieldChange.emit({ field: 'manufacturerId', value: $event })" + (valueChange)="fieldChange.emit({ field: 'manufacturer_id', value: $event })" (searchQueryChange)="manufacturerSearch.emit($event)" /> @@ -61,19 +61,19 @@ import { GeneralOptionQuery } from '../../../../../../core/models/generals/gener [value]="formPartOriginId()" [disabled]="isReadOnly()" [error]="errors()['part_origin_id']" - (valueChange)="fieldChange.emit({ field: 'partOriginId', value: $event })" + (valueChange)="fieldChange.emit({ field: 'part_origin_id', value: $event })" /> @if (!isCreateMode()) { - + } @@ -83,7 +83,7 @@ import { GeneralOptionQuery } from '../../../../../../core/models/generals/gener [value]="formInternalCode()" [disabled]="isReadOnly()" helperText="Código interno único de identificação" - (valueChange)="fieldChange.emit({ field: 'internalCode', value: $event })" + (valueChange)="fieldChange.emit({ field: 'internal_code', value: $event })" /> diff --git a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html index 0b08da2..25e6f3d 100644 --- a/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html +++ b/src/app/features/catalog/components/product-form/sidebar/product-workspace-sidebar.html @@ -25,9 +25,9 @@ class="font-bold text-xs sm:text-sm text-gray-900 dark:text-slate-100 truncate leading-tight" [title]="productName()" > - {{ productName() || 'Produto sem nome' }} @if (internalCode()) { + {{ productName() || 'Produto sem nome' }} @if (internal_code()) { - (SKU: {{ internalCode() }}) + (SKU: {{ internal_code() }}) }

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 index 4f163c8..cc805bb 100644 --- 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 @@ -22,7 +22,7 @@ export class ProductWorkspaceSidebarComponent implements AfterViewInit { @ViewChild('navContainer') navContainer?: ElementRef; readonly productName = input(''); - readonly internalCode = input(''); + readonly internal_code = input(''); readonly status = input<{ name?: string; color?: string; code?: string } | null>(null); readonly activeTab = input.required(); @@ -90,7 +90,7 @@ export class ProductWorkspaceSidebarComponent implements AfterViewInit { const errs = this.errors(); if (!errs) return false; if (tab === 'geral') { - return !!(errs['name'] || errs['categoryId'] || errs['manufacturerId']); + return !!(errs['name'] || errs['category_id'] || errs['manufacturer_id']); } if (tab === 'comercial') { return !!errs['price']; diff --git a/src/app/features/catalog/models/product-workspace.model.ts b/src/app/features/catalog/models/product-workspace.model.ts index b0d234b..625b22a 100644 --- a/src/app/features/catalog/models/product-workspace.model.ts +++ b/src/app/features/catalog/models/product-workspace.model.ts @@ -83,12 +83,12 @@ export interface ProductNoteItem { } export interface ProductFormData { - categoryId: string; - manufacturerId: string; - partOriginId: string; - statusId: string; + category_id: string; + manufacturer_id: string; + part_origin_id: string; + status_id: string; - internalCode: string; + internal_code: string; barcode: string; name: string; @@ -97,7 +97,7 @@ export interface ProductFormData { icon: string; image: string; - shortDescription: string; + short_description: string; description: string; weight: number; @@ -105,7 +105,7 @@ export interface ProductFormData { width: number; length: number; - unitId: string; + unit_id: string; featured: boolean; active: boolean; @@ -136,12 +136,12 @@ export interface ProductFormData { export function defaultProductFormData(): ProductFormData { return { - categoryId: '', - manufacturerId: '', - partOriginId: '', - statusId: 'st-01', + category_id: '', + manufacturer_id: '', + part_origin_id: '', + status_id: 'st-01', - internalCode: '', + internal_code: '', barcode: '', name: '', @@ -150,7 +150,7 @@ export function defaultProductFormData(): ProductFormData { icon: '', image: '', - shortDescription: '', + short_description: '', description: '', weight: 0, @@ -158,7 +158,7 @@ export function defaultProductFormData(): ProductFormData { width: 0, length: 0, - unitId: 'un', + unit_id: 'un', featured: false, active: true, diff --git a/src/app/features/catalog/product-create/product-create.ts b/src/app/features/catalog/product-create/product-create.ts index 1d2be89..af6b9db 100644 --- a/src/app/features/catalog/product-create/product-create.ts +++ b/src/app/features/catalog/product-create/product-create.ts @@ -167,19 +167,13 @@ export class ProductCreateComponent implements OnInit { this.isFormDirty.set(true); const fieldMapping: Record = { name: 'name', - internal_code: 'internalCode', - internalCode: 'internalCode', + internal_code: 'internal_code', barcode: 'barcode', - category_id: 'categoryId', - categoryId: 'categoryId', - manufacturer_id: 'manufacturerId', - manufacturerId: 'manufacturerId', - part_origin_id: 'partOriginId', - partOriginId: 'partOriginId', - unit_id: 'unitId', - unitId: 'unitId', - short_description: 'shortDescription', - shortDescription: 'shortDescription', + 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) { @@ -262,19 +256,19 @@ export class ProductCreateComponent implements OnInit { if (!p.name.trim()) { errs['name'] = 'O nome do produto é obrigatório.'; } - if (!p.internalCode.trim()) { + if (!p.internal_code.trim()) { errs['internal_code'] = 'O código interno é obrigatório.'; } - if (!p.categoryId) { + if (!p.category_id) { errs['category_id'] = 'Selecione uma categoria.'; } - if (!p.manufacturerId) { + if (!p.manufacturer_id) { errs['manufacturer_id'] = 'Selecione um fabricante.'; } - if (!p.unitId) { + if (!p.unit_id) { errs['unit_id'] = 'Selecione a unidade de medida.'; } - if (!p.partOriginId) { + if (!p.part_origin_id) { errs['part_origin_id'] = 'Selecione a origem da peça.'; } diff --git a/src/app/features/catalog/product-workspace/product-workspace.html b/src/app/features/catalog/product-workspace/product-workspace.html index 08bc6ff..5cba9fe 100644 --- a/src/app/features/catalog/product-workspace/product-workspace.html +++ b/src/app/features/catalog/product-workspace/product-workspace.html @@ -3,7 +3,7 @@
@@ -68,7 +68,7 @@

('edit'); readonly activeTab = signal('geral'); + readonly mode = signal('edit'); + readonly isSaving = signal(false); readonly isDragging = signal(false); readonly isFormDirty = signal(false); - readonly productCreatedSuccessBanner = signal(false); - readonly categoryLoading = signal(false); - readonly fetchedCategories = signal([]); readonly manufacturerLoading = signal(false); - readonly fetchedManufacturers = signal([]); + 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); @@ -108,6 +112,7 @@ export class ProductWorkspaceComponent implements OnInit { partOriginOptions: this.fetchedPartOrigins(), unitOptions: this.unitOptions, statusOptions: this.statusOptions(), + warehouseOptions: this.fetchedWarehouses(), })); readonly isReadOnly = computed(() => this.mode() === 'view'); @@ -142,12 +147,6 @@ export class ProductWorkspaceComponent implements OnInit { { label: 'Conjunto', value: 'conjunto' }, ]; - readonly warehouseOptions: DSelectOption[] = [ - { 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' }, @@ -221,6 +220,16 @@ export class ProductWorkspaceComponent implements OnInit { 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 { @@ -250,12 +259,13 @@ export class ProductWorkspaceComponent implements OnInit { | undefined; this.productForm.set({ - categoryId: prod.category?.id || (prod['category_id'] as string) || '', - manufacturerId: prod.manufacturer?.id || (prod['manufacturer_id'] as string) || '', - partOriginId: (prod['part_origin_id'] as string) || '', - statusId: prod.status?.id || (prod['status_id'] as string) || 'st-01', + 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', - internalCode: prod.internal_code || '', + internal_code: prod.internal_code || '', barcode: (prod['barcode'] as string) || '', name: prod.name || '', @@ -264,7 +274,7 @@ export class ProductWorkspaceComponent implements OnInit { icon: prod.icon || '', image: prod.image || '', - shortDescription: (prod['short_description'] as string) || '', + short_description: (prod['short_description'] as string) || '', description: prod.description || '', weight: parseFloat(prod.weight as string) || 0, @@ -272,8 +282,6 @@ export class ProductWorkspaceComponent implements OnInit { width: parseFloat(prod.width as string) || 0, length: parseFloat(prod.length as string) || 0, - unitId: (prod['unit']?.id || prod['unit_id'] || 'un') as string, - featured: !!prod['featured'], active: prod['active'] !== undefined ? !!prod['active'] : true, @@ -336,7 +344,7 @@ export class ProductWorkspaceComponent implements OnInit { errs['category_id'] || errs['manufacturer_id'] || errs['unit_id'] || - errs['status_id'] + errs['unit_id'] ); } if (tab === 'comercial') { @@ -362,22 +370,15 @@ export class ProductWorkspaceComponent implements OnInit { this.isFormDirty.set(true); const fieldMapping: Record = { name: 'name', - internal_code: 'internalCode', - internalCode: 'internalCode', + internal_code: 'internal_code', barcode: 'barcode', - category_id: 'categoryId', - categoryId: 'categoryId', - manufacturer_id: 'manufacturerId', - manufacturerId: 'manufacturerId', - part_origin_id: 'partOriginId', - partOriginId: 'partOriginId', - unit_id: 'unitId', - unitId: 'unitId', - status_id: 'statusId', - statusId: 'statusId', + 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: 'shortDescription', - shortDescription: 'shortDescription', + short_description: 'short_description', promotion_start_date: 'promotionStartDate', promotion_end_date: 'promotionEndDate', warehouse_id: 'warehouseId', @@ -856,16 +857,16 @@ export class ProductWorkspaceComponent implements OnInit { if (!p.name.trim()) { errs['name'] = 'O nome do produto é obrigatório.'; } - if (!p.internalCode.trim()) { + if (!p.internal_code.trim()) { errs['internal_code'] = 'O código interno é obrigatório.'; } - if (!p.categoryId) { + if (!p.category_id) { errs['category_id'] = 'Selecione uma categoria.'; } - if (!p.manufacturerId) { + if (!p.manufacturer_id) { errs['manufacturer_id'] = 'Selecione um fabricante.'; } - if (!p.unitId) { + if (!p.unit_id) { errs['unit_id'] = 'Selecione a unidade de medida.'; } if (p.price <= 0) { @@ -903,18 +904,18 @@ export class ProductWorkspaceComponent implements OnInit { const p = this.productForm(); const payload: UpdateProductPayload = { - category_id: p.categoryId, - manufacturer_id: p.manufacturerId, - part_origin_id: p.partOriginId, - status_id: p.statusId, + 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.internalCode, + internal_code: p.internal_code, barcode: p.barcode, name: p.name, slug: p.slug, - short_description: p.shortDescription, + short_description: p.short_description, description: p.description, weight: p.weight, @@ -922,10 +923,9 @@ export class ProductWorkspaceComponent implements OnInit { width: p.width, length: p.length, - unit: p.unitId, + unit: p.unit_id, featured: p.featured, - active: p.active, price: { price: p.price,