From be86ab35641290c7a3f2d20f6014f037cb215b7a Mon Sep 17 00:00:00 2001 From: Gagan7900 <54118395+Gagan7900@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:19:46 +0530 Subject: [PATCH 1/2] add endpoint configuration for city, currency, language, and timezone --- .gitignore | 7 + angular.json | 33 +- package-lock.json | 1789 +++++++++++++++++ package.json | 11 +- .../core/end-points/city/city.endpoints.ts | 11 + .../end-points/country/country.endpoints.ts | 7 +- .../end-points/currency/currency.endpoints.ts | 44 + .../end-points/language/language.endpoints.ts | 11 + .../core/end-points/state/state.endpoints.ts | 7 +- .../end-points/timezone/timezone.endpoints.ts | 11 + src/app/core/interceptors/auth.interceptor.ts | 22 +- src/app/core/models/city/city.model.ts | 26 + src/app/core/models/country/country.model.ts | 24 +- .../core/models/currency/currency.model.ts | 33 + .../core/models/language/language.model.ts | 31 + src/app/core/models/state/state.model.ts | 30 + .../core/models/timezone/timezone.model.ts | 27 + .../services/auth/token-storage.service.ts | 1 - src/app/core/services/city/city.service.ts | 42 + src/app/core/services/common/menu.data.ts | 5 +- .../core/services/country/country.service.ts | 34 +- .../services/currency/currency.service.ts | 46 + .../services/language/language.service.ts | 48 + src/app/core/services/state/state.service.ts | 37 +- .../services/timezone/timezone.service.ts | 45 + .../cities/pages/city-list/city-list.html | 166 +- .../cities/pages/city-list/city-list.ts | 556 ++++- .../pages/country-list/country-list.html | 93 +- .../pages/country-list/country-list.ts | 536 +++-- .../pages/currency-list/currency-list.html | 160 ++ .../pages/currency-list/currency-list.scss | 0 .../pages/currency-list/currency-list.ts | 507 +++++ .../global-masters/global-masters.routes.ts | 21 +- .../pages/language-list/language-list.html | 123 ++ .../pages/language-list/language-list.scss | 1 + .../pages/language-list/language-list.ts | 356 ++++ .../states/pages/state-list/state-list.html | 97 +- .../states/pages/state-list/state-list.ts | 580 +++++- .../pages/timezone-list/timezone-list.html | 133 ++ .../pages/timezone-list/timezone-list.scss | 1 + .../pages/timezone-list/timezone-list.ts | 348 ++++ .../pages/tenant-list/tenant-list.html | 11 +- .../tenants/pages/tenant-list/tenant-list.ts | 33 +- .../confirm-dialog/confirm-dialog.html | 10 + .../confirm-dialog/confirm-dialog.scss | 0 .../confirm-dialog/confirm-dialog.ts | 62 + .../components/data-table/data-table.html | 34 +- .../components/data-table/data-table.ts | 23 +- .../components/data-table/data-table.types.ts | 13 +- .../form/autocomplete/autocomplete.html | 105 + .../form/autocomplete/autocomplete.spec.ts | 289 +++ .../form/autocomplete/autocomplete.ts | 338 ++++ .../form/autocomplete/autocomplete.types.ts | 16 + .../form/form-field/form-field.html | 2 +- .../components/form/form-field/form-field.ts | 50 +- .../form/form-input/form-input.html | 3 +- .../components/form/form-input/form-input.ts | 56 +- .../form/form-select/form-select.html | 224 ++- .../form/form-select/form-select.ts | 720 ++++++- .../form-validation-message.ts | 51 +- .../form/models/form-select.models.ts | 37 + src/app/shared/components/modal/modal.html | 45 +- src/app/shared/components/modal/modal.scss | 37 - src/app/shared/components/modal/modal.ts | 23 +- .../data-table-toolbar.directive.spec.ts | 8 + .../data-table-toolbar.directive.ts | 15 + .../directives/tooltip/tooltip/tooltip.html | 15 +- src/environments/environment.model.ts | 24 + src/environments/environment.prod.ts | 9 +- src/environments/environment.ts | 6 +- src/styles.scss | 23 +- tsconfig.spec.json | 3 - 72 files changed, 7874 insertions(+), 471 deletions(-) create mode 100644 src/app/core/end-points/city/city.endpoints.ts create mode 100644 src/app/core/end-points/currency/currency.endpoints.ts create mode 100644 src/app/core/end-points/language/language.endpoints.ts create mode 100644 src/app/core/end-points/timezone/timezone.endpoints.ts create mode 100644 src/app/core/models/city/city.model.ts create mode 100644 src/app/core/models/currency/currency.model.ts create mode 100644 src/app/core/models/language/language.model.ts create mode 100644 src/app/core/models/state/state.model.ts create mode 100644 src/app/core/models/timezone/timezone.model.ts create mode 100644 src/app/core/services/city/city.service.ts create mode 100644 src/app/core/services/currency/currency.service.ts create mode 100644 src/app/core/services/language/language.service.ts create mode 100644 src/app/core/services/timezone/timezone.service.ts create mode 100644 src/app/features/global-masters/currencies/pages/currency-list/currency-list.html create mode 100644 src/app/features/global-masters/currencies/pages/currency-list/currency-list.scss create mode 100644 src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts create mode 100644 src/app/features/global-masters/languages/pages/language-list/language-list.html create mode 100644 src/app/features/global-masters/languages/pages/language-list/language-list.scss create mode 100644 src/app/features/global-masters/languages/pages/language-list/language-list.ts create mode 100644 src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html create mode 100644 src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.scss create mode 100644 src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts create mode 100644 src/app/shared/components/confirm-dialog/confirm-dialog.html create mode 100644 src/app/shared/components/confirm-dialog/confirm-dialog.scss create mode 100644 src/app/shared/components/confirm-dialog/confirm-dialog.ts create mode 100644 src/app/shared/components/form/autocomplete/autocomplete.html create mode 100644 src/app/shared/components/form/autocomplete/autocomplete.spec.ts create mode 100644 src/app/shared/components/form/autocomplete/autocomplete.ts create mode 100644 src/app/shared/components/form/autocomplete/autocomplete.types.ts create mode 100644 src/app/shared/components/form/models/form-select.models.ts create mode 100644 src/app/shared/directives/data-table-toolbar/data-table-toolbar.directive.spec.ts create mode 100644 src/app/shared/directives/data-table-toolbar/data-table-toolbar.directive.ts create mode 100644 src/environments/environment.model.ts diff --git a/.gitignore b/.gitignore index 8969bd9d..efe8002c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,10 @@ __screenshots__/ # System files .DS_Store Thumbs.db + + +# Ignore local ESLint config +eslint.config.js + +# Ignore preview folder +preview/ \ No newline at end of file diff --git a/angular.json b/angular.json index 084e6572..83cef95e 100644 --- a/angular.json +++ b/angular.json @@ -3,7 +3,10 @@ "version": 1, "cli": { "packageManager": "npm", - "analytics": "0b3da18f-5d81-4a09-9b2f-b0ce36040773" + "analytics": "0b3da18f-5d81-4a09-9b2f-b0ce36040773", + "schematicCollections": [ + "angular-eslint" + ] }, "newProjectRoot": "projects", "projects": { @@ -23,7 +26,7 @@ "build": { "builder": "@angular/build:application", "options": { - "allowedCommonJsDependencies": [ + "allowedCommonJsDependencies": [ "sweetalert2", "inputmask", "filepond", @@ -53,13 +56,22 @@ "src/.htaccess" ], "styles": [ + "node_modules/@ng-select/ng-select/themes/default.theme.css", "src/styles.scss" ], - "scripts": ["node_modules/preline/dist/preline.js"] + "scripts": [ + "node_modules/preline/dist/preline.js" + ] }, "configurations": { "production": { - "baseHref": "/angular/ynex-tailwind/preview/", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "baseHref": "/", "budgets": [ { "type": "initial", @@ -80,7 +92,7 @@ "sourceMap": true } }, - "defaultConfiguration": "production" + "defaultConfiguration": "development" }, "serve": { "builder": "@angular/build:dev-server", @@ -96,8 +108,17 @@ }, "test": { "builder": "@angular/build:unit-test" + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } } } } } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f3ea583f..741b5dc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "^21.2.10", "@angular/platform-browser-dynamic": "^21.2.10", "@angular/router": "^21.2.10", + "@ng-select/ng-select": "^21.8.2", "@tailwindcss/forms": "^0.5.11", "@tsparticles/angular": "^3.0.0", "apexcharts": "^5.10.5", @@ -33,6 +34,7 @@ "preline": "^4.1.2", "rxjs": "~7.8.0", "simplebar-angular": "^3.3.2", + "sweetalert2": "^11.26.25", "tslib": "^2.3.0", "tsparticles": "^3.9.1" }, @@ -40,13 +42,17 @@ "@angular/build": "^21.0.5", "@angular/cli": "^21.0.5", "@angular/compiler-cli": "^21.2.10", + "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.2.2", + "angular-eslint": "21.4.0", "autoprefixer": "^10.4.27", + "eslint": "^10.3.0", "jsdom": "^27.1.0", "postcss": "^8.5.8", "postcss-cli": "^11.0.1", "tailwindcss": "^4.2.2", "typescript": "~5.9.2", + "typescript-eslint": "8.59.2", "vitest": "^4.0.8" } }, @@ -357,6 +363,115 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-eslint/builder": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.4.0.tgz", + "integrity": "sha512-3kgGmrVaCYbLtDjC8g4BmMBbdz4thsOB8/NYly8JtXM8EuDZEk5Pz6VTRpJR02ARprwayraTTmhyvq6OGBlQ9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": ">= 0.2100.0 < 0.2200.0", + "@angular-devkit/core": ">= 21.0.0 < 22.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.4.0.tgz", + "integrity": "sha512-/3H4BPbQ1BHJkkrUsfusZtmHc+qiFWBBZ9UDPWah4xZMjflexOK9U4GYeH7nMjcuyqFnIlMMeJJNwNLGt/hmdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.4.0.tgz", + "integrity": "sha512-mow2DMj+xBvGl5t7jzC34R8YfbHbaGNyCNFzpovtl9qc0JbuqLyg6htmt8xb05f8ZjATOr4nz0ESt6HV4c51hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", + "ts-api-utils": "^2.1.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.4.0.tgz", + "integrity": "sha512-sJEHx2WYnvOgPpzP1eHnUdRS06zgKmRxbiIR0JiCcaSen5iv1HlsMieXy//FS9TtNW+abHOy4UtDuGuSPflPFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@angular-eslint/template-parser": "21.4.0", + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-21.4.0.tgz", + "integrity": "sha512-crD6Hfxs7x5bN9FCqTZI7uVSiGvprfCS3MCPOpyIQl87bRr/9aNhnicJ3ROUHv+2A713BgPHIgiCII/bxzrfPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 21.0.0 < 22.0.0", + "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", + "@angular-eslint/eslint-plugin": "21.4.0", + "@angular-eslint/eslint-plugin-template": "21.4.0", + "ignore": "7.0.5", + "semver": "7.7.4", + "strip-json-comments": "3.1.1" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.4.0.tgz", + "integrity": "sha512-BaUSLSyS+43fzDoJkTMkGqNdCXq3fGnUZsfXTmrlZPJf5AYFbgAlAPGZXDJyoNWw43fux+DafdlrlKcYUSgSIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "eslint-scope": "9.1.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.4.0.tgz", + "integrity": "sha512-7pi+Ga7QmdH5Ig/diau6fR5L4yubgKr9TOjdCg7OeuE/zo0O3osTCNT6JOodzS/iQM1kSCJFDoIBKFeUOttiNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, "node_modules/@angular/animations": { "version": "21.2.14", "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.14.tgz", @@ -2359,6 +2474,121 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -3233,6 +3463,72 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -4243,6 +4539,23 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@ng-select/ng-select": { + "version": "21.8.2", + "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-21.8.2.tgz", + "integrity": "sha512-Gk5xlVKLOG3WTIgOteduwlKBZTt0njkoUXheKgUSKn+h+poYQAf61uENCK08Tj0XWxes9BQUc5J+R2GC+86jgA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || ^24.0.0" + }, + "peerDependencies": { + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/forms": "^21.0.0" + } + }, "node_modules/@npmcli/agent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", @@ -6545,6 +6858,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6558,6 +6878,13 @@ "integrity": "sha512-nEBoa6iDNipICtxJ5VlrOgPNZQ6ixIg5nuv8iryFj0Z/1NLgxyg3pQCVegPuCzGCyTQwQI/N3uZvLUysqAzaaw==", "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -6567,6 +6894,677 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", @@ -6737,6 +7735,29 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -6806,6 +7827,30 @@ "node": ">= 14.0.0" } }, + "node_modules/angular-eslint": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-21.4.0.tgz", + "integrity": "sha512-LH7bWmtJvsubzwPoztnl1pWgI5X0VrfGTUITGSYcwn2J+SXuN/avzrKrxJmhUiIrNvLtfV+18GG6xZS1IGZdKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 21.0.0 < 22.0.0", + "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", + "@angular-eslint/builder": "21.4.0", + "@angular-eslint/eslint-plugin": "21.4.0", + "@angular-eslint/eslint-plugin-template": "21.4.0", + "@angular-eslint/schematics": "21.4.0", + "@angular-eslint/template-parser": "21.4.0", + "@typescript-eslint/types": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*", + "typescript-eslint": "^8.0.0" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -6880,6 +7925,16 @@ "integrity": "sha512-PJuXT6zdiCbv0IkX5cqkKFVIIh+9v3kqP9zsOHEGpIWi7DfTgzvfOKc8icw6G3/ulR3V1alDDUtOVH0zWCWGEQ==", "license": "SEE LICENSE IN LICENSE" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -6927,6 +7982,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -7622,6 +8687,13 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7949,6 +9021,237 @@ "dev": true, "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -7959,6 +9262,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -8085,6 +9398,20 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -8131,6 +9458,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8166,6 +9506,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/firebase": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.10.0.tgz", @@ -8203,6 +9560,27 @@ "@firebase/util": "1.12.1" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8617,6 +9995,16 @@ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-walk": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", @@ -8637,6 +10025,16 @@ "dev": true, "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8894,6 +10292,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", @@ -8917,6 +10322,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8965,6 +10377,30 @@ "integrity": "sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ==", "license": "MIT" }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -9329,6 +10765,22 @@ "@lmdb/lmdb-win32-x64": "3.5.1" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -9809,6 +11261,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -10206,6 +11665,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", @@ -10236,6 +11713,38 @@ "license": "MIT", "optional": true }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", @@ -10356,6 +11865,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -10849,6 +12368,16 @@ "@yr/monotone-cubic-spline": "^1.0.3" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", @@ -11702,6 +13231,29 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sweetalert2": { + "version": "11.26.25", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.26.25.tgz", + "integrity": "sha512-+hunCOJdJ6FLj04T9YSLvvZXRjsvIkTeTKP2e4VF8CaBias961BTnWiSFAy7F/CM5eq3QK2Rraoc5Gzftslvkg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/limonte" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -11876,6 +13428,19 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -11933,6 +13498,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -11980,6 +13558,184 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/undici": { "version": "7.24.4", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", @@ -12047,6 +13803,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -12373,6 +14139,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -12583,6 +14359,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index e0329c59..40272561 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "test": "ng test", "sass": "sass ./public/assets/scss:./public/assets/css/", "sass-min": "sass ./public/assets/scss:./public/assets/css/ --style compressed", - "postcss": "sass ./public/assets/scss:./public/assets/css && postcss ./public/assets/css/*.css --dir ./public/assets/css" + "postcss": "sass ./public/assets/scss:./public/assets/css && postcss ./public/assets/css/*.css --dir ./public/assets/css", + "lint": "ng lint" }, "prettier": { "printWidth": 100, @@ -38,6 +39,7 @@ "@angular/platform-browser": "^21.2.10", "@angular/platform-browser-dynamic": "^21.2.10", "@angular/router": "^21.2.10", + "@ng-select/ng-select": "^21.8.2", "@tailwindcss/forms": "^0.5.11", "@tsparticles/angular": "^3.0.0", "apexcharts": "^5.10.5", @@ -51,6 +53,7 @@ "preline": "^4.1.2", "rxjs": "~7.8.0", "simplebar-angular": "^3.3.2", + "sweetalert2": "^11.26.25", "tslib": "^2.3.0", "tsparticles": "^3.9.1" }, @@ -58,13 +61,17 @@ "@angular/build": "^21.0.5", "@angular/cli": "^21.0.5", "@angular/compiler-cli": "^21.2.10", + "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.2.2", + "angular-eslint": "21.4.0", "autoprefixer": "^10.4.27", + "eslint": "^10.3.0", "jsdom": "^27.1.0", "postcss": "^8.5.8", "postcss-cli": "^11.0.1", "tailwindcss": "^4.2.2", "typescript": "~5.9.2", + "typescript-eslint": "8.59.2", "vitest": "^4.0.8" } -} +} \ No newline at end of file diff --git a/src/app/core/end-points/city/city.endpoints.ts b/src/app/core/end-points/city/city.endpoints.ts new file mode 100644 index 00000000..6d53c35e --- /dev/null +++ b/src/app/core/end-points/city/city.endpoints.ts @@ -0,0 +1,11 @@ +import { buildApiUrl } from '../../config/api-url.util'; + +export const CITY_ENDPOINTS = { + dataTable: buildApiUrl('masterAdmin', '/v1/cities/datatable'), + create: buildApiUrl('masterAdmin', '/v1/cities'), + getById: (id: string) => + buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), + update: (id: string) => + buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), + autocomplete: buildApiUrl('masterAdmin', '/v1/cities/autocomplete') +} as const; diff --git a/src/app/core/end-points/country/country.endpoints.ts b/src/app/core/end-points/country/country.endpoints.ts index b1644cf8..626350f5 100644 --- a/src/app/core/end-points/country/country.endpoints.ts +++ b/src/app/core/end-points/country/country.endpoints.ts @@ -18,6 +18,11 @@ export const COUNTRY_ENDPOINTS = { `/v1/countries/${encodeURIComponent(id)}` ), + autocomplete: buildApiUrl( + 'masterAdmin', + '/v1/countries/autocomplete' + ), + update: (id: string) => buildApiUrl( 'masterAdmin', @@ -35,4 +40,4 @@ export const COUNTRY_ENDPOINTS = { 'masterAdmin', `/v1/countries/${encodeURIComponent(id)}/status` ), -} as const; \ No newline at end of file +} as const; diff --git a/src/app/core/end-points/currency/currency.endpoints.ts b/src/app/core/end-points/currency/currency.endpoints.ts new file mode 100644 index 00000000..1d297c32 --- /dev/null +++ b/src/app/core/end-points/currency/currency.endpoints.ts @@ -0,0 +1,44 @@ +import { buildApiUrl } from '../../config/api-url.util'; + + +export const CURRENCY_ENDPOINTS = { + dataTable: buildApiUrl( + 'masterAdmin', + '/v1/currencies/datatable' + ), + + create: buildApiUrl( + 'masterAdmin', + '/v1/currencies' + ), + + getById: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/currencies/${encodeURIComponent(id)}` + ), + + update: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/currencies/${encodeURIComponent(id)}` + ), + + delete: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/currencies/${encodeURIComponent(id)}` + ), + + changeStatus: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/currencies/${encodeURIComponent(id)}/status` + ), + + autocomplete: + buildApiUrl( + 'masterAdmin', + '/v1/currencies/autocomplete' + ), +} as const; diff --git a/src/app/core/end-points/language/language.endpoints.ts b/src/app/core/end-points/language/language.endpoints.ts new file mode 100644 index 00000000..3e591822 --- /dev/null +++ b/src/app/core/end-points/language/language.endpoints.ts @@ -0,0 +1,11 @@ +import { buildApiUrl } from '../../config/api-url.util'; + +export const LANGUAGE_ENDPOINTS = { + dataTable: buildApiUrl('masterAdmin', '/v1/languages/datatable'), + create: buildApiUrl('masterAdmin', '/v1/languages'), + getById: (id: string) => + buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), + update: (id: string) => + buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), + autocomplete: buildApiUrl('masterAdmin', '/v1/languages/autocomplete') +} as const; diff --git a/src/app/core/end-points/state/state.endpoints.ts b/src/app/core/end-points/state/state.endpoints.ts index afdadc65..3a1886a2 100644 --- a/src/app/core/end-points/state/state.endpoints.ts +++ b/src/app/core/end-points/state/state.endpoints.ts @@ -18,6 +18,11 @@ export const STATE_ENDPOINTS = { `/v1/states/${encodeURIComponent(id)}` ), + autocomplete: buildApiUrl( + 'masterAdmin', + '/v1/states/autocomplete' + ), + update: (id: string) => buildApiUrl( 'masterAdmin', @@ -35,4 +40,4 @@ export const STATE_ENDPOINTS = { 'masterAdmin', `/v1/states/${encodeURIComponent(id)}/status` ), -} as const; \ No newline at end of file +} as const; diff --git a/src/app/core/end-points/timezone/timezone.endpoints.ts b/src/app/core/end-points/timezone/timezone.endpoints.ts new file mode 100644 index 00000000..96a800ef --- /dev/null +++ b/src/app/core/end-points/timezone/timezone.endpoints.ts @@ -0,0 +1,11 @@ +import { buildApiUrl } from '../../config/api-url.util'; + +export const TIMEZONE_ENDPOINTS = { + dataTable: buildApiUrl('masterAdmin', '/v1/timezones/datatable'), + create: buildApiUrl('masterAdmin', '/v1/timezones'), + getById: (id: string) => + buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), + update: (id: string) => + buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), + autocomplete: buildApiUrl('masterAdmin', '/v1/timezones/autocomplete') +} as const; diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 8f3e4fa1..8418add5 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -4,18 +4,32 @@ import { Router } from '@angular/router'; import { catchError, switchMap, throwError } from 'rxjs'; import { AuthService } from '../services/auth/auth.service'; import { API_CONFIG } from '../config/api.config'; +import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints'; const RETRY_HEADER = 'X-Auth-Retry'; +function isEndpointRequest(requestUrl: string, endpointUrl: string): boolean { + return ( + requestUrl === endpointUrl || + requestUrl.startsWith(`${endpointUrl}?`) + ); +} + export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const router = inject(Router); - const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`; - const isAuthRequest = req.url.includes(authBaseUrl); - const isRefreshRequest = req.url.includes(`${authBaseUrl}/refresh`); + const isLoginRequest = isEndpointRequest( + req.url, + AUTH_ENDPOINTS.login + ); - if (isAuthRequest) { + const isRefreshRequest = isEndpointRequest( + req.url, + AUTH_ENDPOINTS.refresh + ); + + if (isLoginRequest || isRefreshRequest) { return next(req); } diff --git a/src/app/core/models/city/city.model.ts b/src/app/core/models/city/city.model.ts new file mode 100644 index 00000000..69dfe3b2 --- /dev/null +++ b/src/app/core/models/city/city.model.ts @@ -0,0 +1,26 @@ +export interface CityDto { + id: string; + stateId: string; + name: string; + code: string | null; + timezoneId: string | null; + isActive: boolean; + createdOn?: string; + modifiedOn?: string | null; +} + +export interface CreateCityRequest { + stateId: string; + name: string; + code: string; + timezoneId: string | null; +} + +export interface UpdateCityRequest { + name: string; + code: string; + timezoneId: string | null; + isActive: boolean; +} + +export type CityModalMode = 'create' | 'edit' ; diff --git a/src/app/core/models/country/country.model.ts b/src/app/core/models/country/country.model.ts index b6698bd5..9c4b220f 100644 --- a/src/app/core/models/country/country.model.ts +++ b/src/app/core/models/country/country.model.ts @@ -5,7 +5,27 @@ export interface CountryDto { name: string; phoneCode: string | null; defaultCurrencyId: string | null; - isActive?: boolean; + isActive: boolean; + createdOn?: string; + modifiedOn?: string | null; } -export type CountryModalMode = 'create' | 'edit'; \ No newline at end of file +export interface CountryLookupDto { + id: string; + iso2: string; + name: string; +} + +export interface CreateCountryRequest { + iso2: string; + iso3: string; + name: string; + phoneCode: string | null; + defaultCurrencyId: string | null; +} + +export interface UpdateCountryRequest extends CreateCountryRequest { + isActive: boolean; +} + +export type CountryModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/currency/currency.model.ts b/src/app/core/models/currency/currency.model.ts new file mode 100644 index 00000000..fd09d502 --- /dev/null +++ b/src/app/core/models/currency/currency.model.ts @@ -0,0 +1,33 @@ +export interface CurrencyDto { + id: string; + code: string; + name: string; + symbol: string; + numericCode: number; + decimalDigits: number; + isActive: boolean; + createdOn?: string; + modifiedOn?: string | null; +} + + +export interface CurrencyLookupDto { + readonly id: string; + readonly code: string; + readonly name: string; + readonly symbol: string; +} + +export interface CreateCurrencyRequest { + code: string; + name: string; + symbol: string; + numericCode: number; + decimalDigits: number; +} + +export interface UpdateCurrencyRequest extends CreateCurrencyRequest { + isActive: boolean; +} + +export type CurrencyModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/language/language.model.ts b/src/app/core/models/language/language.model.ts new file mode 100644 index 00000000..fc1f7539 --- /dev/null +++ b/src/app/core/models/language/language.model.ts @@ -0,0 +1,31 @@ +export interface LanguageDto { + id: string; + code: string; + name: string; + nativeName: string; + isRightToLeft: boolean; + isActive: boolean; + createdOn: string; + modifiedOn: string | null; +} + +export interface LanguageLookupDto { + readonly id: string; + readonly code: string; + readonly name: string; + readonly nativeName: string; + readonly isRightToLeft: boolean; +} + +export interface CreateLanguageRequest { + code: string; + name: string; + nativeName: string; + isRightToLeft: boolean; +} + +export interface UpdateLanguageRequest extends CreateLanguageRequest { + isActive: boolean; +} + +export type LanguageModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/state/state.model.ts b/src/app/core/models/state/state.model.ts new file mode 100644 index 00000000..384358d9 --- /dev/null +++ b/src/app/core/models/state/state.model.ts @@ -0,0 +1,30 @@ +export interface StateDto { + id: string; + countryId: string; + name: string; + code: string | null; + isActive: boolean; + createdOn?: string; + modifiedOn?: string | null; +} + +export interface StateLookupDto { + id: string; + name: string; + code: string; +} + +export interface CreateStateRequest { + countryId: string; + name: string; + code: string; +} + +export interface UpdateStateRequest { + countryId: null; + name: string; + code: string; + isActive: boolean; +} + +export type StateModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/timezone/timezone.model.ts b/src/app/core/models/timezone/timezone.model.ts new file mode 100644 index 00000000..31a75ee6 --- /dev/null +++ b/src/app/core/models/timezone/timezone.model.ts @@ -0,0 +1,27 @@ +export interface TimezoneDto { + readonly id: string; + readonly ianaId: string; + readonly displayName: string; + readonly utcOffsetMinutes: number; + readonly isActive: boolean; + readonly createdOn: string; + readonly modifiedOn: string | null; +} + +export interface TimezoneLookupDto { + readonly id: string; + readonly ianaId: string; + readonly displayName: string; +} + +export interface CreateTimezoneRequest { + readonly ianaId: string; + readonly displayName: string; + readonly utcOffsetMinutes: number; +} + +export interface UpdateTimezoneRequest extends CreateTimezoneRequest { + readonly isActive: boolean; +} + +export type TimezoneModalMode = 'create' | 'edit' | 'view'; diff --git a/src/app/core/services/auth/token-storage.service.ts b/src/app/core/services/auth/token-storage.service.ts index 96c60183..b62a6bec 100644 --- a/src/app/core/services/auth/token-storage.service.ts +++ b/src/app/core/services/auth/token-storage.service.ts @@ -121,7 +121,6 @@ export class TokenStorageService { } private isExpired(expiresOn: string | null): boolean { - debugger; if (!expiresOn) { return true; } diff --git a/src/app/core/services/city/city.service.ts b/src/app/core/services/city/city.service.ts new file mode 100644 index 00000000..cdcd0d80 --- /dev/null +++ b/src/app/core/services/city/city.service.ts @@ -0,0 +1,42 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { CITY_ENDPOINTS } from '../../end-points/city/city.endpoints'; +import { + CityDto, + CreateCityRequest, + UpdateCityRequest +} from '../../models/city/city.model'; +import { + DataTableQuery, + DataTableResult +} from '../../../shared/components/data-table/data-table.types'; + +@Injectable({ providedIn: 'root' }) +export class CityService { + private readonly http = inject(HttpClient); + + getCityDataTable( + query: DataTableQuery, + stateId: string + ): Observable> { + return this.http.post>( + CITY_ENDPOINTS.dataTable, + query, + { params: new HttpParams().set('stateId', stateId) } + ); + } + + createCity(request: CreateCityRequest): Observable { + return this.http.post(CITY_ENDPOINTS.create, request); + } + + updateCity(id: string, request: UpdateCityRequest): Observable { + return this.http.put(CITY_ENDPOINTS.update(id), request); + } + + getCityById(id: string): Observable { + return this.http.get(CITY_ENDPOINTS.getById(id)); + } +} diff --git a/src/app/core/services/common/menu.data.ts b/src/app/core/services/common/menu.data.ts index 68dc584f..4288b964 100644 --- a/src/app/core/services/common/menu.data.ts +++ b/src/app/core/services/common/menu.data.ts @@ -33,6 +33,9 @@ export const SAAS_MENU_DATA: MenuContext = { selected: false, dirchange: false, children: [ + { path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false }, + { path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false }, + { path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false }, { path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false }, { path: '/global-masters/states', title: 'State', type: 'link', dirchange: false }, { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, @@ -75,4 +78,4 @@ export const SAAS_MENU_DATA: MenuContext = { ], }, ], -}; \ No newline at end of file +}; diff --git a/src/app/core/services/country/country.service.ts b/src/app/core/services/country/country.service.ts index e73f7b04..8d5a955b 100644 --- a/src/app/core/services/country/country.service.ts +++ b/src/app/core/services/country/country.service.ts @@ -1,8 +1,14 @@ -import { HttpClient } from "@angular/common/http"; +import { HttpClient, HttpParams } from "@angular/common/http"; import { Injectable, inject } from "@angular/core"; import { COUNTRY_ENDPOINTS } from "../../../core/end-points/country/country.endpoints"; import { Observable } from "rxjs"; import { DataTableQuery, DataTableResult } from "../../../shared/components/data-table/data-table.types"; +import { + CountryDto, + CountryLookupDto, + CreateCountryRequest, + UpdateCountryRequest +} from "../../models/country/country.model"; @Injectable({ providedIn: 'root' @@ -11,7 +17,27 @@ export class CountryService { private readonly http = inject(HttpClient); - getCountryDataTable(query: DataTableQuery): Observable> { - return this.http.post>(`${COUNTRY_ENDPOINTS.dataTable}`, query); + getCountryDataTable(query: DataTableQuery): Observable> { + return this.http.post>(`${COUNTRY_ENDPOINTS.dataTable}`, query); } -} \ No newline at end of file + + createCountry(request: CreateCountryRequest): Observable { + return this.http.post(COUNTRY_ENDPOINTS.create, request); + } + + updateCountry(id: string, request: UpdateCountryRequest): Observable { + return this.http.put(COUNTRY_ENDPOINTS.update(id), request); + } + + getCountryById(id: string): Observable { + return this.http.get(COUNTRY_ENDPOINTS.getById(id)); + } + + autocomplete(term = '', limit = 50): Observable { + return this.http.get(COUNTRY_ENDPOINTS.autocomplete, { + params: new HttpParams() + .set('term', term) + .set('limit', limit) + }); + } +} diff --git a/src/app/core/services/currency/currency.service.ts b/src/app/core/services/currency/currency.service.ts new file mode 100644 index 00000000..d1245378 --- /dev/null +++ b/src/app/core/services/currency/currency.service.ts @@ -0,0 +1,46 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { CURRENCY_ENDPOINTS } from '../../../core/end-points/currency/currency.endpoints'; +import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types'; +import { + CreateCurrencyRequest, + CurrencyDto, + CurrencyLookupDto, + UpdateCurrencyRequest +} from '../../models/currency/currency.model'; + +@Injectable({ + providedIn: 'root' +}) +export class CurrencyService { + private readonly http = inject(HttpClient); + + getCurrencyDataTable(query: DataTableQuery): Observable> { + return this.http.post>(CURRENCY_ENDPOINTS.dataTable, query); + } + + createCurrency(request: CreateCurrencyRequest): Observable { + return this.http.post(CURRENCY_ENDPOINTS.create, request); + } + + updateCurrency(id: string, request: UpdateCurrencyRequest): Observable { + return this.http.put(CURRENCY_ENDPOINTS.update(id), request); + } + + getCurrencyById(id: string): Observable { + return this.http.get(CURRENCY_ENDPOINTS.getById(id)); + } + + autocomplete(term: string | null, limit = 10): Observable { + let params = new HttpParams().set('limit', limit); + const normalizedTerm = term?.trim(); + + if (normalizedTerm) { + params = params.set('term', normalizedTerm); + } + + return this.http.get(CURRENCY_ENDPOINTS.autocomplete, { params }); + } +} diff --git a/src/app/core/services/language/language.service.ts b/src/app/core/services/language/language.service.ts new file mode 100644 index 00000000..c5aed5d8 --- /dev/null +++ b/src/app/core/services/language/language.service.ts @@ -0,0 +1,48 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { LANGUAGE_ENDPOINTS } from '../../end-points/language/language.endpoints'; +import { + CreateLanguageRequest, + LanguageDto, + LanguageLookupDto, + UpdateLanguageRequest +} from '../../models/language/language.model'; +import { + DataTableQuery, + DataTableResult +} from '../../../shared/components/data-table/data-table.types'; + +@Injectable({ providedIn: 'root' }) +export class LanguageService { + private readonly http = inject(HttpClient); + + getDataTable(query: DataTableQuery): Observable> { + return this.http.post>(LANGUAGE_ENDPOINTS.dataTable, query); + } + + getById(id: string): Observable { + return this.http.get(LANGUAGE_ENDPOINTS.getById(id)); + } + + create(request: CreateLanguageRequest): Observable { + return this.http.post(LANGUAGE_ENDPOINTS.create, request); + } + + update(id: string, request: UpdateLanguageRequest): Observable { + return this.http.put(LANGUAGE_ENDPOINTS.update(id), request); + } + + autocomplete( + term: string | null, + limit = 10 + ): Observable { + let params = new HttpParams().set('limit', limit); + if (term !== null) { + params = params.set('term', term); + } + + return this.http.get(LANGUAGE_ENDPOINTS.autocomplete, { params }); + } +} diff --git a/src/app/core/services/state/state.service.ts b/src/app/core/services/state/state.service.ts index e8990770..aceeb36d 100644 --- a/src/app/core/services/state/state.service.ts +++ b/src/app/core/services/state/state.service.ts @@ -1,8 +1,14 @@ -import { HttpClient } from "@angular/common/http"; +import { HttpClient, HttpParams } from "@angular/common/http"; import { Injectable, inject } from "@angular/core"; import { STATE_ENDPOINTS } from "../../end-points/state/state.endpoints" import { Observable } from "rxjs"; import { DataTableQuery, DataTableResult } from "../../../shared/components/data-table/data-table.types"; +import { + CreateStateRequest, + StateDto, + StateLookupDto, + UpdateStateRequest +} from "../../models/state/state.model"; @Injectable({ providedIn: 'root' @@ -11,7 +17,30 @@ export class StateService { private readonly http = inject(HttpClient); - getStateDataTable(query: DataTableQuery, countryId: string): Observable> { - return this.http.post>(`${STATE_ENDPOINTS.dataTable}`, { ...query, countryId }); + getStateDataTable(query: DataTableQuery, countryId: string): Observable> { + return this.http.post>(`${STATE_ENDPOINTS.dataTable}`, query, { + params: new HttpParams().set('countryId', countryId) + }); } -} \ No newline at end of file + + createState(request: CreateStateRequest): Observable { + return this.http.post(STATE_ENDPOINTS.create, request); + } + + updateState(id: string, request: UpdateStateRequest): Observable { + return this.http.put(STATE_ENDPOINTS.update(id), request); + } + + getStateById(id: string): Observable { + return this.http.get(STATE_ENDPOINTS.getById(id)); + } + + autocomplete(countryId: string, term = '', limit = 50): Observable { + return this.http.get(STATE_ENDPOINTS.autocomplete, { + params: new HttpParams() + .set('countryId', countryId) + .set('term', term) + .set('limit', limit) + }); + } +} diff --git a/src/app/core/services/timezone/timezone.service.ts b/src/app/core/services/timezone/timezone.service.ts new file mode 100644 index 00000000..7fcc1caa --- /dev/null +++ b/src/app/core/services/timezone/timezone.service.ts @@ -0,0 +1,45 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { TIMEZONE_ENDPOINTS } from '../../end-points/timezone/timezone.endpoints'; +import { + CreateTimezoneRequest, + TimezoneDto, + TimezoneLookupDto, + UpdateTimezoneRequest +} from '../../models/timezone/timezone.model'; +import { + DataTableQuery, + DataTableResult +} from '../../../shared/components/data-table/data-table.types'; + +@Injectable({ providedIn: 'root' }) +export class TimezoneService { + private readonly http = inject(HttpClient); + + getDataTable(query: DataTableQuery): Observable> { + return this.http.post>(TIMEZONE_ENDPOINTS.dataTable, query); + } + + getById(id: string): Observable { + return this.http.get(TIMEZONE_ENDPOINTS.getById(id)); + } + + create(request: CreateTimezoneRequest): Observable { + return this.http.post(TIMEZONE_ENDPOINTS.create, request); + } + + update(id: string, request: UpdateTimezoneRequest): Observable { + return this.http.put(TIMEZONE_ENDPOINTS.update(id), request); + } + + autocomplete(term: string | null, limit = 10): Observable { + const normalizedTerm = term?.trim() || null; + let params = new HttpParams().set('limit', limit); + if (normalizedTerm !== null) { + params = params.set('term', normalizedTerm); + } + return this.http.get(TIMEZONE_ENDPOINTS.autocomplete, { params }); + } +} diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.html b/src/app/features/global-masters/cities/pages/city-list/city-list.html index e122f1eb..abb7b310 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.html +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.html @@ -1 +1,165 @@ -

