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()) {
+
Loading data... @@ -56,9 +64,23 @@
- {{ emptyMessage() }} +
+
+
+ +
+ +

+ {{ emptyMessage() }} +

+ + @if (emptyDescription()) { +

+ {{ emptyDescription() }} +

+ } +