city-list works!

+ +
+
+
+
+
+
+
Location Selection
+
+ +
+
+ +
+ +
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.ts b/src/app/features/global-masters/cities/pages/city-list/city-list.ts index 68580794..ccaec871 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.ts +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.ts @@ -1,11 +1,561 @@ -import { Component } from '@angular/core'; +import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { + Subject, + catchError, + debounceTime, + distinctUntilChanged, + finalize, + map, + of, + switchMap, + take +} from 'rxjs'; + +import { + CityDto, + CityModalMode, + CreateCityRequest, + UpdateCityRequest +} from '../../../../../core/models/city/city.model'; +import { CountryLookupDto } from '../../../../../core/models/country/country.model'; +import { StateLookupDto } from '../../../../../core/models/state/state.model'; +import { TimezoneDto, TimezoneLookupDto } from '../../../../../core/models/timezone/timezone.model'; +import { CityService } from '../../../../../core/services/city/city.service'; +import { CountryService } from '../../../../../core/services/country/country.service'; +import { StateService } from '../../../../../core/services/state/state.service'; +import { TimezoneService } from '../../../../../core/services/timezone/timezone.service'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableSortEvent +} from '../../../../../shared/components/data-table/data-table.types'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteResolveValueFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +interface CityTableRow extends DataTableRecord { + id: string; + stateId: string; + name: string; + code: string | null; + timezoneId: string | null; + isActive: boolean; + serialNumber: number; + stateName: string; + countryName: string; + createdOn?: string; + modifiedOn?: string | null; +} @Component({ selector: 'city-list', - imports: [], + standalone: true, + imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete], templateUrl: './city-list.html', - styleUrl: './city-list.scss', + styleUrl: './city-list.scss' }) export class CityList { + private readonly destroyRef = inject(DestroyRef); + private readonly cityApi = inject(CityService); + private readonly countryApi = inject(CountryService); + private readonly stateApi = inject(StateService); + private readonly timezoneApi = inject(TimezoneService); + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly cityQueryRequests$ = new Subject(); + readonly queryState = new DataTableQueryState(); + readonly cities = signal([]); + readonly selectedCountryId = signal(null); + readonly selectedStateId = signal(null); + readonly selectedCountry = signal(null); + readonly selectedFilterState = signal(null); + readonly selectedFormCountry = signal(null); + readonly selectedFormState = signal(null); + readonly totalRecords = signal(0); + readonly saving = signal(false); + readonly showCityModal = signal(false); + readonly modalMode = signal('create'); + readonly selectedCity = signal(null); + readonly submitAttempted = signal(false); + + readonly filterForm = this.formBuilder.nonNullable.group({ + countryId: [''], + stateId: [{ value: '', disabled: true }] + }); + + readonly cityForm = this.formBuilder.nonNullable.group({ + countryId: ['', Validators.required], + stateId: [{ value: '', disabled: true }, Validators.required], + name: ['', [Validators.required, Validators.maxLength(150)]], + code: [ + '', + [ + Validators.required, + Validators.maxLength(16), + Validators.pattern(/^[A-Za-z0-9_-]+$/) + ] + ], + timezoneId: this.formBuilder.control(null) + }); + + readonly searchTimezones: AutocompleteSearchFn = + (term, limit) => this.timezoneApi.autocomplete(term, limit); + readonly searchCountries: AutocompleteSearchFn = + (term, limit) => this.countryApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load countries.'); + return of([]); + }) + ); + readonly searchFilterStates: AutocompleteSearchFn = + (term, limit) => { + const countryId = this.selectedCountryId(); + if (!countryId) return of([]); + return this.stateApi.autocomplete(countryId, term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load states.'); + return of([]); + }) + ); + }; + readonly searchFormStates: AutocompleteSearchFn = + (term, limit) => { + const countryId = this.cityForm.controls.countryId.value; + if (!countryId) return of([]); + return this.stateApi.autocomplete(countryId, term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load states.'); + return of([]); + }) + ); + }; + readonly displayCountry: AutocompleteDisplayFn = country => country.name; + readonly countryValue: AutocompleteValueFn = country => country.id; + readonly displayState: AutocompleteDisplayFn = state => state.name; + readonly stateValue: AutocompleteValueFn = state => state.id; + readonly displayTimezone: AutocompleteDisplayFn = + timezone => `${timezone.ianaId} — ${timezone.displayName}`; + readonly timezoneValue: AutocompleteValueFn = + timezone => timezone.id; + readonly resolveTimezone: AutocompleteResolveValueFn = + value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone))); + readonly filterStatePlaceholder = computed(() => + this.selectedCountryId() ? 'Search state' : 'Select a country first' + ); + readonly formStatePlaceholder = computed(() => + this.cityForm.controls.countryId.value ? 'Search state' : 'Select a country first' + ); + readonly canAddCity = computed(() => !!this.selectedStateId()); + readonly emptyMessage = computed(() => + this.selectedCountryId() && this.selectedStateId() + ? 'No cities found' + : 'Select a country and state' + ); + + readonly emptyDescription = computed(() => + this.selectedCountryId() && this.selectedStateId() + ? 'There are no cities available for the selected state.' + : 'Choose a country and state to view available cities.' + ); + readonly modalTitle = computed(() => { + const mode = this.modalMode(); + return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City'; + }); + readonly submitLabel = computed(() => + this.modalMode() === 'create' ? 'Save City' : 'Update City' + ); + readonly loadingLabel = computed(() => + this.modalMode() === 'create' ? 'Saving City...' : 'Updating City...' + ); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '70px' }, + { key: 'name', label: 'City Name', header: 'City Name', sortable: true, align: 'left' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true }, + { key: 'stateName', label: 'State', header: 'State', sortable: false }, + { key: 'countryName', label: 'Country', header: 'Country', sortable: false }, + { + key: 'isActive', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + width: '100px', + formatter: value => value ? 'Active' : 'Inactive', + badgeClass: value => value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger' + } + ]); + + readonly actions = signal[]>([ + { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, + { + type: 'deactivate', + label: 'Deactivate', + icon: 'ti ti-ban', + className: 'text-danger', + visible: row => row.isActive + }, + { + type: 'activate', + label: 'Activate', + icon: 'ti ti-check', + className: 'text-success', + visible: row => !row.isActive + } + ]); + + constructor() { + this.configureCityQueries(); + this.configureFilterChanges(); + this.configureFormChanges(); + } + + ngOnInit(): void {} + + onFilterCountrySelected(country: CountryLookupDto): void { + this.selectedCountry.set(country); + } + + onFilterCountryCleared(): void { + this.selectedCountry.set(null); + } + + onFilterStateSelected(state: StateLookupDto): void { + this.selectedFilterState.set(state); + } + + onFilterStateCleared(): void { + this.selectedFilterState.set(null); + } + + onFormCountrySelected(country: CountryLookupDto): void { + this.selectedFormCountry.set(country); + } + + onFormCountryCleared(): void { + this.selectedFormCountry.set(null); + } + + onFormStateSelected(state: StateLookupDto): void { + this.selectedFormState.set(state); + } + + onFormStateCleared(): void { + this.selectedFormState.set(null); + } + + loadCities(query: DataTableQuery): void { + if (!this.selectedStateId()) { + this.clearGrid(); + return; + } + this.cityQueryRequests$.next(query); + } + + onSearch(value: string): void { + this.loadCities(this.queryState.setSearch(value.trim())); + } + + onPageChange(event: DataTablePageEvent): void { + this.loadCities(this.queryState.setPage(event)); + } + + onSortChange(event: DataTableSortEvent): void { + this.loadCities(this.queryState.setSort(event)); + } + + onActionClick(event: DataTableActionEvent): void { + const city = this.toCityDto(event.row); + switch (event.action.type) { + case 'edit': + this.openExistingCity(city, 'edit', { + id: event.row.stateId, + name: event.row.stateName, + code: '' + }); + break; + case 'activate': + this.updateCityStatus(city, true); + break; + case 'deactivate': + this.updateCityStatus(city, false); + break; + } + } + + onAddCity(): void { + const countryId = this.selectedCountryId(); + const stateId = this.selectedStateId(); + if (!countryId || !stateId) { + this.toastr.error('Select a country and state before adding a city.'); + return; + } + + this.modalMode.set('create'); + this.selectedCity.set(null); + this.submitAttempted.set(false); + this.selectedFormCountry.set(this.selectedCountry()); + this.selectedFormState.set(this.selectedFilterState()); + this.cityForm.enable({ emitEvent: false }); + this.cityForm.reset({ countryId, stateId, name: '', code: '', timezoneId: null }, { emitEvent: false }); + this.cityForm.controls.stateId.enable({ emitEvent: false }); + this.resetFormState(); + this.showCityModal.set(true); + } + + closeCityModal(): void { + if (this.saving()) return; + this.showCityModal.set(false); + this.selectedCity.set(null); + this.selectedFormCountry.set(null); + this.selectedFormState.set(null); + this.submitAttempted.set(false); + } + + saveCity(): void { + if (this.saving()) return; + if (this.cityForm.invalid) { + this.submitAttempted.set(true); + this.cityForm.markAllAsTouched(); + this.focusFirstInvalidControl(); + return; + } + + this.saving.set(true); + const city = this.selectedCity(); + const request$ = this.modalMode() === 'create' + ? this.cityApi.createCity(this.buildCreateRequest()) + : city + ? this.cityApi.updateCity(city.id, this.buildUpdateRequest(city.isActive)) + : null; + + if (!request$) { + this.saving.set(false); + return; + } + + request$.pipe(finalize(() => this.saving.set(false))).subscribe({ + next: () => { + this.toastr.success( + this.modalMode() === 'create' + ? 'City saved successfully.' + : 'City updated successfully.' + ); + this.showCityModal.set(false); + this.selectedCity.set(null); + this.loadCities(this.queryState.getQuery()); + } + }); + } + + private configureCityQueries(): void { + this.cityQueryRequests$.pipe( + switchMap(query => { + const stateId = this.selectedStateId(); + return stateId + ? this.cityApi.getCityDataTable(query, stateId).pipe( + catchError(() => { + this.toastr.error('Unable to load cities.'); + this.clearGrid(); + return of(null); + }) + ) + : of(null); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(response => { + if (!response) return; + const query = this.queryState.getQuery(); + if (response.draw !== query.draw) return; + + const stateName = this.selectedFilterState()?.name ?? '—'; + const countryName = this.selectedCountry()?.name ?? '—'; + this.cities.set(response.rows.map((city, index) => ({ + ...city, + serialNumber: (query.page - 1) * query.pageSize + index + 1, + stateName, + countryName + }))); + this.totalRecords.set(response.filtered); + }); + } + + private configureFilterChanges(): void { + this.filterForm.controls.countryId.valueChanges.pipe( + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ).subscribe(countryId => { + if (!countryId || this.selectedCountry()?.id !== countryId) { + this.selectedCountry.set(null); + } + + this.selectedCountryId.set(countryId || null); + this.selectedStateId.set(null); + this.selectedFilterState.set(null); + + this.filterForm.controls.stateId.reset('', { emitEvent: false }); + countryId + ? this.filterForm.controls.stateId.enable({ emitEvent: false }) + : this.filterForm.controls.stateId.disable({ emitEvent: false }); + + this.clearGrid(); + this.queryState.reset(); + }); + + this.filterForm.controls.stateId.valueChanges.pipe( + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ).subscribe(stateId => { + if (!stateId || this.selectedFilterState()?.id !== stateId) { + this.selectedFilterState.set(null); + } + + this.selectedStateId.set(stateId || null); + this.clearGrid(); + const query = this.queryState.reset(); + if (stateId) this.loadCities(query); + }); + } + + private configureFormChanges(): void { + this.cityForm.controls.countryId.valueChanges.pipe( + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ).subscribe(countryId => { + if (!this.showCityModal() || this.modalMode() !== 'create') return; + + if (!countryId || this.selectedFormCountry()?.id !== countryId) { + this.selectedFormCountry.set(null); + } + + this.selectedFormState.set(null); + this.cityForm.controls.stateId.reset('', { emitEvent: false }); + countryId + ? this.cityForm.controls.stateId.enable({ emitEvent: false }) + : this.cityForm.controls.stateId.disable({ emitEvent: false }); + }); + } + + private openExistingCity(city: CityDto, mode: 'edit', stateSeed?: StateLookupDto): void { + this.cityApi.getCityById(city.id).pipe( + take(1) + ).subscribe(details => { + this.selectedCity.set(details); + this.modalMode.set(mode); + this.submitAttempted.set(false); + this.selectedFormCountry.set(this.selectedCountry()); + this.selectedFormState.set( + stateSeed + ?? (this.selectedFilterState()?.id === details.stateId ? this.selectedFilterState() : null) + ); + this.cityForm.enable({ emitEvent: false }); + this.cityForm.reset({ + countryId: this.selectedCountryId() ?? '', + stateId: details.stateId, + name: details.name ?? '', + code: details.code ?? '', + timezoneId: details.timezoneId + }, { emitEvent: false }); + this.cityForm.controls.countryId.disable({ emitEvent: false }); + this.cityForm.controls.stateId.disable({ emitEvent: false }); + this.resetFormState(); + this.showCityModal.set(true); + }); + } + + private updateCityStatus(city: CityDto, isActive: boolean): void { + this.cityApi.updateCity(city.id, { + name: city.name.trim(), + code: city.code?.trim().toUpperCase() ?? '', + timezoneId: city.timezoneId, + isActive + }).subscribe(() => { + this.toastr.success( + isActive ? 'City activated successfully.' : 'City deactivated successfully.' + ); + this.loadCities(this.queryState.getQuery()); + }); + } + + private buildCreateRequest(): CreateCityRequest { + const value = this.cityForm.getRawValue(); + return { + stateId: value.stateId, + name: value.name.trim(), + code: value.code.trim().toUpperCase(), + timezoneId: value.timezoneId + }; + } + + private buildUpdateRequest(isActive: boolean): UpdateCityRequest { + const value = this.cityForm.getRawValue(); + return { + name: value.name.trim(), + code: value.code.trim().toUpperCase(), + timezoneId: value.timezoneId, + isActive + }; + } + + private clearGrid(): void { + this.cities.set([]); + this.totalRecords.set(0); + } + + private resetFormState(): void { + this.cityForm.markAsPristine(); + this.cityForm.markAsUntouched(); + this.cityForm.updateValueAndValidity(); + } + + private focusFirstInvalidControl(): void { + queueMicrotask(() => { + const control = this.elementRef.nativeElement.querySelector( + 'modal .form-control.is-invalid, modal [aria-invalid="true"]' + ); + control?.focus(); + control?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + } + + private toCityDto(row: CityTableRow): CityDto { + return { + id: row.id, + stateId: row.stateId, + name: row.name, + code: row.code, + timezoneId: row.timezoneId, + isActive: row.isActive, + createdOn: row.createdOn, + modifiedOn: row.modifiedOn + }; + } + + private toTimezoneLookup(timezone: TimezoneDto): TimezoneLookupDto { + return { + id: timezone.id, + ianaId: timezone.ianaId, + displayName: timezone.displayName + }; + } } diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.html b/src/app/features/global-masters/countries/pages/country-list/country-list.html index 34eb31b1..deffafe6 100644 --- a/src/app/features/global-masters/countries/pages/country-list/country-list.html +++ b/src/app/features/global-masters/countries/pages/country-list/country-list.html @@ -1,9 +1,9 @@ - + (actionClicked)="onActionClick($event)" toolTip="Add Country">
@if (getFlagUrl(row.iso2); as flagUrl) { @@ -18,85 +18,78 @@ - - + +
+ }" [submitAttempted]="countrySubmitAttempted()" />
- +
- + }" [submitAttempted]="countrySubmitAttempted()" />
- + }" [submitAttempted]="countrySubmitAttempted()" />
-
- -
- + inputMode="tel" placeholder="e.g.: +91" autocomplete="off" [maxLength]="16" [validationMessages]="{ + maxlength: 'Phone Code cannot exceed 16 characters.', + pattern: 'Phone Code can contain an optional plus sign, digits, hyphens, and spaces only.' + }" [submitAttempted]="countrySubmitAttempted()" />
- - \ No newline at end of file diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.ts b/src/app/features/global-masters/countries/pages/country-list/country-list.ts index dfff1bc3..19ec664c 100644 --- a/src/app/features/global-masters/countries/pages/country-list/country-list.ts +++ b/src/app/features/global-masters/countries/pages/country-list/country-list.ts @@ -1,52 +1,131 @@ -import { Component, ElementRef, viewChild } from '@angular/core'; -import { inject, signal, computed } from '@angular/core'; -import type { CountryModalMode } from '../../../../../core/models/country/country.model'; -import { CountryService } from '../../../../../core/services/country/country.service'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; -import { DataTablePageEvent, DataTableSortEvent, DataTableQuery, DataTableColumn, DataTableAction, DataTableActionEvent } from '../../../../../shared/components/data-table/data-table.types'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; -import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; -import { finalize } from 'rxjs/operators'; -import { Modal } from '../../../../../shared/components/modal/modal'; +import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs'; -import { Button } from '../../../../../shared/components/button/button'; +import { + CountryDto, + CountryModalMode, + CreateCountryRequest, + UpdateCountryRequest +} from '../../../../../core/models/country/country.model'; +import { CurrencyLookupDto } from '../../../../../core/models/currency/currency.model'; +import { CountryService } from '../../../../../core/services/country/country.service'; +import { CurrencyService } from '../../../../../core/services/currency/currency.service'; +import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableSortEvent +} from '../../../../../shared/components/data-table/data-table.types'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { CountryDto } from '../../../../../core/models/country/country.model'; - +import { Modal } from '../../../../../shared/components/modal/modal'; +import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +interface CountryTableRow extends DataTableRecord { + id: string; + iso2: string; + iso3: string; + name: string; + phoneCode: string | null; + defaultCurrencyId: string | null; + isActive: boolean; + serialNumber: number; + createdOn?: string; + modifiedOn?: string | null; +} @Component({ selector: 'country-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, Button, - FormInput], + imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog], templateUrl: './country-list.html', styleUrl: './country-list.scss', }) - - export class CountryList { - - private readonly countryApi: CountryService = inject(CountryService); + private readonly destroyRef = inject(DestroyRef); + private readonly countryApi = inject(CountryService); + private readonly currencyApi = inject(CurrencyService); private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly countryQueryRequests$ = new Subject(); readonly queryState = new DataTableQueryState(); - readonly countries = signal([]); + readonly countries = signal([]); readonly totalRecords = signal(0); readonly filteredRecords = signal(0); - readonly loading = signal(false); - readonly modalMode = signal('create'); + readonly saving = signal(false); readonly showCountryModal = signal(false); readonly countryModalMode = signal('create'); readonly selectedCountryId = signal(null); - readonly saving = signal(false); + readonly selectedCountry = signal(null); + readonly selectedCurrency = signal(null); + readonly countrySubmitAttempted = signal(false); + readonly pendingDeleteCountry = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); + + readonly countryForm = this.formBuilder.nonNullable.group({ + name: [ + '', + [ + Validators.required, + Validators.maxLength(150) + ] + ], + iso2: [ + '', + [ + Validators.required, + Validators.pattern(/^[A-Za-z]{2}$/) + ] + ], + iso3: [ + '', + [ + Validators.required, + Validators.pattern(/^[A-Za-z]{3}$/) + ] + ], + phoneCode: [ + '', + [ + Validators.maxLength(16), + Validators.pattern(/^\+?[0-9\- ]{1,15}$/) + ] + ], + defaultCurrencyId: this.formBuilder.control(null) + }); + + readonly searchCurrencies: AutocompleteSearchFn = + (term, limit) => this.currencyApi.autocomplete(term, limit); + readonly displayCurrency: AutocompleteDisplayFn = currency => { + const baseLabel = [currency.code, currency.name].filter(Boolean).join(' - '); + + return currency.symbol?.trim() + ? `${baseLabel} (${currency.symbol})` + : baseLabel; + }; + readonly currencyValue: AutocompleteValueFn = currency => currency.id; readonly countryModalTitle = computed(() => this.countryModalMode() === 'create' @@ -54,12 +133,6 @@ export class CountryList { : 'Edit Country' ); - readonly countryModalSubtitle = computed(() => - this.countryModalMode() === 'create' - ? 'Enter the country details below.' - : 'Update the country details below.' - ); - readonly countrySubmitLabel = computed(() => this.countryModalMode() === 'create' ? 'Save Country' @@ -78,132 +151,91 @@ export class CountryList { : 'update' ); - - - readonly countryForm = this.formBuilder.nonNullable.group({ - name: [ - '', - [ - Validators.required, - Validators.maxLength(150) - ] - ], - - iso2: [ - '', - [ - Validators.required, - Validators.pattern(/^[A-Za-z]{2}$/) - ] - ], - - iso3: [ - '', - [ - Validators.required, - Validators.pattern(/^[A-Za-z]{3}$/) - ] - ], - - phoneCode: [ - '', - [ - Validators.maxLength(20), - Validators.pattern(/^\+?[0-9]*$/) - ] - ], - currency: [ - '', - [ - Validators.maxLength(3), - Validators.pattern(/^[A-Za-z]{3}$/) - ] - ], - - - defaultCurrencyId: - this.formBuilder.control(null) - }); - - - readonly columns = signal([ - { key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '100px' }, + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, { key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true }, { key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true }, { key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true }, { - key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, badgeClass: value => + key: 'isActive', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', - formatter: (value) => value ? 'Active' : 'Inactive' + formatter: value => value ? 'Active' : 'Inactive' } ]); - readonly actions = signal([ + readonly actions = signal[]>([ { type: 'edit', label: 'Edit', - icon: 'ti ti-edit ti-btn-info', - className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-info me-2' + icon: 'ti ti-edit', + className: 'text-primary' }, { type: 'delete', label: 'Delete', - icon: 'ti ti-trash ti-btn-danger', - className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-danger me-2', - visible: (row: any) => row.isActive + icon: 'ti ti-trash', + className: 'text-danger', + visible: row => row.isActive }, { type: 'activate', label: 'Activate', - icon: 'ti ti-check ti-btn-success', - className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-success me-2', - visible: (row: any) => !row.isActive - }, + icon: 'ti ti-check', + className: 'text-success', + visible: row => !row.isActive + } ]); + constructor() { + this.countryQueryRequests$ + .pipe( + switchMap(query => + this.countryApi.getCountryDataTable(query).pipe( + catchError(() => { + this.toastr.error('Unable to load countries.'); + this.clearCountryGrid(); + return of(null); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(response => { + if (!response) { + return; + } + + const query = this.queryState.getQuery(); + + if (response.draw !== query.draw) { + return; + } + + const countriesWithSerialNumbers: CountryTableRow[] = response.rows.map((country, index) => ({ + ...country, + serialNumber: (query.page - 1) * query.pageSize + index + 1 + })); + + this.countries.set(countriesWithSerialNumbers); + this.totalRecords.set(response.total); + this.filteredRecords.set(response.filtered); + }); + } + ngOnInit(): void { this.loadCountries(this.queryState.getQuery()); } - loadCountries(query: DataTableQuery): void { - this.loading.set(true); - - this.countryApi - .getCountryDataTable(query) - .pipe( - finalize(() => { - this.loading.set(false); - }) - ) - .subscribe({ - next: (response: any) => { - console.log('Country data loaded:', response); - if (response.draw !== this.queryState.getQuery().draw) { - return; - } - - // Add serial numbers to countries - const countriesWithSerialNumbers = response.rows.map((country: any, index: number) => ({ - ...country, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.countries.set(countriesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }, - error: (error: any) => { - console.error('Unable to load countries.', error); - - this.countries.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - }); + this.countryQueryRequests$.next(query); } onSearch(value: string): void { @@ -224,12 +256,10 @@ export class CountryList { onRefresh(): void { const currentQuery = this.queryState.getQuery(); - const query: DataTableQuery = { + this.loadCountries({ ...currentQuery, draw: currentQuery.draw + 1 - }; - - this.loadCountries(query); + }); } onReset(): void { @@ -237,11 +267,25 @@ export class CountryList { this.loadCountries(query); } - onActionClick(event: DataTableActionEvent): void { - const action = event.action.type; - const country = event.row; + onDeleteConfirmed(): void { + const country = this.pendingDeleteCountry(); - switch (action) { + if (!country) { + return; + } + + this.pendingDeleteCountry.set(null); + this.deleteCountry(country); + } + + onDeleteCancelled(): void { + this.pendingDeleteCountry.set(null); + } + + onActionClick(event: DataTableActionEvent): void { + const country = this.toCountryDto(event.row); + + switch (event.action.type) { case 'view': this.viewCountry(country); break; @@ -249,7 +293,7 @@ export class CountryList { this.openEditCountry(country); break; case 'delete': - this.deleteCountry(country); + this.requestDeleteCountry(country); break; case 'activate': this.activateCountry(country); @@ -260,6 +304,9 @@ export class CountryList { onAddCountry(): void { this.countryModalMode.set('create'); this.selectedCountryId.set(null); + this.selectedCountry.set(null); + this.selectedCurrency.set(null); + this.countrySubmitAttempted.set(false); this.countryForm.reset({ name: '', @@ -268,6 +315,7 @@ export class CountryList { phoneCode: '', defaultCurrencyId: null }); + this.resetCountryFormState(); this.showCountryModal.set(true); } @@ -279,52 +327,102 @@ export class CountryList { this.showCountryModal.set(false); this.selectedCountryId.set(null); + this.selectedCountry.set(null); + this.selectedCurrency.set(null); + this.countrySubmitAttempted.set(false); } + saveCountry(): void { if (this.countryForm.invalid) { + this.countrySubmitAttempted.set(true); this.countryForm.markAllAsTouched(); + this.focusFirstInvalidCountryControl(); + return; + } + + if (this.saving()) { return; } this.saving.set(true); - const request = this.countryForm.getRawValue(); + if (this.countryModalMode() === 'create') { + this.countryApi + .createCountry(this.buildCreateCountryRequest()) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastr.success('Country saved successfully.'); + this.finishCountrySave(); + } + }); - // Replace with the actual API request. - console.log(request); + return; + } - this.saving.set(false); - this.showCountryModal.set(false); - } + const countryId = this.selectedCountryId(); - private viewCountry(country: any): void { - console.log('Viewing country:', country); - // TODO: Implement view logic (open modal, navigate to details page, etc.) + if (!countryId) { + this.saving.set(false); + return; + } + + this.countryApi + .updateCountry( + countryId, + this.buildUpdateCountryRequest(this.selectedCountry()?.isActive ?? true) + ) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastr.success('Country updated successfully.'); + this.finishCountrySave(); + } + }); } openEditCountry(country: CountryDto): void { this.countryModalMode.set('edit'); this.selectedCountryId.set(country.id); + this.selectedCountry.set(null); + this.selectedCurrency.set(null); + this.countrySubmitAttempted.set(false); - this.countryForm.reset({ - name: country.name ?? '', - iso2: country.iso2 ?? '', - iso3: country.iso3 ?? '', - phoneCode: country.phoneCode ?? '', - defaultCurrencyId: country.defaultCurrencyId ?? null - }); + this.countryApi + .getCountryById(country.id) + .pipe( + switchMap(countryDetails => { + const currencyId = countryDetails.defaultCurrencyId; - this.showCountryModal.set(true); - } + if (!currencyId) { + return of({ countryDetails, currency: null }); + } - private deleteCountry(country: any): void { - console.log('Deleting country:', country); - // TODO: Implement delete logic (API call to delete country) - } + return this.currencyApi.getCurrencyById(currencyId).pipe( + map(currency => ({ countryDetails, currency })), + catchError(() => { + this.toastr.error('Unable to load the selected currency.'); + return of({ countryDetails, currency: null }); + }) + ); + }) + ) + .subscribe({ + next: ({ countryDetails, currency }) => { + this.selectedCountry.set(countryDetails); + this.selectedCurrency.set(currency); + this.countryForm.reset({ + name: countryDetails.name ?? '', + iso2: countryDetails.iso2 ?? '', + iso3: countryDetails.iso3 ?? '', + phoneCode: countryDetails.phoneCode ?? '', + defaultCurrencyId: countryDetails.defaultCurrencyId ?? null + }); + this.resetCountryFormState(); - private activateCountry(country: any): void { - console.log('Activating country:', country); - // TODO: Implement activate logic (API call to activate country) + this.showCountryModal.set(true); + } + }); } getFlagUrl(iso2: string | null | undefined): string { @@ -340,4 +438,122 @@ export class CountryList { image.style.display = 'none'; } + private viewCountry(country: CountryDto): void { + this.openEditCountry(country); + } + + private requestDeleteCountry(country: CountryDto): void { + this.pendingDeleteCountry.set(country); + this.deleteConfirmDialog()?.open(); + } + + private deleteCountry(country: CountryDto): void { + this.updateCountryStatus(country, false); + } + + private activateCountry(country: CountryDto): void { + this.updateCountryStatus(country, true); + } + + private buildCreateCountryRequest(): CreateCountryRequest { + const value = this.countryForm.getRawValue(); + + return { + name: value.name.trim(), + iso2: value.iso2.trim().toUpperCase(), + iso3: value.iso3.trim().toUpperCase(), + phoneCode: this.nullWhenBlank(value.phoneCode), + defaultCurrencyId: this.nullWhenBlank(value.defaultCurrencyId) + }; + } + + private buildUpdateCountryRequest(isActive: boolean): UpdateCountryRequest { + return { + ...this.buildCreateCountryRequest(), + isActive + }; + } + + private countryToUpdateRequest(country: CountryDto, isActive: boolean): UpdateCountryRequest { + return { + name: country.name?.trim() ?? '', + iso2: country.iso2?.trim().toUpperCase() ?? '', + iso3: country.iso3?.trim().toUpperCase() ?? '', + phoneCode: this.nullWhenBlank(country.phoneCode), + defaultCurrencyId: this.nullWhenBlank(country.defaultCurrencyId), + isActive + }; + } + + private updateCountryStatus(country: CountryDto, isActive: boolean): void { + this.countryApi + .updateCountry(country.id, this.countryToUpdateRequest(country, isActive)) + .subscribe({ + next: () => { + this.toastr.success( + isActive + ? 'Country activated successfully.' + : 'Country deactivated successfully.' + ); + this.loadCountries(this.queryState.getQuery()); + } + }); + } + + private resetCountryFormState(): void { + this.countryForm.markAsPristine(); + this.countryForm.markAsUntouched(); + this.countryForm.updateValueAndValidity(); + } + + private finishCountrySave(): void { + this.showCountryModal.set(false); + this.selectedCountryId.set(null); + this.selectedCountry.set(null); + this.countrySubmitAttempted.set(false); + this.loadCountries(this.queryState.getQuery()); + } + + private clearCountryGrid(): void { + this.countries.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + } + + private focusFirstInvalidCountryControl(): void { + queueMicrotask(() => { + const firstInvalidControl = + this.elementRef.nativeElement.querySelector( + 'modal .form-control.is-invalid, modal [aria-invalid="true"]' + ); + + firstInvalidControl?.focus(); + firstInvalidControl?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }); + } + + private nullWhenBlank(value: string | null | undefined): string | null { + const normalized = value?.trim(); + + return normalized + ? normalized + : null; + } + + private toCountryDto(row: CountryTableRow): CountryDto { + return { + id: row.id, + iso2: row.iso2, + iso3: row.iso3, + name: row.name, + phoneCode: row.phoneCode, + defaultCurrencyId: row.defaultCurrencyId, + isActive: row.isActive, + createdOn: row.createdOn, + modifiedOn: row.modifiedOn + }; + } } diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html new file mode 100644 index 00000000..63c60709 --- /dev/null +++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html @@ -0,0 +1,160 @@ + + +
+ + {{ row.symbol }} + + + + {{ value }} + +
+
+ + + + {{ value }} + + +
+ + + + +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.scss b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts new file mode 100644 index 00000000..7ab5c7e1 --- /dev/null +++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts @@ -0,0 +1,507 @@ +import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + FormBuilder, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; + +import { + CreateCurrencyRequest, + CurrencyDto, + CurrencyModalMode, + UpdateCurrencyRequest +} from '../../../../../core/models/currency/currency.model'; +import { CurrencyService } from '../../../../../core/services/currency/currency.service'; +import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableSortEvent +} from '../../../../../shared/components/data-table/data-table.types'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../../shared/components/modal/modal'; +import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; + +interface CurrencyTableRow extends DataTableRecord { + id: string; + code: string; + name: string; + symbol: string; + numericCode: number; + decimalDigits: number; + isActive: boolean; + serialNumber: number; + createdOn?: string; + modifiedOn?: string | null; +} + +@Component({ + selector: 'currency-list', + standalone: true, + imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog], + templateUrl: './currency-list.html', + styleUrl: './currency-list.scss', +}) +export class CurrencyList { + private readonly destroyRef = inject(DestroyRef); + private readonly currencyApi = inject(CurrencyService); + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly currencyQueryRequests$ = new Subject(); + + readonly queryState = new DataTableQueryState(); + + readonly currencies = signal([]); + readonly totalRecords = signal(0); + readonly filteredRecords = signal(0); + readonly saving = signal(false); + + readonly showCurrencyModal = signal(false); + readonly currencyModalMode = signal('create'); + readonly selectedCurrencyId = signal(null); + readonly selectedCurrency = signal(null); + readonly currencySubmitAttempted = signal(false); + readonly pendingDeleteCurrency = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); + + readonly currencyForm = this.formBuilder.nonNullable.group({ + name: [ + '', + [ + Validators.required, + Validators.maxLength(100) + ] + ], + code: [ + '', + [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(3), + Validators.pattern(/^[A-Za-z]{3}$/) + ] + ], + symbol: [ + '', + [ + Validators.required, + Validators.maxLength(8) + ] + ], + numericCode: [ + 0, + [ + Validators.required, + Validators.min(1), + Validators.max(999) + ] + ], + decimalDigits: [ + 2, + [ + Validators.required, + Validators.min(0), + Validators.max(4) + ] + ] + }); + + readonly currencyModalTitle = computed(() => + this.currencyModalMode() === 'create' + ? 'Add Currency' + : 'Edit Currency' + ); + + readonly currencySubmitLabel = computed(() => + this.currencyModalMode() === 'create' + ? 'Save Currency' + : 'Update Currency' + ); + + readonly currencyLoadingLabel = computed(() => + this.currencyModalMode() === 'create' + ? 'Saving Currency...' + : 'Updating Currency...' + ); + + readonly currencySubmitAction = computed<'save' | 'update'>(() => + this.currencyModalMode() === 'create' + ? 'save' + : 'update' + ); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, + { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true }, + { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true }, + { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true }, + { key: 'decimalDigits', label: 'Decimal Digits', header: 'Decimal Digits', sortable: true }, + { + key: 'isActive', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => + value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger', + formatter: value => value ? 'Active' : 'Inactive' + } + ]); + + readonly actions = signal[]>([ + { + type: 'edit', + label: 'Edit', + icon: 'ti ti-edit', + className: 'text-primary' + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger', + visible: row => row.isActive + }, + { + type: 'activate', + label: 'Activate', + icon: 'ti ti-check', + className: 'text-success', + visible: row => !row.isActive + } + ]); + + constructor() { + this.currencyQueryRequests$ + .pipe( + switchMap(query => + this.currencyApi.getCurrencyDataTable(query).pipe( + catchError(() => { + this.toastr.error('Unable to load currencies.'); + this.clearCurrencyGrid(); + return of(null); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(response => { + if (!response) { + return; + } + + const query = this.queryState.getQuery(); + + if (response.draw !== query.draw) { + return; + } + + const currenciesWithSerialNumbers: CurrencyTableRow[] = response.rows.map((currency, index) => ({ + ...currency, + serialNumber: (query.page - 1) * query.pageSize + index + 1 + })); + + this.currencies.set(currenciesWithSerialNumbers); + this.totalRecords.set(response.total); + this.filteredRecords.set(response.filtered); + }); + } + + ngOnInit(): void { + this.loadCurrencies(this.queryState.getQuery()); + } + + loadCurrencies(query: DataTableQuery): void { + this.currencyQueryRequests$.next(query); + } + + onSearch(value: string): void { + const query = this.queryState.setSearch(value.trim()); + this.loadCurrencies(query); + } + + onPageChange(event: DataTablePageEvent): void { + const query = this.queryState.setPage(event); + this.loadCurrencies(query); + } + + onSortChange(event: DataTableSortEvent): void { + const query = this.queryState.setSort(event); + this.loadCurrencies(query); + } + + onRefresh(): void { + const currentQuery = this.queryState.getQuery(); + + this.loadCurrencies({ + ...currentQuery, + draw: currentQuery.draw + 1 + }); + } + + onReset(): void { + const query = this.queryState.reset(); + this.loadCurrencies(query); + } + + onDeleteConfirmed(): void { + const currency = this.pendingDeleteCurrency(); + + if (!currency) { + return; + } + + this.pendingDeleteCurrency.set(null); + this.deleteCurrency(currency); + } + + onDeleteCancelled(): void { + this.pendingDeleteCurrency.set(null); + } + + onActionClick(event: DataTableActionEvent): void { + const currency = this.toCurrencyDto(event.row); + + switch (event.action.type) { + case 'view': + this.viewCurrency(currency); + break; + case 'edit': + this.openEditCurrency(currency); + break; + case 'delete': + this.requestDeleteCurrency(currency); + break; + case 'activate': + this.activateCurrency(currency); + break; + } + } + + onAddCurrency(): void { + this.currencyModalMode.set('create'); + this.selectedCurrencyId.set(null); + this.selectedCurrency.set(null); + this.currencySubmitAttempted.set(false); + + this.currencyForm.reset({ + name: '', + code: '', + symbol: '', + numericCode: 0, + decimalDigits: 2 + }); + this.resetCurrencyFormState(); + + this.showCurrencyModal.set(true); + } + + closeCurrencyModal(): void { + if (this.saving()) { + return; + } + + this.showCurrencyModal.set(false); + this.selectedCurrencyId.set(null); + this.selectedCurrency.set(null); + this.currencySubmitAttempted.set(false); + } + + saveCurrency(): void { + if (this.currencyForm.invalid) { + this.currencySubmitAttempted.set(true); + this.currencyForm.markAllAsTouched(); + this.focusFirstInvalidCurrencyControl(); + return; + } + + if (this.saving()) { + return; + } + + this.saving.set(true); + + if (this.currencyModalMode() === 'create') { + this.currencyApi + .createCurrency(this.buildCreateCurrencyRequest()) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastr.success('Currency saved successfully.'); + this.finishCurrencySave(); + } + }); + + return; + } + + const currencyId = this.selectedCurrencyId(); + + if (!currencyId) { + this.saving.set(false); + return; + } + + this.currencyApi + .updateCurrency( + currencyId, + this.buildUpdateCurrencyRequest(this.selectedCurrency()?.isActive ?? true) + ) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastr.success('Currency updated successfully.'); + this.finishCurrencySave(); + } + }); + } + + private viewCurrency(currency: CurrencyDto): void { + this.openEditCurrency(currency); + } + + private openEditCurrency(currency: CurrencyDto): void { + this.currencyModalMode.set('edit'); + this.selectedCurrencyId.set(currency.id); + this.selectedCurrency.set(currency); + this.currencySubmitAttempted.set(false); + + this.currencyApi + .getCurrencyById(currency.id) + .subscribe({ + next: currencyDetails => { + this.selectedCurrency.set(currencyDetails); + this.currencyForm.reset({ + name: currencyDetails.name ?? '', + code: currencyDetails.code ?? '', + symbol: currencyDetails.symbol ?? '', + numericCode: currencyDetails.numericCode ?? 0, + decimalDigits: currencyDetails.decimalDigits ?? 2 + }); + this.resetCurrencyFormState(); + + this.showCurrencyModal.set(true); + } + }); + } + + private requestDeleteCurrency(currency: CurrencyDto): void { + this.pendingDeleteCurrency.set(currency); + this.deleteConfirmDialog()?.open(); + } + + private deleteCurrency(currency: CurrencyDto): void { + this.updateCurrencyStatus(currency, false); + } + + private activateCurrency(currency: CurrencyDto): void { + this.updateCurrencyStatus(currency, true); + } + + private buildCreateCurrencyRequest(): CreateCurrencyRequest { + const value = this.currencyForm.getRawValue(); + + return { + name: value.name.trim(), + code: value.code.trim().toUpperCase(), + symbol: value.symbol.trim(), + numericCode: value.numericCode, + decimalDigits: value.decimalDigits + }; + } + + private buildUpdateCurrencyRequest(isActive: boolean): UpdateCurrencyRequest { + return { + ...this.buildCreateCurrencyRequest(), + isActive + }; + } + + private currencyToUpdateRequest(currency: CurrencyDto, isActive: boolean): UpdateCurrencyRequest { + return { + name: currency.name?.trim() ?? '', + code: currency.code?.trim().toUpperCase() ?? '', + symbol: currency.symbol?.trim() ?? '', + numericCode: currency.numericCode, + decimalDigits: currency.decimalDigits, + isActive + }; + } + + private updateCurrencyStatus(currency: CurrencyDto, isActive: boolean): void { + this.currencyApi + .updateCurrency(currency.id, this.currencyToUpdateRequest(currency, isActive)) + .subscribe({ + next: () => { + this.toastr.success( + isActive + ? 'Currency activated successfully.' + : 'Currency deactivated successfully.' + ); + this.loadCurrencies(this.queryState.getQuery()); + } + }); + } + + private resetCurrencyFormState(): void { + this.currencyForm.markAsPristine(); + this.currencyForm.markAsUntouched(); + this.currencyForm.updateValueAndValidity(); + } + + private finishCurrencySave(): void { + this.showCurrencyModal.set(false); + this.selectedCurrencyId.set(null); + this.selectedCurrency.set(null); + this.currencySubmitAttempted.set(false); + this.loadCurrencies(this.queryState.getQuery()); + } + + private clearCurrencyGrid(): void { + this.currencies.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + } + + private focusFirstInvalidCurrencyControl(): void { + queueMicrotask(() => { + const firstInvalidControl = + this.elementRef.nativeElement.querySelector( + 'modal .form-control.is-invalid, modal [aria-invalid="true"]' + ); + + firstInvalidControl?.focus(); + firstInvalidControl?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }); + } + + private toCurrencyDto(row: CurrencyTableRow): CurrencyDto { + return { + id: row.id, + code: row.code, + name: row.name, + symbol: row.symbol, + numericCode: row.numericCode, + decimalDigits: row.decimalDigits, + isActive: row.isActive, + createdOn: row.createdOn, + modifiedOn: row.modifiedOn + }; + } +} diff --git a/src/app/features/global-masters/global-masters.routes.ts b/src/app/features/global-masters/global-masters.routes.ts index 666207af..35cf4e8e 100644 --- a/src/app/features/global-masters/global-masters.routes.ts +++ b/src/app/features/global-masters/global-masters.routes.ts @@ -4,16 +4,31 @@ export const globalMastersRoutes: Routes = [ { path: 'countries', loadComponent: () => import('./countries/pages/country-list/country-list').then((m) => m.CountryList), - data: { childTitle: 'Country Management', parentTitle: 'Platform', subParentTitle: 'Configuration' }, + data: { childTitle: 'Country Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, }, { path: 'states', loadComponent: () => import('./states/pages/state-list/state-list').then((m) => m.StateList), - data: { childTitle: 'State Management', parentTitle: 'Platform', subParentTitle: 'Configuration' }, + data: { childTitle: 'State Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, }, { path: 'cities', loadComponent: () => import('./cities/pages/city-list/city-list').then((m) => m.CityList), - data: { childTitle: 'City Management', parentTitle: 'Platform', subParentTitle: 'Configuration' }, + data: { childTitle: 'City Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, }, + { + path: 'currencies', + loadComponent: () => import('./currencies/pages/currency-list/currency-list').then((m) => m.CurrencyList), + data: { childTitle: 'Currency Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, + }, + { + path: 'languages', + loadComponent: () => import('./languages/pages/language-list/language-list').then((m) => m.LanguageList), + data: { childTitle: 'Language Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, + }, + { + path: 'timezones', + loadComponent: () => import('./timezones/pages/timezone-list/timezone-list').then((m) => m.TimezoneList), + data: { childTitle: 'Timezone Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, + } ]; diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.html b/src/app/features/global-masters/languages/pages/language-list/language-list.html new file mode 100644 index 00000000..1b547826 --- /dev/null +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.html @@ -0,0 +1,123 @@ + + + {{ value }} + + + {{ value }} + + + + + + + @if (modalLoading()) { +
+ + Loading language... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.scss b/src/app/features/global-masters/languages/pages/language-list/language-list.scss new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.scss @@ -0,0 +1 @@ + diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.ts b/src/app/features/global-masters/languages/pages/language-list/language-list.ts new file mode 100644 index 00000000..fe40cf7a --- /dev/null +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.ts @@ -0,0 +1,356 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; + +import { + CreateLanguageRequest, + LanguageDto, + LanguageModalMode, + UpdateLanguageRequest +} from '../../../../../core/models/language/language.model'; +import { LanguageService } from '../../../../../core/services/language/language.service'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableSortEvent +} from '../../../../../shared/components/data-table/data-table.types'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../../shared/components/modal/modal'; +import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; +import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; + +interface LanguageTableRow extends DataTableRecord { + id: string; + code: string; + name: string; + nativeName: string; + isRightToLeft: boolean; + isActive: boolean; + serialNumber: number; + createdOn: string; + modifiedOn: string | null; +} + +@Component({ + selector: 'language-list', + standalone: true, + imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog], + templateUrl: './language-list.html', + styleUrl: './language-list.scss' +}) +export class LanguageList { + private readonly destroyRef = inject(DestroyRef); + private readonly languageApi = inject(LanguageService); + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly queryRequests$ = new Subject(); + + readonly queryState = new DataTableQueryState(); + readonly languages = signal([]); + readonly totalRecords = signal(0); + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly statusChangingId = signal(null); + readonly showModal = signal(false); + readonly modalMode = signal('create'); + readonly selectedLanguageId = signal(null); + readonly selectedLanguage = signal(null); + readonly submitAttempted = signal(false); + readonly pendingDeleteLanguageId = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); + + readonly languageForm = this.formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], + code: ['', [ + Validators.required, + Validators.maxLength(35), + Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/) + ]], + nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], + isRightToLeft: [false] + }); + + readonly modalTitle = computed(() => + this.modalMode() === 'create' ? 'Add Language' : 'Edit Language' + ); + readonly submitLabel = computed(() => + this.modalMode() === 'create' ? 'Save Language' : 'Update Language' + ); + readonly loadingLabel = computed(() => + this.modalMode() === 'create' ? 'Saving Language...' : 'Updating Language...' + ); + readonly submitAction = computed<'save' | 'update'>(() => + this.modalMode() === 'create' ? 'save' : 'update' + ); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, + { key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' }, + { key: 'code', label: 'Language Code', header: 'Language Code', sortable: true }, + { key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true }, + { + key: 'isRightToLeft', label: 'Direction', header: 'Direction', sortable: true, + formatter: value => value ? 'RTL' : 'LTR' + }, + { + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger', + formatter: value => value ? 'Active' : 'Inactive' + } + ]); + + readonly actions = signal[]>([ + { + type: 'edit', + label: 'Edit', + icon: 'ti ti-edit', + className: 'text-primary' + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger', + visible: row => row.isActive, + disabled: row => this.statusChangingId() === row.id + }, + { + type: 'activate', + label: 'Activate', + icon: 'ti ti-check', + className: 'text-success', + visible: row => !row.isActive, + disabled: row => this.statusChangingId() === row.id + } + ]); + + constructor() { + this.queryRequests$.pipe( + switchMap(query => this.languageApi.getDataTable(query).pipe( + catchError(() => { + this.toastr.error('Unable to load languages.'); + this.languages.set([]); + this.totalRecords.set(0); + return of(null); + }) + )), + takeUntilDestroyed(this.destroyRef) + ).subscribe(response => { + if (!response || response.draw !== this.queryState.getQuery().draw) { + return; + } + const query = this.queryState.getQuery(); + this.languages.set(response.rows.map((language, index) => ({ + ...language, + serialNumber: (query.page - 1) * query.pageSize + index + 1 + }))); + this.totalRecords.set(response.total); + }); + } + + ngOnInit(): void { + this.loadLanguages(this.queryState.getQuery()); + } + + loadLanguages(query: DataTableQuery): void { this.queryRequests$.next(query); } + onSearch(value: string): void { this.loadLanguages(this.queryState.setSearch(value.trim())); } + onPageChange(event: DataTablePageEvent): void { this.loadLanguages(this.queryState.setPage(event)); } + onSortChange(event: DataTableSortEvent): void { this.loadLanguages(this.queryState.setSort(event)); } + + onDeleteConfirmed(): void { + const languageId = this.pendingDeleteLanguageId(); + + if (!languageId) { + return; + } + + this.pendingDeleteLanguageId.set(null); + this.changeLanguageStatus(languageId, false); + } + + onDeleteCancelled(): void { + this.pendingDeleteLanguageId.set(null); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'edit') { + this.openEditLanguage(event.row.id); + } else if (event.action.type === 'delete') { + this.requestDeleteLanguage(event.row.id); + } else if (event.action.type === 'activate') { + this.changeLanguageStatus(event.row.id, true); + } + } + + private requestDeleteLanguage(id: string): void { + this.pendingDeleteLanguageId.set(id); + this.deleteConfirmDialog()?.open(); + } + + onAddLanguage(): void { + this.modalMode.set('create'); + this.selectedLanguageId.set(null); + this.selectedLanguage.set(null); + this.submitAttempted.set(false); + this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false }); + this.resetFormState(); + this.showModal.set(true); + } + + openEditLanguage(id: string): void { + this.modalMode.set('edit'); + this.selectedLanguageId.set(id); + this.selectedLanguage.set(null); + this.submitAttempted.set(false); + this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false }); + this.resetFormState(); + this.modalLoading.set(true); + this.showModal.set(true); + + this.languageApi.getById(id).pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: language => { + if (this.selectedLanguageId() !== language.id || !this.showModal()) { + return; + } + this.selectedLanguage.set(language); + this.languageForm.reset({ + name: language.name, + code: language.code, + nativeName: language.nativeName, + isRightToLeft: language.isRightToLeft + }); + this.resetFormState(); + }, + error: () => this.showModal.set(false) + }); + } + + closeModal(): void { + if (this.saving()) return; + this.showModal.set(false); + this.selectedLanguageId.set(null); + this.selectedLanguage.set(null); + this.submitAttempted.set(false); + } + + saveLanguage(): void { + if (this.languageForm.invalid) { + this.submitAttempted.set(true); + this.languageForm.markAllAsTouched(); + this.focusFirstInvalidControl(); + return; + } + if (this.saving() || this.modalLoading()) return; + + this.saving.set(true); + const request = this.buildCreateRequest(); + const operation = this.modalMode() === 'create' + ? this.languageApi.create(request) + : this.languageApi.update( + this.selectedLanguageId() ?? '', + { ...request, isActive: this.selectedLanguage()?.isActive ?? true } + ); + + operation.pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success( + this.modalMode() === 'create' + ? 'Language saved successfully.' + : 'Language updated successfully.' + ); + this.finishSave(); + }, + error: (error: HttpErrorResponse) => this.handleSaveError(error) + }); + } + + private buildCreateRequest(): CreateLanguageRequest { + const value = this.languageForm.getRawValue(); + return { + code: this.normalizeCode(value.code), + name: value.name.trim(), + nativeName: value.nativeName.trim(), + isRightToLeft: value.isRightToLeft + }; + } + + private normalizeCode(code: string): string { + return code.trim().split('-').map((part, index) => + index === 0 ? part.toLowerCase() : part.toUpperCase() + ).join('-'); + } + + private changeLanguageStatus(id: string, isActive: boolean): void { + if (this.statusChangingId()) return; + this.statusChangingId.set(id); + this.languageApi.getById(id).pipe( + switchMap(language => this.languageApi.update(id, { + code: language.code, + name: language.name, + nativeName: language.nativeName, + isRightToLeft: language.isRightToLeft, + isActive + })), + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(isActive + ? 'Language activated successfully.' + : 'Language deleted successfully.'); + this.loadLanguages(this.queryState.getQuery()); + }, + error: (error: HttpErrorResponse) => { + if (error.status === 404) this.toastr.error('The language is no longer available.'); + } + }); + } + + private handleSaveError(error: HttpErrorResponse): void { + if (error.status === 409) { + this.toastr.error('A language with this code already exists.', 'Duplicate language code'); + } + } + + private finishSave(): void { + this.showModal.set(false); + this.selectedLanguageId.set(null); + this.selectedLanguage.set(null); + this.submitAttempted.set(false); + this.loadLanguages(this.queryState.getQuery()); + } + + private resetFormState(): void { + this.languageForm.markAsPristine(); + this.languageForm.markAsUntouched(); + this.languageForm.updateValueAndValidity(); + } + + private focusFirstInvalidControl(): void { + queueMicrotask(() => { + const control = this.elementRef.nativeElement.querySelector( + 'modal .form-control.is-invalid, modal [aria-invalid="true"]' + ); + control?.focus(); + control?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + } +} diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.html b/src/app/features/global-masters/states/pages/state-list/state-list.html index f621fc5b..87e3e591 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.html +++ b/src/app/features/global-masters/states/pages/state-list/state-list.html @@ -1,19 +1,80 @@ - +
+
+
+
+
+
+
Country Selection
+
- (searchChanged)="onSearch($event)" - (pageChanged)="onPageChange($event)" - (sortChanged)="onSortChange($event)" - (actionClicked)="onActionClick($event)"> - +
+
+ +
+
+
+
+
+
+
+ + + + + + + +
+
+
+ +
+ +
+ +
+
+
+
diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.ts b/src/app/features/global-masters/states/pages/state-list/state-list.ts index ee33ad76..e7afd256 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.ts +++ b/src/app/features/global-masters/states/pages/state-list/state-list.ts @@ -1,50 +1,184 @@ -import { Component } from '@angular/core'; -import { inject, signal } from '@angular/core'; -import { StateService } from '../../../../../core/services/state/state.service'; +import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + FormBuilder, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, catchError, distinctUntilChanged, finalize, of, switchMap } from 'rxjs'; + +import { + CountryLookupDto +} from '../../../../../core/models/country/country.model'; +import { + CreateStateRequest, + StateDto, + StateModalMode, + UpdateStateRequest +} from '../../../../../core/models/state/state.model'; +import { CountryService } from '../../../../../core/services/country/country.service'; +import { StateService } from '../../../../../core/services/state/state.service'; import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; -import { DataTablePageEvent, DataTableSortEvent, DataTableQuery, DataTableColumn, DataTableAction, DataTableActionEvent } from '../../../../../shared/components/data-table/data-table.types'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableSortEvent +} from '../../../../../shared/components/data-table/data-table.types'; import { DataTable } from '../../../../../shared/components/data-table/data-table'; -import { finalize} from 'rxjs/operators'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../../shared/components/modal/modal'; +import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; + +interface StateTableRow extends DataTableRecord { + id: string; + countryId: string; + name: string; + code: string | null; + isActive: boolean; + serialNumber: number; + createdOn?: string; + modifiedOn?: string | null; +} @Component({ selector: 'state-list', - imports: [DataTable], + standalone: true, + imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog], templateUrl: './state-list.html', styleUrl: './state-list.scss', }) export class StateList { - - - private readonly stateApi: StateService = inject(StateService); + + private readonly destroyRef = inject(DestroyRef); + private readonly stateApi = inject(StateService); + private readonly countryApi = inject(CountryService); + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly stateQueryRequests$ = new Subject(); + private readonly defaultCountrySelectionApplied = signal(false); readonly queryState = new DataTableQueryState(); - readonly states = signal([]); + readonly states = signal([]); + readonly selectedCountryLookup = signal(null); + readonly selectedCountryId = signal(null); readonly totalRecords = signal(0); readonly filteredRecords = signal(0); - readonly loading = signal(false); + readonly saving = signal(false); + readonly showStateModal = signal(false); + readonly stateModalMode = signal('create'); + readonly selectedStateId = signal(null); + readonly selectedState = signal(null); + readonly stateSubmitAttempted = signal(false); + readonly pendingDeleteState = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly columns = signal([ - { key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '60px' }, - { key: 'name', header: 'Name', label: 'Name', sortable: true }, + readonly countryFilterForm = this.formBuilder.nonNullable.group({ + countryId: [''] + }); + + readonly searchCountries: AutocompleteSearchFn = + (term, limit) => this.countryApi.autocomplete(term, limit); + readonly displayCountry: AutocompleteDisplayFn = country => country.name; + readonly countryValue: AutocompleteValueFn = country => country.id; + + readonly stateForm = this.formBuilder.nonNullable.group({ + countryId: [ + '', + [ + Validators.required + ] + ], + name: [ + '', + [ + Validators.required, + Validators.maxLength(150) + ] + ], + code: [ + '', + [ + Validators.required, + Validators.maxLength(16), + Validators.pattern(/^[A-Za-z0-9_-]+$/) + ] + ] + }); + + readonly showStateAddButton = computed(() => + !!this.selectedCountryId() + ); + + readonly emptyMessage = computed(() => + this.selectedCountryId() + ? 'No states found' + : 'No records found' + ); + + readonly emptyDescription = computed(() => + this.selectedCountryId() + ? 'There are no states available for the selected country.' + : 'There is currently no data to display.' + ); + + readonly stateModalTitle = computed(() => + this.stateModalMode() === 'create' + ? 'Add State' + : 'Edit State' + ); + + readonly stateSubmitLabel = computed(() => + this.stateModalMode() === 'create' + ? 'Save State' + : 'Update State' + ); + + readonly stateLoadingLabel = computed(() => + this.stateModalMode() === 'create' + ? 'Saving State...' + : 'Updating State...' + ); + + readonly stateSubmitAction = computed<'save' | 'update'>(() => + this.stateModalMode() === 'create' + ? 'save' + : 'update' + ); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' }, + { key: 'name', header: 'Name', label: 'Name', sortable: true ,align: 'left'}, { key: 'code', header: 'Code', label: 'Code', sortable: true }, - { key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, badgeClass: value => - value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', + { + key: 'isActive', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => + value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger', width: '100px', - formatter: (value) => value ? 'Active' : 'Inactive' + formatter: value => value ? 'Active' : 'Inactive' } ]); - readonly actions = signal([ - { - type: 'view', - label: 'View', - icon: 'ti ti-eye', - className: 'text-info' - }, + readonly actions = signal[]>([ { type: 'edit', label: 'Edit', @@ -56,70 +190,98 @@ export class StateList { label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', - visible: (row: any) => row.isActive + visible: row => row.isActive }, { type: 'activate', label: 'Activate', icon: 'ti ti-check', className: 'text-success', - visible: (row: any) => !row.isActive + visible: row => !row.isActive } ]); - ngOnInit(): void { - this.loadStates(this.queryState.getQuery(), 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da'); - } - loadStates(query: DataTableQuery, countryId: any): void { - this.loading.set(true); - - this.stateApi - .getStateDataTable(query, countryId) + constructor() { + this.countryFilterForm.controls.countryId.valueChanges .pipe( - finalize(() => { - this.loading.set(false); - }) + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) ) - .subscribe({ - next: (response: any) => { - console.log('State data loaded:', response); - if (response.draw !== this.queryState.getQuery().draw) { - return; + .subscribe(countryId => { + this.onCountrySelected(countryId || null); + }); + + this.stateQueryRequests$ + .pipe( + switchMap(query => { + const countryId = this.selectedCountryId(); + + if (!countryId) { + return of(null); } - // Add serial numbers to states - const statesWithSerialNumbers = response.rows.map((state: any, index: number) => ({ - ...state, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.states.set(statesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }, - error: (error: any) => { - console.error('Unable to load states.', error); - - this.states.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); + return this.stateApi.getStateDataTable(query, countryId).pipe( + catchError(() => { + this.toastr.error('Unable to load states.'); + this.clearStateGrid(); + return of(null); + }) + ); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(response => { + if (!response) { + return; } + + const query = this.queryState.getQuery(); + + if (response.draw !== query.draw) { + return; + } + + const statesWithSerialNumbers: StateTableRow[] = response.rows.map((state, index) => ({ + ...state, + serialNumber: (query.page - 1) * query.pageSize + index + 1 + })); + + this.states.set(statesWithSerialNumbers); + this.totalRecords.set(response.total); + this.filteredRecords.set(response.filtered); }); } + ngOnInit(): void { + this.applyInitialDefaultCountry(); + } + + loadStates(query: DataTableQuery): void { + if (!this.selectedCountryId()) { + this.clearStateGrid(); + return; + } + + this.stateQueryRequests$.next(query); + } + + onCountryLookupSelected(country: CountryLookupDto): void { + this.selectedCountryLookup.set(country); + } + onSearch(value: string): void { const query = this.queryState.setSearch(value.trim()); - this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da'); + this.loadStates(query); } onPageChange(event: DataTablePageEvent): void { const query = this.queryState.setPage(event); - this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da'); + this.loadStates(query); } onSortChange(event: DataTableSortEvent): void { const query = this.queryState.setSort(event); - this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da'); + this.loadStates(query); } onRefresh(): void { @@ -130,27 +292,42 @@ export class StateList { draw: currentQuery.draw + 1 }; - this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da'); + this.loadStates(query); } onReset(): void { const query = this.queryState.reset(); - this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da'); + this.loadStates(query); } - onActionClick(event: DataTableActionEvent): void { + onDeleteConfirmed(): void { + const state = this.pendingDeleteState(); + + if (!state) { + return; + } + + this.pendingDeleteState.set(null); + this.deleteState(state); + } + + onDeleteCancelled(): void { + this.pendingDeleteState.set(null); + } + + onActionClick(event: DataTableActionEvent): void { const action = event.action.type; - const state = event.row; + const state = this.toStateDto(event.row); switch (action) { case 'view': this.viewState(state); break; case 'edit': - this.editState(state); + this.openEditState(state); break; case 'delete': - this.deleteState(state); + this.requestDeleteState(state); break; case 'activate': this.activateState(state); @@ -158,24 +335,265 @@ export class StateList { } } - private viewState(state: any): void { - console.log('Viewing state:', state); - // TODO: Implement view logic (open modal, navigate to details page, etc.) + onAddState(): void { + const countryId = this.selectedCountryId(); + + if (!countryId) { + this.toastr.error('Select a country before adding a state.'); + return; + } + + this.stateModalMode.set('create'); + this.selectedStateId.set(null); + this.selectedState.set(null); + this.stateSubmitAttempted.set(false); + + this.stateForm.reset({ + countryId, + name: '', + code: '' + }); + this.resetStateFormState(); + + this.showStateModal.set(true); } - private editState(state: any): void { - console.log('Editing state:', state); - // TODO: Implement edit logic (open modal, navigate to edit page, etc.) + closeStateModal(): void { + if (this.saving()) { + return; + } + + this.showStateModal.set(false); + this.selectedStateId.set(null); + this.selectedState.set(null); + this.stateSubmitAttempted.set(false); } - private deleteState(state: any): void { - console.log('Deleting state:', state); - // TODO: Implement delete logic (API call to delete state) + saveState(): void { + if (this.stateForm.invalid) { + this.stateSubmitAttempted.set(true); + this.stateForm.markAllAsTouched(); + this.focusFirstInvalidStateControl(); + return; + } + + if (this.saving()) { + return; + } + + this.saving.set(true); + + if (this.stateModalMode() === 'create') { + this.stateApi + .createState(this.buildCreateStateRequest()) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastr.success('State saved successfully.'); + this.finishStateSave(); + } + }); + + return; + } + + const stateId = this.selectedStateId(); + + if (!stateId) { + this.saving.set(false); + return; + } + + this.stateApi + .updateState( + stateId, + this.buildUpdateStateRequest(this.selectedState()?.isActive ?? true) + ) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastr.success('State updated successfully.'); + this.finishStateSave(); + } + }); } - private activateState(state: any): void { - console.log('Activating state:', state); - // TODO: Implement activate logic (API call to activate state) + private onCountrySelected(countryId: string | null): void { + this.selectedCountryId.set(countryId); + + if (!countryId || this.selectedCountryLookup()?.id !== countryId) { + this.selectedCountryLookup.set(null); + } + + this.clearStateGrid(); + + const query = this.queryState.reset(); + + if (countryId) { + this.loadStates(query); + } } + private applyInitialDefaultCountry(): void { + if ( + this.defaultCountrySelectionApplied() || + this.selectedCountryId() + ) { + return; + } + + this.defaultCountrySelectionApplied.set(true); + + this.countryApi.autocomplete('AE', 50).pipe( + catchError(() => { + this.toastr.error('Unable to load countries.'); + return of([]); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(countries => { + const uae = countries.find(country => + country.iso2.trim().toUpperCase() === 'AE' + ); + + if (!uae) { + this.clearStateGrid(); + return; + } + + this.selectedCountryLookup.set(uae); + this.countryFilterForm.controls.countryId.setValue(uae.id); + }); + } + + private clearStateGrid(): void { + this.states.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + } + + private viewState(state: StateDto): void { + this.openEditState(state); + } + + private openEditState(state: StateDto): void { + this.stateModalMode.set('edit'); + this.selectedStateId.set(state.id); + this.selectedState.set(state); + this.stateSubmitAttempted.set(false); + + this.stateApi + .getStateById(state.id) + .subscribe({ + next: stateDetails => { + this.selectedState.set(stateDetails); + this.stateForm.reset({ + countryId: stateDetails.countryId ?? this.selectedCountryId() ?? '', + name: stateDetails.name ?? '', + code: stateDetails.code ?? '' + }); + this.resetStateFormState(); + + this.showStateModal.set(true); + } + }); + } + + private requestDeleteState(state: StateDto): void { + this.pendingDeleteState.set(state); + this.deleteConfirmDialog()?.open(); + } + + private deleteState(state: StateDto): void { + this.updateStateStatus(state, false); + } + + private activateState(state: StateDto): void { + this.updateStateStatus(state, true); + } + + private buildCreateStateRequest(): CreateStateRequest { + const value = this.stateForm.getRawValue(); + + return { + countryId: value.countryId, + name: value.name.trim(), + code: value.code.trim().toUpperCase() + }; + } + + private buildUpdateStateRequest(isActive: boolean): UpdateStateRequest { + const value = this.stateForm.getRawValue(); + + return { + countryId: null, + name: value.name.trim(), + code: value.code.trim().toUpperCase(), + isActive + }; + } + + private stateToUpdateRequest(state: StateDto, isActive: boolean): UpdateStateRequest { + return { + countryId: null, + name: state.name?.trim() ?? '', + code: state.code?.trim().toUpperCase() ?? '', + isActive + }; + } + + private updateStateStatus(state: StateDto, isActive: boolean): void { + this.stateApi + .updateState(state.id, this.stateToUpdateRequest(state, isActive)) + .subscribe({ + next: () => { + this.toastr.success( + isActive + ? 'State activated successfully.' + : 'State deactivated successfully.' + ); + this.loadStates(this.queryState.getQuery()); + } + }); + } + + private resetStateFormState(): void { + this.stateForm.markAsPristine(); + this.stateForm.markAsUntouched(); + this.stateForm.updateValueAndValidity(); + } + + private finishStateSave(): void { + this.showStateModal.set(false); + this.selectedStateId.set(null); + this.selectedState.set(null); + this.stateSubmitAttempted.set(false); + this.loadStates(this.queryState.getQuery()); + } + + private focusFirstInvalidStateControl(): void { + queueMicrotask(() => { + const firstInvalidControl = + this.elementRef.nativeElement.querySelector( + 'modal .form-control.is-invalid, modal [aria-invalid="true"]' + ); + + firstInvalidControl?.focus(); + firstInvalidControl?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }); + } + + private toStateDto(row: StateTableRow): StateDto { + return { + id: row.id, + countryId: row.countryId, + name: row.name, + code: row.code, + isActive: row.isActive, + createdOn: row.createdOn, + modifiedOn: row.modifiedOn + }; + } } diff --git a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html new file mode 100644 index 00000000..c6ad6ccc --- /dev/null +++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html @@ -0,0 +1,133 @@ + + + {{ value }} + + + {{ value }} + + + + + + + @if (modalLoading()) { +
+ + Loading timezone... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+ @if (isViewMode() && selectedTimezone(); as timezone) { +
+ Formatted UTC Offset + {{ formatUtcOffset(timezone.utcOffsetMinutes) }} +
+
+ Status + + {{ timezone.isActive ? 'Active' : 'Inactive' }} + +
+ } +
+
+ } +
diff --git a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.scss b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.scss new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.scss @@ -0,0 +1 @@ + diff --git a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts new file mode 100644 index 00000000..d27a5f86 --- /dev/null +++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts @@ -0,0 +1,348 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, ElementRef, OnInit, computed, inject, signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; + +import { + CreateTimezoneRequest, + TimezoneDto, + TimezoneModalMode, + UpdateTimezoneRequest +} from '../../../../../core/models/timezone/timezone.model'; +import { TimezoneService } from '../../../../../core/services/timezone/timezone.service'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableSortEvent +} from '../../../../../shared/components/data-table/data-table.types'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../../shared/components/modal/modal'; +import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; +import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; + +interface TimezoneTableRow extends DataTableRecord { + readonly id: string; + readonly ianaId: string; + readonly displayName: string; + readonly utcOffsetMinutes: number; + readonly isActive: boolean; + readonly serialNumber: number; + readonly createdOn: string; + readonly modifiedOn: string | null; +} + +@Component({ + selector: 'timezone-list', + standalone: true, + imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog], + templateUrl: './timezone-list.html', + styleUrl: './timezone-list.scss' +}) +export class TimezoneList implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly timezoneApi = inject(TimezoneService); + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly queryRequests$ = new Subject(); + + readonly queryState = new DataTableQueryState(); + readonly timezones = signal([]); + readonly totalRecords = signal(0); + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly statusChangingId = signal(null); + readonly showModal = signal(false); + readonly modalMode = signal('create'); + readonly selectedTimezoneId = signal(null); + readonly selectedTimezone = signal(null); + readonly submitAttempted = signal(false); + readonly pendingDeleteTimezoneId = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); + + readonly timezoneForm = this.formBuilder.nonNullable.group({ + ianaId: ['', [ + Validators.required, + Validators.maxLength(64), + Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/) + ]], + displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]], + utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]] + }); + + readonly isViewMode = computed(() => this.modalMode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.modalMode()) { + case 'create': return 'Add Timezone'; + case 'edit': return 'Edit Timezone'; + case 'view': return 'View Timezone'; + } + }); + readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save Timezone' : 'Update Timezone'); + readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving Timezone...' : 'Updating Timezone...'); + readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update'); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'ianaId', label: 'IANA ID', header: 'IANA ID', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'displayName', label: 'Display Name', header: 'Display Name', sortable: true, headerAlign: 'center', align: 'left' }, + { + key: 'utcOffsetMinutes', label: 'UTC Offset', header: 'UTC Offset', sortable: true, + formatter: value => this.formatUtcOffset(Number(value)) + }, + { + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger', + formatter: value => value ? 'Active' : 'Inactive' + } + ]); + + readonly actions = signal[]>([ + { + type: 'edit', + label: 'Edit', + icon: 'ti ti-edit', + className: 'text-primary' + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger', + visible: row => row.isActive, + disabled: row => this.statusChangingId() === row.id + }, + { + type: 'activate', + label: 'Activate', + icon: 'ti ti-check', + className: 'text-success', + visible: row => !row.isActive, + disabled: row => this.statusChangingId() === row.id + } + ]); + + constructor() { + this.queryRequests$.pipe( + switchMap(query => this.timezoneApi.getDataTable(query).pipe( + catchError(() => { + this.timezones.set([]); + this.totalRecords.set(0); + return of(null); + }) + )), + takeUntilDestroyed(this.destroyRef) + ).subscribe(response => { + if (!response || response.draw !== this.queryState.getQuery().draw) return; + const query = this.queryState.getQuery(); + this.timezones.set(response.rows.map((timezone, index) => ({ + ...timezone, + serialNumber: (query.page - 1) * query.pageSize + index + 1 + }))); + this.totalRecords.set(response.total); + }); + } + + ngOnInit(): void { this.loadTimezones(this.queryState.getQuery()); } + loadTimezones(query: DataTableQuery): void { this.queryRequests$.next(query); } + onSearch(value: string): void { this.loadTimezones(this.queryState.setSearch(value.trim())); } + onPageChange(event: DataTablePageEvent): void { this.loadTimezones(this.queryState.setPage(event)); } + onSortChange(event: DataTableSortEvent): void { this.loadTimezones(this.queryState.setSort(event)); } + + onDeleteConfirmed(): void { + const timezoneId = this.pendingDeleteTimezoneId(); + + if (!timezoneId) { + return; + } + + this.pendingDeleteTimezoneId.set(null); + this.changeTimezoneStatus(timezoneId, false); + } + + onDeleteCancelled(): void { + this.pendingDeleteTimezoneId.set(null); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.openTimezone(event.row.id, 'view'); + if (event.action.type === 'edit') this.openTimezone(event.row.id, 'edit'); + if (event.action.type === 'delete') this.requestDeleteTimezone(event.row.id); + if (event.action.type === 'activate') this.changeTimezoneStatus(event.row.id, true); + } + + private requestDeleteTimezone(id: string): void { + this.pendingDeleteTimezoneId.set(id); + this.deleteConfirmDialog()?.open(); + } + + onAddTimezone(): void { + this.modalMode.set('create'); + this.prepareModal(null); + this.showModal.set(true); + } + + openTimezone(id: string, mode: 'edit' | 'view'): void { + this.modalMode.set(mode); + this.prepareModal(id); + this.modalLoading.set(true); + this.showModal.set(true); + this.timezoneApi.getById(id).pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: timezone => { + if (this.selectedTimezoneId() !== timezone.id || !this.showModal()) return; + this.selectedTimezone.set(timezone); + this.timezoneForm.reset({ + ianaId: timezone.ianaId, + displayName: timezone.displayName, + utcOffsetMinutes: timezone.utcOffsetMinutes + }); + if (mode === 'view') this.timezoneForm.disable(); + this.resetFormState(); + }, + error: (error: HttpErrorResponse) => { + this.showModal.set(false); + if (error.status === 404) this.toastr.error('The timezone is no longer available.'); + } + }); + } + + closeModal(): void { + if (this.saving()) return; + this.showModal.set(false); + this.selectedTimezoneId.set(null); + this.selectedTimezone.set(null); + this.submitAttempted.set(false); + this.timezoneForm.enable(); + } + + saveTimezone(): void { + if (this.isViewMode() || this.saving() || this.modalLoading()) return; + if (this.timezoneForm.invalid) { + this.submitAttempted.set(true); + this.timezoneForm.markAllAsTouched(); + this.focusFirstInvalidControl(); + return; + } + const selected = this.selectedTimezone(); + const id = this.selectedTimezoneId(); + if (this.modalMode() === 'edit' && (!selected || !id)) return; + + this.saving.set(true); + const createRequest = this.buildCreateRequest(); + const operation = this.modalMode() === 'create' + ? this.timezoneApi.create(createRequest) + : this.timezoneApi.update(id!, { ...createRequest, isActive: selected!.isActive }); + operation.pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(this.modalMode() === 'create' + ? 'Timezone saved successfully.' + : 'Timezone updated successfully.'); + this.finishSave(); + }, + error: (error: HttpErrorResponse) => this.handleSaveError(error) + }); + } + + formatUtcOffset(minutes: number): string { + const sign = minutes >= 0 ? '+' : '-'; + const absolute = Math.abs(minutes); + return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`; + } + + private prepareModal(id: string | null): void { + this.selectedTimezoneId.set(id); + this.selectedTimezone.set(null); + this.submitAttempted.set(false); + this.timezoneForm.enable(); + this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 }); + this.resetFormState(); + } + + private buildCreateRequest(): CreateTimezoneRequest { + const value = this.timezoneForm.getRawValue(); + return { + ianaId: value.ianaId.trim(), + displayName: value.displayName.trim(), + utcOffsetMinutes: value.utcOffsetMinutes + }; + } + + private changeTimezoneStatus(id: string, isActive: boolean): void { + if (this.statusChangingId()) return; + this.statusChangingId.set(id); + this.timezoneApi.getById(id).pipe( + switchMap(timezone => this.timezoneApi.update(id, { + ianaId: timezone.ianaId, + displayName: timezone.displayName, + utcOffsetMinutes: timezone.utcOffsetMinutes, + isActive + })), + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(isActive + ? 'Timezone activated successfully.' + : 'Timezone deleted successfully.'); + this.loadTimezones(this.queryState.getQuery()); + }, + error: (error: HttpErrorResponse) => { + if (error.status === 404) this.toastr.error('The timezone is no longer available.'); + } + }); + } + + private handleSaveError(error: HttpErrorResponse): void { + if (error.status === 409) { + const message = this.apiErrorMessage(error) ?? 'A timezone with this IANA ID already exists.'; + this.toastr.error(message, 'Duplicate IANA timezone ID'); + } else if (error.status === 404) { + this.toastr.error('The timezone is no longer available.'); + this.closeModal(); + } + } + + private apiErrorMessage(error: HttpErrorResponse): string | null { + const body: unknown = error.error; + if (!body || typeof body !== 'object') return null; + if ('detail' in body && typeof body.detail === 'string') return body.detail; + if ('message' in body && typeof body.message === 'string') return body.message; + return null; + } + + private finishSave(): void { + this.showModal.set(false); + this.selectedTimezoneId.set(null); + this.selectedTimezone.set(null); + this.submitAttempted.set(false); + this.loadTimezones(this.queryState.getQuery()); + } + + private resetFormState(): void { + this.timezoneForm.markAsPristine(); + this.timezoneForm.markAsUntouched(); + this.timezoneForm.updateValueAndValidity(); + } + + private focusFirstInvalidControl(): void { + queueMicrotask(() => this.elementRef.nativeElement + .querySelector('modal .form-control.is-invalid, modal [aria-invalid="true"]') + ?.focus()); + } +} diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.html b/src/app/features/tenants/pages/tenant-list/tenant-list.html index 81414c4e..c54a2ab0 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.html +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.html @@ -50,4 +50,13 @@ -
\ No newline at end of file +
+ + \ No newline at end of file diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.ts b/src/app/features/tenants/pages/tenant-list/tenant-list.ts index a2a2932b..e8c1ba0e 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.ts +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.ts @@ -1,14 +1,15 @@ -import { Component } from '@angular/core'; +import { Component, signal, viewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DataTable } from '../../../../shared/components/data-table/data-table'; import { DataTableColumn, DataTableAction } from '../../../../shared/components/data-table/data-table.types'; import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; import { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive'; +import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; @Component({ selector: 'tenant-list', - imports: [CommonModule, DataTable, DataTableCellDirective], + imports: [CommonModule, DataTable, DataTableCellDirective, ConfirmDialog], templateUrl: './tenant-list.html', styleUrl: './tenant-list.scss', }) @@ -21,6 +22,8 @@ export class TenantList { pageSize = 10; totalRecords = 3; searchText = ''; + readonly pendingDeleteTenant = signal<{ id: number } | null>(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); allowedPermissions: string[] = [ 'tenant.view', @@ -125,7 +128,7 @@ export class TenantList { icon: 'ri-delete-bin-line', className: 'ti-btn ti-btn-sm ti-btn-danger !rounded-full', permission: 'tenant.delete', - visible: row => row.status !== 'Active' + //visible: row => row.status !== 'Active' } ]; @@ -159,9 +162,33 @@ export class TenantList { } onTableAction(event: any): void { + if (event?.action?.type === 'delete') { + this.pendingDeleteTenant.set(event.row as { id: number }); + this.deleteConfirmDialog()?.open(); + return; + } + console.log('Action:', event.action.type, event.row); } + onDeleteConfirmed(): void { + const tenant = this.pendingDeleteTenant(); + + if (!tenant) { + return; + } + + this.pendingDeleteTenant.set(null); + this.tenants = this.tenants.filter(currentTenant => currentTenant.id !== tenant.id); + this.tenants1 = this.tenants1.filter(currentTenant => currentTenant.id !== tenant.id); + this.totalRecords = this.tenants.length; + console.log('Deleted tenant:', tenant); + } + + onDeleteCancelled(): void { + this.pendingDeleteTenant.set(null); + } + onRowClick(row: any): void { console.log('Row clicked:', row); } diff --git a/src/app/shared/components/confirm-dialog/confirm-dialog.html b/src/app/shared/components/confirm-dialog/confirm-dialog.html new file mode 100644 index 00000000..cc1df3b6 --- /dev/null +++ b/src/app/shared/components/confirm-dialog/confirm-dialog.html @@ -0,0 +1,10 @@ + + + diff --git a/src/app/shared/components/confirm-dialog/confirm-dialog.scss b/src/app/shared/components/confirm-dialog/confirm-dialog.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/shared/components/confirm-dialog/confirm-dialog.ts b/src/app/shared/components/confirm-dialog/confirm-dialog.ts new file mode 100644 index 00000000..0bd791bb --- /dev/null +++ b/src/app/shared/components/confirm-dialog/confirm-dialog.ts @@ -0,0 +1,62 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output +} from '@angular/core'; +import Swal, { SweetAlertIcon } from 'sweetalert2'; + +@Component({ + selector: 'app-confirm-dialog', + standalone: true, + templateUrl: './confirm-dialog.html', + styleUrl: './confirm-dialog.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ConfirmDialog { + readonly title = input('Are you sure?'); + readonly text = input("You won't be able to revert this!"); + readonly icon = input('warning'); + readonly confirmButtonText = input('Yes, delete it!'); + readonly cancelButtonText = input('No, cancel!'); + readonly confirmButtonColor = input('#1f3f81'); + readonly cancelButtonColor = input('#ff007f'); + readonly disabled = input(false); + readonly ariaLabel = input('Confirmation dialog trigger'); + + readonly confirmed = output(); + readonly cancelled = output(); + + async open(event?: Event): Promise { + event?.preventDefault(); + + if (this.disabled()) { + return; + } + + const result = await Swal.fire({ + title: this.title(), + text: this.text(), + icon: this.icon(), + showCancelButton: false, + showDenyButton: true, + confirmButtonText: this.confirmButtonText(), + denyButtonText: this.cancelButtonText(), + confirmButtonColor: this.confirmButtonColor(), + denyButtonColor: this.cancelButtonColor(), + customClass: { + confirmButton: 'app-confirm-dialog-btn', + denyButton: 'app-confirm-dialog-btn' + } + }); + + if (result.isConfirmed) { + this.confirmed.emit(); + return; + } + + if (result.isDenied) { + this.cancelled.emit(); + } + } +} diff --git a/src/app/shared/components/data-table/data-table.html b/src/app/shared/components/data-table/data-table.html index 74ba9d74..b6dca138 100644 --- a/src/app/shared/components/data-table/data-table.html +++ b/src/app/shared/components/data-table/data-table.html @@ -8,10 +8,17 @@
@if(showAddButton()){ +
+ - +
} + @if (showSearch()) {
+
@@ -48,7 +56,7 @@ @if (loading()) { - } @else if (rows().length === 0) { - - + } @else diff --git a/src/app/shared/components/data-table/data-table.ts b/src/app/shared/components/data-table/data-table.ts index 11dd0c99..00ae63c8 100644 --- a/src/app/shared/components/data-table/data-table.ts +++ b/src/app/shared/components/data-table/data-table.ts @@ -6,16 +6,21 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DataTableCellDirective } from '../../directives/data-table-cell.directive'; import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; -import { TooltipDirective } from '../../directives/tooltip/tooltip.directive'; import { DataTableAction, DataTableActionEvent, DataTableCellContext, DataTableColumn, DataTablePageEvent, DataTableRecord, DataTableSortEvent } from './data-table.types'; import { Button } from '../button/button'; +import { contentChild } from '@angular/core'; + +import { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive'; @Component({ selector: 'app-data-table', - imports: [NgTemplateOutlet, MatPaginatorModule, CdkOverlayOrigin, - CdkConnectedOverlay, Button, TooltipDirective], + imports: [NgTemplateOutlet, + MatPaginatorModule, + CdkOverlayOrigin, + CdkConnectedOverlay, + Button], templateUrl: './data-table.html', styleUrl: './data-table.scss', standalone: true @@ -27,6 +32,7 @@ export class DataTable { private readonly searchTerms$ = new Subject(); readonly cellTemplates = contentChildren(DataTableCellDirective); + readonly toolbarTemplate = contentChild(DataTableToolbarDirective); private readonly defaultRowClasses = [ 'table-primary', @@ -92,15 +98,18 @@ export class DataTable { /*---------------------------*/ - tableTitle = input(''); + tableTitle = input(''); + toolTip = input(''); buttonTitle = input(''); /* --------- Search inputs ---- */ showSearch = input(false); + showformSelect = input(false); showAddButton = input(false); searchPlaceholder = input('Search...'); searchDebounceTime = input(300); emptyMessage = input('No records found'); + emptyDescription = input('There is currently no data to display.'); /*---------------------------*/ /* --------- Permission inputs ---- */ @@ -119,7 +128,7 @@ export class DataTable { sortDirection = signal<'asc' | 'desc'>('asc'); openActionRowId = signal(null); - tableClass = input('table table-hover whitespace-nowrap min-w-full'); + tableClass = input('table table-bordered whitespace-nowrap min-w-full'); tableHeadClass = input(''); tableBodyClass = input(''); trHeadClass = input('border-b border-defaultborder bg-primary/10 dark:bg-primary/15 dark:border-defaultborder/10'); @@ -133,7 +142,7 @@ export class DataTable { actionCellClass = input('!text-center'); addClicked = output(); - colspan = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0)); + totalVisibleColumns = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0)); allowedPermissionSet = computed(() => new Set(this.allowedPermissions())); @@ -383,7 +392,7 @@ export class DataTable { } getHeaderClass(column: DataTableColumn): string { - return this.composeClass(this.defaultThClass(), this.getAlignClass(column.align), column.headerClass); + return this.composeClass(this.defaultThClass(), this.getAlignClass(column.headerAlign), column.headerClass); } getCellClass(column: DataTableColumn): string { diff --git a/src/app/shared/components/data-table/data-table.types.ts b/src/app/shared/components/data-table/data-table.types.ts index e886a43e..61895424 100644 --- a/src/app/shared/components/data-table/data-table.types.ts +++ b/src/app/shared/components/data-table/data-table.types.ts @@ -1,4 +1,8 @@ -export type DataTableRecord = Record; /* table row is an object with string keys */ +export type DataTableRecord = Record & { + readonly id?: string | number; + readonly status?: unknown; + readonly isActive?: boolean; +}; /* table row is an object with string keys */ export type DataTableActionType = 'view' | 'edit' | 'delete' | string; @@ -9,6 +13,7 @@ export interface DataTableColumn { sortable?: boolean; width?: string; align?: 'left' | 'center' | 'right'; + headerAlign?: 'left' | 'center' | 'right'; formatter?: (value: unknown, row: T) => string | number; /* for change the value of custom cell like date format */ badge?: boolean; badgeClass?: (value: unknown, row: T) => string; /* this is to show the badge according to status of record */ @@ -17,7 +22,7 @@ export interface DataTableColumn { cellClass?: string; } -export interface DataTableAction { +export interface DataTableAction { type: DataTableActionType; label: string; icon?: string; @@ -38,7 +43,7 @@ export interface DataTableSortEvent { direction: 'asc' | 'desc'; } -export interface DataTableActionEvent { +export interface DataTableActionEvent { action: DataTableAction; row: T; } @@ -65,4 +70,4 @@ export interface DataTableResult { total: number; filtered: number; rows: T[]; -} \ No newline at end of file +} diff --git a/src/app/shared/components/form/autocomplete/autocomplete.html b/src/app/shared/components/form/autocomplete/autocomplete.html new file mode 100644 index 00000000..7d45c52f --- /dev/null +++ b/src/app/shared/components/form/autocomplete/autocomplete.html @@ -0,0 +1,105 @@ + +
+ + + @if (loading()) { + + } @else if (clearable() && (hasSelectedItem() || searchText())) { + + } +
+ + {{ message() }} + + +
+ @if (message()) { +
+ {{ message() }} +
+ } @else { + @for (item of options(); track optionKey(item, $index); let index = $index) { + + } + } +
+
+
diff --git a/src/app/shared/components/form/autocomplete/autocomplete.spec.ts b/src/app/shared/components/form/autocomplete/autocomplete.spec.ts new file mode 100644 index 00000000..faa8c086 --- /dev/null +++ b/src/app/shared/components/form/autocomplete/autocomplete.spec.ts @@ -0,0 +1,289 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { Observable, Subject, of, throwError } from 'rxjs'; + +import { Autocomplete } from './autocomplete'; +import { AutocompleteSearchFn } from './autocomplete.types'; + +interface LookupItem { + readonly code: string; + readonly title: string; +} + +const INDIA: LookupItem = { code: 'IN', title: 'India' }; +const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' }; + +@Component({ + standalone: true, + imports: [ReactiveFormsModule, Autocomplete], + template: ` + + ` +}) +class HostComponent { + readonly control = new FormControl(null); + readonly selectedItem = signal(null); + searchFn: AutocompleteSearchFn = () => of([INDIA, INDONESIA]); + readonly displayWith = (item: LookupItem): string => item.title; + readonly valueWith = (item: LookupItem): string => item.code; + minLength = 2; + readonly delay = signal(300); + readonly readonly = signal(false); + openOnFocus = false; +} + +describe('Autocomplete', () => { + let fixture: ComponentFixture; + let host: HostComponent; + let component: Autocomplete; + let overlayContainer: OverlayContainer; + + const input = (): HTMLInputElement => fixture.nativeElement.querySelector('input'); + const type = (value: string): void => { + input().value = value; + input().dispatchEvent(new Event('input')); + fixture.detectChanges(); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + overlayContainer = TestBed.inject(OverlayContainer); + fixture.detectChanges(); + component = fixture.debugElement.children[0].componentInstance; + }); + + afterEach(() => overlayContainer.ngOnDestroy()); + + it('initializes with an empty selection', () => { + expect(component.searchText()).toBe(''); + expect(component.options()).toEqual([]); + }); + + it('integrates with a reactive form control', () => { + component.select(INDIA); + expect(host.control.value).toBe('IN'); + }); + + it('does not emit onChange from writeValue', () => { + const change = vi.fn(); + component.registerOnChange(change); + component.writeValue('IN'); + expect(change).not.toHaveBeenCalled(); + }); + + it('applies a disabled form state', () => { + host.control.disable(); + fixture.detectChanges(); + expect(input().disabled).toBe(true); + }); + + it('waits for the configured debounce', () => { + vi.useFakeTimers(); + const search = vi.fn(() => of([INDIA])); + host.searchFn = search; + fixture.detectChanges(); + type('in'); + vi.advanceTimersByTime(299); + expect(search).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(search).toHaveBeenCalledWith('in', 10); + vi.useRealTimers(); + }); + + it('does not search below the minimum length', () => { + vi.useFakeTimers(); + const search = vi.fn(() => of([INDIA])); + host.searchFn = search; + fixture.detectChanges(); + type('i'); + vi.advanceTimersByTime(300); + expect(search).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('cancels stale search requests', () => { + vi.useFakeTimers(); + const first = new Subject(); + const second = new Subject(); + host.delay.set(0); + host.searchFn = term => term === 'in' ? first : second; + fixture.detectChanges(); + type('in'); vi.advanceTimersByTime(0); + type('ind'); vi.advanceTimersByTime(0); + first.next([INDONESIA]); + second.next([INDIA]); + expect(component.options()).toEqual([INDIA]); + vi.useRealTimers(); + }); + + it('resets loading after a successful search', () => { + vi.useFakeTimers(); + host.delay.set(0); + fixture.detectChanges(); + type('in'); vi.advanceTimersByTime(0); + expect(component.loading()).toBe(false); + vi.useRealTimers(); + }); + + it('resets loading after an error', () => { + vi.useFakeTimers(); + host.delay.set(0); + host.searchFn = () => throwError(() => new Error('server')); + fixture.detectChanges(); + type('in'); vi.advanceTimersByTime(0); + expect(component.loading()).toBe(false); + expect(component.error()).toBe('Unable to load results'); + vi.useRealTimers(); + }); + + it('recovers on a later search after an error', () => { + vi.useFakeTimers(); + let attempts = 0; + host.delay.set(0); + host.searchFn = (): Observable => + ++attempts === 1 ? throwError(() => new Error('server')) : of([INDIA]); + fixture.detectChanges(); + type('in'); vi.advanceTimersByTime(0); + type('ind'); vi.advanceTimersByTime(0); + expect(component.options()).toEqual([INDIA]); + expect(component.error()).toBeNull(); + vi.useRealTimers(); + }); + + it('renders returned options', () => { + vi.useFakeTimers(); + host.delay.set(0); + fixture.detectChanges(); + type('in'); vi.advanceTimersByTime(0); fixture.detectChanges(); + expect(overlayContainer.getContainerElement().textContent).toContain('India'); + vi.useRealTimers(); + }); + + it('selects an option with the mouse', () => { + vi.useFakeTimers(); + host.delay.set(0); + fixture.detectChanges(); + type('in'); vi.advanceTimersByTime(0); fixture.detectChanges(); + overlayContainer.getContainerElement().querySelector('[role="option"]')?.click(); + expect(host.control.value).toBe('IN'); + vi.useRealTimers(); + }); + + it('selects the highlighted option with Enter', () => { + component.options.set([INDIA]); + component.open(); + input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(host.control.value).toBe('IN'); + }); + + it('only highlights on arrow navigation', () => { + component.options.set([INDIA]); + input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(component.activeIndex()).toBe(0); + expect(host.control.value).toBeNull(); + }); + + it('closes on Escape', () => { + component.open(); + input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(component.isOpen()).toBe(false); + }); + + it('clears the form value to null', () => { + component.select(INDIA); + component.clear(); + expect(host.control.value).toBeNull(); + }); + + it('clears visible state when the form resets', () => { + component.select(INDIA); + host.control.reset(); + fixture.detectChanges(); + expect(component.searchText()).toBe(''); + expect(component.activeItem()).toBeNull(); + }); + + it('displays an existing selected item', () => { + host.selectedItem.set(INDIA); + host.control.setValue('IN'); + fixture.detectChanges(); + expect(component.searchText()).toBe('India'); + }); + + it('does not retain a stale label when edit values change', () => { + host.selectedItem.set(INDIA); + host.control.setValue('IN'); + fixture.detectChanges(); + host.control.setValue('ID'); + fixture.detectChanges(); + expect(component.searchText()).toBe(''); + }); + + it('closes when the overlay backdrop is clicked', () => { + component.open(); + fixture.detectChanges(); + overlayContainer.getContainerElement().querySelector('.cdk-overlay-backdrop')?.click(); + expect(component.isOpen()).toBe(false); + }); + + it('prevents modification in read-only mode', () => { + host.readonly.set(true); + fixture.detectChanges(); + component.select(INDIA); + expect(host.control.value).toBeNull(); + }); + + it('prevents interaction while disabled', () => { + host.control.disable(); + fixture.detectChanges(); + component.open(); + expect(component.isOpen()).toBe(false); + }); + + it('updates combobox ARIA state', () => { + component.open(); + fixture.detectChanges(); + expect(input().getAttribute('role')).toBe('combobox'); + expect(input().getAttribute('aria-expanded')).toBe('true'); + expect(input().getAttribute('aria-controls')).toBe('country-listbox'); + }); + + it('renders the empty state', () => { + vi.useFakeTimers(); + host.delay.set(0); + host.searchFn = () => of([]); + fixture.detectChanges(); + type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges(); + expect(overlayContainer.getContainerElement().textContent).toContain('No results found'); + vi.useRealTimers(); + }); + + it('renders the friendly error state', () => { + vi.useFakeTimers(); + host.delay.set(0); + host.searchFn = () => throwError(() => new Error('raw error')); + fixture.detectChanges(); + type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges(); + const text = overlayContainer.getContainerElement().textContent ?? ''; + expect(text).toContain('Unable to load results'); + expect(text).not.toContain('raw error'); + vi.useRealTimers(); + }); +}); diff --git a/src/app/shared/components/form/autocomplete/autocomplete.ts b/src/app/shared/components/form/autocomplete/autocomplete.ts new file mode 100644 index 00000000..51dc00eb --- /dev/null +++ b/src/app/shared/components/form/autocomplete/autocomplete.ts @@ -0,0 +1,338 @@ +import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Injector, + ViewChild, + computed, + effect, + forwardRef, + inject, + input, + output, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AbstractControl, ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; +import { + Subject, + catchError, + debounce, + distinctUntilChanged, + map, + of, + switchMap, + timer +} from 'rxjs'; + +import { FormField, FormLabelPosition } from '../form-field/form-field'; +import { ValidationMessageMap } from '../form-validation-message/form-validation-message'; +import { + AutocompleteDisplayFn, + AutocompleteResolveValueFn, + AutocompleteSearchFn, + AutocompleteTrackFn, + AutocompleteValueFn +} from './autocomplete.types'; + +interface SearchResult { + readonly term: string; + readonly options: readonly TItem[]; + readonly failed: boolean; +} + +@Component({ + selector: 'app-autocomplete', + standalone: true, + imports: [CdkConnectedOverlay, CdkOverlayOrigin, FormField], + templateUrl: './autocomplete.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Autocomplete), + multi: true + }] +}) +export class Autocomplete implements ControlValueAccessor { + private static nextId = 0; + + private readonly injector = inject(Injector); + private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`; + private readonly inputTerms$ = new Subject(); + private readonly valuesToResolve$ = new Subject(); + private formValue: TValue | null = null; + private labelEdited = false; + private onChange: (value: TValue | null) => void = () => {}; + private onTouched: () => void = () => {}; + + @ViewChild('textInput') private textInput?: ElementRef; + @ViewChild(CdkOverlayOrigin, { read: ElementRef }) private origin?: ElementRef; + + readonly searchFn = input.required>(); + readonly displayWith = input.required>(); + readonly valueWith = input.required>(); + readonly trackBy = input | null>(null); + readonly selectedItem = input(null); + readonly resolveValueFn = input | null>(null); + readonly label = input(''); + readonly inputId = input(null); + readonly placeholder = input('Search...'); + readonly minSearchLength = input(1); + readonly debounceTime = input(300); + readonly limit = input(10); + readonly disabled = input(false); + readonly readonly = input(false); + readonly clearable = input(true); + readonly required = input(false); + readonly loadingText = input('Loading...'); + readonly emptyText = input('No results found'); + readonly typeToSearchText = input('Type to search'); + readonly errorText = input('Unable to load results'); + readonly showDropdownOnFocus = input(false); + readonly closeOnSelect = input(true); + readonly autocomplete = input('off'); + readonly ariaLabel = input(null); + readonly panelClass = input(''); + readonly inputClass = input(''); + readonly wrapperClass = input(''); + readonly hideLabel = input(false); + readonly hideValidation = input(false); + readonly submitAttempted = input(false); + readonly validationMessages = input({}); + readonly description = input(null); + readonly hint = input(null); + readonly labelPosition = input('top'); + + readonly itemSelected = output(); + readonly cleared = output(); + readonly searchChanged = output(); + readonly opened = output(); + readonly closed = output(); + readonly loadError = output(); + + readonly isOpen = signal(false); + readonly loading = signal(false); + readonly options = signal([]); + readonly activeIndex = signal(-1); + readonly activeItem = signal(null); + readonly searchText = signal(''); + readonly error = signal(null); + readonly formDisabled = signal(false); + readonly panelWidth = signal(0); + + readonly resolvedInputId = computed(() => this.inputId()?.trim() || this.generatedId); + readonly panelId = computed(() => `${this.resolvedInputId()}-listbox`); + readonly statusId = computed(() => `${this.resolvedInputId()}-status`); + readonly control = computed(() => + this.injector.get(NgControl, null, { self: true, optional: true })?.control ?? null + ); + readonly isDisabled = computed(() => this.disabled() || this.formDisabled()); + readonly hasSelectedItem = computed(() => this.activeItem() !== null); + readonly activeDescendant = computed(() => { + const index = this.activeIndex(); + return this.isOpen() && index >= 0 ? `${this.resolvedInputId()}-option-${index}` : null; + }); + readonly message = computed(() => { + if (this.loading()) return this.loadingText(); + if (this.error()) return this.errorText(); + if (this.searchText().trim().length < this.minSearchLength()) { + return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`; + } + return this.options().length ? '' : this.emptyText(); + }); + readonly resolvedInputClass = computed(() => { + const control = this.control(); + const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted())); + return [ + 'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor', + 'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70', + 'focus:border-primary focus:ring-1 focus:ring-primary', + 'pe-16', + invalid ? 'is-invalid border-danger' : '', + this.isDisabled() ? 'cursor-not-allowed opacity-60' : '', + this.inputClass() + ].filter(Boolean).join(' '); + }); + + readonly overlayPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 } + ]; + + constructor() { + this.inputTerms$.pipe( + map(term => term.trim()), + debounce(() => timer(Math.max(0, this.debounceTime()))), + distinctUntilChanged(), + switchMap(term => { + if (term.length < this.minSearchLength()) { + return of>({ term, options: [], failed: false }); + } + this.loading.set(true); + this.error.set(null); + return this.searchFn()(term, this.limit()).pipe( + map(options => ({ term, options, failed: false })), + catchError(() => of>({ term, options: [], failed: true })) + ); + }), + takeUntilDestroyed() + ).subscribe(result => { + this.loading.set(false); + this.options.set(result.options); + this.activeIndex.set(-1); + this.error.set(result.failed ? this.errorText() : null); + if (result.failed) this.loadError.emit(); + }); + + this.valuesToResolve$.pipe( + switchMap(value => this.resolveValueFn()?.(value) ?? of(null)), + takeUntilDestroyed() + ).subscribe(item => this.applyResolvedItem(item)); + + effect(() => { + const item = this.selectedItem(); + if (item === null) { + if (this.formValue === null) this.applyResolvedItem(null); + return; + } + if (this.valuesEqual(this.valueWith()(item), this.formValue)) this.applyResolvedItem(item); + }); + } + + writeValue(value: TValue | null): void { + this.formValue = value ?? null; + this.labelEdited = false; + if (this.formValue === null) { + this.applyResolvedItem(null); + return; + } + const supplied = this.selectedItem(); + if (supplied !== null && this.valuesEqual(this.valueWith()(supplied), this.formValue)) { + this.applyResolvedItem(supplied); + } else { + this.applyResolvedItem(null); + if (this.resolveValueFn()) this.valuesToResolve$.next(this.formValue); + } + } + + registerOnChange(fn: (value: TValue | null) => void): void { this.onChange = fn; } + registerOnTouched(fn: () => void): void { this.onTouched = fn; } + setDisabledState(disabled: boolean): void { + this.formDisabled.set(disabled); + if (disabled) this.close(); + } + + onInput(event: Event): void { + if (!(event.target instanceof HTMLInputElement) || this.isDisabled() || this.readonly()) return; + const text = event.target.value; + const previousText = this.searchText(); + this.searchText.set(text); + this.labelEdited = this.formValue !== null && text !== previousText; + this.searchChanged.emit(text.trim()); + this.open(); + this.inputTerms$.next(text); + } + + onFocus(): void { + if (this.showDropdownOnFocus()) { + this.open(); + this.inputTerms$.next(this.searchText()); + } + } + + onBlur(): void { + queueMicrotask(() => { + this.onTouched(); + if (this.labelEdited) this.clearValue(false, false); + }); + } + + onKeydown(event: KeyboardEvent): void { + if (this.isDisabled() || this.readonly()) return; + const options = this.options(); + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); this.open(); this.setActive(Math.min(this.activeIndex() + 1, options.length - 1)); break; + case 'ArrowUp': + event.preventDefault(); this.open(); this.setActive(Math.max(this.activeIndex() - 1, 0)); break; + case 'Home': + if (this.isOpen() && options.length) { event.preventDefault(); this.setActive(0); } break; + case 'End': + if (this.isOpen() && options.length) { event.preventDefault(); this.setActive(options.length - 1); } break; + case 'Enter': { + const item = options[this.activeIndex()]; + if (this.isOpen() && item !== undefined) { event.preventDefault(); this.select(item); } + break; + } + case 'Escape': + if (this.isOpen()) { event.preventDefault(); this.close(); } break; + } + } + + select(item: TItem): void { + if (this.isDisabled() || this.readonly()) return; + this.activeItem.set(item); + this.formValue = this.valueWith()(item); + this.searchText.set(this.displayWith()(item)); + this.labelEdited = false; + this.onChange(this.formValue); + this.onTouched(); + this.itemSelected.emit(item); + if (this.closeOnSelect()) this.close(); + } + + clear(event?: MouseEvent): void { + event?.preventDefault(); + event?.stopPropagation(); + if (this.isDisabled() || this.readonly()) return; + this.clearValue(true, true); + } + + open(): void { + if (this.isDisabled() || this.readonly() || this.isOpen()) return; + this.panelWidth.set(this.origin?.nativeElement.getBoundingClientRect().width ?? 0); + this.isOpen.set(true); + this.opened.emit(); + } + + close(): void { + if (!this.isOpen()) return; + this.isOpen.set(false); + this.activeIndex.set(-1); + this.closed.emit(); + } + + optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; } + optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; } + + private setActive(index: number): void { + if (index < 0 || index >= this.options().length) return; + this.activeIndex.set(index); + queueMicrotask(() => document.getElementById(this.optionId(index))?.scrollIntoView({ block: 'nearest' })); + } + + private clearValue(emitCleared: boolean, restoreFocus: boolean): void { + this.formValue = null; + this.activeItem.set(null); + this.searchText.set(''); + this.options.set([]); + this.error.set(null); + this.loading.set(false); + this.labelEdited = false; + this.onChange(null); + this.onTouched(); + if (emitCleared) this.cleared.emit(); + this.close(); + if (restoreFocus) queueMicrotask(() => this.textInput?.nativeElement.focus()); + } + + private applyResolvedItem(item: TItem | null, clearText = true): void { + this.activeItem.set(item); + if (item !== null) this.searchText.set(this.displayWith()(item)); + else if (clearText) this.searchText.set(''); + this.labelEdited = false; + } + + private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); } +} diff --git a/src/app/shared/components/form/autocomplete/autocomplete.types.ts b/src/app/shared/components/form/autocomplete/autocomplete.types.ts new file mode 100644 index 00000000..cdad47ad --- /dev/null +++ b/src/app/shared/components/form/autocomplete/autocomplete.types.ts @@ -0,0 +1,16 @@ +import { Observable } from 'rxjs'; + +export type AutocompleteSearchFn = ( + term: string, + limit: number +) => Observable; + +export type AutocompleteDisplayFn = (item: TItem) => string; + +export type AutocompleteValueFn = (item: TItem) => TValue; + +export type AutocompleteTrackFn = (item: TItem) => string | number; + +export type AutocompleteResolveValueFn = ( + value: TValue +) => Observable; diff --git a/src/app/shared/components/form/form-field/form-field.html b/src/app/shared/components/form/form-field/form-field.html index c6c0f316..c8e05937 100644 --- a/src/app/shared/components/form/form-field/form-field.html +++ b/src/app/shared/components/form/form-field/form-field.html @@ -42,4 +42,4 @@ /> } - \ No newline at end of file + diff --git a/src/app/shared/components/form/form-field/form-field.ts b/src/app/shared/components/form/form-field/form-field.ts index f1f04ef8..1194fb70 100644 --- a/src/app/shared/components/form/form-field/form-field.ts +++ b/src/app/shared/components/form/form-field/form-field.ts @@ -2,7 +2,9 @@ import { ChangeDetectionStrategy, Component, computed, - input + effect, + input, + signal } from '@angular/core'; import { AbstractControl } from '@angular/forms'; @@ -43,6 +45,8 @@ export class FormField { readonly showValidationWhenDirty = input(false); + readonly submitAttempted = input(false); + readonly validationMessages = input({}); @@ -64,6 +68,36 @@ export class FormField { 'ms-0.5 text-danger' ); + private readonly controlStateVersion = signal(0); + + constructor() { + effect((onCleanup) => { + const control = this.control(); + + if (!control) { + return; + } + + const statusSubscription = control.statusChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + const valueSubscription = control.valueChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + const eventsSubscription = control.events?.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + onCleanup(() => { + statusSubscription.unsubscribe(); + valueSubscription.unsubscribe(); + eventsSubscription?.unsubscribe(); + }); + }); + } + readonly validationId = computed( () => `${this.inputId()}-validation` ); @@ -77,17 +111,19 @@ export class FormField { ); readonly hasVisibleError = computed(() => { + this.controlStateVersion(); + const control = this.control(); if (!control?.invalid) { return false; } - if (this.showValidationWhenDirty()) { - return control.touched || control.dirty; - } - - return control.touched; + return ( + control.touched || + control.dirty || + this.submitAttempted() + ); }); readonly resolvedWrapperClass = computed(() => { @@ -138,4 +174,4 @@ export class FormField { readonly showHint = computed(() => { return !!this.hint() && !this.hasVisibleError(); }); -} \ No newline at end of file +} diff --git a/src/app/shared/components/form/form-input/form-input.html b/src/app/shared/components/form/form-input/form-input.html index a272f164..465c489f 100644 --- a/src/app/shared/components/form/form-input/form-input.html +++ b/src/app/shared/components/form/form-input/form-input.html @@ -8,6 +8,7 @@ [hint]="hint()" [hideValidation]="hideValidation()" [showValidationWhenDirty]="showValidationWhenDirty()" + [submitAttempted]="submitAttempted()" [validationMessages]="validationMessages()" [wrapperClass]="wrapperClass()" [labelClass]="labelClass()" @@ -112,4 +113,4 @@ } - \ No newline at end of file + diff --git a/src/app/shared/components/form/form-input/form-input.ts b/src/app/shared/components/form/form-input/form-input.ts index ffcf5614..8adaeff0 100644 --- a/src/app/shared/components/form/form-input/form-input.ts +++ b/src/app/shared/components/form/form-input/form-input.ts @@ -65,6 +65,7 @@ export class FormInput implements ControlValueAccessor { readonly hideValidation = input(false); readonly showValidationWhenDirty = input(false); + readonly submitAttempted = input(false); readonly validationMessages = input({}); @@ -91,6 +92,7 @@ export class FormInput implements ControlValueAccessor { readonly value = signal(null); readonly formDisabled = signal(false); readonly passwordVisible = signal(false); + private readonly controlStateVersion = signal(0); private onChange: (value: string | number | null) => void = () => {}; @@ -102,6 +104,32 @@ export class FormInput implements ControlValueAccessor { this.passwordVisible.set(false); } }); + + effect((onCleanup) => { + const control = this.control(); + + if (!control) { + return; + } + + const statusSubscription = control.statusChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + const valueSubscription = control.valueChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + const eventsSubscription = control.events?.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + onCleanup(() => { + statusSubscription.unsubscribe(); + valueSubscription.unsubscribe(); + eventsSubscription?.unsubscribe(); + }); + }); } readonly control = computed(() => { @@ -158,12 +186,23 @@ export class FormInput implements ControlValueAccessor { }); readonly resolvedInputClass = computed(() => { + this.controlStateVersion(); + + const control = this.control(); + const showInvalidState = !!( + control?.invalid && + ( + control.touched || + control.dirty || + this.submitAttempted() + ) + ); + return [ 'form-control', this.hasPrefixIcon() ? '!ps-10' : '', this.hasSuffixContent() ? '!pe-10' : '', - this.control()?.invalid && - (this.control()?.touched || this.control()?.dirty) + showInvalidState ? 'is-invalid' : '', this.inputClass() @@ -173,7 +212,10 @@ export class FormInput implements ControlValueAccessor { }); readonly describedBy = computed(() => { + this.controlStateVersion(); + const ids: string[] = []; + const control = this.control(); if (this.description()) { ids.push(`${this.inputId()}-description`); @@ -184,8 +226,12 @@ export class FormInput implements ControlValueAccessor { } if ( - this.control()?.invalid && - (this.control()?.touched || this.control()?.dirty) + control?.invalid && + ( + control.touched || + control.dirty || + this.submitAttempted() + ) ) { ids.push(`${this.inputId()}-validation`); } @@ -252,4 +298,4 @@ export class FormInput implements ControlValueAccessor { ? null : numericValue; } -} \ No newline at end of file +} diff --git a/src/app/shared/components/form/form-select/form-select.html b/src/app/shared/components/form/form-select/form-select.html index 54c36368..aaaef022 100644 --- a/src/app/shared/components/form/form-select/form-select.html +++ b/src/app/shared/components/form/form-select/form-select.html @@ -1 +1,223 @@ -

form-select works!

+ + + @if (showDropdownHeader()) { + + @if (searchable()) { +
+ +
+ } + + @if (isMultiple() && showSelectAll()) { + + } +
+ } + + +
+ @if (isMultiple() && showCheckboxes()) { + + } + + @if (item.prefixText) { + + {{ item.prefixText }} + + } + + + + {{ getOptionDisplayLabel(item, item$.label) }} + + + @if (item.description) { + + {{ item.description }} + + } + +
+
+ + + + @if (getSelectionPrefixText(item)) { + + {{ getSelectionPrefixText(item) }} + + } + + + {{ getSelectionDisplayLabel(item, label) }} + + + @if (isMultiple() && clearable() && !resolvedReadonly()) { + + } + + + + @if (isMultiple()) { + +
+ @if (items.length > 0) { + + + {{ getMultiLabelText(items) }} + + + @if (clearable() && !resolvedReadonly()) { + + } + + } +
+
+ } + + @if (isMultiple() && showMultiSelectFooter()) { + +
+ + {{ pendingValue().length }} selected + + + + + + + +
+
+ } +
+
diff --git a/src/app/shared/components/form/form-select/form-select.ts b/src/app/shared/components/form/form-select/form-select.ts index 580c90e1..24de8de0 100644 --- a/src/app/shared/components/form/form-select/form-select.ts +++ b/src/app/shared/components/form/form-select/form-select.ts @@ -1,11 +1,723 @@ -import { Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Injector, + computed, + forwardRef, + inject, + input, + output, + signal +} from '@angular/core'; +import { AbstractControl, ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; +import { + NgFooterTemplateDirective, + NgHeaderTemplateDirective, + NgLabelTemplateDirective, + NgMultiLabelTemplateDirective, + NgOptionTemplateDirective, + NgSelectComponent +} from '@ng-select/ng-select'; + +import { FormField, FormLabelPosition } from '../form-field/form-field'; +import { ValidationMessageMap } from '../form-validation-message/form-validation-message'; +import { + FormSelectDropdownPosition, + FormSelectMode, + FormSelectOption, + FormSelectPrimitive, + FormSelectScrollEvent, + FormSelectSearchEvent, + FormSelectSearchMode, + FormSelectValue +} from '../models/form-select.models'; @Component({ - selector: 'form-select', - imports: [], + selector: 'app-form-select', + standalone: true, + imports: [ + FormsModule, + FormField, + NgFooterTemplateDirective, + NgHeaderTemplateDirective, + NgLabelTemplateDirective, + NgMultiLabelTemplateDirective, + NgOptionTemplateDirective, + NgSelectComponent + ], templateUrl: './form-select.html', styleUrl: './form-select.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormSelect), + multi: true + } + ] }) -export class FormSelect { +export class FormSelect implements ControlValueAccessor { + private static nextId = 0; + private readonly injector = inject(Injector); + + private readonly generatedInputId = `form-select-${FormSelect.nextId++}`; + + readonly inputId = input(null); + + readonly label = input(''); + + readonly name = input(null); + + readonly options = input[]>([]); + + readonly mode = input('single'); + + readonly searchable = input(true); + + readonly searchMode = input('client'); + + readonly editableSearchTerm = input(null); + + readonly clearable = input(true); + + readonly hideSelected = input(false); + + readonly closeOnSelect = input(null); + + readonly maxSelectedItems = input(null); + + readonly placeholder = input(''); + + readonly required = input(false); + + readonly disabled = input(false); + + readonly readonly = input(false); + + readonly loading = input(false); + + readonly loadingText = input('Loading...'); + + readonly notFoundText = input('No options found'); + + readonly typeToSearchText = input('Type to search'); + + readonly clearAllText = input('Clear all'); + + readonly appendTo = input(''); + + readonly dropdownPosition = input('auto'); + + readonly virtualScroll = input(false); + + readonly bufferAmount = input(4); + + readonly groupBy = input(''); + + readonly selectableGroup = input(false); + + readonly selectableGroupAsModel = input(false); + + readonly selectOnTab = input(true); + + readonly clearOnBackspace = input(true); + + readonly showCheckboxes = input(true); + + readonly showSelectAll = input(true); + + readonly showMultiSelectFooter = input(false); + + readonly confirmLabel = input('OK'); + + readonly cancelLabel = input('Cancel'); + + readonly selectAllLabel = input('Select All'); + + readonly hideValidation = input(false); + + readonly showValidationWhenDirty = input(false); + + readonly submitAttempted = input(false); + + readonly validationMessages = input({}); + + readonly description = input(null); + + readonly hint = input(null); + + readonly labelPosition = input('top'); + + readonly hideLabel = input(false); + + readonly wrapperClass = input(''); + + readonly labelClass = input(''); + + readonly fieldContentClass = input(''); + + readonly selectClass = input(''); + + readonly ariaLabel = input(null); + + readonly ariaDescription = input(null); + + readonly selectionChanged = output>(); + + readonly searchChanged = output(); + + readonly opened = output(); + + readonly closed = output(); + + readonly cleared = output(); + + readonly focused = output(); + + readonly blurred = output(); + + readonly scrolled = output(); + + readonly scrolledToEnd = output(); + + readonly value = signal>(null); + + readonly pendingValue = signal([]); + + readonly formDisabled = signal(false); + + readonly dropdownOpen = signal(false); + + readonly searchTerm = signal(''); + + private onChange: (value: FormSelectValue) => void = () => {}; + + private onTouched: () => void = () => {}; + + readonly control = computed(() => { + return this.injector.get(NgControl, null, { + self: true, + optional: true + })?.control ?? null; + }); + + readonly resolvedInputId = computed(() => + this.inputId()?.trim() || this.generatedInputId + ); + + readonly resolvedName = computed(() => + this.name()?.trim() || this.resolvedInputId() + ); + + readonly isMultiple = computed(() => this.mode() === 'multiple'); + + readonly isDisabled = computed(() => + this.disabled() || + this.formDisabled() || + this.loading() + ); + + readonly resolvedReadonly = computed(() => + this.readonly() || + this.isDisabled() + ); + + readonly resolvedCloseOnSelect = computed(() => + this.closeOnSelect() ?? !this.isMultiple() + ); + + readonly resolvedEditableSearchTerm = computed(() => + this.editableSearchTerm() ?? false + ); + + readonly resolvedOptions = computed[]>(() => { + const options = this.options(); + const term = this.searchTerm().trim().toLocaleLowerCase(); + + if ( + !this.searchable() || + this.searchMode() === 'server' || + !term + ) { + return options; + } + + return options.filter(option => + this.optionMatchesSearchTerm(option, term) + ); + }); + + readonly selectItems = computed[]>(() => { + const selected = new Set(this.activeSelectedValues()); + const selectedOptions = this.options() + .filter(option => selected.has(option.value)); + const optionValues = new Set(selectedOptions.map(option => option.value)); + const visibleOptions = this.resolvedOptions() + .filter(option => !optionValues.has(option.value)); + + return [ + ...selectedOptions, + ...visibleOptions + ]; + }); + + readonly showDropdownHeader = computed(() => + this.searchable() || + ( + this.isMultiple() && + this.showSelectAll() + ) + ); + + readonly selectedValues = computed(() => { + const currentValue = this.value(); + + return Array.isArray(currentValue) + ? currentValue + : []; + }); + + readonly modelValue = computed>(() => { + if ( + this.isMultiple() && + this.showMultiSelectFooter() && + this.dropdownOpen() + ) { + return this.pendingValue(); + } + + return this.value(); + }); + + readonly allEnabledValues = computed(() => + this.options() + .filter(option => !option.disabled) + .map(option => option.value) + ); + + readonly allSelected = computed(() => { + const selected = new Set( + this.showMultiSelectFooter() + ? this.pendingValue() + : this.selectedValues() + ); + + const enabledValues = this.allEnabledValues(); + + return ( + enabledValues.length > 0 && + enabledValues.every(value => selected.has(value)) + ); + }); + + readonly partiallySelected = computed(() => { + const selected = new Set( + this.showMultiSelectFooter() + ? this.pendingValue() + : this.selectedValues() + ); + + const selectedCount = this.allEnabledValues() + .filter(value => selected.has(value)) + .length; + + return ( + selectedCount > 0 && + selectedCount < this.allEnabledValues().length + ); + }); + + readonly selectedCount = computed(() => + this.selectedValues().length + ); + + readonly resolvedPlaceholder = computed(() => + this.placeholder().trim() || + ( + this.label().trim() + ? `Select ${this.label().trim()}` + : 'Select' + ) + ); + + readonly resolvedSearchPlaceholder = computed(() => + this.placeholder().trim() || + ( + this.label().trim() + ? `Search ${this.label().trim()}` + : 'Search' + ) + ); + + + + readonly describedBy = computed(() => { + const ids: string[] = []; + const control = this.control(); + + if (this.description()) { + ids.push(`${this.resolvedInputId()}-description`); + } + + if (this.hint()) { + ids.push(`${this.resolvedInputId()}-hint`); + } + + if ( + control?.invalid && + ( + control.touched || + control.dirty || + this.submitAttempted() + ) + ) { + ids.push(`${this.resolvedInputId()}-validation`); + } + + return ids.length ? ids.join(' ') : null; + }); + + readonly inputAttrs = computed>(() => { + const attrs: Record = { + name: this.resolvedName() + }; + + const describedBy = this.describedBy(); + const ariaDescription = this.ariaDescription(); + + if (describedBy) { + attrs['aria-describedby'] = describedBy; + } + + if (ariaDescription) { + attrs['aria-description'] = ariaDescription; + } + + if (this.required()) { + attrs['aria-required'] = 'true'; + } + + return attrs; + }); + + readonly resolvedSelectClass = computed(() => { + const control = this.control(); + const showInvalidState = !!( + control?.invalid && + ( + control.touched || + control.dirty || + this.submitAttempted() + ) + ); + + return [ + 'ti-form-select', + 'rounded-sm', + 'border-defaultborder', + 'text-defaulttextcolor', + 'dark:border-defaultborder/10', + 'dark:text-white/70', + 'w-full', + 'app-form-select-control', + showInvalidState ? 'is-invalid' : '', + this.isDisabled() ? 'opacity-60 pointer-events-none' : '', + this.selectClass() + ] + .filter(Boolean) + .join(' '); + }); + + writeValue(value: FormSelectValue): void { + this.value.set(this.normalizeValue(value)); + } + + registerOnChange(fn: (value: FormSelectValue) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.formDisabled.set(disabled); + } + + onValueChange(incomingValue: FormSelectValue): void { + const normalizedValue = this.normalizeValue(incomingValue); + + if ( + this.isMultiple() && + this.showMultiSelectFooter() && + this.dropdownOpen() + ) { + this.pendingValue.set( + Array.isArray(normalizedValue) + ? normalizedValue + : [] + ); + + return; + } + + this.commitValue(normalizedValue); + } + + onSearchInput(event: Event): void { + const inputElement = event.target instanceof HTMLInputElement + ? event.target + : null; + const term = inputElement?.value ?? ''; + + this.searchTerm.set(term); + this.searchChanged.emit({ + term: term.trim() + }); + } + + toggleSelectAll(): void { + if (!this.isMultiple() || this.isDisabled()) { + return; + } + + const nextValue = this.allSelected() + ? [] + : [...this.allEnabledValues()]; + + if ( + this.showMultiSelectFooter() && + this.dropdownOpen() + ) { + this.pendingValue.set(nextValue); + return; + } + + this.commitValue(nextValue); + } + + onOpen(): void { + this.dropdownOpen.set(true); + this.searchTerm.set(''); + + if ( + this.isMultiple() && + this.showMultiSelectFooter() + ) { + this.pendingValue.set([ + ...this.selectedValues() + ]); + } + + this.opened.emit(); + } + + onClose(): void { + this.dropdownOpen.set(false); + this.searchTerm.set(''); + this.onTouched(); + this.closed.emit(); + } + + onFocus(): void { + this.focused.emit(); + } + + onBlur(): void { + this.onTouched(); + this.blurred.emit(); + } + + onClear(): void { + this.searchTerm.set(''); + this.cleared.emit(); + } + + onScroll(event: FormSelectScrollEvent): void { + this.scrolled.emit(event); + } + + onScrollToEnd(): void { + this.scrolledToEnd.emit(); + } + + confirmSelection(select: NgSelectComponent): void { + if (!this.isMultiple()) { + return; + } + + this.commitValue([ + ...this.pendingValue() + ]); + + select.close(); + } + + cancelSelection(select: NgSelectComponent): void { + this.pendingValue.set([ + ...this.selectedValues() + ]); + + select.close(); + } + + isOptionSelected(option: FormSelectOption): boolean { + const value = option.value; + const source = this.activeSelectedValues(); + + return source.includes(value); + } + + getMultiLabelText(items: readonly FormSelectOption[]): string { + const firstLabel = items[0]?.label ?? ''; + const remainingCount = items.length - 1; + + return remainingCount > 0 + ? `${firstLabel} +${remainingCount}` + : firstLabel; + } + + trackOption(option: FormSelectOption): TValue { + return option.value; + } + + getOptionContainerClass(option: FormSelectOption): string { + return [ + 'flex min-w-0 w-full items-start gap-2 text-inherit', + option.disabled ? 'text-textmuted opacity-60' : '', + this.isOptionSelected(option) && !option.disabled ? 'text-white' : '' + ] + .filter(Boolean) + .join(' '); + } + + getOptionLabelClass(option: FormSelectOption): string { + return [ + 'block truncate text-inherit', + option.disabled ? 'text-textmuted' : '' + ] + .filter(Boolean) + .join(' '); + } + + getOptionDisplayLabel( + option: FormSelectOption, + resolvedLabel: string | null | undefined + ): string { + return this.cleanText(option.label) || this.cleanText(resolvedLabel); + } + + getSelectionPrefixText(item: FormSelectOption | TValue): string { + return this.isOption(item) + ? item.prefixText?.trim() ?? '' + : ''; + } + + getSelectionDisplayLabel( + item: FormSelectOption | TValue, + resolvedLabel: string | null | undefined + ): string { + if (this.isOption(item)) { + return this.cleanText(item.label) || this.cleanText(resolvedLabel); + } + + return this.cleanText(resolvedLabel) || this.findOptionLabel(item); + } + + getOptionPrefixClass(option: FormSelectOption): string { + return [ + 'shrink-0 rounded-sm px-1.5 py-0.5 text-[0.6875rem] font-medium', + option.disabled + ? 'bg-light text-textmuted dark:bg-black/20' + : 'bg-light text-primary dark:bg-black/20' + ] + .filter(Boolean) + .join(' '); + } + + private commitValue(value: FormSelectValue): void { + this.value.set(value); + this.onChange(value); + this.selectionChanged.emit(value); + } + + private activeSelectedValues(): readonly TValue[] { + if ( + this.isMultiple() && + this.showMultiSelectFooter() && + this.dropdownOpen() + ) { + return this.pendingValue(); + } + + const currentValue = this.value(); + + if (this.isValueArray(currentValue)) { + return currentValue; + } + + return currentValue === null + ? [] + : [currentValue]; + } + + private normalizeValue(value: FormSelectValue): FormSelectValue { + if (this.isMultiple()) { + return this.isValueArray(value) + ? value.filter(item => !this.isEmptyStringValue(item)) + : []; + } + + return this.isValueArray(value) + ? this.normalizeSingleValue(value[0] ?? null) + : this.normalizeSingleValue(value); + } + + private normalizeSingleValue(value: TValue | null): TValue | null { + return this.isEmptyStringValue(value) + ? null + : value ?? null; + } + + private isValueArray(value: FormSelectValue): value is readonly TValue[] { + return Array.isArray(value); + } + + private isEmptyStringValue(value: TValue | null): boolean { + return typeof value === 'string' && value.trim() === ''; + } + + private isOption(value: FormSelectOption | TValue): value is FormSelectOption { + return ( + typeof value === 'object' && + value !== null && + 'value' in value && + 'label' in value + ); + } + + private findOptionLabel(value: TValue): string { + return this.options() + .find(option => option.value === value) + ?.label + ?.trim() ?? ''; + } + + private optionMatchesSearchTerm( + option: FormSelectOption, + term: string + ): boolean { + return [ + option.label, + option.prefixText ?? '', + option.description ?? '' + ].some(value => + this.cleanText(value).toLocaleLowerCase().includes(term) + ); + } + + private cleanText(value: string | null | undefined): string { + return value?.trim() ?? ''; + } } diff --git a/src/app/shared/components/form/form-validation-message/form-validation-message.ts b/src/app/shared/components/form/form-validation-message/form-validation-message.ts index 3b18ef15..aa975827 100644 --- a/src/app/shared/components/form/form-validation-message/form-validation-message.ts +++ b/src/app/shared/components/form/form-validation-message/form-validation-message.ts @@ -2,7 +2,9 @@ import { ChangeDetectionStrategy, Component, computed, - input + effect, + input, + signal } from '@angular/core'; import { AbstractControl } from '@angular/forms'; @@ -23,23 +25,62 @@ export class FormValidationMessage { readonly showWhenDirty = input(false); + readonly submitAttempted = input(false); + readonly customClass = input( 'mt-1 text-[0.75rem] text-danger' ); + private readonly controlStateVersion = signal(0); + + constructor() { + effect((onCleanup) => { + const control = this.control(); + + if (!control) { + return; + } + + const statusSubscription = control.statusChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + const valueSubscription = control.valueChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + const eventsSubscription = control.events?.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + onCleanup(() => { + statusSubscription.unsubscribe(); + valueSubscription.unsubscribe(); + eventsSubscription?.unsubscribe(); + }); + }); + } + readonly shouldShow = computed(() => { + this.controlStateVersion(); + const control = this.control(); if (!control || !control.invalid) { return false; } - return this.showWhenDirty() - ? control.touched || control.dirty - : control.touched; + return ( + control.touched || + control.dirty || + this.submitAttempted() + ); }); readonly message = computed(() => { + this.controlStateVersion(); + this.submitAttempted(); + const control = this.control(); if (!control?.errors) { @@ -124,4 +165,4 @@ export class FormValidationMessage { return `${fieldName} is invalid.`; } } -} \ No newline at end of file +} diff --git a/src/app/shared/components/form/models/form-select.models.ts b/src/app/shared/components/form/models/form-select.models.ts new file mode 100644 index 00000000..8506d088 --- /dev/null +++ b/src/app/shared/components/form/models/form-select.models.ts @@ -0,0 +1,37 @@ +export type FormSelectPrimitive = string | number; + +export type FormSelectMode = 'single' | 'multiple'; + +export type FormSelectSearchMode = 'client' | 'server'; + +export type FormSelectDropdownPosition = + | 'auto' + | 'bottom' + | 'top' + | 'left' + | 'right'; + +export type FormSelectValue< + TValue extends FormSelectPrimitive = FormSelectPrimitive +> = TValue | readonly TValue[] | null; + +export interface FormSelectOption< + TValue extends FormSelectPrimitive = FormSelectPrimitive +> { + readonly value: TValue; + readonly label: string; + readonly prefixText?: string | null; + readonly description?: string | null; + readonly group?: string | null; + readonly disabled?: boolean; + readonly metadata?: Readonly>; +} + +export interface FormSelectSearchEvent { + readonly term: string; +} + +export interface FormSelectScrollEvent { + readonly start: number; + readonly end: number; +} diff --git a/src/app/shared/components/modal/modal.html b/src/app/shared/components/modal/modal.html index 254ed89a..ee0e83b8 100644 --- a/src/app/shared/components/modal/modal.html +++ b/src/app/shared/components/modal/modal.html @@ -1,52 +1,49 @@ @if (open()) { diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts index 7ab5c7e1..a749e989 100644 --- a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts +++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts @@ -34,6 +34,7 @@ import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/c interface CurrencyTableRow extends DataTableRecord { id: string; code: string; + iso2: string; name: string; symbol: string; numericCode: number; @@ -124,14 +125,14 @@ export class CurrencyList { readonly currencySubmitLabel = computed(() => this.currencyModalMode() === 'create' - ? 'Save Currency' - : 'Update Currency' + ? 'Save' + : 'Update' ); readonly currencyLoadingLabel = computed(() => this.currencyModalMode() === 'create' - ? 'Saving Currency...' - : 'Updating Currency...' + ? 'Saving...' + : 'Updating...' ); readonly currencySubmitAction = computed<'save' | 'update'>(() => @@ -143,6 +144,7 @@ export class CurrencyList { readonly columns = signal[]>([ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, + { key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true }, { key: 'code', label: 'Code', header: 'Code', sortable: true }, { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true }, { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true }, @@ -495,6 +497,7 @@ export class CurrencyList { return { id: row.id, code: row.code, + iso2: row.iso2?.toLowerCase() ?? '', name: row.name, symbol: row.symbol, numericCode: row.numericCode, @@ -504,4 +507,16 @@ export class CurrencyList { modifiedOn: row.modifiedOn }; } + + getFlagUrl(iso2: string | null | undefined): string { + const code = iso2?.trim().toLowerCase(); + + return code && /^[a-z]{2}$/.test(code) + ? `https://flagcdn.com/24x18/${code}.png` + : ''; + } + onFlagError(event: Event): void { + const image = event.target as HTMLImageElement; + image.style.display = 'none'; + } } diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.ts b/src/app/features/global-masters/languages/pages/language-list/language-list.ts index fe40cf7a..bc3ba2ef 100644 --- a/src/app/features/global-masters/languages/pages/language-list/language-list.ts +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.ts @@ -84,10 +84,10 @@ export class LanguageList { this.modalMode() === 'create' ? 'Add Language' : 'Edit Language' ); readonly submitLabel = computed(() => - this.modalMode() === 'create' ? 'Save Language' : 'Update Language' + this.modalMode() === 'create' ? 'Save' : 'Update' ); readonly loadingLabel = computed(() => - this.modalMode() === 'create' ? 'Saving Language...' : 'Updating Language...' + this.modalMode() === 'create' ? 'Saving...' : 'Updating...' ); readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update' diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.html b/src/app/features/global-masters/states/pages/state-list/state-list.html index 87e3e591..f560158d 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.html +++ b/src/app/features/global-masters/states/pages/state-list/state-list.html @@ -1,20 +1,14 @@ -
-
-
+
-
-
Country Selection
-
-
-
-
+
- @@ -59,6 +51,32 @@ (closed)="closeStateModal()" (submitted)="saveState()">
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+
+ diff --git a/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.scss b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts new file mode 100644 index 00000000..c4edeea4 --- /dev/null +++ b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts @@ -0,0 +1,410 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs'; +import { TenantCurrenciesService } from '../../../../core/services/tenant/tenant-currencies.service'; +import { LanguageService } from '../../../../core/services/language/language.service'; +import { CurrencyService } from '../../../../core/services/currency/currency.service'; +import { TimezoneService } from '../../../../core/services/timezone/timezone.service'; +import { DataTable } from '../../../../shared/components/data-table/data-table'; +import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; +import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; + +import { CreateTenantCurrencyRequest, TenantCurrencyDto, TenantCurrencyModalMode, TenantCurrencyTableRow, UpdateTenantCurrencyRequest } from '../../../../core/models/tenant/tenant-currencies.model'; +import { LanguageLookupDto } from '../../../../core/models/language/language.model'; +import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; +import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model'; +import { TenantLookupDto } from '../../../../core/models/tenant/tenant.model'; +import { TenantService } from '../../../../core/services/tenant/tenant.service'; +import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; +import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; +import { FormInput } from '../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../shared/components/modal/modal'; +import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; +import { FilterCard } from '../../../../shared/components/filter-card/filter-card'; + +@Component({ + selector: 'tenant-currencies', + imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete, ConfirmDialog, FilterCard], + templateUrl: './tenant-currencies.html', + styleUrl: './tenant-currencies.scss', +}) +export class TenantCurrencies { + + private readonly destroyRef = inject(DestroyRef); + private readonly tenantCurrencyApi = inject(TenantCurrenciesService); + private readonly tenantApi = inject(TenantService); + private readonly languageApi = inject(LanguageService); + private readonly currencyApi = inject(CurrencyService); + private readonly timezoneApi = inject(TimezoneService); + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly queryRequests$ = new Subject<{ tenantId: string; query: DataTableQuery; }>(); + + readonly queryState = new DataTableQueryState(); + readonly tenantCurrencies = signal([]); + readonly totalRecords = signal(0); + readonly filteredRecords = signal(0); + + readonly saving = signal(false); + readonly showTenantCurrencyModal = signal(false); + readonly tenantCurrencyModalMode = signal('create'); + readonly selectedTenantId = signal(null); + readonly selectedTenantLookup = signal(null); + readonly tenantSubmitAttempted = signal(false); + + + + readonly tenantCurrencyForm = this.formBuilder.group({ + tenantId: this.formBuilder.control(null, [Validators.required]), + currencyId: this.formBuilder.control(null, [Validators.required]), + isActive: this.formBuilder.control(1, [Validators.required]), + isBaseCurrency: this.formBuilder.control(false, [Validators.required]), + isReporting: this.formBuilder.control(false, [Validators.required]), + }); + readonly tenantCurrencyFilterForm = this.formBuilder.nonNullable.group({ + tenantId: [''] + }); + + readonly searchTenants: + AutocompleteSearchFn = + (term, limit) => + this.tenantApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error( + 'Unable to load tenants.' + ); + + return of< + readonly TenantLookupDto[] + >([]); + }) + ); + + readonly displayTenant: + AutocompleteDisplayFn = + tenant => + [tenant.code, tenant.name] + .filter(Boolean) + .join(' - '); + + readonly tenantValue: + AutocompleteValueFn< + TenantLookupDto, + string + > = + tenant => tenant.id; + + readonly resolveTenant: + AutocompleteResolveValueFn< + TenantLookupDto, + string + > = + tenantId => + this.tenantApi + .getTenantById(tenantId) + .pipe( + map(tenant => ({ + id: tenant.id, + code: tenant.code, + name: tenant.name + })) + ); + + readonly searchCurrencies: AutocompleteSearchFn = (term, limit) => + this.currencyApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load currencies.'); + return of([]); + }) + ); + + readonly displayCurrency: AutocompleteDisplayFn = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : ''] + .filter(Boolean) + .join(' '); + + readonly currencyValue: AutocompleteValueFn = currency => currency.id; + + readonly resolveCurrency: AutocompleteResolveValueFn = value => + this.currencyApi.getCurrencyById(value).pipe( + map(currency => ({ + id: currency.id, + code: currency.code, + name: currency.name, + symbol: currency.symbol + })) + ); + + + readonly modalTitle = computed(() => + this.tenantCurrencyModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant' + ); + + readonly submitLabel = computed(() => + this.tenantCurrencyModalMode() === 'create' ? 'Save' : 'Update' + ); + + readonly loadingLabel = computed(() => + this.tenantCurrencyModalMode() === 'create' ? 'Saving...' : 'Updating...' + ); + + readonly submitAction = computed<'save' | 'update'>(() => + this.tenantCurrencyModalMode() === 'create' ? 'save' : 'update' + ); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true, align: 'left' }, + { key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' }, + { + key: 'isBaseCurrency', + label: 'Base Currency', + header: 'Base Currency', + sortable: true, + badge: true, + formatter: value => value === true ? 'Yes' : 'No' + }, + { + key: 'isReporting', + label: 'Reporting Currency', + header: 'Reporting Currency', + sortable: true, + badge: true, + formatter: value => value === true ? 'Yes' : 'No' + }, + { + key: 'isActive', + label: 'Active', + header: 'Active', + sortable: true, + badge: true, + badgeClass: value => value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger', + formatter: value => value === true ? 'Active' : 'Inactive' + }, + { + key: 'createdOn', + label: 'Created On', + header: 'Created On', + sortable: true, + formatter: value => this.formatDateTime(value) + } + ]); + + readonly actions = signal[]>([ + { + type: 'edit', + label: 'Edit', + icon: 'ti ti-edit', + className: 'text-primary' + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger', + visible: row => row.isActive + }, + { + type: 'activate', + label: 'Activate', + icon: 'ti ti-check', + className: 'text-success', + visible: row => !row.isActive + } + ]); + + private formatDateTime(value: unknown): string { + if (typeof value !== 'string' || value.trim().length === 0) { + return ''; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); + } + + constructor() { + this.queryRequests$ + .pipe( + switchMap(({ tenantId, query }) => + this.tenantCurrencyApi + .getTenantDataTable(this.buildTenantCurrencyQuery(query)) + .pipe( + map(response => ({ + response, + tenantId, + query + })), + catchError(() => { + this.toastr.error( + 'Unable to load tenant currencies.' + ); + + this.clearTenantCurrencyGrid(); + + return of(null); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(result => { + if (!result) { + return; + } + + const { + response, + tenantId, + query + } = result; + + if (this.selectedTenantId() !== tenantId) { + return; + } + + if (response.draw !== query.draw) { + return; + } + + const rows: TenantCurrencyTableRow[] = + response.rows.map( + (tenantCurrency, index) => ({ + ...tenantCurrency, + serialNumber: + (query.page - 1) * + query.pageSize + + index + + 1 + }) + ); + + this.tenantCurrencies.set(rows); + this.totalRecords.set(response.total); + this.filteredRecords.set( + response.filtered + ); + }); + } + ngOnInit(): void { + this.clearTenantCurrencyGrid(); + } + + loadTenantCurrencies(query: DataTableQuery): void { + const tenantId = this.selectedTenantId(); + + if (!tenantId) { + this.clearTenantCurrencyGrid(); + return; + } + + this.queryRequests$.next({ + tenantId, + query + }); + } + + onSearch(value: string): void { + this.loadTenantCurrencies(this.queryState.setSearch(value.trim())); + } + + onPageChange(event: DataTablePageEvent): void { + this.loadTenantCurrencies(this.queryState.setPage(event)); + } + + onSortChange(event: DataTableSortEvent): void { + this.loadTenantCurrencies(this.queryState.setSort(event)); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'edit') { + this.openEditTenantCurrency(event.row.id); + } + } + private buildTenantCurrencyQuery(query: DataTableQuery): DataTableQuery { + return { + ...query, + sortBy: this.resolveSortField(query.sortBy ?? null) + }; + } + + private resolveSortField(sortBy: string | null | undefined): string | null { + if (!sortBy) { + return null; + } + + switch (sortBy) { + case 'code': + case 'name': + case 'status': + case 'dataRegion': + case 'isActive': + case 'createdOn': + return sortBy; + default: + return null; + } + } + + onTenantLookupSelected(tenant: TenantLookupDto | null): void { + this.selectedTenantLookup.set(tenant); + + const tenantId = tenant?.id ?? null; + + this.tenantCurrencyFilterForm.controls.tenantId + .setValue(tenantId ?? ''); + + this.onTenantSelected(tenantId); + } + + onAddTenantCurrency(): void { + this.tenantCurrencyModalMode.set('create'); + + this.showTenantCurrencyModal.set(true); + } + + closeTenantCurrencyModal(): void { + if (this.saving()) { + return; + } + + this.showTenantCurrencyModal.set(false); + } + + private openEditTenantCurrency(id: string): void { + this.tenantCurrencyModalMode.set('edit'); + + } + + private onTenantSelected(tenantId: string | null): void { + this.selectedTenantId.set(tenantId); + + + this.clearTenantCurrencyGrid(); + + const query = this.queryState.reset(); + + if (!tenantId) { + return; + } + + this.loadTenantCurrencies(query); + } + + + private clearTenantCurrencyGrid(): void { + this.tenantCurrencies.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + } + +} diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.html b/src/app/features/tenants/pages/tenant-list/tenant-list.html index c54a2ab0..c5dcba5a 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.html +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.html @@ -1,62 +1,185 @@ + + +
+
+
+ +
- -
- - - +
+ +
- - {{ value }} - -
-
+
+ +
- +
+ +
- \ No newline at end of file +
+ +
+ +
+ +
+ +
+ +
+
+ +
\ No newline at end of file diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.scss b/src/app/features/tenants/pages/tenant-list/tenant-list.scss index e69de29b..a0638cc7 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.scss +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.scss @@ -0,0 +1 @@ +/* Intentionally empty: tenant screen reuses the shared Ynex layout classes. */ diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.ts b/src/app/features/tenants/pages/tenant-list/tenant-list.ts index e8c1ba0e..2365dce2 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.ts +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.ts @@ -1,195 +1,579 @@ -import { Component, signal, viewChild } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs'; + +import { CreateTenantRequest, TenantDto, TenantModalMode, TenantStatus, TenantTableRow, UpdateTenantRequest } from '../../../../core/models/tenant/tenant.model'; +import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model'; +import { LanguageLookupDto } from '../../../../core/models/language/language.model'; +import { TimezoneLookupDto } from '../../../../core/models/timezone/timezone.model'; +import { CurrencyService } from '../../../../core/services/currency/currency.service'; +import { LanguageService } from '../../../../core/services/language/language.service'; +import { TenantService } from '../../../../core/services/tenant/tenant.service'; +import { TimezoneService } from '../../../../core/services/timezone/timezone.service'; import { DataTable } from '../../../../shared/components/data-table/data-table'; -import { DataTableColumn, DataTableAction } from '../../../../shared/components/data-table/data-table.types'; import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; -import { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; -import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive'; -import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; +import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableRecord, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; +import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; +import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; +import { FormInput } from '../../../../shared/components/form/form-input/form-input'; +import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; +import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models'; +import { Modal } from '../../../../shared/components/modal/modal'; + + @Component({ - selector: 'tenant-list', - imports: [CommonModule, DataTable, DataTableCellDirective, ConfirmDialog], - templateUrl: './tenant-list.html', - styleUrl: './tenant-list.scss', + selector: 'tenant-list', + standalone: true, + imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete], + templateUrl: './tenant-list.html', + styleUrl: './tenant-list.scss' }) export class TenantList { + private readonly destroyRef = inject(DestroyRef); + private readonly tenantApi = inject(TenantService); + private readonly languageApi = inject(LanguageService); + private readonly currencyApi = inject(CurrencyService); + private readonly timezoneApi = inject(TimezoneService); + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + private readonly toastr = inject(ToastrService); + private readonly queryRequests$ = new Subject(); - tableQuery = new DataTableQueryState(); + readonly queryState = new DataTableQueryState(); + readonly tenants = signal([]); + readonly totalRecords = signal(0); + readonly filteredRecords = signal(0); + readonly saving = signal(false); + readonly showTenantModal = signal(false); + readonly tenantModalMode = signal('create'); + readonly selectedTenantId = signal(null); + readonly selectedTenant = signal(null); + readonly tenantSubmitAttempted = signal(false); - loading = false; - pageIndex = 1; - pageSize = 10; - totalRecords = 3; - searchText = ''; - readonly pendingDeleteTenant = signal<{ id: number } | null>(null); - readonly deleteConfirmDialog = viewChild(ConfirmDialog); + readonly tenantForm = this.formBuilder.group({ + code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]), + name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]), + status: this.formBuilder.control(TenantStatus.Trial, [Validators.required]), + defaultLanguageId: this.formBuilder.control(null, [Validators.required]), + defaultCurrencyId: this.formBuilder.control(null, [Validators.required]), + defaultTimezoneId: this.formBuilder.control(null, [Validators.required]), + dataRegion: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(100)]), + isActive: this.formBuilder.control(1, [Validators.required]) + }); - allowedPermissions: string[] = [ - 'tenant.view', - 'tenant.edit', - 'tenant.delete' - ]; + readonly tenantStatusOptions = signal[]>([ + { value: TenantStatus.Trial, label: 'Trial' }, + { value: TenantStatus.Active, label: 'Active' }, + { value: TenantStatus.Suspended, label: 'Suspended' }, + { value: TenantStatus.Cancelled, label: 'Cancelled' } + ]); - columns: DataTableColumn[] = [ - { key: 'id', header: 'Id', label: 'Id', sortable: true, headerClass: 'text-center', cellClass: 'text-center' }, - { key: 'tenantName', header: 'Tenant Name', label: 'Tenant Name', sortable: true }, - { key: 'companyCode', header: 'Company Code', label: 'Company Code', sortable: true }, - { key: 'email', header: 'Email', label: 'Email' }, - { key: 'country', header: 'Country', label: 'Country', sortable: true }, - { - key: 'status', - header: 'Status', - label: 'Status', - sortable: true, - cellClass: 'text-center', - badge: true, - badgeClass: value => - value === 'Active' - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger' - } - ]; + readonly activeStatusOptions = signal[]>([ + { value: 1, label: 'Active' }, + { value: 0, label: 'Inactive' } + ]); - tenants = [ - { - id: 1, - tenantName: 'Syscom Corporation', - companyCode: 'SYSCOM', - email: 'admin@syscom.com', - country: 'UAE', - status: 'Active', - logo: 'assets/images/brand-logos/erp-logo-icon.png' - }, - { - id: 2, - tenantName: 'Biz360 Demo', - companyCode: 'BIZ360', - email: 'demo@biz360.com', - country: 'India', - status: 'Active', - logo:'assets/images/brand-logos/erp-logo-icon.png' - }, - { - id: 3, - tenantName: 'Test Tenant', - companyCode: 'TEST', - email: 'test@test.com', - country: 'India', - status: 'Inactive' - } - ]; + readonly searchLanguages: AutocompleteSearchFn = (term, limit) => + this.languageApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load languages.'); + return of([]); + }) + ); - tenants1 = [ - { - id: 1, - tenantName: 'Syscom Corporation', - companyCode: 'SYSCOM', - email: 'admin@syscom.com', - country: 'UAE', - status: 'Active' - }, - { - id: 2, - tenantName: 'Biz360 Demo', - companyCode: 'BIZ360', - email: 'demo@biz360.com', - country: 'India', - status: 'Active' - }, - { - id: 3, - tenantName: 'Test Tenant', - companyCode: 'TEST', - email: 'test@test.com', - country: 'India', - status: 'Inactive' - } - ]; + readonly searchCurrencies: AutocompleteSearchFn = (term, limit) => + this.currencyApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load currencies.'); + return of([]); + }) + ); - tableActions: DataTableAction[] = [ - { - type: 'download', - label: 'Download', - icon: 'ri-download-2-line !mb-0', - className: 'ti-btn ti-btn-sm ti-btn-success !rounded-full', - permission: 'tenant.view' - }, - { - type: 'edit', - label: 'Edit', - icon: 'ri-edit-line !mb-0', - className: 'ti-btn ti-btn-sm ti-btn-info !rounded-full', - permission: 'tenant.edit' - }, - { - type: 'delete', - label: 'Delete', - icon: 'ri-delete-bin-line', - className: 'ti-btn ti-btn-sm ti-btn-danger !rounded-full', - permission: 'tenant.delete', - //visible: row => row.status !== 'Active' - } - ]; + readonly searchTimezones: AutocompleteSearchFn = (term, limit) => + this.timezoneApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load timezones.'); + return of([]); + }) + ); + readonly displayLanguage: AutocompleteDisplayFn = language => [language.code, language.name].filter(Boolean).join(' - '); - onSearch(searchText: string): void { - this.searchText = searchText; - this.pageIndex = 1; + readonly languageValue: AutocompleteValueFn = language => language.id; - if (searchText.trim() === '') { - this.tenants = [...this.tenants1]; - } else { - this.tenants = this.tenants.filter(tenant => - tenant.tenantName.toLowerCase().includes(searchText.toLowerCase()) || - tenant.companyCode.toLowerCase().includes(searchText.toLowerCase()) || - tenant.email.toLowerCase().includes(searchText.toLowerCase()) || - tenant.country.toLowerCase().includes(searchText.toLowerCase()) || - tenant.status.toLowerCase().includes(searchText.toLowerCase()) - ); + readonly resolveLanguage: AutocompleteResolveValueFn = value => + this.languageApi.getById(value).pipe( + map(language => ({ + id: language.id, + code: language.code, + name: language.name, + nativeName: language.nativeName, + isRightToLeft: language.isRightToLeft + })) + ); + + readonly displayCurrency: AutocompleteDisplayFn = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : ''] + .filter(Boolean) + .join(' '); + + readonly currencyValue: AutocompleteValueFn = currency => currency.id; + + readonly resolveCurrency: AutocompleteResolveValueFn = value => + this.currencyApi.getCurrencyById(value).pipe( + map(currency => ({ + id: currency.id, + code: currency.code, + name: currency.name, + symbol: currency.symbol + })) + ); + + readonly displayTimezone: AutocompleteDisplayFn = timezone => [timezone.ianaId, timezone.displayName].filter(Boolean).join(' — '); + + readonly timezoneValue: AutocompleteValueFn = timezone => timezone.id; + + readonly resolveTimezone: AutocompleteResolveValueFn = value => + this.timezoneApi.getById(value).pipe( + map(timezone => ({ + id: timezone.id, + ianaId: timezone.ianaId, + displayName: timezone.displayName + })) + ); + + readonly modalTitle = computed(() => + this.tenantModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant' + ); + + readonly submitLabel = computed(() => + this.tenantModalMode() === 'create' ? 'Save' : 'Update' + ); + + readonly loadingLabel = computed(() => + this.tenantModalMode() === 'create' ? 'Saving...' : 'Updating...' + ); + + readonly submitAction = computed<'save' | 'update'>(() => + this.tenantModalMode() === 'create' ? 'save' : 'update' + ); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true, align: 'left' }, + { key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' }, + { + key: 'status', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => this.getTenantStatusBadgeClass(value as TenantStatus), + formatter: value => this.formatTenantStatus(value as TenantStatus) + }, + { key: 'dataRegion', label: 'Data Region', header: 'Data Region', sortable: true, align: 'left' }, + { + key: 'isActive', + label: 'Active', + header: 'Active', + sortable: true, + badge: true, + badgeClass: value => value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger', + formatter: value => value === true ? 'Active' : 'Inactive' + }, + { + key: 'createdOn', + label: 'Created On', + header: 'Created On', + sortable: true, + formatter: value => this.formatDateTime(value) + } + ]); + + readonly actions = signal[]>([ + { + type: 'edit', + label: 'Edit', + icon: 'ti ti-edit', + className: 'text-primary' + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger', + visible: row => row.isActive + }, + { + type: 'activate', + label: 'Activate', + icon: 'ti ti-check', + className: 'text-success', + visible: row => !row.isActive + } + ]); + + constructor() { + this.queryRequests$ + .pipe( + switchMap(query => + this.tenantApi.getTenantDataTable(this.buildTenantQuery(query)).pipe( + catchError(() => { + this.toastr.error('Unable to load tenants.'); + this.clearTenantGrid(); + return of(null); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(response => { + if (!response) { + return; + } + + const query = this.queryState.getQuery(); + + if (response.draw !== query.draw) { + return; + } + + const tenantsWithSerialNumbers: TenantTableRow[] = response.rows.map((tenant, index) => ({ + ...tenant, + serialNumber: (query.page - 1) * query.pageSize + index + 1 + })); + + this.tenants.set(tenantsWithSerialNumbers); + this.totalRecords.set(response.total); + this.filteredRecords.set(response.filtered); + }); } - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.tableQuery.setPage(event); - //this.loadTenants(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.tableQuery.setSort(event); - //this.loadTenants(query); - } - - onTableAction(event: any): void { - if (event?.action?.type === 'delete') { - this.pendingDeleteTenant.set(event.row as { id: number }); - this.deleteConfirmDialog()?.open(); - return; + ngOnInit(): void { + this.loadTenants(this.queryState.getQuery()); } - console.log('Action:', event.action.type, event.row); - } - - onDeleteConfirmed(): void { - const tenant = this.pendingDeleteTenant(); - - if (!tenant) { - return; + loadTenants(query: DataTableQuery): void { + this.queryRequests$.next(query); } - this.pendingDeleteTenant.set(null); - this.tenants = this.tenants.filter(currentTenant => currentTenant.id !== tenant.id); - this.tenants1 = this.tenants1.filter(currentTenant => currentTenant.id !== tenant.id); - this.totalRecords = this.tenants.length; - console.log('Deleted tenant:', tenant); - } + onSearch(value: string): void { + this.loadTenants(this.queryState.setSearch(value.trim())); + } - onDeleteCancelled(): void { - this.pendingDeleteTenant.set(null); - } + onPageChange(event: DataTablePageEvent): void { + this.loadTenants(this.queryState.setPage(event)); + } - onRowClick(row: any): void { - console.log('Row clicked:', row); - } + onSortChange(event: DataTableSortEvent): void { + this.loadTenants(this.queryState.setSort(event)); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'edit') { + this.openEditTenant(event.row.id); + } + } + + onAddTenant(): void { + this.tenantModalMode.set('create'); + this.selectedTenantId.set(null); + this.selectedTenant.set(null); + this.tenantSubmitAttempted.set(false); + this.resetTenantForm({ + code: '', + name: '', + status: TenantStatus.Trial, + defaultLanguageId: null, + defaultCurrencyId: null, + defaultTimezoneId: null, + dataRegion: '', + isActive: 1 + }); + this.showTenantModal.set(true); + } + + closeTenantModal(): void { + if (this.saving()) { + return; + } + + this.showTenantModal.set(false); + this.selectedTenantId.set(null); + this.selectedTenant.set(null); + this.tenantSubmitAttempted.set(false); + this.resetTenantForm({ + code: '', + name: '', + status: TenantStatus.Trial, + defaultLanguageId: null, + defaultCurrencyId: null, + defaultTimezoneId: null, + dataRegion: '', + isActive: 1 + }); + } + + saveTenant(): void { + if (this.tenantForm.invalid) { + this.tenantSubmitAttempted.set(true); + this.tenantForm.markAllAsTouched(); + this.focusFirstInvalidControl(); + return; + } + + if (this.saving()) { + return; + } + + this.saving.set(true); + + if (this.tenantModalMode() === 'create') { + this.tenantApi + .createTenant(this.buildCreateTenantRequest()) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.toastr.success('Tenant saved successfully.'); + this.finishTenantSave(); + }, + error: (error: HttpErrorResponse) => this.handleSaveError(error) + }); + + return; + } + + const tenantId = this.selectedTenantId(); + + if (!tenantId) { + this.saving.set(false); + return; + } + + this.tenantApi + .updateTenant(tenantId, this.buildUpdateTenantRequest()) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.toastr.success('Tenant updated successfully.'); + this.finishTenantSave(); + }, + error: (error: HttpErrorResponse) => this.handleSaveError(error) + }); + } + + openEditTenant(id: string): void { + this.tenantModalMode.set('edit'); + this.selectedTenantId.set(id); + this.selectedTenant.set(null); + this.tenantSubmitAttempted.set(false); + this.resetTenantForm({ + code: '', + name: '', + status: TenantStatus.Trial, + defaultLanguageId: null, + defaultCurrencyId: null, + defaultTimezoneId: null, + dataRegion: '', + isActive: 1 + }); + + this.tenantApi + .getTenantById(id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: tenant => { + if (this.selectedTenantId() !== tenant.id) { + return; + } + + this.selectedTenant.set(tenant); + this.resetTenantForm({ + code: tenant.code ?? '', + name: tenant.name ?? '', + status: tenant.status ?? TenantStatus.Trial, + defaultLanguageId: tenant.defaultLanguageId ?? null, + defaultCurrencyId: tenant.defaultCurrencyId ?? null, + defaultTimezoneId: tenant.defaultTimezoneId ?? null, + dataRegion: tenant.dataRegion ?? '', + isActive: tenant.isActive ? 1 : 0 + }); + this.showTenantModal.set(true); + }, + error: (error: HttpErrorResponse) => { + if (error.status === 404) { + this.toastr.error('The tenant is no longer available.'); + } + + this.selectedTenantId.set(null); + } + }); + } + + private buildTenantQuery(query: DataTableQuery): DataTableQuery { + return { + ...query, + sortBy: this.resolveSortField(query.sortBy ?? null) + }; + } + + private resolveSortField(sortBy: string | null | undefined): string | null { + if (!sortBy) { + return null; + } + + switch (sortBy) { + case 'code': + case 'name': + case 'status': + case 'dataRegion': + case 'isActive': + case 'createdOn': + return sortBy; + default: + return null; + } + } + + private buildCreateTenantRequest(): CreateTenantRequest { + const value = this.tenantForm.getRawValue(); + + return { + code: value.code.trim(), + name: value.name.trim(), + status: value.status ?? TenantStatus.Trial, + defaultLanguageId: value.defaultLanguageId ?? '', + defaultCurrencyId: value.defaultCurrencyId ?? '', + defaultTimezoneId: value.defaultTimezoneId ?? '', + dataRegion: value.dataRegion.trim() + }; + } + + private buildUpdateTenantRequest(): UpdateTenantRequest { + const value = this.tenantForm.getRawValue(); + + return { + code: value.code.trim(), + name: value.name.trim(), + status: value.status ?? TenantStatus.Trial, + defaultLanguageId: value.defaultLanguageId ?? '', + defaultCurrencyId: value.defaultCurrencyId ?? '', + defaultTimezoneId: value.defaultTimezoneId ?? '', + defaultDbConnectionId: this.selectedTenant()?.defaultDbConnectionId ?? null, + dataRegion: value.dataRegion.trim(), + isActive: value.isActive === 1 + }; + } + + private finishTenantSave(): void { + this.showTenantModal.set(false); + this.selectedTenantId.set(null); + this.selectedTenant.set(null); + this.tenantSubmitAttempted.set(false); + this.resetTenantForm({ + code: '', + name: '', + status: TenantStatus.Trial, + defaultLanguageId: null, + defaultCurrencyId: null, + defaultTimezoneId: null, + dataRegion: '', + isActive: 1 + }); + this.loadTenants(this.queryState.getQuery()); + } + + private resetTenantForm(value: { + code: string; + name: string; + status: TenantStatus; + defaultLanguageId: string | null; + defaultCurrencyId: string | null; + defaultTimezoneId: string | null; + dataRegion: string; + isActive: number | null; + }): void { + this.tenantForm.reset(value); + this.tenantForm.markAsPristine(); + this.tenantForm.markAsUntouched(); + this.tenantForm.updateValueAndValidity(); + } + + private formatTenantStatus(status: TenantStatus): string { + switch (status) { + case TenantStatus.Trial: + return 'Trial'; + case TenantStatus.Active: + return 'Active'; + case TenantStatus.Suspended: + return 'Suspended'; + case TenantStatus.Cancelled: + return 'Cancelled'; + default: + return 'Unknown'; + } + } + + private getTenantStatusBadgeClass(status: TenantStatus): string { + switch (status) { + case TenantStatus.Trial: + return 'badge bg-warning/10 text-warning'; + case TenantStatus.Active: + return 'badge bg-success/10 text-success'; + case TenantStatus.Suspended: + return 'badge bg-info/10 text-info'; + case TenantStatus.Cancelled: + return 'badge bg-danger/10 text-danger'; + default: + return 'badge bg-secondary/10 text-secondary'; + } + } + + private formatDateTime(value: unknown): string { + if (typeof value !== 'string' || value.trim().length === 0) { + return ''; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); + } + + private handleSaveError(error: HttpErrorResponse): void { + if (error.status === 409) { + this.toastr.error('A tenant with this code already exists.', 'Duplicate tenant code'); + } + } + + private focusFirstInvalidControl(): void { + queueMicrotask(() => { + const control = this.elementRef.nativeElement.querySelector( + 'modal .form-control.is-invalid, modal .ti-form-select.is-invalid, modal [aria-invalid="true"]' + ); + + control?.focus(); + control?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + } + + private clearTenantGrid(): void { + this.tenants.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + } } \ No newline at end of file diff --git a/src/app/features/tenants/tenants.routes.ts b/src/app/features/tenants/tenants.routes.ts index aaf22ed5..17d06206 100644 --- a/src/app/features/tenants/tenants.routes.ts +++ b/src/app/features/tenants/tenants.routes.ts @@ -4,6 +4,11 @@ export const tenantsRoutes: Routes = [ { path: '', loadComponent: () => import('./pages/tenant-list/tenant-list').then((m) => m.TenantList), - data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' }, + data: { childTitle: 'Tenant Management', parentTitle: 'Platform', subParentTitle: 'Configuration' }, + }, + { + path: 'tenant-currencies', + loadComponent: () => import('./pages/tenant-currencies/tenant-currencies').then((m) => m.TenantCurrencies), + data: { childTitle: 'Tenant Currencies', parentTitle: 'Platform', subParentTitle: 'Configuration' }, }, ]; \ No newline at end of file diff --git a/src/app/features/users/pages/users-list/users-list.html b/src/app/features/users/pages/users-list/users-list.html index cd3dfc73..e373e763 100644 --- a/src/app/features/users/pages/users-list/users-list.html +++ b/src/app/features/users/pages/users-list/users-list.html @@ -1,4 +1,19 @@ -
-

Platform users

-

Users, roles, assignment, deactivation, and password reset will be implemented here.

-
+ + + diff --git a/src/app/features/users/pages/users-list/users-list.ts b/src/app/features/users/pages/users-list/users-list.ts index 0c2637bb..2efd1869 100644 --- a/src/app/features/users/pages/users-list/users-list.ts +++ b/src/app/features/users/pages/users-list/users-list.ts @@ -1,11 +1,521 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + inject, + signal +} from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + catchError, + finalize, + of, + Subject, + switchMap +} from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToastrService } from 'ngx-toastr'; + +import { + CreateUserRequest, + UserModalMode, + UserStatus +} from '../../../../core/models/user/user.model'; +import { UserService } from '../../../../core/services/user/user.service'; + +import { DataTable } from '../../../../shared/components/data-table/data-table'; +import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableSortEvent +} from '../../../../shared/components/data-table/data-table.types'; +import { Modal } from '../../../../shared/components/modal/modal'; + +interface UserTableRow extends DataTableRecord { + id: string; + email: string; + status: UserStatus; + roles: string[]; + isActive: boolean; + serialNumber: number; + createdOn: string; + lastLoginOn: string | null; +} @Component({ selector: 'app-users-list', standalone: true, - imports: [CommonModule], + imports: [ + ReactiveFormsModule, + RouterLink, + DataTable, + Modal + ], templateUrl: './users-list.html', styleUrl: './users-list.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) -export class UsersList {} +export class UsersList implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly usersApi = inject(UserService); + private readonly toastr = inject(ToastrService); + + private readonly usersQueryRequests$ = + new Subject(); + + readonly users = signal([]); + readonly queryState = new DataTableQueryState(); + + readonly totalRecords = signal(0); + readonly filteredRecords = signal(0); + + readonly userModalMode = signal('create'); + readonly showUserModal = signal(false); + + readonly saving = signal(false); + readonly userSubmitAttempted = signal(false); + + readonly userForm = this.formBuilder.nonNullable.group({ + email: [ + '', + [ + Validators.required, + Validators.email, + Validators.maxLength(256) + ] + ], + password: [ + '', + [ + Validators.required, + Validators.minLength(8), + Validators.maxLength(128) + ] + ], + roleCodes: this.formBuilder.nonNullable.control( + [], + [ + Validators.required, + control => + control.value.length > 0 + ? null + : { required: true } + ] + ) + }); + + readonly columns = signal[]>([ + { + key: 'serialNumber', + label: 'Sr. No.', + header: 'Sr. No.', + sortable: false, + width: '100px' + }, + { + key: 'email', + label: 'Email', + header: 'Email', + sortable: true, + align: 'left' + }, + { + key: 'status', + label: 'User Status', + header: 'User Status', + sortable: true, + formatter: value => + this.formatUserStatus(value as UserStatus) + }, + { + key: 'roles', + label: 'Roles', + header: 'Roles', + sortable: false, + align: 'left', + formatter: value => + this.formatRoles( + Array.isArray(value) + ? value.filter( + (item): item is string => + typeof item === 'string' + ) + : [] + ) + }, + { + key: 'createdOn', + label: 'Created On', + header: 'Created On', + sortable: true + }, + { + key: 'lastLoginOn', + label: 'Last Login On', + header: 'Last Login On', + sortable: true, + formatter: value => + typeof value === 'string' && value.trim().length > 0 + ? value + : 'Never' + }, + { + key: 'isActive', + label: 'Active', + header: 'Active', + sortable: true, + badge: true, + badgeClass: value => + value === true + ? 'badge bg-success/10 text-success' + : 'badge bg-danger/10 text-danger', + formatter: value => + value === true ? 'Active' : 'Inactive' + } + ]); + + onActionClick(event: DataTableActionEvent): void { + // const user = this.toUserDto(event.row); + + // switch (event.action.type) { + // case 'view': + // this.viewUser(user); + // break; + // case 'edit': + // this.openEditUser(user); + // break; + // case 'delete': + // this.requestDeleteUser(user); + // break; + // case 'activate': + // this.activateUser(user); + // break; + // } + } + readonly actions = signal[]>([]); + + constructor() { + this.initializeUserQueryStream(); + } + + ngOnInit(): void { + this.loadUsers(this.queryState.getQuery()); + } + + loadUsers(query: DataTableQuery): void { + this.usersQueryRequests$.next(query); + } + + onSearch(value: string): void { + const query = this.queryState.setSearch(value.trim()); + this.loadUsers(query); + } + + onPageChange(event: DataTablePageEvent): void { + const query = this.queryState.setPage(event); + this.loadUsers(query); + } + + onSortChange(event: DataTableSortEvent): void { + const query = this.queryState.setSort(event); + this.loadUsers(query); + } + + onRefresh(): void { + const query = this.queryState.reset(); + this.loadUsers(query); + } + + onAddUser(): void { + this.userModalMode.set('create'); + this.resetUserForm(); + this.showUserModal.set(true); + } + + closeUserModal(): void { + if (this.saving()) { + return; + } + + this.showUserModal.set(false); + this.resetUserForm(); + } + + saveUser(): void { + this.userSubmitAttempted.set(true); + + if (this.userForm.invalid || this.saving()) { + return; + } + + const request = this.buildCreateUserRequest(); + + if (request.roleCodes.length === 0) { + this.userForm.controls.roleCodes.setErrors({ + required: true + }); + + return; + } + + this.saving.set(true); + + this.usersApi + .createUser(request) + .pipe( + finalize(() => { + this.saving.set(false); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.toastr.success('User created successfully.'); + + this.showUserModal.set(false); + this.resetUserForm(); + this.refreshUsersAfterSave(); + }, + error: (error: HttpErrorResponse) => { + this.handleCreateUserError(error); + } + }); + } + + isControlInvalid( + controlName: keyof typeof this.userForm.controls + ): boolean { + const control = this.userForm.controls[controlName]; + + return this.userSubmitAttempted() && control.invalid; + } + + hasControlError( + controlName: keyof typeof this.userForm.controls, + errorName: string + ): boolean { + const control = this.userForm.controls[controlName]; + + return ( + this.userSubmitAttempted() && + control.hasError(errorName) + ); + } + + private initializeUserQueryStream(): void { + this.usersQueryRequests$ + .pipe( + switchMap(requestedQuery => + this.usersApi.getDataTable(requestedQuery).pipe( + catchError(() => { + this.clearUsersGrid(); + this.toastr.error('Unable to load users.'); + + return of(null); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(response => { + if (response === null) { + return; + } + + const currentQuery = this.queryState.getQuery(); + + if (response.draw !== currentQuery.draw) { + return; + } + + const usersWithSerialNumbers: UserTableRow[] = + response.rows.map((user, index) => ({ + id: user.id, + email: user.email, + status: user.status, + roles: [...user.roles], + isActive: user.isActive, + createdOn: user.createdOn, + lastLoginOn: user.lastLoginOn, + serialNumber: + (currentQuery.page - 1) * + currentQuery.pageSize + + index + + 1 + })); + + this.users.set(usersWithSerialNumbers); + this.totalRecords.set(response.total); + this.filteredRecords.set(response.filtered); + }); + } + + private buildCreateUserRequest(): CreateUserRequest { + const value = this.userForm.getRawValue(); + + const normalizedRoleCodes = Array.from( + new Set( + value.roleCodes + .map(roleCode => roleCode.trim()) + .filter(roleCode => roleCode.length > 0) + ) + ); + + return { + email: value.email.trim(), + password: value.password, + roleCodes: normalizedRoleCodes + }; + } + + private refreshUsersAfterSave(): void { + const query = this.queryState.reset(); + this.loadUsers(query); + } + + private resetUserForm(): void { + this.userForm.reset({ + email: '', + password: '', + roleCodes: [] + }); + + this.userSubmitAttempted.set(false); + + this.userForm.markAsPristine(); + this.userForm.markAsUntouched(); + this.userForm.updateValueAndValidity({ + emitEvent: false + }); + } + + private clearUsersGrid(): void { + this.users.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + } + + private handleCreateUserError( + error: HttpErrorResponse + ): void { + if (error.status === 409) { + this.toastr.error( + 'A user with this email address already exists.' + ); + + return; + } + + if (error.status === 400) { + this.toastr.error( + this.extractApiErrorMessage(error) ?? + 'The user information is invalid.' + ); + + return; + } + + /* + * Authentication/session errors should normally be handled + * by the global authentication interceptor. + */ + if (error.status === 401 || error.status === 403) { + return; + } + + this.toastr.error( + 'Unable to create the user. Please try again.' + ); + } + + private extractApiErrorMessage( + error: HttpErrorResponse + ): string | null { + const responseBody: unknown = error.error; + + if ( + typeof responseBody !== 'object' || + responseBody === null + ) { + return null; + } + + const apiError = responseBody as { + detail?: unknown; + message?: unknown; + title?: unknown; + }; + + if (typeof apiError.detail === 'string') { + return apiError.detail; + } + + if (typeof apiError.message === 'string') { + return apiError.message; + } + + if (typeof apiError.title === 'string') { + return apiError.title; + } + + return null; + } + + private formatRoles(roles: readonly string[]): string { + if (roles.length === 0) { + return '—'; + } + + return roles + .map(role => this.formatRoleCode(role)) + .join(', '); + } + + private formatRoleCode(roleCode: string): string { + return roleCode + .split('_') + .filter(part => part.length > 0) + .map( + part => + `${part.charAt(0).toUpperCase()}${part + .slice(1) + .toLowerCase()}` + ) + .join(' '); + } + + private formatUserStatus(status: UserStatus): string { + switch (status) { + case UserStatus.Pending: + return 'Pending'; + + case UserStatus.Active: + return 'Active'; + + case UserStatus.Suspended: + return 'Suspended'; + + case UserStatus.Locked: + return 'Locked'; + + case UserStatus.Disabled: + return 'Disabled'; + + default: + return 'Unknown'; + } + } +} \ No newline at end of file diff --git a/src/app/features/users/users.routes.ts b/src/app/features/users/users.routes.ts index efc2a578..5f2a8847 100644 --- a/src/app/features/users/users.routes.ts +++ b/src/app/features/users/users.routes.ts @@ -1,9 +1,11 @@ import { Routes } from '@angular/router'; +import { superAdminGuard } from '../../core/guards/auth/super-admin.guard'; export const usersRoutes: Routes = [ { path: '', + canActivate: [superAdminGuard], loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList), data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' }, - }, + } ]; diff --git a/src/app/shared/components/filter-card/filter-card.html b/src/app/shared/components/filter-card/filter-card.html new file mode 100644 index 00000000..0162008d --- /dev/null +++ b/src/app/shared/components/filter-card/filter-card.html @@ -0,0 +1,41 @@ +
+
+
{{ title() }}
+ +
+ + + +
+
+ +
+
+
+ +
+
+
+
diff --git a/src/app/shared/components/filter-card/filter-card.spec.ts b/src/app/shared/components/filter-card/filter-card.spec.ts new file mode 100644 index 00000000..e54b2147 --- /dev/null +++ b/src/app/shared/components/filter-card/filter-card.spec.ts @@ -0,0 +1,142 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilterCard } from './filter-card'; + +@Component({ + standalone: true, + imports: [FilterCard], + template: ` + + + + + ` +}) +class HostComponent { + readonly defaultCollapsed = signal(false); + readonly collapsible = signal(true); + readonly contentId = signal(''); + readonly changes: boolean[] = []; + collapsedIcon = 'custom-collapsed'; + expandedIcon = 'custom-expanded'; +} + +describe('FilterCard', () => { + let fixture: ComponentFixture; + let host: HostComponent; + let overlayContainer: OverlayContainer; + + const card = (): FilterCard => fixture.debugElement.children[0].componentInstance; + const toggle = (): HTMLButtonElement => fixture.nativeElement.querySelector('button[aria-controls]'); + const icon = (): HTMLElement => toggle().querySelector('i') as HTMLElement; + const bodyGrid = (): HTMLElement => fixture.nativeElement.querySelector('.filter-card-body-grid'); + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + overlayContainer = TestBed.inject(OverlayContainer); + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + overlayContainer.ngOnDestroy(); + }); + + it('starts expanded by default', () => { + expect(card().collapsed()).toBe(false); + expect(bodyGrid().classList.contains('is-collapsed')).toBe(false); + }); + + it('reacts to a collapsed default state', () => { + host.defaultCollapsed.set(true); + fixture.detectChanges(); + expect(card().collapsed()).toBe(true); + expect(bodyGrid().classList.contains('is-collapsed')).toBe(true); + }); + + it('switches icons and tooltips when toggled', () => { + expect(icon().className).toBe('custom-expanded'); + expect(toggle().getAttribute('aria-label')).toBe('Hide Filters'); + toggle().click(); + fixture.detectChanges(); + expect(icon().className).toBe('custom-collapsed'); + expect(toggle().getAttribute('aria-label')).toBe('Show Filters'); + }); + + it('updates an open tooltip when toggled without requiring another hover', () => { + vi.useFakeTimers(); + toggle().dispatchEvent(new MouseEvent('mouseenter')); + vi.advanceTimersByTime(200); + fixture.detectChanges(); + expect(overlayContainer.getContainerElement().textContent).toContain('Hide Filters'); + + toggle().click(); + fixture.detectChanges(); + + expect(overlayContainer.getContainerElement().textContent).toContain('Show Filters'); + expect(overlayContainer.getContainerElement().textContent).not.toContain('Hide Filters'); + }); + + it('emits the new collapsed state', () => { + toggle().click(); + toggle().click(); + expect(host.changes).toEqual([true, false]); + }); + + it('sets accessible expanded and controls attributes', () => { + const id = card().resolvedContentId(); + expect(toggle().getAttribute('aria-expanded')).toBe('true'); + expect(toggle().getAttribute('aria-controls')).toBe(id); + expect(fixture.nativeElement.querySelector(`#${id}`)).not.toBeNull(); + }); + + it('keeps projected content mounted while collapsed', () => { + const input = fixture.nativeElement.querySelector('[data-testid="projected-input"]'); + toggle().click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="projected-input"]')).toBe(input); + expect(fixture.nativeElement.textContent).toContain('Reset'); + }); + + it('uses custom icons', () => { + expect(icon().classList.contains('custom-expanded')).toBe(true); + toggle().click(); + fixture.detectChanges(); + expect(icon().classList.contains('custom-collapsed')).toBe(true); + }); + + it('uses a custom content id', () => { + host.contentId.set('state-filter-content'); + fixture.detectChanges(); + expect(toggle().getAttribute('aria-controls')).toBe('state-filter-content'); + expect(fixture.nativeElement.querySelector('#state-filter-content')).not.toBeNull(); + }); + + it('generates a stable content id', () => { + const generatedId = card().resolvedContentId(); + fixture.detectChanges(); + expect(generatedId).toMatch(/^filter-card-content-\d+$/); + expect(card().resolvedContentId()).toBe(generatedId); + }); + + it('does not collapse or emit when non-collapsible', () => { + host.collapsible.set(false); + fixture.detectChanges(); + toggle().click(); + expect(card().collapsed()).toBe(false); + expect(host.changes).toEqual([]); + expect(toggle().disabled).toBe(true); + }); +}); diff --git a/src/app/shared/components/filter-card/filter-card.ts b/src/app/shared/components/filter-card/filter-card.ts new file mode 100644 index 00000000..48e84993 --- /dev/null +++ b/src/app/shared/components/filter-card/filter-card.ts @@ -0,0 +1,71 @@ +import { Component, computed, effect, input, output, signal } from '@angular/core'; + +import { TooltipDirective } from '../../directives/tooltip/tooltip.directive'; + +let nextFilterCardId = 0; + +@Component({ + selector: 'app-filter-card', + standalone: true, + imports: [TooltipDirective], + templateUrl: './filter-card.html', + styles: ` + :host { display: block; } + .filter-card-body-grid { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 200ms ease, visibility 200ms ease; + visibility: visible; + } + .filter-card-body-grid.is-collapsed { + grid-template-rows: 0fr; + visibility: hidden; + } + .filter-card-body-content { min-height: 0; overflow: hidden; } + @media (prefers-reduced-motion: reduce) { + .filter-card-body-grid { transition: none; } + } + ` +}) +export class FilterCard { + readonly title = input('Filters'); + readonly defaultCollapsed = input(false); + readonly collapsible = input(true); + readonly showToggleButton = input(true); + readonly collapsedIcon = input('ri-filter-3-line'); + readonly expandedIcon = input('ri-arrow-up-s-line'); + readonly collapsedTooltip = input('Show Filters'); + readonly expandedTooltip = input('Hide Filters'); + readonly cardClass = input(''); + readonly headerClass = input(''); + readonly bodyClass = input(''); + readonly toggleButtonClass = input(''); + readonly contentId = input(''); + + readonly collapseChanged = output(); + readonly collapsed = signal(false); + + private readonly generatedContentId = `filter-card-content-${++nextFilterCardId}`; + + readonly resolvedContentId = computed(() => this.contentId().trim() || this.generatedContentId); + readonly currentIcon = computed(() => + this.collapsed() ? this.collapsedIcon() : this.expandedIcon() + ); + readonly currentTooltip = computed(() => + this.collapsed() ? this.collapsedTooltip() : this.expandedTooltip() + ); + + constructor() { + effect(() => this.collapsed.set(this.defaultCollapsed())); + } + + toggleCollapse(): void { + if (!this.collapsible()) { + return; + } + + const collapsed = !this.collapsed(); + this.collapsed.set(collapsed); + this.collapseChanged.emit(collapsed); + } +} diff --git a/src/app/shared/components/form/autocomplete/autocomplete.html b/src/app/shared/components/form/autocomplete/autocomplete.html index 7d45c52f..3d65d104 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.html +++ b/src/app/shared/components/form/autocomplete/autocomplete.html @@ -6,6 +6,7 @@ [disabled]="isDisabled()" [description]="description()" [hint]="hint()" + [help]="help()" [labelPosition]="labelPosition()" [hideLabel]="hideLabel()" [hideValidation]="hideValidation()" @@ -35,18 +36,19 @@ [attr.aria-readonly]="readonly()" (input)="onInput($event)" (focus)="onFocus()" + (click)="onClick()" (blur)="onBlur()" (keydown)="onKeydown($event)" /> @if (loading()) { -
diff --git a/src/app/shared/components/form/autocomplete/autocomplete.spec.ts b/src/app/shared/components/form/autocomplete/autocomplete.spec.ts index faa8c086..7773a44c 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.spec.ts +++ b/src/app/shared/components/form/autocomplete/autocomplete.spec.ts @@ -1,11 +1,11 @@ import { OverlayContainer } from '@angular/cdk/overlay'; import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { Observable, Subject, of, throwError } from 'rxjs'; import { Autocomplete } from './autocomplete'; -import { AutocompleteSearchFn } from './autocomplete.types'; +import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types'; interface LookupItem { readonly code: string; @@ -26,24 +26,29 @@ const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' }; [searchFn]="searchFn" [displayWith]="displayWith" [valueWith]="valueWith" + placeholder="e.g.: USD" [selectedItem]="selectedItem()" + [resolveValueFn]="resolveValueFn()" [minSearchLength]="minLength" [debounceTime]="delay()" [readonly]="readonly()" - [showDropdownOnFocus]="openOnFocus" + [showDropdownOnFocus]="openOnFocus()" + [submitAttempted]="submitAttempted()" /> ` }) class HostComponent { readonly control = new FormControl(null); readonly selectedItem = signal(null); + readonly resolveValueFn = signal | null>(null); searchFn: AutocompleteSearchFn = () => of([INDIA, INDONESIA]); readonly displayWith = (item: LookupItem): string => item.title; readonly valueWith = (item: LookupItem): string => item.code; minLength = 2; readonly delay = signal(300); readonly readonly = signal(false); - openOnFocus = false; + readonly openOnFocus = signal(false); + readonly submitAttempted = signal(false); } describe('Autocomplete', () => { @@ -73,6 +78,11 @@ describe('Autocomplete', () => { it('initializes with an empty selection', () => { expect(component.searchText()).toBe(''); expect(component.options()).toEqual([]); + expect(input().value).toBe(''); + expect(input().placeholder).toBe('e.g.: USD'); + expect(input().className).not.toContain('ti-form-control'); + expect(input().className).toContain('placeholder:text-textmuted'); + expect(input().className).toContain('dark:placeholder:text-white/50'); }); it('integrates with a reactive form control', () => { @@ -80,6 +90,14 @@ describe('Autocomplete', () => { expect(host.control.value).toBe('IN'); }); + it('treats edited selected text as search text rather than a selected item', () => { + component.select(INDIA); + expect(component.activeItem()).toEqual(INDIA); + type('Ind'); + expect(component.searchText()).toBe('Ind'); + expect(component.activeItem()).toBeNull(); + }); + it('does not emit onChange from writeValue', () => { const change = vi.fn(); component.registerOnChange(change); @@ -87,6 +105,28 @@ describe('Autocomplete', () => { expect(change).not.toHaveBeenCalled(); }); + it('does not show invalid styling after touch before submit', () => { + host.control.setValidators(Validators.required); + host.control.updateValueAndValidity(); + host.control.markAsTouched(); + fixture.detectChanges(); + expect(input().classList.contains('is-invalid')).toBe(false); + }); + + it('shows invalid styling after submit and removes it when valid', () => { + host.control.setValidators(Validators.required); + host.control.updateValueAndValidity(); + host.submitAttempted.set(true); + fixture.detectChanges(); + expect(input().classList.contains('is-invalid')).toBe(true); + expect(fixture.nativeElement.textContent).toContain('Country is required.'); + + host.control.setValue('IN'); + fixture.detectChanges(); + expect(input().classList.contains('is-invalid')).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain('Country is required.'); + }); + it('applies a disabled form state', () => { host.control.disable(); fixture.detectChanges(); @@ -117,6 +157,40 @@ describe('Autocomplete', () => { vi.useRealTimers(); }); + it('loads five preview records on focus before the user types', () => { + vi.useFakeTimers(); + const search = vi.fn(() => of([INDIA, INDONESIA])); + host.openOnFocus.set(true); + host.searchFn = search; + fixture.detectChanges(); + input().focus(); + vi.advanceTimersByTime(0); + fixture.detectChanges(); + expect(search).toHaveBeenCalledWith('', 5); + expect(component.options()).toEqual([INDIA, INDONESIA]); + expect(component.showingPreview()).toBe(true); + expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...'); + vi.useRealTimers(); + }); + + it('replaces preview records with typed search results', () => { + vi.useFakeTimers(); + host.openOnFocus.set(true); + host.delay.set(0); + host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]); + fixture.detectChanges(); + input().focus(); + vi.advanceTimersByTime(0); + expect(component.options()).toEqual([INDIA]); + type('in'); + vi.advanceTimersByTime(0); + fixture.detectChanges(); + expect(component.options()).toEqual([INDONESIA]); + expect(component.showingPreview()).toBe(false); + expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...'); + vi.useRealTimers(); + }); + it('cancels stale search requests', () => { vi.useFakeTimers(); const first = new Subject(); @@ -227,6 +301,36 @@ describe('Autocomplete', () => { expect(component.searchText()).toBe('India'); }); + it('does not resolve an empty string form value', () => { + const resolve = vi.fn(() => of(INDIA)); + host.resolveValueFn.set(resolve); + fixture.detectChanges(); + host.control.setValue(''); + fixture.detectChanges(); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('uses the matching selected item without resolving it again', () => { + const resolve = vi.fn(() => of(INDIA)); + host.resolveValueFn.set(resolve); + host.selectedItem.set(INDIA); + fixture.detectChanges(); + host.control.setValue('IN'); + fixture.detectChanges(); + expect(component.searchText()).toBe('India'); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('resolves a non-empty value when no matching selected item is supplied', () => { + const resolve = vi.fn(() => of(INDIA)); + host.resolveValueFn.set(resolve); + fixture.detectChanges(); + host.control.setValue('IN'); + fixture.detectChanges(); + expect(resolve).toHaveBeenCalledWith('IN'); + expect(component.searchText()).toBe('India'); + }); + it('does not retain a stale label when edit values change', () => { host.selectedItem.set(INDIA); host.control.setValue('IN'); diff --git a/src/app/shared/components/form/autocomplete/autocomplete.ts b/src/app/shared/components/form/autocomplete/autocomplete.ts index 51dc00eb..94f2aa4d 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.ts +++ b/src/app/shared/components/form/autocomplete/autocomplete.ts @@ -40,6 +40,12 @@ interface SearchResult { readonly term: string; readonly options: readonly TItem[]; readonly failed: boolean; + readonly preview: boolean; +} + +interface SearchRequest { + readonly term: string; + readonly preview: boolean; } @Component({ @@ -59,9 +65,10 @@ export class Autocomplete implements ControlValueAccessor { private readonly injector = inject(Injector); private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`; - private readonly inputTerms$ = new Subject(); + private readonly searchRequests$ = new Subject(); private readonly valuesToResolve$ = new Subject(); private formValue: TValue | null = null; + private readonly controlStateVersion = signal(0); private labelEdited = false; private onChange: (value: TValue | null) => void = () => {}; private onTouched: () => void = () => {}; @@ -81,6 +88,8 @@ export class Autocomplete implements ControlValueAccessor { readonly minSearchLength = input(1); readonly debounceTime = input(300); readonly limit = input(10); + readonly previewLimit = input(5); + readonly previewText = input('Type to search more...'); readonly disabled = input(false); readonly readonly = input(false); readonly clearable = input(true); @@ -89,7 +98,7 @@ export class Autocomplete implements ControlValueAccessor { readonly emptyText = input('No results found'); readonly typeToSearchText = input('Type to search'); readonly errorText = input('Unable to load results'); - readonly showDropdownOnFocus = input(false); + readonly showDropdownOnFocus = input(true); readonly closeOnSelect = input(true); readonly autocomplete = input('off'); readonly ariaLabel = input(null); @@ -102,6 +111,7 @@ export class Autocomplete implements ControlValueAccessor { readonly validationMessages = input({}); readonly description = input(null); readonly hint = input(null); + readonly help = input(null); readonly labelPosition = input('top'); readonly itemSelected = output(); @@ -118,6 +128,7 @@ export class Autocomplete implements ControlValueAccessor { readonly activeItem = signal(null); readonly searchText = signal(''); readonly error = signal(null); + readonly showingPreview = signal(false); readonly formDisabled = signal(false); readonly panelWidth = signal(0); @@ -136,21 +147,24 @@ export class Autocomplete implements ControlValueAccessor { readonly message = computed(() => { if (this.loading()) return this.loadingText(); if (this.error()) return this.errorText(); + if (this.showingPreview()) return ''; if (this.searchText().trim().length < this.minSearchLength()) { return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`; } return this.options().length ? '' : this.emptyText(); }); readonly resolvedInputClass = computed(() => { + this.controlStateVersion(); const control = this.control(); - const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted())); + const invalid = !!(control?.invalid && this.submitAttempted()); return [ - 'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor', + 'ti-form-select w-full rounded-sm border border-defaultborder bg-white text-defaulttextcolor', 'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70', - 'focus:border-primary focus:ring-1 focus:ring-primary', - 'pe-16', + 'placeholder:text-textmuted placeholder:opacity-100 dark:placeholder:text-white/50', + 'focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none', + 'pe-10 transition-colors', invalid ? 'is-invalid border-danger' : '', - this.isDisabled() ? 'cursor-not-allowed opacity-60' : '', + this.isDisabled() ? 'cursor-not-allowed bg-light opacity-60 dark:bg-black/20' : '', this.inputClass() ].filter(Boolean).join(' '); }); @@ -161,24 +175,49 @@ export class Autocomplete implements ControlValueAccessor { ]; constructor() { - this.inputTerms$.pipe( - map(term => term.trim()), - debounce(() => timer(Math.max(0, this.debounceTime()))), - distinctUntilChanged(), - switchMap(term => { - if (term.length < this.minSearchLength()) { - return of>({ term, options: [], failed: false }); + effect((onCleanup) => { + const control = this.control(); + if (!control) return; + + const statusSubscription = control.statusChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + const valueSubscription = control.valueChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + const eventsSubscription = control.events?.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + onCleanup(() => { + statusSubscription.unsubscribe(); + valueSubscription.unsubscribe(); + eventsSubscription?.unsubscribe(); + }); + }); + + this.searchRequests$.pipe( + map(request => ({ ...request, term: request.term.trim() })), + debounce(request => timer(request.preview ? 0 : Math.max(0, this.debounceTime()))), + distinctUntilChanged((previous, current) => + previous.term === current.term && previous.preview === current.preview + ), + switchMap(request => { + if (!request.preview && request.term.length < this.minSearchLength()) { + return of>({ ...request, options: [], failed: false }); } this.loading.set(true); this.error.set(null); - return this.searchFn()(term, this.limit()).pipe( - map(options => ({ term, options, failed: false })), - catchError(() => of>({ term, options: [], failed: true })) + const limit = request.preview ? this.previewLimit() : this.limit(); + return this.searchFn()(request.term, limit).pipe( + map(options => ({ ...request, options, failed: false })), + catchError(() => of>({ ...request, options: [], failed: true })) ); }), takeUntilDestroyed() ).subscribe(result => { this.loading.set(false); + this.showingPreview.set(result.preview && !result.failed); this.options.set(result.options); this.activeIndex.set(-1); this.error.set(result.failed ? this.errorText() : null); @@ -203,7 +242,7 @@ export class Autocomplete implements ControlValueAccessor { writeValue(value: TValue | null): void { this.formValue = value ?? null; this.labelEdited = false; - if (this.formValue === null) { + if (!this.hasResolvableValue(this.formValue)) { this.applyResolvedItem(null); return; } @@ -228,17 +267,21 @@ export class Autocomplete implements ControlValueAccessor { const text = event.target.value; const previousText = this.searchText(); this.searchText.set(text); + this.activeItem.set(null); + this.showingPreview.set(false); + this.options.set([]); this.labelEdited = this.formValue !== null && text !== previousText; this.searchChanged.emit(text.trim()); this.open(); - this.inputTerms$.next(text); + this.searchRequests$.next({ term: text, preview: false }); } onFocus(): void { - if (this.showDropdownOnFocus()) { - this.open(); - this.inputTerms$.next(this.searchText()); - } + if (this.showDropdownOnFocus()) this.openPreview(); + } + + onClick(): void { + if (this.showDropdownOnFocus()) this.openPreview(); } onBlur(): void { @@ -305,6 +348,19 @@ export class Autocomplete implements ControlValueAccessor { optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; } optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; } + isOptionSelected(item: TItem): boolean { + return this.formValue !== null && this.valuesEqual(this.valueWith()(item), this.formValue); + } + optionClass(item: TItem, index: number): string { + const highlighted = this.activeIndex() === index; + const selected = this.isOptionSelected(item); + return [ + 'block w-full px-3 py-2 text-start text-[0.8125rem] transition-colors', + highlighted || selected + ? 'bg-primary text-white' + : 'bg-white text-defaulttextcolor hover:bg-primary hover:text-white dark:bg-bodybg dark:text-white/70 dark:hover:bg-primary dark:hover:text-white' + ].join(' '); + } private setActive(index: number): void { if (index < 0 || index >= this.options().length) return; @@ -319,6 +375,7 @@ export class Autocomplete implements ControlValueAccessor { this.options.set([]); this.error.set(null); this.loading.set(false); + this.showingPreview.set(false); this.labelEdited = false; this.onChange(null); this.onTouched(); @@ -335,4 +392,16 @@ export class Autocomplete implements ControlValueAccessor { } private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); } + + private hasResolvableValue(value: TValue | null): value is TValue { + return value !== null && (typeof value !== 'string' || value.trim().length > 0); + } + + private openPreview(): void { + if (this.isDisabled() || this.readonly() || this.isOpen()) return; + this.open(); + if (!this.searchText().trim()) { + this.searchRequests$.next({ term: '', preview: true }); + } + } } diff --git a/src/app/shared/components/form/form-field/form-field.html b/src/app/shared/components/form/form-field/form-field.html index c8e05937..cbd3cf39 100644 --- a/src/app/shared/components/form/form-field/form-field.html +++ b/src/app/shared/components/form/form-field/form-field.html @@ -1,45 +1,49 @@
@if (showLabel()) { - }
@if (description()) { -

- {{ description() }} -

+

+ {{ description() }} +

} @if (showHint()) { -

- {{ hint() }} -

+

+ {{ hint() }} +

} @if (!hideValidation()) { - + }
-
+
\ No newline at end of file diff --git a/src/app/shared/components/form/form-field/form-field.ts b/src/app/shared/components/form/form-field/form-field.ts index 1194fb70..3f869a92 100644 --- a/src/app/shared/components/form/form-field/form-field.ts +++ b/src/app/shared/components/form/form-field/form-field.ts @@ -12,13 +12,14 @@ import { FormValidationMessage, ValidationMessageMap } from '../form-validation-message/form-validation-message'; +import { TooltipDirective } from '../../../directives/tooltip/tooltip.directive'; export type FormLabelPosition = 'top' | 'left' | 'hidden'; @Component({ selector: 'app-form-field', standalone: true, - imports: [FormValidationMessage], + imports: [FormValidationMessage, TooltipDirective], templateUrl: './form-field.html', changeDetection: ChangeDetectionStrategy.OnPush }) @@ -36,6 +37,15 @@ export class FormField { readonly description = input(null); readonly hint = input(null); + readonly help = input(null); + + readonly showHelpIcon = computed(() => { + return !!this.help()?.trim(); + }); + + readonly resolvedHelp = computed(() => { + return this.help()?.trim() ?? ''; + }); readonly labelPosition = input('top'); @@ -120,9 +130,8 @@ export class FormField { } return ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ); }); diff --git a/src/app/shared/components/form/form-input/form-input.html b/src/app/shared/components/form/form-input/form-input.html index 465c489f..c8756cca 100644 --- a/src/app/shared/components/form/form-input/form-input.html +++ b/src/app/shared/components/form/form-input/form-input.html @@ -6,6 +6,7 @@ [disabled]="isDisabled()" [description]="description()" [hint]="hint()" + [help]="help()" [hideValidation]="hideValidation()" [showValidationWhenDirty]="showValidationWhenDirty()" [submitAttempted]="submitAttempted()" diff --git a/src/app/shared/components/form/form-input/form-input.ts b/src/app/shared/components/form/form-input/form-input.ts index 8adaeff0..14592d6c 100644 --- a/src/app/shared/components/form/form-input/form-input.ts +++ b/src/app/shared/components/form/form-input/form-input.ts @@ -7,7 +7,7 @@ import { ValidationMessageMap } from '../form-validation-message/form-validation export type FormInputType = | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search'; -export type FormInputMode = | 'none'| 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; +export type FormInputMode = | 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; export type FormInputIconPosition = 'left' | 'right'; @@ -28,7 +28,7 @@ export type FormInputIconPosition = 'left' | 'right'; export class FormInput implements ControlValueAccessor { private readonly injector = inject(Injector); - + readonly inputId = input.required(); readonly label = input.required(); @@ -54,14 +54,15 @@ export class FormInput implements ControlValueAccessor { readonly pattern = input(null); - + readonly min = input(null); readonly max = input(null); readonly step = input(null); - + readonly description = input(null); readonly hint = input(null); + readonly help = input(null); readonly hideValidation = input(false); readonly showValidationWhenDirty = input(false); @@ -78,25 +79,25 @@ export class FormInput implements ControlValueAccessor { readonly showPasswordToggle = input(true); readonly loading = input(false); - + readonly wrapperClass = input(''); readonly fieldContentClass = input(''); readonly labelClass = input(''); readonly inputClass = input(''); - + readonly ariaLabel = input(null); readonly ariaDescription = input(null); - + readonly value = signal(null); readonly formDisabled = signal(false); readonly passwordVisible = signal(false); private readonly controlStateVersion = signal(0); - private onChange: (value: string | number | null) => void = () => {}; + private onChange: (value: string | number | null) => void = () => { }; - private onTouched: () => void = () => {}; + private onTouched: () => void = () => { }; constructor() { effect(() => { @@ -192,9 +193,8 @@ export class FormInput implements ControlValueAccessor { const showInvalidState = !!( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ); @@ -228,9 +228,8 @@ export class FormInput implements ControlValueAccessor { if ( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ) { ids.push(`${this.inputId()}-validation`); diff --git a/src/app/shared/components/form/form-select/form-select.ts b/src/app/shared/components/form/form-select/form-select.ts index 24de8de0..9144ae3c 100644 --- a/src/app/shared/components/form/form-select/form-select.ts +++ b/src/app/shared/components/form/form-select/form-select.ts @@ -367,9 +367,8 @@ export class FormSelect implements if ( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ) { ids.push(`${this.resolvedInputId()}-validation`); @@ -406,9 +405,8 @@ export class FormSelect implements const showInvalidState = !!( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ); diff --git a/src/app/shared/components/form/form-validation-message/form-validation-message.ts b/src/app/shared/components/form/form-validation-message/form-validation-message.ts index aa975827..804d5e92 100644 --- a/src/app/shared/components/form/form-validation-message/form-validation-message.ts +++ b/src/app/shared/components/form/form-validation-message/form-validation-message.ts @@ -71,9 +71,8 @@ export class FormValidationMessage { } return ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showWhenDirty() && control.dirty) ); }); diff --git a/src/app/shared/directives/tooltip/tooltip.directive.ts b/src/app/shared/directives/tooltip/tooltip.directive.ts index f65b5542..1028027f 100644 --- a/src/app/shared/directives/tooltip/tooltip.directive.ts +++ b/src/app/shared/directives/tooltip/tooltip.directive.ts @@ -1,10 +1,24 @@ -import { Directive, ElementRef, HostListener, OnDestroy, inject, input } from '@angular/core'; - -import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; - +import { + ComponentRef, + Directive, + ElementRef, + HostListener, + OnDestroy, + effect, + inject, + input +} from '@angular/core'; +import { + ConnectedPosition, + Overlay, + OverlayRef +} from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; -import { Tooltip } from './tooltip/tooltip'; +import { + Tooltip +} from './tooltip/tooltip'; +import { TooltipVariant } from './tooltip/tooltip'; export type TooltipPosition = | 'top' @@ -27,20 +41,42 @@ export class TooltipDirective implements OnDestroy { readonly tooltipPosition = input('top'); + readonly tooltipVariant = + input('default'); + readonly tooltipDisabled = input(false); readonly tooltipDelay = input(200); private overlayRef: OverlayRef | null = null; - private showTimeout: ReturnType | null = null; + private tooltipComponentRef: + ComponentRef | null = null; + + private showTimeout: + ReturnType | null = null; + + constructor() { + effect(() => { + const text = this.appTooltip(); + const variant = this.tooltipVariant(); + + this.tooltipComponentRef?.setInput('text', text); + this.tooltipComponentRef?.setInput( + 'variant', + variant + ); + }); + } @HostListener('mouseenter') @HostListener('focusin') show(): void { + const tooltipText = this.appTooltip().trim(); + if ( this.tooltipDisabled() || - !this.appTooltip() + tooltipText.length === 0 ) { return; } @@ -48,8 +84,9 @@ export class TooltipDirective implements OnDestroy { this.clearTimeout(); this.showTimeout = setTimeout(() => { + this.showTimeout = null; this.openTooltip(); - }, this.tooltipDelay()); + }, Math.max(0, this.tooltipDelay())); } @HostListener('mouseleave') @@ -60,37 +97,49 @@ export class TooltipDirective implements OnDestroy { } private openTooltip(): void { - if (this.overlayRef) { + if (this.overlayRef?.hasAttached()) { return; } const positionStrategy = this.overlay .position() - .flexibleConnectedTo(this.elementRef) - .withPositions(this.getPositions()); + .flexibleConnectedTo( + this.elementRef.nativeElement + ) + .withPositions(this.getPositions()) + .withPush(true); this.overlayRef = this.overlay.create({ positionStrategy, - scrollStrategy: this.overlay.scrollStrategies.reposition() + scrollStrategy: + this.overlay.scrollStrategies.reposition() }); const portal = new ComponentPortal(Tooltip); - const componentRef = this.overlayRef.attach(portal); + this.tooltipComponentRef = + this.overlayRef.attach(portal); - componentRef.setInput( + this.tooltipComponentRef.setInput( 'text', - this.appTooltip() + this.appTooltip().trim() + ); + + this.tooltipComponentRef.setInput( + 'variant', + this.tooltipVariant() ); } private closeTooltip(): void { this.overlayRef?.dispose(); + this.overlayRef = null; + this.tooltipComponentRef = null; } private clearTimeout(): void { - if (!this.showTimeout) { + if (this.showTimeout === null) { return; } @@ -110,6 +159,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'center', overlayY: 'bottom', offsetY: -8 + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8 } ], @@ -120,6 +176,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'center', overlayY: 'top', offsetY: 8 + }, + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8 } ], @@ -130,6 +193,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'end', overlayY: 'center', offsetX: -8 + }, + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8 } ], @@ -140,6 +210,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'start', overlayY: 'center', offsetX: 8 + }, + { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8 } ] }; diff --git a/src/app/shared/directives/tooltip/tooltip/tooltip.html b/src/app/shared/directives/tooltip/tooltip/tooltip.html index fe845cbc..ca66ff07 100644 --- a/src/app/shared/directives/tooltip/tooltip/tooltip.html +++ b/src/app/shared/directives/tooltip/tooltip/tooltip.html @@ -1,7 +1,7 @@ -
{{ text() }} +
--> + +
+ {{ text() }}
\ No newline at end of file diff --git a/src/app/shared/directives/tooltip/tooltip/tooltip.ts b/src/app/shared/directives/tooltip/tooltip/tooltip.ts index b182b1e7..d38b7207 100644 --- a/src/app/shared/directives/tooltip/tooltip/tooltip.ts +++ b/src/app/shared/directives/tooltip/tooltip/tooltip.ts @@ -1,9 +1,15 @@ import { ChangeDetectionStrategy, Component, - input + input, + computed } from '@angular/core'; + +export type TooltipVariant = + | 'default' + | 'info'; + @Component({ selector: 'app-tooltip', standalone: true, @@ -12,4 +18,42 @@ import { }) export class Tooltip { readonly text = input.required(); + + readonly variant = + input('default'); + + readonly resolvedTooltipClass = computed(() => { + const baseClasses = [ + 'pointer-events-none', + 'max-w-xs', + 'whitespace-normal', + 'rounded-sm', + 'px-2', + 'py-1', + 'text-xs', + 'font-medium', + 'leading-4' + ]; + + const variantClasses = + this.variant() === 'info' + ? [ + 'border', + 'border-defaultborder', + 'bg-white', + 'text-defaulttextcolor', + 'shadow-lg', + 'dark:bg-bodybg' + ] + : [ + 'bg-primary', + 'text-white', + 'shadow-sm' + ]; + + return [ + ...baseClasses, + ...variantClasses + ].join(' '); + }); } \ No newline at end of file
+
Loading data... @@ -56,9 +64,23 @@
- {{ emptyMessage() }} +
+
+
+ +
+ +

+ {{ emptyMessage() }} +

+ + @if (emptyDescription()) { +

+ {{ emptyDescription() }} +

+ } +