diff --git a/.gitea/workflows/docker-publish.yaml b/.gitea/workflows/docker-publish.yaml
index 47e71bc8..40cd0091 100644
--- a/.gitea/workflows/docker-publish.yaml
+++ b/.gitea/workflows/docker-publish.yaml
@@ -38,19 +38,25 @@ jobs:
env-slug: ${{ steps.env_config.outputs.INFISICAL_ENV }}
project-slug: "syscom-xpq-0"
domain: "https://infisical.biz360.me/"
- export-type: "file"
- file-output-path: "/infisical.env"
+ # export-type: "file"
+ # file-output-path: "/infisical.env"
+
+ # - name: Brute-force overwrite environment.ts
+ # run: |
+ # source infisical.env
+
+ # # Forcefully overwrite the file the developers are importing
+ # echo "${!PROJECT_SECRET_KEY}" > "$ENV_FILE_PATH"
+
+ # # Clean up
+ # rm -f infisical.env
- name: Brute-force overwrite environment.ts
run: |
- source infisical.env
-
- # Forcefully overwrite the file the developers are importing
+ # Because the secret is now a native environment variable, we can write it directly.
+ # The double quotes around the indirect expansion preserve all newlines and internal single quotes!
echo "${!PROJECT_SECRET_KEY}" > "$ENV_FILE_PATH"
- # Clean up
- rm -f infisical.env
-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,7 +73,6 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
- type=ref,event=branch
type=raw,value=dev-{{date 'YYYYMMDDTHHmm'}},enable=${{ github.ref == 'refs/heads/dev' }}
type=raw,value=prod-{{date 'YYYYMMDDTHHmm'}},enable=${{ github.ref == 'refs/heads/prod' }}
@@ -75,7 +80,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
- file: ./docker/Dockerfile
+ file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..fd279028
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,27 @@
+# ----- Stage 1: Build Angular App -----
+FROM node:22-alpine AS build
+WORKDIR /app
+
+# Copy package files
+COPY package.json package-lock.json ./
+
+# Install dependencies
+RUN npm ci
+
+# Copy the rest of the code
+COPY . .
+
+# Build the Angular app for production
+RUN npm run build
+
+# ----- Stage 2: Serve Angular App with Nginx -----
+FROM nginx:alpine
+
+# Copy the built assets from the build stage into Nginx's default HTML folder
+COPY --from=build /app/preview /usr/share/nginx/html
+
+# Expose standard HTTP port 80
+EXPOSE 80
+
+# Start Nginx in the foreground
+CMD ["nginx", "-g", "daemon off;"]
\ 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/docker/Dockerfile b/docker/Dockerfile
deleted file mode 100644
index bfa4810c..00000000
--- a/docker/Dockerfile
+++ /dev/null
@@ -1,29 +0,0 @@
-# ----- Stage 1: Build Angular SSR App -----
-FROM node:22-alpine AS build
-WORKDIR /app
-
-# Copy package files
-COPY package.json package-lock.json ./
-
-# Install dependencies
-RUN npm ci
-
-# Copy the rest of the code (including the environment.ts injected by Gitea Actions)
-COPY . .
-
-# Build the Angular app for production
-RUN npm run build
-
-# ----- Stage 2: Serve SSR App -----
-FROM node:22-alpine AS final
-
-WORKDIR /app
-
-# Copy the built assets from the build stage.
-COPY --from=build /app/preview ./preview
-
-# Expose the standard Angular SSR port
-EXPOSE 4000
-
-# Start the Node.js SSR server
-CMD ["node", "preview/server/server.mjs"]
\ 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/.editorconfig b/src/app/.editorconfig
new file mode 100644
index 00000000..f166060d
--- /dev/null
+++ b/src/app/.editorconfig
@@ -0,0 +1,17 @@
+# Editor configuration, see https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.ts]
+quote_type = single
+ij_typescript_use_double_quotes = false
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/src/app/.gitignore b/src/app/.gitignore
new file mode 100644
index 00000000..d480cdcf
--- /dev/null
+++ b/src/app/.gitignore
@@ -0,0 +1,55 @@
+# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+/node_modules
+npm-debug.log
+yarn-error.log
+
+node_modules/
+.angular/
+dist/
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Miscellaneous
+/.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+__screenshots__/
+
+# System files
+.DS_Store
+Thumbs.db
+
+
+# Ignore local ESLint config
+eslint.config.js
+
+# Ignore preview folder
+preview/
+src/.htaccess
\ No newline at end of file
diff --git a/src/app/.postcssrc.json b/src/app/.postcssrc.json
new file mode 100644
index 00000000..6e4364ec
--- /dev/null
+++ b/src/app/.postcssrc.json
@@ -0,0 +1,6 @@
+{
+ "plugins": {
+ "@tailwindcss/postcss": {},
+ "autoprefixer": {}
+ }
+}
diff --git a/src/app/@spk/charts/charts/spk-echarts/spk-echarts.html b/src/app/@spk/charts/charts/spk-echarts/spk-echarts.html
new file mode 100644
index 00000000..79a0230a
--- /dev/null
+++ b/src/app/@spk/charts/charts/spk-echarts/spk-echarts.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/app/@spk/charts/charts/spk-echarts/spk-echarts.scss b/src/app/@spk/charts/charts/spk-echarts/spk-echarts.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/@spk/charts/charts/spk-echarts/spk-echarts.ts b/src/app/@spk/charts/charts/spk-echarts/spk-echarts.ts
new file mode 100644
index 00000000..c0b9fe4e
--- /dev/null
+++ b/src/app/@spk/charts/charts/spk-echarts/spk-echarts.ts
@@ -0,0 +1,16 @@
+import { NgClass } from '@angular/common';
+import { Component, input } from '@angular/core';
+import { NgxEchartsDirective } from 'ngx-echarts';
+import { EChartsOption } from 'echarts';
+@Component({
+ selector: 'spk-echarts',
+ imports: [NgxEchartsDirective, NgClass],
+ templateUrl: './spk-echarts.html',
+ styleUrl: './spk-echarts.scss'
+})
+export class SpkEcharts {
+ options = input()
+ id = input()
+ echartClass = input()
+ theme = input()
+}
diff --git a/src/app/README.md b/src/app/README.md
new file mode 100644
index 00000000..da9cc0e1
--- /dev/null
+++ b/src/app/README.md
@@ -0,0 +1,59 @@
+# YnexTailwind
+
+This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.5.
+
+## Development server
+
+To start a local development server, run:
+
+```bash
+ng serve
+```
+
+Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
+
+## Code scaffolding
+
+Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
+
+```bash
+ng generate component component-name
+```
+
+For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
+
+```bash
+ng generate --help
+```
+
+## Building
+
+To build the project run:
+
+```bash
+ng build
+```
+
+This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
+
+## Running unit tests
+
+To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
+
+```bash
+ng test
+```
+
+## Running end-to-end tests
+
+For end-to-end (e2e) testing, run:
+
+```bash
+ng e2e
+```
+
+Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
+
+## Additional Resources
+
+For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
diff --git a/src/app/angular.json b/src/app/angular.json
new file mode 100644
index 00000000..83cef95e
--- /dev/null
+++ b/src/app/angular.json
@@ -0,0 +1,124 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "cli": {
+ "packageManager": "npm",
+ "analytics": "0b3da18f-5d81-4a09-9b2f-b0ce36040773",
+ "schematicCollections": [
+ "angular-eslint"
+ ]
+ },
+ "newProjectRoot": "projects",
+ "projects": {
+ "Ynex-Tailwind": {
+ "projectType": "application",
+ "schematics": {
+ "@schematics/angular:component": {
+ "skipTests": true,
+ "style": "scss",
+ "prefix": ""
+ }
+ },
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular/build:application",
+ "options": {
+ "allowedCommonJsDependencies": [
+ "sweetalert2",
+ "inputmask",
+ "filepond",
+ "moment",
+ "leaflet",
+ "apexcharts",
+ "glightbox",
+ "intl-tel-input",
+ "filepond-plugin-image-preview",
+ "dropzone",
+ "quill-delta",
+ "sweetalert",
+ "dayjs"
+ ],
+ "browser": "src/main.ts",
+ "tsConfig": "tsconfig.app.json",
+ "inlineStyleLanguage": "scss",
+ "outputPath": {
+ "base": "preview",
+ "browser": ""
+ },
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ },
+ "src/.htaccess"
+ ],
+ "styles": [
+ "node_modules/@ng-select/ng-select/themes/default.theme.css",
+ "src/styles.scss"
+ ],
+ "scripts": [
+ "node_modules/preline/dist/preline.js"
+ ]
+ },
+ "configurations": {
+ "production": {
+ "fileReplacements": [
+ {
+ "replace": "src/environments/environment.ts",
+ "with": "src/environments/environment.prod.ts"
+ }
+ ],
+ "baseHref": "/",
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "5MB",
+ "maximumError": "5MB"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "4kB",
+ "maximumError": "8kB"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "serve": {
+ "builder": "@angular/build:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "Ynex-Tailwind:build:production"
+ },
+ "development": {
+ "buildTarget": "Ynex-Tailwind:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "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/src/app/app.config.ts b/src/app/app.config.ts
index db2874ef..b26bebc6 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -26,7 +26,7 @@ export const appConfig: ApplicationConfig = {
AngularFireModule,
provideCharts(withDefaultRegisterables()),
importProvidersFrom(
- AngularFireModule.initializeApp(environment.firebase),
+ // AngularFireModule.initializeApp(environment.firebase),
ToastrModule.forRoot({
timeOut: 1500,
closeButton: true,
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 5502effb..41a5239b 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -8,7 +8,8 @@ export const App_Route: Route[] = [
{
path: 'auth',
component: AuthenticationLayout,
- loadChildren: () => import('./shell/routes/auth.routes').then((m) => m.authen),
+ loadChildren: () =>
+ import('./features/authentication/authentication.routes').then((m) => m.authen),
},
{
path: '',
diff --git a/src/app/components/dashboards/crm/crm.html b/src/app/components/dashboards/crm/crm.html
new file mode 100644
index 00000000..55adc727
--- /dev/null
+++ b/src/app/components/dashboards/crm/crm.html
@@ -0,0 +1,529 @@
+
+
+
+
+
+
+
+
+
+
+
+
Your target is incomplete
+
You have
+ completed
+ 48% of the given
+ target, you can also check your status.
+
Click
+ here
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @for (deal of topDeals; track $index) {
+ -
+
+
+
+
+ @if (deal.avatarImg) {
+
+ } @else {
+
+ {{ deal.initials }}
+
+ }
+
+
+
+
+
+ {{ deal.name }}
+
+
+ {{ deal.email }}
+
+
+
+
+ {{ deal.amount }}
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+ @for (item of crmCards; track $index) {
+
+
+
+
+
+
+
+
+
+
+
+
+
{{item.title}}
+
{{item.number}}
+
+
+
+
+
+
+
{{item.percentage}}
+
+
this month
+
+
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ @for (row of SalesRepData.rows; track row.id) {
+
+
+ |
+
+ |
+
+
+
+
+
+
+ {{ row.name }}
+
+ |
+
+ {{ row.category }} |
+
+ {{ row.mail }} |
+
+
+
+ {{ row.location }}
+
+ |
+
+ {{ row.date }} |
+
+
+
+ |
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total
+ 4,145
+
+
+
+
+
+ @for (item of deviceLeads; track item.label) {
+
+
+
+
+ {{ item.label }}
+
+
+
+ {{ item.value }}
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
4,289
+
+ 1.02
+ compared to last week
+
+
+
+
+
+
+ @for (deal of dealStats; track deal.title) {
+ -
+
+
{{ deal.title }}
+
+
+ {{ deal.count }}
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @for (item of recentActivity; track $index) {
+ -
+
+
+
+
+
+
+
+
+
+
{{ item.mainText }}
+
+ @if (item.linkText) {
+
{{ item.linkText }}
+ }
+
+ @if (item.boldText) {
+
{{ item.boldText }}
+ }
+
+ @if (item.highlightText) {
+
{{ item.highlightText }}
+ }
+
+ @if (item.tagText) {
+
{{ item.tagText }}
+ }
+
+ @if (item.afterText) {
+
{{ item.afterText }}
+ }
+
+ @if (item.showAddIcon) {
+
+
+
+ }
+
+ @if (item.showCheckIcon) {
+
+ }
+
+ @if (item.subText) {
+
+ {{ item.subText }}
+
+ }
+
+
+
+
+ {{ item.time }}
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+
diff --git a/src/app/components/dashboards/crm/crm.scss b/src/app/components/dashboards/crm/crm.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/components/dashboards/crm/crm.ts b/src/app/components/dashboards/crm/crm.ts
new file mode 100644
index 00000000..12d5cc75
--- /dev/null
+++ b/src/app/components/dashboards/crm/crm.ts
@@ -0,0 +1,618 @@
+import { Chart, ChartConfiguration, Plugin } from 'chart.js';
+import { Component } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { SpkApexcharts } from "../../../@spk/charts/charts/spk-apexcharts/spk-apexcharts";
+import { ApexOptions } from 'ng-apexcharts';
+import { SpkReusableTables } from "../../../@spk/tables/spk-reusable-tables/spk-reusable-tables";
+import { SpkChartjs } from "../../../@spk/charts/charts/spk-chartjs/spk-chartjs";
+@Component({
+ selector: 'app-crm',
+ standalone: true,
+ imports: [RouterModule, SpkApexcharts, SpkReusableTables, SpkChartjs],
+ templateUrl: './crm.html',
+ styleUrl: './crm.scss'
+})
+export class Crm {
+ YourtargetisincompleteChart: ApexOptions = {
+ chart: {
+ height: 127,
+ width: 100,
+ type: 'radialBar',
+ },
+
+ series: [48],
+ // colors: ['#fff'],
+ plotOptions: {
+ radialBar: {
+ hollow: {
+ margin: 0,
+ size: '55%',
+ background: '#fff',
+ },
+ dataLabels: {
+ name: {
+ offsetY: -10,
+ color: '#4b9bfa',
+ fontSize: '.625rem',
+ show: false,
+ },
+ value: {
+ offsetY: 5,
+ color: '#4b9bfa',
+ fontSize: '.875rem',
+ show: true,
+ fontWeight: 600,
+ },
+ },
+ },
+ },
+ stroke: {
+ lineCap: 'round',
+ },
+ labels: ['Status'],
+ colors: ['#fff']
+ }
+
+ topDeals = [
+ {
+ name: 'Michael Jordan',
+ email: 'michael.jordan@example.com',
+ amount: '$2,893',
+ avatarImg: './assets/images/faces/10.jpg'
+ },
+ {
+ name: 'Emigo Kiaren',
+ email: 'emigo.kiaren@gmail.com',
+ amount: '$4,289',
+ initials: 'EK',
+ color: 'warning', // Used for text-warning and bg-warning/10
+ },
+ {
+ name: 'Randy Origoan',
+ email: 'randy.origoan@gmail.com',
+ amount: '$6,347',
+ avatarImg: './assets/images/faces/12.jpg'
+ },
+ {
+ name: 'George Pieterson',
+ email: 'george.pieterson@gmail.com',
+ amount: '$3,894',
+ initials: 'GP',
+ color: 'success',
+ },
+ {
+ name: 'Kiara Advain',
+ email: 'kiaraadvain214@gmail.com',
+ amount: '$2,679',
+ initials: 'KA',
+ color: 'primary',
+ }
+ ];
+
+ profitEarnedChart: ApexOptions = {
+ series: [
+ {
+ name: 'Profit Earned',
+ data: [44, 42, 57, 86, 58, 55, 70],
+ },
+ {
+ name: 'Total Sales',
+ data: [34, 22, 37, 56, 21, 35, 60],
+ },
+ ],
+ chart: {
+ type: 'bar',
+ height: 180,
+ toolbar: {
+ show: false,
+ },
+ },
+ grid: {
+ borderColor: '#f1f1f1',
+ strokeDashArray: 3,
+ },
+ colors: ['rgb(132, 90, 223)', '#e4e7ed'],
+ plotOptions: {
+ bar: {
+ colors: {
+ ranges: [
+ {
+ from: -100,
+ to: -46,
+ color: '#ebeff5',
+ },
+ {
+ from: -45,
+ to: 0,
+ color: '#ebeff5',
+ },
+ ],
+ },
+ columnWidth: '60%',
+ borderRadius: 5,
+ },
+ },
+ dataLabels: {
+ enabled: false,
+ },
+ stroke: {
+ show: true,
+ width: 2,
+ colors: undefined,
+ },
+ legend: {
+ show: false,
+ position: 'top',
+ },
+ yaxis: {
+ title: {
+ style: {
+ color: '#adb5be',
+ fontSize: '13px',
+ fontFamily: 'poppins, sans-serif',
+ fontWeight: 600,
+ cssClass: 'apexcharts-yaxis-label',
+ },
+ },
+ labels: {
+ formatter: function (y: number) {
+ return y.toFixed(0) + '';
+ },
+ },
+ },
+ xaxis: {
+ type: 'category',
+ categories: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
+ axisBorder: {
+ show: true,
+ color: 'rgba(119, 119, 142, 0.05)',
+ offsetX: 0,
+ offsetY: 0,
+ },
+ axisTicks: {
+ show: true,
+ borderType: 'solid',
+ color: 'rgba(119, 119, 142, 0.05)',
+
+ offsetX: 0,
+ offsetY: 0,
+ },
+ labels: {
+ rotate: -90,
+ },
+ },
+ }
+
+
+
+ crmCardsChartOptions({ series, colors }: { colors: string[], series: { data: number[], name: string }[] }): ApexOptions {
+ return {
+ chart: {
+ type: 'line',
+ height: 40,
+ width: 100,
+ sparkline: {
+ enabled: true
+ }
+ },
+ dataLabels: {
+ enabled: false
+ },
+
+ stroke: {
+ show: true,
+ curve: 'smooth',
+ lineCap: 'butt',
+ colors: undefined,
+ width: 1.5,
+ dashArray: 0,
+ },
+ fill: {
+ type: 'gradient',
+ gradient: {
+ opacityFrom: 0.9,
+ opacityTo: 0.9,
+ stops: [0, 98],
+ }
+ },
+ series: series,
+ yaxis: {
+ min: 0,
+ show: false,
+ axisBorder: {
+ show: false
+ },
+ },
+ xaxis: {
+ labels: {
+ show: false,
+ },
+ axisBorder: {
+ show: false
+ },
+ },
+ tooltip: {
+ enabled: true,
+ },
+ colors: colors,
+ }
+ }
+
+ crmCards = [
+ {
+ color: 'primary',
+ icon: 'ti ti-users',
+ title: 'Total Customers',
+ number: '1,02,890',
+ chartId: 'crm-total-customers',
+ chartoptions: this.crmCardsChartOptions({
+ series: [{
+ name: 'Value',
+ data: [20, 14, 19, 10, 23, 20, 22, 9, 12]
+ }], colors: ["rgb(132, 90, 223)"],
+ }),
+ viewallTextColor: 'text-primary',
+ percentage: '+40%',
+ percentageColor: 'text-success',
+ },
+ {
+ color: 'secondary',
+ icon: 'ti ti-wallet',
+ title: 'Total Revenue',
+ number: '$56,562',
+ chartId: 'crm-total-revenue',
+ chartoptions: this.crmCardsChartOptions({
+ series: [
+ {
+ name: 'Value',
+ data: [20, 14, 20, 22, 9, 12, 19, 10, 25],
+ },
+ ], colors: ['rgb(35, 183, 229)'],
+ }),
+ viewallTextColor: 'text-secondary',
+ percentage: '+25%',
+ percentageColor: 'text-success',
+ },
+ {
+ color: 'success',
+ icon: 'ti ti-wave-square',
+ title: 'Conversion Ratio',
+ number: '12.08%',
+ chartId: 'crm-conversion-ratio',
+ chartoptions: this.crmCardsChartOptions({
+ series: [
+ {
+ name: 'Value',
+ data: [20, 20, 22, 9, 14, 19, 10, 25, 12],
+ },
+ ], colors: ['rgb(38, 191, 148)'],
+ }),
+ viewallTextColor: 'text-success',
+ percentage: '-12%',
+ percentageColor: 'text-danger',
+ },
+ {
+ color: 'warning',
+ icon: 'ti ti-briefcase',
+ title: 'Total Deals',
+ number: '2,543',
+ chartId: 'crm-total-deals',
+ chartoptions: this.crmCardsChartOptions({
+ series: [
+ {
+ name: 'Value',
+ data: [20, 20, 22, 9, 12, 14, 19, 10, 25],
+ },
+ ], colors: ['rgb(245, 184, 73)'],
+ }),
+ viewallTextColor: 'text-warning',
+ percentage: '+19%',
+ percentageColor: 'text-success',
+ }
+ ];
+
+
+
+ RevenueAnalytics: ApexOptions = {
+ series: [
+ {
+ type: 'line',
+ name: 'Profit',
+ data: [
+ { x: 'Jan', y: 100 },
+ { x: 'Feb', y: 210 },
+ { x: 'Mar', y: 180 },
+ { x: 'Apr', y: 454 },
+ { x: 'May', y: 230 },
+ { x: 'Jun', y: 320 },
+ { x: 'Jul', y: 656 },
+ { x: 'Aug', y: 830 },
+ { x: 'Sep', y: 350 },
+ { x: 'Oct', y: 350 },
+ { x: 'Nov', y: 210 },
+ { x: 'Dec', y: 410 },
+ ],
+ },
+ {
+ type: 'line',
+ name: 'Revenue',
+ data: [
+ { x: 'Jan', y: 180 },
+ { x: 'Feb', y: 620 },
+ { x: 'Mar', y: 476 },
+ { x: 'Apr', y: 220 },
+ { x: 'May', y: 520 },
+ { x: 'Jun', y: 780 },
+ { x: 'Jul', y: 435 },
+ { x: 'Aug', y: 515 },
+ { x: 'Sep', y: 738 },
+ { x: 'Oct', y: 454 },
+ { x: 'Nov', y: 525 },
+ { x: 'Dec', y: 230 },
+ ],
+ },
+ {
+ type: 'area',
+ name: 'Sales',
+ data: [
+ { x: 'Jan', y: 200 },
+ { x: 'Feb', y: 530 },
+ { x: 'Mar', y: 110 },
+ { x: 'Apr', y: 130 },
+ { x: 'May', y: 480 },
+ { x: 'Jun', y: 520 },
+ { x: 'Jul', y: 780 },
+ { x: 'Aug', y: 435 },
+ { x: 'Sep', y: 475 },
+ { x: 'Oct', y: 738 },
+ { x: 'Nov', y: 454 },
+ { x: 'Dec', y: 480 },
+ ],
+ },
+ ],
+ chart: {
+ type: 'line',
+ height: 350,
+ animations: {
+ speed: 500,
+ },
+ toolbar: {
+ show: true
+ },
+ zoom: {
+ enabled: true,
+ },
+ dropShadow: {
+ enabled: false,
+ enabledOnSeries: undefined,
+ top: 8,
+ left: 0,
+ blur: 3,
+ color: '#000',
+ opacity: 0.1,
+ },
+ },
+ colors: ["rgb(132, 90, 223)", "rgba(35, 183, 229, 0.85)", "rgba(119, 119, 142, 0.05)"],
+ dataLabels: {
+ enabled: false,
+ },
+ grid: {
+ padding:{
+ left:0,
+ right:0,
+ top:0,
+ bottom:0
+ },
+ borderColor: '#f1f1f1',
+ strokeDashArray: 3,
+ yaxis: {
+ lines: {
+ show: false
+ }
+ },
+ },
+ stroke: {
+ curve: 'smooth',
+ width: [2, 2, 0],
+ dashArray: [0, 5, 0],
+ },
+ xaxis: {
+ axisTicks: {
+ show: false,
+ },
+ },
+ yaxis: {
+
+ labels: {
+ formatter: (value: number) => `$${value}`,
+ },
+
+ },
+ tooltip: {
+ y: [
+ {
+ formatter: (value: number) => `$${value}`,
+ },
+ {
+ formatter: (value: number) => `$${value.toFixed(0)}`,
+ },
+ {
+ formatter: (value: number) => `$${value.toFixed(0)}`,
+ },
+ ],
+ },
+ legend: {
+ show: true,
+ offsetY: 15,
+ customLegendItems: ['Profit', 'Revenue', 'Sales'],
+ inverseOrder: true,
+ markers: {
+ size: 5
+ }
+ },
+ title: {
+ text: 'Revenue Analytics with sales & profit (USD)',
+ align: 'left',
+ style: {
+ fontSize: '.8125rem',
+ fontWeight: 'semibold',
+ color: '#8c9097',
+ },
+ },
+ markers: {
+ hover: {
+ sizeOffset: 5,
+ },
+ },
+ }
+
+ SalesRepData = {
+ columns: [
+ { header: 'Sales Rep', tableHeadColumn: '!text-start !text-[0.85rem] min-w-[200px]' },
+ { header: 'Category', tableHeadColumn: '!text-start !text-[0.85rem]' },
+ { header: 'Mail', tableHeadColumn: '!text-start !text-[0.85rem]' },
+ { header: 'Location', tableHeadColumn: '!text-start !text-[0.85rem]' },
+ { header: 'Date', tableHeadColumn: '!text-start !text-[0.85rem]' },
+ { header: 'Action', tableHeadColumn: '!text-start !text-[0.85rem]' }
+ ],
+ rows: [
+ { id: 1, name: 'Mayor Kelly', img: './assets/images/faces/4.jpg', category: 'Manufacture', mail: 'mayorkelly@gmail.com', location: 'Germany', locationClass: 'info', date: 'Sep 15 - Oct 12, 2023', checked: false },
+ { id: 2, name: 'Andrew Garfield', img: './assets/images/faces/15.jpg', category: 'Development', mail: 'andrewgarfield@gmail.com', location: 'Canada', locationClass: 'primary', date: 'Apr 10 - Dec 12, 2023', checked: true },
+ { id: 3, name: 'Simon Cowel', img: './assets/images/faces/11.jpg', category: 'Service', mail: 'simoncowel234@gmail.com', location: 'Europe', locationClass: 'danger', date: 'Sep 15 - Oct 12, 2023', checked: false },
+ { id: 4, name: 'Mirinda Hers', img: './assets/images/faces/8.jpg', category: 'Marketing', mail: 'mirindahers@gmail.com', location: 'USA', locationClass: 'warning', date: 'Apr 14 - Dec 14, 2023', checked: true },
+ { id: 5, name: 'Jacob Smith', img: './assets/images/faces/9.jpg', category: 'Social Plataform', mail: 'jacobsmith@gmail.com', location: 'Singapore', locationClass: 'success', date: 'Feb 25 - Nov 25, 2023', checked: true }
+ ]
+ };
+
+
+
+
+
+
+ LeadsBySourceChart: ChartConfiguration<'doughnut'> = {
+ type: 'doughnut',
+ data: {
+ datasets: [{
+ data: [32, 27, 25, 16],
+ backgroundColor: [
+ 'rgb(132, 90, 223)', // Purple
+ 'rgb(35, 183, 229)', // Blue
+ 'rgb(245, 184, 73)', // Orange
+ 'rgb(38, 191, 148)', // Green
+ ],
+ // 1. INCREASE SPACING: This creates the clear gap between segments
+ spacing: -20,
+ // 2. ROUNDED BORDER: 10-20 is usually the sweet spot for this thinness
+ borderRadius: 20,
+ borderWidth: 0,
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ // 3. THINNESS: Ensure this is high enough (80% to 90%)
+ cutout: '86%',
+ plugins: {
+ legend: { display: false },
+ tooltip: { enabled: true }
+ }
+ }
+ };
+ recentActivity = [
+ {
+ statusClass: 'text-primary bg-primary/10',
+ time: '4:45PM',
+ mainText: 'Update of calendar events & ',
+ boldMain: true,
+ linkText: 'Added new events in next week.',
+ linkClass: 'text-primary font-semibold'
+ },
+ {
+ statusClass: 'text-secondary bg-secondary/10',
+ time: '3 hrs',
+ mainText: 'New theme for ',
+ boldText: 'Spruko Website',
+ afterText: ' completed',
+ subText: 'Lorem ipsum, dolor sit amet.'
+ },
+ {
+ statusClass: 'text-success bg-success/10',
+ time: '22 hrs',
+ mainText: 'Created a ',
+ highlightText: 'New Task',
+ highlightClass: 'text-success font-semibold',
+ afterText: ' today',
+ showAddIcon: true
+ },
+ {
+ statusClass: 'text-pink bg-pink/10',
+ time: 'Today',
+ mainText: 'New member ',
+ tagText: '@andreas gurrero',
+ tagClass: 'py-[0.2rem] px-[0.45rem] font-semibold rounded-sm text-pink text-[0.75em] bg-pink/10',
+ afterText: ' added today to AI Summit.'
+ },
+ {
+ statusClass: 'text-warning bg-warning/10',
+ time: '22 hrs',
+ mainText: '32 New people joined summit.'
+ },
+ {
+ statusClass: 'text-info bg-info/10',
+ time: '12 hrs',
+ mainText: 'Neon Tarly added ',
+ highlightText: 'Robert Bright',
+ highlightClass: 'text-info font-semibold',
+ afterText: ' to AI summit project.'
+ },
+ {
+ statusClass: 'text-[#232323] dark:text-white bg-[#232323]/10 dark:bg-white/20',
+ time: '4 hrs',
+ mainText: 'Replied to new support request ',
+ showCheckIcon: true
+ },
+ {
+ statusClass: 'text-purple bg-purple/10',
+ time: '4 hrs',
+ mainText: 'Completed documentation of ',
+ linkText: 'AI Summit.',
+ linkClass: 'text-purple underline font-semibold'
+ }
+ ];
+
+ deviceLeads = [
+ {
+ label: 'Mobile',
+ value: '1,624',
+ legendClass: 'mobile',
+ containerClass: '!ps-4 p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
+ },
+ {
+ label: 'Desktop',
+ value: '1,267',
+ legendClass: 'desktop',
+ containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
+ },
+ {
+ label: 'Laptop',
+ value: '1,153',
+ legendClass: 'laptop',
+ containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
+ },
+ {
+ label: 'Tablet',
+ value: '679',
+ legendClass: 'tablet',
+ containerClass: '!pe-4 p-[0.95rem] text-center'
+ }
+ ];
+
+ dealStats = [
+ { title: 'Successful Deals', count: '987 deals', liClass: 'primary' },
+ { title: 'Pending Deals', count: '1,073 deals', liClass: 'info' },
+ { title: 'Rejected Deals', count: '1,674 deals', liClass: 'warning' },
+ { title: 'Upcoming Deals', count: '921 deals', liClass: 'success' }
+ ];
+}
+
+
diff --git a/src/app/components/dashboards/dashboard.routes.ts b/src/app/components/dashboards/dashboard.routes.ts
new file mode 100644
index 00000000..7f34349f
--- /dev/null
+++ b/src/app/components/dashboards/dashboard.routes.ts
@@ -0,0 +1,12 @@
+import { Routes } from '@angular/router';
+
+export const dashboardRoutingModule: Routes = [
+
+ {
+ path: 'crm',
+ loadComponent: () => import('./crm/crm').then((m) => m.Crm),
+ title: 'YNEX - Crm',
+ },
+
+
+];
diff --git a/src/app/components/error/error.routes.ts b/src/app/components/error/error.routes.ts
new file mode 100644
index 00000000..04e3aeb7
--- /dev/null
+++ b/src/app/components/error/error.routes.ts
@@ -0,0 +1,15 @@
+import { Routes } from '@angular/router';
+
+export const errorRoutingModule: Routes = [
+ {
+ path: 'error', children: [
+ {
+ path: 'error404',
+ loadComponent: () => import('./error404/error404').then( (m) => m.Error404 ),
+ title: 'YNEX - Error 404'
+ },
+
+ ]
+ }
+];
+
diff --git a/src/app/components/error/error404/error404.html b/src/app/components/error/error404/error404.html
new file mode 100644
index 00000000..cf0f11a3
--- /dev/null
+++ b/src/app/components/error/error404/error404.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
404
+
Oops 😭,The page you are looking for is not available.
+
+
+
We are sorry for the inconvenience,The page you are trying to access has
+ been removed or never been existed.
+
+
+
BACK TO HOME
+
+
+
+
+
+
+
diff --git a/src/app/components/error/error404/error404.scss b/src/app/components/error/error404/error404.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/components/error/error404/error404.ts b/src/app/components/error/error404/error404.ts
new file mode 100644
index 00000000..5f5998ac
--- /dev/null
+++ b/src/app/components/error/error404/error404.ts
@@ -0,0 +1,16 @@
+import { Component } from '@angular/core';
+import {RouterModule} from'@angular/router';
+import { SpkParticles } from "../../../@spk/plugins&reusable/spk-particles/spk-particles";
+import {particlesOptions} from "../particleoptions"
+@Component({
+ selector: 'app-error404',
+ standalone: true,
+ imports: [RouterModule, SpkParticles],
+ templateUrl: './error404.html',
+ styleUrls: ['./error404.scss']
+})
+export class Error404 {
+particlesOptions=particlesOptions
+}
+
+
diff --git a/src/app/components/error/particleoptions.ts b/src/app/components/error/particleoptions.ts
new file mode 100644
index 00000000..6e7c01ec
--- /dev/null
+++ b/src/app/components/error/particleoptions.ts
@@ -0,0 +1,36 @@
+ export const particlesOptions = {
+ fpsLimit: 60, // 200 is excessive; 60 is smooth and efficient
+ interactivity: {
+ events: {
+ onClick: { enable: true },
+ onHover: { enable: true },
+ resize: { enable: true }
+ },
+ modes: {
+ push: { quantity: 4 },
+ repulse: { distance: 200, duration: 0.4 }
+ }
+ },
+ particles: {
+ number: {
+ value: 80,
+ density: { enable: true, value_area: 800 }
+ },
+ color: { value: "#845adf" },
+ shape: { type: "circle" },
+ opacity: { value: 0.5 },
+ size: { value: 2, random: true },
+ line_linked: {
+ enable: true,
+ distance: 150,
+ color: "#d1d9e0",
+ opacity: 0.4,
+ width: 1
+ },
+ move: {
+ enable: true,
+ speed: 2,
+ out_mode: "out"
+ }
+ }
+ };
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
new file mode 100644
index 00000000..25a123f3
--- /dev/null
+++ b/src/app/core/auth/auth.service.ts
@@ -0,0 +1,133 @@
+import { computed, Injectable, inject, signal } from '@angular/core';
+import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model';
+import { API_CONFIG } from '../config/api.config';
+import { TokenStorageService } from './token-storage.service';
+import { AppContextService } from '../services/app-context.service';
+
+@Injectable({ providedIn: 'root' })
+export class AuthService {
+ private readonly http = inject(HttpClient);
+ private readonly tokenStorage = inject(TokenStorageService);
+ private readonly appContextService = inject(AppContextService);
+
+ private readonly userSubject = new BehaviorSubject(null);
+ readonly user$ = this.userSubject.asObservable();
+ readonly currentUserSignal = signal(null);
+ private readonly accessTokenSignal = signal(null);
+ readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
+
+ private refreshRequest$: Observable | null = null;
+
+ constructor() {
+ this.restoreAuthState();
+ }
+
+ login(payload: LoginRequest, rememberMe: boolean): Observable {
+ return this.http.post(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/login`, payload).pipe(
+ map((response) => {
+ const user = this.normalizeUserProfile(response);
+ this.tokenStorage.saveAuth(response, rememberMe);
+ this.setAuthState(user, this.tokenStorage.getAccessToken());
+ return response;
+ })
+ );
+ }
+
+ refreshAccessToken(): Observable {
+ if (this.refreshRequest$) {
+ return this.refreshRequest$;
+ }
+
+ const refreshToken = this.tokenStorage.getRefreshToken();
+ if (!refreshToken) {
+ this.logout();
+ return throwError(() => new HttpErrorResponse({ status: 401, statusText: 'Refresh token missing' }));
+ }
+
+ this.refreshRequest$ = this.http.post(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe(
+ map((response) => {
+ const user = this.normalizeUserProfile(response, this.currentUserSignal());
+ const storageType = this.tokenStorage.getStorageType();
+ const rememberMe = storageType === 'local';
+ this.tokenStorage.saveAuth(response, rememberMe);
+ this.setAuthState(user, this.tokenStorage.getAccessToken());
+ return response;
+ }),
+ catchError((error) => {
+ this.logout();
+ return throwError(() => error);
+ }),
+ finalize(() => {
+ this.refreshRequest$ = null;
+ }),
+ shareReplay(1)
+ );
+
+ return this.refreshRequest$;
+ }
+
+ logout(): void {
+ this.tokenStorage.clearAuth();
+ this.appContextService.clearContext();
+ this.setAuthState(null, null);
+ this.refreshRequest$ = null;
+ }
+
+ get accessToken(): string | null {
+ return this.tokenStorage.getAccessToken();
+ }
+
+ get refreshToken(): string | null {
+ return this.tokenStorage.getRefreshToken();
+ }
+
+ get isAuthenticated(): boolean {
+ return this.isAuthenticatedSignal();
+ }
+
+ get isLoggedIn(): boolean {
+ return this.isAuthenticatedSignal();
+ }
+
+ get currentUser(): UserProfile | null {
+ return this.currentUserSignal();
+ }
+
+ private restoreAuthState(): void {
+ const storedUser = this.tokenStorage.getUser();
+ const storedAccessToken = this.tokenStorage.getAccessToken();
+
+ if (storedUser && storedAccessToken) {
+ this.setAuthState(storedUser, storedAccessToken);
+ return;
+ }
+
+ this.setAuthState(null, storedAccessToken ?? null);
+ }
+
+ private setAuthState(user: UserProfile | null, token: string | null): void {
+ this.userSubject.next(user);
+ this.currentUserSignal.set(user);
+ this.accessTokenSignal.set(token);
+ }
+
+ private normalizeUserProfile(response: LoginResponse, fallbackUser: UserProfile | null = null): UserProfile | null {
+ if (response.user) {
+ return response.user;
+ }
+
+ const userId = response.userId?.trim();
+ const email = response.email?.trim();
+ if (userId || email) {
+ return {
+ id: userId ?? fallbackUser?.id ?? '',
+ email: email ?? fallbackUser?.email ?? '',
+ roles: response.roles,
+ };
+ }
+
+ return fallbackUser;
+ }
+}
diff --git a/src/app/core/auth/token-storage.service.ts b/src/app/core/auth/token-storage.service.ts
new file mode 100644
index 00000000..1bc3ab7d
--- /dev/null
+++ b/src/app/core/auth/token-storage.service.ts
@@ -0,0 +1,152 @@
+import { Injectable } from '@angular/core';
+import { LoginResponse, UserProfile } from '../models/auth.model';
+
+type StorageType = 'local' | 'session';
+
+@Injectable({ providedIn: 'root' })
+export class TokenStorageService {
+ private readonly accessTokenKey = 'master-admin-access-token';
+ private readonly refreshTokenKey = 'master-admin-refresh-token';
+ private readonly accessTokenExpiresOnKey = 'master-admin-access-token-expires-on';
+ private readonly refreshTokenExpiresOnKey = 'master-admin-refresh-token-expires-on';
+ private readonly userKey = 'master-admin-user';
+
+ saveAuth(response: LoginResponse, rememberMe: boolean): void {
+ this.clearAuth();
+ const selectedStorage = rememberMe ? localStorage : sessionStorage;
+
+ if (response.accessToken) {
+ selectedStorage.setItem(this.accessTokenKey, response.accessToken);
+ }
+
+ if (response.refreshToken) {
+ selectedStorage.setItem(this.refreshTokenKey, response.refreshToken);
+ }
+
+ if (response.accessTokenExpiresOn) {
+ selectedStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn);
+ }
+
+ if (response.refreshTokenExpiresOn) {
+ selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn);
+ }
+
+ const user = this.buildUserProfile(response);
+ if (user) {
+ selectedStorage.setItem(this.userKey, JSON.stringify(user));
+ }
+ }
+
+ clearAuth(): void {
+ this.removeFromStorage(localStorage);
+ this.removeFromStorage(sessionStorage);
+ }
+
+ getAccessToken(): string | null {
+ return this.getByPriority(this.accessTokenKey);
+ }
+
+ getRefreshToken(): string | null {
+ return this.getByPriority(this.refreshTokenKey);
+ }
+
+ getAccessTokenExpiresOn(): string | null {
+ return this.getByPriority(this.accessTokenExpiresOnKey);
+ }
+
+ getRefreshTokenExpiresOn(): string | null {
+ return this.getByPriority(this.refreshTokenExpiresOnKey);
+ }
+
+ getUser(): UserProfile | null {
+ const raw = this.getByPriority(this.userKey);
+
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(raw) as UserProfile;
+ } catch {
+ this.clearAuth();
+ return null;
+ }
+ }
+
+ getStorageType(): 'local' | 'session' | null {
+ if (this.hasAnyAuthKey(sessionStorage)) {
+ return 'session';
+ }
+
+ if (this.hasAnyAuthKey(localStorage)) {
+ return 'local';
+ }
+
+ return null;
+ }
+
+ isAccessTokenExpired(): boolean {
+ return this.isExpired(this.getAccessTokenExpiresOn());
+ }
+
+ isRefreshTokenExpired(): boolean {
+ return this.isExpired(this.getRefreshTokenExpiresOn());
+ }
+
+ private getByPriority(key: string): string | null {
+ const fromSession = sessionStorage.getItem(key);
+ if (fromSession !== null) {
+ return fromSession;
+ }
+
+ return localStorage.getItem(key);
+ }
+
+ private hasAnyAuthKey(storage: Storage): boolean {
+ return [
+ this.accessTokenKey,
+ this.refreshTokenKey,
+ this.accessTokenExpiresOnKey,
+ this.refreshTokenExpiresOnKey,
+ this.userKey,
+ ].some((key) => storage.getItem(key) !== null);
+ }
+
+ private removeFromStorage(storage: Storage): void {
+ storage.removeItem(this.accessTokenKey);
+ storage.removeItem(this.refreshTokenKey);
+ storage.removeItem(this.accessTokenExpiresOnKey);
+ storage.removeItem(this.refreshTokenExpiresOnKey);
+ storage.removeItem(this.userKey);
+ }
+
+ private isExpired(expiresOn: string | null): boolean {
+ debugger;
+ if (!expiresOn) {
+ return true;
+ }
+
+ const parsedExpiry = Date.parse(expiresOn);
+
+ if (Number.isNaN(parsedExpiry)) {
+ return true;
+ }
+
+ return parsedExpiry <= Date.now();
+ }
+ private buildUserProfile(response: LoginResponse): UserProfile | null {
+ if (response.user) {
+ return response.user;
+ }
+
+ if (response.userId || response.email) {
+ return {
+ id: response.userId ?? '',
+ email: response.email ?? '',
+ roles: response.roles,
+ };
+ }
+
+ return null;
+ }
+}
diff --git a/src/app/core/config/api.config.ts b/src/app/core/config/api.config.ts
index 9d76e400..ce9d4275 100644
--- a/src/app/core/config/api.config.ts
+++ b/src/app/core/config/api.config.ts
@@ -1,6 +1,9 @@
export const API_CONFIG = {
baseUrl: '/api',
endpoints: {
- auth: '/auth'
- }
+ auth: '/auth',
+ currentUserProfile: '/users/me',
+ tenantContext: '/tenants/current-context',
+ permissionContext: '/users/me/permissions',
+ },
};
diff --git a/src/app/core/end-points/country/country.endpoints.ts b/src/app/core/end-points/country/country.endpoints.ts
deleted file mode 100644
index b1644cf8..00000000
--- a/src/app/core/end-points/country/country.endpoints.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { buildApiUrl } from '../../config/api-url.util';
-
-
-export const COUNTRY_ENDPOINTS = {
- dataTable: buildApiUrl(
- 'masterAdmin',
- '/v1/countries/datatable'
- ),
-
- create: buildApiUrl(
- 'masterAdmin',
- '/v1/countries'
- ),
-
- getById: (id: string) =>
- buildApiUrl(
- 'masterAdmin',
- `/v1/countries/${encodeURIComponent(id)}`
- ),
-
- update: (id: string) =>
- buildApiUrl(
- 'masterAdmin',
- `/v1/countries/${encodeURIComponent(id)}`
- ),
-
- delete: (id: string) =>
- buildApiUrl(
- 'masterAdmin',
- `/v1/countries/${encodeURIComponent(id)}`
- ),
-
- changeStatus: (id: string) =>
- buildApiUrl(
- 'masterAdmin',
- `/v1/countries/${encodeURIComponent(id)}/status`
- ),
-} as const;
\ No newline at end of file
diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts
new file mode 100644
index 00000000..cb0fab9b
--- /dev/null
+++ b/src/app/core/guards/auth.guard.ts
@@ -0,0 +1,37 @@
+import { inject } from '@angular/core';
+import { CanActivateChildFn, Router } from '@angular/router';
+import { catchError, map, of } from 'rxjs';
+import { AuthService } from '../auth/auth.service';
+import { TokenStorageService } from '../auth/token-storage.service';
+
+export const authGuard: CanActivateChildFn = (_childRoute, state) => {
+ const authService = inject(AuthService);
+ const tokenStorage = inject(TokenStorageService);
+ const router = inject(Router);
+
+ const loginUrlTree = router.createUrlTree(['/auth/login'], {
+ queryParams: { returnUrl: state.url },
+ });
+
+ if (!authService.accessToken || !authService.currentUser) {
+ authService.logout();
+ return loginUrlTree;
+ }
+
+ if (!tokenStorage.isAccessTokenExpired()) {
+ return true;
+ }
+
+ if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
+ authService.logout();
+ return loginUrlTree;
+ }
+
+ return authService.refreshAccessToken().pipe(
+ map(() => true),
+ catchError(() => {
+ authService.logout();
+ return of(loginUrlTree);
+ })
+ );
+};
diff --git a/src/app/core/guards/auth/super-admin.guard.ts b/src/app/core/guards/auth/super-admin.guard.ts
new file mode 100644
index 00000000..d109777b
--- /dev/null
+++ b/src/app/core/guards/auth/super-admin.guard.ts
@@ -0,0 +1,11 @@
+import { inject } from '@angular/core';
+import { CanActivateFn, Router } from '@angular/router';
+import { AuthService } from '../../services/auth/auth.service';
+
+export const superAdminGuard: CanActivateFn = () => {
+ const auth = inject(AuthService);
+ const router = inject(Router);
+ return (auth.currentUser?.roles ?? []).includes('super_admin')
+ ? true
+ : router.createUrlTree(['/dashboards/crm']);
+};
diff --git a/src/app/core/guards/guest.guard.ts b/src/app/core/guards/guest.guard.ts
new file mode 100644
index 00000000..42629512
--- /dev/null
+++ b/src/app/core/guards/guest.guard.ts
@@ -0,0 +1,53 @@
+import { inject } from '@angular/core';
+import { CanActivateFn, Router } from '@angular/router';
+import { catchError, map, of } from 'rxjs';
+import { AuthService } from '../auth/auth.service';
+import { TokenStorageService } from '../auth/token-storage.service';
+
+const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
+
+function resolveSafeReturnUrl(returnUrl: string | null): string {
+ const candidate = returnUrl?.trim();
+ if (!candidate) {
+ return DEFAULT_AUTHENTICATED_REDIRECT;
+ }
+
+ const lowerCandidate = candidate.toLowerCase();
+ const isSafeInternal =
+ candidate.startsWith('/') &&
+ !candidate.startsWith('//') &&
+ !lowerCandidate.includes('http://') &&
+ !lowerCandidate.includes('https://');
+
+ return isSafeInternal ? candidate : DEFAULT_AUTHENTICATED_REDIRECT;
+}
+
+export const guestGuard: CanActivateFn = (route) => {
+ const authService = inject(AuthService);
+ const tokenStorage = inject(TokenStorageService);
+ const router = inject(Router);
+
+ const targetUrl = resolveSafeReturnUrl(route.queryParamMap.get('returnUrl'));
+ const targetUrlTree = router.createUrlTree([targetUrl]);
+
+ if (!authService.accessToken || !authService.currentUser) {
+ return true;
+ }
+
+ if (!tokenStorage.isAccessTokenExpired()) {
+ return targetUrlTree;
+ }
+
+ if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
+ authService.logout();
+ return true;
+ }
+
+ return authService.refreshAccessToken().pipe(
+ map(() => targetUrlTree),
+ catchError(() => {
+ authService.logout();
+ return of(true);
+ })
+ );
+};
\ No newline at end of file
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/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts
index 1fd7c0c3..31706513 100644
--- a/src/app/core/interceptors/error.interceptor.ts
+++ b/src/app/core/interceptors/error.interceptor.ts
@@ -4,6 +4,31 @@ import { ToastrService } from 'ngx-toastr';
import { catchError, throwError } from 'rxjs';
import { API_CONFIG } from '../config/api.config';
+const backendErrorMessage = (body: unknown): string | null => {
+ if (typeof body === 'string') return body.trim() || null;
+ if (typeof body !== 'object' || body === null) return null;
+
+ const record = body as Record;
+ for (const key of ['detail', 'message', 'title']) {
+ const value = record[key];
+ if (typeof value === 'string' && value.trim()) return value.trim();
+ }
+ return null;
+};
+
+const httpErrorMessage = (error: HttpErrorResponse): string => {
+ const backendMessage = backendErrorMessage(error.error);
+ if (backendMessage) return backendMessage;
+
+ if (error.status > 0) {
+ const statusText = error.statusText.trim();
+ const meaningfulStatusText = statusText && statusText.toUpperCase() !== 'OK';
+ return `HTTP ${error.status}${meaningfulStatusText ? ` ${statusText}` : ''}`;
+ }
+
+ return error.message || 'Request failed';
+};
+
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const toastr = inject(ToastrService);
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
@@ -13,7 +38,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
- const message = error.error?.detail ?? error.error?.message ?? error.message ?? 'Request failed';
+ const message = httpErrorMessage(error);
toastr.error(message, 'Request failed');
}
diff --git a/src/app/core/models/api-response.model.ts b/src/app/core/models/api-response.model.ts
new file mode 100644
index 00000000..0ec47b3b
--- /dev/null
+++ b/src/app/core/models/api-response.model.ts
@@ -0,0 +1,12 @@
+export interface ApiResponse {
+ data: T;
+ message?: string;
+ success: boolean;
+}
+
+export interface ProblemDetails {
+ title?: string;
+ status?: number;
+ detail?: string;
+ errors?: Record;
+}
diff --git a/src/app/core/models/auth.model.ts b/src/app/core/models/auth.model.ts
new file mode 100644
index 00000000..0c747c92
--- /dev/null
+++ b/src/app/core/models/auth.model.ts
@@ -0,0 +1,24 @@
+export interface LoginRequest {
+ email: string;
+ password: string;
+}
+
+export interface LoginResponse {
+ accessToken: string;
+ refreshToken?: string;
+ accessTokenExpiresOn?: string;
+ refreshTokenExpiresOn?: string;
+ expiresIn?: number;
+ user?: UserProfile;
+ userId?: string;
+ email?: string;
+ roles?: string[];
+}
+
+export interface UserProfile {
+ id: string;
+ email: string;
+ displayName?: string;
+ roles?: string[];
+ tenantId?: string;
+}
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/context.model.ts b/src/app/core/models/context.model.ts
new file mode 100644
index 00000000..aab308a8
--- /dev/null
+++ b/src/app/core/models/context.model.ts
@@ -0,0 +1,33 @@
+import { UserProfile } from './auth.model';
+import { Menu } from '../../shared/services/nav.service';
+
+export interface CurrentUserContext extends UserProfile {
+ fullName?: string;
+ defaultLandingPage?: string;
+}
+
+export interface TenantContext {
+ tenantId?: string;
+ companyId?: string;
+ companyName?: string;
+ tenantName?: string;
+ defaultLandingPage?: string;
+}
+
+export interface PermissionContext {
+ roles: string[];
+ permissions: string[];
+ defaultLandingPage?: string;
+}
+
+export interface MenuContext {
+ items: Menu[];
+ defaultLandingPage?: string;
+}
+
+export interface AppContextState {
+ user: CurrentUserContext;
+ tenant: TenantContext;
+ permissions: PermissionContext;
+ menu: MenuContext;
+}
\ No newline at end of file
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..012e2a19
--- /dev/null
+++ b/src/app/core/models/currency/currency.model.ts
@@ -0,0 +1,34 @@
+export interface CurrencyDto {
+ id: string;
+ code: string;
+ iso2: 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/tenant/tenant-currencies.model.ts b/src/app/core/models/tenant/tenant-currencies.model.ts
new file mode 100644
index 00000000..8ba9c947
--- /dev/null
+++ b/src/app/core/models/tenant/tenant-currencies.model.ts
@@ -0,0 +1,53 @@
+import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
+
+
+export interface TenantCurrencyDto {
+ id: string;
+ tenantId: string;
+ tenantName: string;
+ currencyId: string;
+ currencyName: string;
+ isBaseCurrency: boolean;
+ isReporting: boolean;
+ isActive: boolean;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
+
+export interface TenantCurrencyLookupDto {
+ id: string;
+ tenantId: string;
+ currencyId: string;
+ isBaseCurrency: boolean;
+ isReporting: boolean;
+}
+
+export interface CreateTenantCurrencyRequest {
+ tenantId: string;
+ currencyId: string;
+ isBaseCurrency: boolean;
+ isReporting: boolean;
+}
+
+export interface UpdateTenantCurrencyRequest {
+ isBaseCurrency: boolean;
+ isReporting: boolean;
+ isActive: boolean;
+}
+
+
+export interface TenantCurrencyTableRow extends DataTableRecord {
+ readonly id: string;
+ readonly tenantId: string;
+ readonly tenantName: string;
+ readonly currencyId: string;
+ readonly currencyName: string;
+ readonly isBaseCurrency: boolean;
+ readonly isReporting: boolean;
+ readonly isActive: boolean;
+ readonly serialNumber: number;
+ readonly createdOn?: string;
+ readonly modifiedOn?: string | null;
+}
+
+export type TenantCurrencyModalMode = 'create' | 'edit';
\ No newline at end of file
diff --git a/src/app/core/models/tenant/tenant.model.ts b/src/app/core/models/tenant/tenant.model.ts
new file mode 100644
index 00000000..5aa875b4
--- /dev/null
+++ b/src/app/core/models/tenant/tenant.model.ts
@@ -0,0 +1,68 @@
+import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
+
+export enum TenantStatus { Trial = 0, Active = 1, Suspended = 2, Cancelled = 3 }
+
+export interface TenantDto {
+ id: string;
+ code: string;
+ name: string;
+ status: TenantStatus;
+ defaultLanguageId: string;
+ defaultLanguageName: string | null;
+ defaultDbConnectionId: string | null;
+ defaultDbConnectionName: string | null;
+ defaultCurrencyId: string;
+ defaultCurrencyName: string | null;
+ defaultTimezoneId: string;
+ defaultTimezoneName: string | null;
+ dataRegion: string;
+ isActive: boolean;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
+
+export interface TenantLookupDto {
+ id: string;
+ name: string;
+ code: string;
+}
+
+export interface CreateTenantRequest {
+ code: string;
+ name: string;
+ status: TenantStatus;
+ defaultLanguageId: string;
+ defaultCurrencyId: string;
+ defaultTimezoneId: string;
+ dataRegion: string;
+}
+
+export interface UpdateTenantRequest {
+ code: string;
+ name: string;
+ status: TenantStatus;
+ defaultLanguageId: string;
+ defaultCurrencyId: string;
+ defaultTimezoneId: string;
+ defaultDbConnectionId: string | null;
+ dataRegion: string;
+ isActive: boolean;
+}
+
+
+export interface TenantTableRow extends DataTableRecord {
+ readonly id: string;
+ readonly code: string;
+ readonly name: string;
+ readonly status: TenantStatus;
+ readonly dataRegion: string;
+ readonly isActive: boolean;
+ readonly serialNumber: number;
+ readonly createdOn?: string;
+ readonly modifiedOn?: string | null;
+ readonly defaultLanguageName: string | null;
+ readonly defaultCurrencyName: string | null;
+ readonly defaultTimezoneName: string | null;
+}
+
+export type TenantModalMode = 'create' | 'edit';
\ No newline at end of file
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/models/user/user.model.ts b/src/app/core/models/user/user.model.ts
new file mode 100644
index 00000000..b7cd6b9c
--- /dev/null
+++ b/src/app/core/models/user/user.model.ts
@@ -0,0 +1,29 @@
+export enum UserStatus { Pending = 0, Active = 1, Suspended = 2, Locked = 3, Disabled = 4 }
+
+export interface CreateUserRequest {
+ email: string;
+ password: string;
+ roleCodes: string[];
+}
+
+export interface UserDto {
+ id: string;
+ email: string;
+ status: UserStatus;
+ roles: string[];
+ isActive: boolean;
+ createdOn: string;
+ updatedOn?: string | null;
+ lastLoginOn: string | null;
+}
+
+export interface UserLookupDto {
+ readonly id: string;
+ readonly ianaId: string;
+ readonly displayName: string;
+}
+export interface UpdateUserRequest extends CreateUserRequest {
+ readonly isActive: boolean;
+}
+
+export type UserModalMode = 'create' | 'edit';
\ No newline at end of file
diff --git a/src/app/core/services/app-context.service.ts b/src/app/core/services/app-context.service.ts
new file mode 100644
index 00000000..eaa14305
--- /dev/null
+++ b/src/app/core/services/app-context.service.ts
@@ -0,0 +1,104 @@
+import { Injectable, inject } from '@angular/core';
+import { finalize, forkJoin, Observable, of, shareReplay, tap } from 'rxjs';
+import { AppContextState, MenuContext } from '../models/context.model';
+import { MenuService } from './menu.service';
+import { PermissionService } from './permission.service';
+import { TenantContextService } from './tenant-context.service';
+import { UserContextService } from './user-context.service';
+import { NavService } from '../../shared/services/nav.service';
+
+@Injectable({ providedIn: 'root' })
+export class AppContextService {
+ private readonly userContextService = inject(UserContextService);
+ private readonly tenantContextService = inject(TenantContextService);
+ private readonly permissionService = inject(PermissionService);
+ private readonly menuService = inject(MenuService);
+ private readonly navService = inject(NavService);
+
+ private loadRequest$: Observable | null = null;
+ private menuLoadRequest$: Observable | null = null;
+ private loaded = false;
+
+ ensureMenuInitialized(forceReload = false): Observable {
+ const currentMenu = this.menuService.menuContext();
+ if (currentMenu && !forceReload) {
+ this.navService.setMenuItems(this.menuService.getNavigationMenu());
+ return of(currentMenu);
+ }
+
+ if (this.menuLoadRequest$ && !forceReload) {
+ return this.menuLoadRequest$;
+ }
+
+ this.menuLoadRequest$ = this.menuService.loadMenu().pipe(
+ tap(() => {
+ this.navService.setMenuItems(this.menuService.getNavigationMenu());
+ }),
+ finalize(() => {
+ this.menuLoadRequest$ = null;
+ }),
+ shareReplay(1)
+ );
+
+ return this.menuLoadRequest$;
+ }
+
+ loadAppContext(forceReload = false): Observable {
+ if (this.loaded && !forceReload) {
+ return of(this.getCurrentState());
+ }
+
+ if (this.loadRequest$ && !forceReload) {
+ return this.loadRequest$;
+ }
+
+ this.loadRequest$ = forkJoin({
+ user: this.userContextService.loadCurrentUserProfile(),
+ tenant: this.tenantContextService.loadTenantContext(),
+ permissions: this.permissionService.loadPermissions(),
+ menu: this.menuService.loadMenu(),
+ }).pipe(
+ tap((context) => {
+ this.navService.setMenuItems(this.menuService.getNavigationMenu());
+ this.loaded = true;
+ }),
+ finalize(() => {
+ this.loadRequest$ = null;
+ }),
+ shareReplay(1)
+ );
+
+ return this.loadRequest$;
+ }
+
+ ensureContextLoaded(): Observable {
+ return this.loadAppContext();
+ }
+
+ clearContext(): void {
+ this.userContextService.clear();
+ this.tenantContextService.clear();
+ this.permissionService.clear();
+ this.menuService.clear();
+ this.navService.clearMenuItems();
+ this.loaded = false;
+ this.loadRequest$ = null;
+ }
+
+ getDefaultLandingPage(): string {
+ return this.userContextService.currentUserContext()?.defaultLandingPage
+ ?? this.permissionService.permissionContext()?.defaultLandingPage
+ ?? this.tenantContextService.tenantContext()?.defaultLandingPage
+ ?? this.menuService.getDefaultLandingPage()
+ ?? '/dashboards/crm';
+ }
+
+ private getCurrentState(): AppContextState {
+ return {
+ user: this.userContextService.currentUserContext() ?? { id: '', email: '', roles: [] },
+ tenant: this.tenantContextService.tenantContext() ?? {},
+ permissions: this.permissionService.permissionContext() ?? { roles: [], permissions: [] },
+ menu: this.menuService.menuContext() ?? { items: [] },
+ };
+ }
+}
\ No newline at end of file
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/base-api.service.ts b/src/app/core/services/base-api.service.ts
new file mode 100644
index 00000000..f432d80b
--- /dev/null
+++ b/src/app/core/services/base-api.service.ts
@@ -0,0 +1,34 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { API_CONFIG } from '../config/api.config';
+import { ApiResponse } from '../models/api-response.model';
+
+@Injectable({ providedIn: 'root' })
+export class BaseApiService {
+ private readonly http = inject(HttpClient);
+
+ protected get(resource: string, params?: Record): Observable {
+ let httpParams = new HttpParams();
+
+ if (params) {
+ Object.entries(params).forEach(([key, value]) => {
+ httpParams = httpParams.set(key, String(value));
+ });
+ }
+
+ return this.http.get(`${API_CONFIG.baseUrl}${resource}`, { params: httpParams });
+ }
+
+ protected post(resource: string, body: T): Observable {
+ return this.http.post(`${API_CONFIG.baseUrl}${resource}`, body);
+ }
+
+ protected put(resource: string, body: T): Observable {
+ return this.http.put(`${API_CONFIG.baseUrl}${resource}`, body);
+ }
+
+ protected delete(resource: string): Observable {
+ return this.http.delete(`${API_CONFIG.baseUrl}${resource}`);
+ }
+}
diff --git a/src/app/core/services/common/firebase.service.ts b/src/app/core/services/common/firebase.service.ts
index 74b07156..8254331d 100644
--- a/src/app/core/services/common/firebase.service.ts
+++ b/src/app/core/services/common/firebase.service.ts
@@ -10,7 +10,7 @@ import { environment } from '../../../../environments/environment';
})
export class FirebaseService {
constructor() {
- AngularFireModule.initializeApp(environment.firebase);
+ // AngularFireModule.initializeApp(environment.firebase);
}
getFirestore() {
diff --git a/src/app/core/services/common/menu.data.ts b/src/app/core/services/common/menu.data.ts
index 68dc584f..137a2564 100644
--- a/src/app/core/services/common/menu.data.ts
+++ b/src/app/core/services/common/menu.data.ts
@@ -24,8 +24,6 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false,
dirchange: false,
children: [
- { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
- { path: '/users', title: 'Users', type: 'link', dirchange: false },
{
title: 'Global Master',
type: 'sub',
@@ -33,11 +31,26 @@ 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 },
],
},
+ {
+ title: 'Tenant Master',
+ type: 'sub',
+ active: false,
+ selected: false,
+ dirchange: false,
+ children: [
+ { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
+ { path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
+ ],
+ },
+ { path: '/users', title: 'Users', type: 'link', dirchange: false },
{
title: 'Configuration',
type: 'sub',
@@ -75,4 +88,4 @@ export const SAAS_MENU_DATA: MenuContext = {
],
},
],
-};
\ No newline at end of file
+};
diff --git a/src/app/core/services/common/menu.service.spec.ts b/src/app/core/services/common/menu.service.spec.ts
new file mode 100644
index 00000000..ec059a31
--- /dev/null
+++ b/src/app/core/services/common/menu.service.spec.ts
@@ -0,0 +1,74 @@
+import { provideHttpClient } from '@angular/common/http';
+import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { firstValueFrom } from 'rxjs';
+import { AuthService } from '../auth/auth.service';
+import { TokenStorageService } from '../auth/token-storage.service';
+import { MenuService } from './menu.service';
+
+describe('MenuService dependency and authorization', () => {
+ let menuService: MenuService;
+ let tokenStorage: TokenStorageService;
+
+ beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ TestBed.configureTestingModule({
+ providers: [provideHttpClient(), provideRouter([])],
+ });
+ menuService = TestBed.inject(MenuService);
+ tokenStorage = TestBed.inject(TokenStorageService);
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+
+ it('constructs MenuService and AuthService without a circular dependency', () => {
+ expect(menuService).toBeTruthy();
+ expect(TestBed.inject(AuthService)).toBeTruthy();
+ });
+
+ it('hides the Users menu when the stored user is not a super administrator', async () => {
+ tokenStorage.saveAuth(
+ { accessToken: 'token', user: { id: '1', email: 'user@example.com', roles: ['admin'] } },
+ false,
+ );
+
+ const context = await firstValueFrom(menuService.loadMenu());
+
+ expect(hasPath(context.items, '/users')).toBe(false);
+ });
+
+ it('shows the Users menu when the stored user is a super administrator', async () => {
+ tokenStorage.saveAuth(
+ {
+ accessToken: 'token',
+ user: { id: '1', email: 'super@example.com', roles: ['super_admin'] },
+ },
+ false,
+ );
+
+ const context = await firstValueFrom(menuService.loadMenu());
+
+ expect(hasPath(context.items, '/users')).toBe(true);
+ });
+
+ it('clears menu state during context cleanup', async () => {
+ await firstValueFrom(menuService.loadMenu());
+
+ menuService.clear();
+
+ expect(menuService.menuContext()).toBeNull();
+ });
+});
+
+function hasPath(items: ReturnType, path: string): boolean {
+ return items.some(
+ (item) =>
+ item.path === path ||
+ (item.children ? hasPath(item.children, path) : false) ||
+ (item.children2 ? hasPath(item.children2, path) : false),
+ );
+}
diff --git a/src/app/core/services/common/menu.service.ts b/src/app/core/services/common/menu.service.ts
index 5639b4e5..50dc8eb6 100644
--- a/src/app/core/services/common/menu.service.ts
+++ b/src/app/core/services/common/menu.service.ts
@@ -1,15 +1,18 @@
-import { Injectable, signal } from '@angular/core';
+import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, tap } from 'rxjs';
import { MenuContext } from '../../models/context/context.model';
import { SAAS_MENU_DATA } from './menu.data';
import { Menu } from '../../../core/services/common/nav.service';
+import { TokenStorageService } from '../auth/token-storage.service';
@Injectable({ providedIn: 'root' })
export class MenuService {
+ private readonly tokenStorage = inject(TokenStorageService);
readonly menuContext = signal(null);
loadMenu(): Observable {
const context = this.cloneMenuContext(SAAS_MENU_DATA);
+ context.items = this.filterAuthorizedItems(context.items);
return of(context).pipe(
tap((menuContext) => {
this.menuContext.set(menuContext);
@@ -39,4 +42,15 @@ export class MenuService {
private cloneMenuItems(items: Menu[]): Menu[] {
return JSON.parse(JSON.stringify(items)) as Menu[];
}
-}
\ No newline at end of file
+
+ private filterAuthorizedItems(items: Menu[]): Menu[] {
+ const isSuperAdmin = (this.tokenStorage.getUser()?.roles ?? []).includes('super_admin');
+ return items
+ .filter(item => isSuperAdmin || item.path !== '/users')
+ .map(item => ({
+ ...item,
+ children: item.children ? this.filterAuthorizedItems(item.children) : item.children,
+ children2: item.children2 ? this.filterAuthorizedItems(item.children2) : item.children2
+ }));
+ }
+}
diff --git a/src/app/core/services/country/country.service.ts b/src/app/core/services/country/country.service.ts
deleted file mode 100644
index e73f7b04..00000000
--- a/src/app/core/services/country/country.service.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { HttpClient } 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";
-
-@Injectable({
- providedIn: 'root'
-})
-export class CountryService {
-
- private readonly http = inject(HttpClient);
-
- getCountryDataTable(query: DataTableQuery): Observable> {
- return this.http.post>(`${COUNTRY_ENDPOINTS.dataTable}`, query);
- }
-}
\ No newline at end of file
diff --git a/src/app/core/services/loading.service.ts b/src/app/core/services/loading.service.ts
new file mode 100644
index 00000000..fa48c82e
--- /dev/null
+++ b/src/app/core/services/loading.service.ts
@@ -0,0 +1,24 @@
+import { Injectable, signal} from '@angular/core';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class LoadingService {
+
+ private requestCount = 0;
+ readonly isLoading = signal(false);
+
+ show(): void {
+ this.requestCount++;
+ this.isLoading.set(true);
+ }
+
+ hide(): void {
+ this.requestCount--;
+
+ if (this.requestCount <= 0) {
+ this.requestCount = 0;
+ this.isLoading.set(false);
+ }
+ }
+}
diff --git a/src/app/core/services/menu.data.ts b/src/app/core/services/menu.data.ts
new file mode 100644
index 00000000..60b32306
--- /dev/null
+++ b/src/app/core/services/menu.data.ts
@@ -0,0 +1,66 @@
+import { MenuContext } from '../models/context.model';
+
+export const SAAS_MENU_DATA: MenuContext = {
+ defaultLandingPage: '/dashboards/crm',
+ items: [
+ { headTitle: 'MAIN' },
+ {
+ title: 'Dashboards',
+ icon: '',
+ type: 'sub',
+ active: false,
+ selected: false,
+ dirchange: false,
+ children: [
+ { path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false },
+ ],
+ },
+ { headTitle: 'SAAS ADMIN' },
+ {
+ title: 'Management',
+ icon: '',
+ type: 'sub',
+ active: false,
+ selected: false,
+ dirchange: false,
+ children: [
+ { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
+ { path: '/users', title: 'Users', type: 'link', dirchange: false },
+ {
+ title: 'Configuration',
+ type: 'sub',
+ active: false,
+ selected: false,
+ dirchange: false,
+ children: [
+ { path: '/global-masters', title: 'Global Masters', type: 'link', dirchange: false },
+ { path: '/localization', title: 'Localization', type: 'link', dirchange: false },
+ {
+ title: 'Branding',
+ type: 'sub',
+ active: false,
+ selected: false,
+ dirchange: false,
+ children: [
+ { path: '/theming', title: 'Theming', type: 'link', dirchange: false },
+ { path: '/platform', title: 'Platform', type: 'link', dirchange: false },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ title: 'Operations',
+ icon: '',
+ type: 'sub',
+ active: false,
+ selected: false,
+ dirchange: false,
+ children: [
+ { path: '/billing', title: 'Billing', type: 'link', dirchange: false },
+ { path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false },
+ ],
+ },
+ ],
+};
\ No newline at end of file
diff --git a/src/app/core/services/menu.service.ts b/src/app/core/services/menu.service.ts
new file mode 100644
index 00000000..51fc72e7
--- /dev/null
+++ b/src/app/core/services/menu.service.ts
@@ -0,0 +1,42 @@
+import { Injectable, signal } from '@angular/core';
+import { Observable, of, tap } from 'rxjs';
+import { MenuContext } from '../models/context.model';
+import { SAAS_MENU_DATA } from './menu.data';
+import { Menu } from '../../shared/services/nav.service';
+
+@Injectable({ providedIn: 'root' })
+export class MenuService {
+ readonly menuContext = signal(null);
+
+ loadMenu(): Observable {
+ const context = this.cloneMenuContext(SAAS_MENU_DATA);
+ return of(context).pipe(
+ tap((menuContext) => {
+ this.menuContext.set(menuContext);
+ })
+ );
+ }
+
+ getNavigationMenu(): Menu[] {
+ return this.cloneMenuItems(this.menuContext()?.items ?? []);
+ }
+
+ getDefaultLandingPage(): string | null {
+ return this.menuContext()?.defaultLandingPage ?? null;
+ }
+
+ clear(): void {
+ this.menuContext.set(null);
+ }
+
+ private cloneMenuContext(context: MenuContext): MenuContext {
+ return {
+ defaultLandingPage: context.defaultLandingPage,
+ items: this.cloneMenuItems(context.items),
+ };
+ }
+
+ private cloneMenuItems(items: Menu[]): Menu[] {
+ return JSON.parse(JSON.stringify(items)) as Menu[];
+ }
+}
\ No newline at end of file
diff --git a/src/app/core/services/permission.service.ts b/src/app/core/services/permission.service.ts
new file mode 100644
index 00000000..773ee393
--- /dev/null
+++ b/src/app/core/services/permission.service.ts
@@ -0,0 +1,34 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject, signal } from '@angular/core';
+import { map, Observable, tap } from 'rxjs';
+import { API_CONFIG } from '../config/api.config';
+import { PermissionContext } from '../models/context.model';
+
+interface PermissionResponse {
+ roles?: string[];
+ permissions?: string[];
+ defaultLandingPage?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class PermissionService {
+ private readonly http = inject(HttpClient);
+ readonly permissionContext = signal(null);
+
+ loadPermissions(): Observable {
+ return this.http.get(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}`).pipe(
+ map((response) => ({
+ roles: response.roles ?? [],
+ permissions: response.permissions ?? [],
+ defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
+ })),
+ tap((context) => {
+ this.permissionContext.set(context);
+ })
+ );
+ }
+
+ clear(): void {
+ this.permissionContext.set(null);
+ }
+}
\ No newline at end of file
diff --git a/src/app/core/services/session-timeout.service.ts b/src/app/core/services/session-timeout.service.ts
new file mode 100644
index 00000000..7a20fd76
--- /dev/null
+++ b/src/app/core/services/session-timeout.service.ts
@@ -0,0 +1,154 @@
+import { DOCUMENT } from '@angular/common';
+import { DestroyRef, effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { Router } from '@angular/router';
+import { merge, fromEvent, Subscription } from 'rxjs';
+import { throttleTime } from 'rxjs/operators';
+import { environment } from '../../../environments/environment';
+import { AuthService } from '../auth/auth.service';
+
+@Injectable()
+export class SessionTimeoutService implements OnDestroy {
+ private readonly document = inject(DOCUMENT);
+ private readonly router = inject(Router);
+ private readonly authService = inject(AuthService);
+ private readonly destroyRef = inject(DestroyRef);
+
+ readonly showWarning = signal(false);
+ readonly remainingSeconds = signal(0);
+
+ private readonly warningAfterMs = environment.sessionTimeout?.warningAfterMs ?? 25 * 60 * 1000;
+ private readonly logoutAfterMs = environment.sessionTimeout?.logoutAfterMs ?? 30 * 60 * 1000;
+
+ private activitySubscription: Subscription | null = null;
+ private warningTimer: ReturnType | null = null;
+ private logoutTimer: ReturnType | null = null;
+ private countdownTimer: ReturnType | null = null;
+ private logoutDeadline = 0;
+ private trackingEnabled = false;
+
+ constructor() {
+ effect(() => {
+ if (this.authService.currentUserSignal()) {
+ this.start();
+ return;
+ }
+
+ this.stop();
+ });
+ }
+
+ start(): void {
+ if (this.trackingEnabled) {
+ this.resetTimers();
+ return;
+ }
+
+ this.trackingEnabled = true;
+ this.bindActivityTracking();
+ this.resetTimers();
+ }
+
+ stop(): void {
+ this.trackingEnabled = false;
+ this.showWarning.set(false);
+ this.remainingSeconds.set(0);
+ this.clearTimers();
+ this.activitySubscription?.unsubscribe();
+ this.activitySubscription = null;
+ }
+
+ staySignedIn(): void {
+ if (!this.trackingEnabled) {
+ return;
+ }
+
+ this.resetTimers();
+ }
+
+ logoutNow(): void {
+ this.handleTimeoutLogout();
+ }
+
+ ngOnDestroy(): void {
+ this.stop();
+ }
+
+ private bindActivityTracking(): void {
+ if (this.activitySubscription) {
+ return;
+ }
+
+ this.activitySubscription = merge(
+ fromEvent(this.document, 'mousemove'),
+ fromEvent(this.document, 'keydown'),
+ fromEvent(this.document, 'click'),
+ fromEvent(window, 'scroll')
+ )
+ .pipe(throttleTime(1000), takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
+ if (!this.trackingEnabled || !this.authService.currentUserSignal()) {
+ return;
+ }
+
+ this.resetTimers();
+ });
+ }
+
+ private resetTimers(): void {
+ if (!this.trackingEnabled) {
+ return;
+ }
+
+ this.clearTimers();
+ this.showWarning.set(false);
+ this.remainingSeconds.set(0);
+
+ const safeWarningDelay = Math.max(Math.min(this.warningAfterMs, this.logoutAfterMs), 0);
+ const safeLogoutDelay = Math.max(this.logoutAfterMs, 0);
+
+ this.warningTimer = setTimeout(() => {
+ this.showWarning.set(true);
+ this.logoutDeadline = Date.now() + Math.max(safeLogoutDelay - safeWarningDelay, 0);
+ this.updateRemainingSeconds();
+ this.countdownTimer = setInterval(() => {
+ this.updateRemainingSeconds();
+ }, 1000);
+ }, safeWarningDelay);
+
+ this.logoutTimer = setTimeout(() => {
+ this.handleTimeoutLogout();
+ }, safeLogoutDelay);
+ }
+
+ private updateRemainingSeconds(): void {
+ const remainingMs = Math.max(this.logoutDeadline - Date.now(), 0);
+ this.remainingSeconds.set(Math.ceil(remainingMs / 1000));
+ }
+
+ private handleTimeoutLogout(): void {
+ const returnUrl = this.router.url.startsWith('/auth') ? '/' : this.router.url;
+ this.stop();
+ this.authService.logout();
+ void this.router.navigate(['/auth/login'], {
+ queryParams: returnUrl && returnUrl !== '/' ? { returnUrl } : undefined,
+ });
+ }
+
+ private clearTimers(): void {
+ if (this.warningTimer) {
+ clearTimeout(this.warningTimer);
+ this.warningTimer = null;
+ }
+
+ if (this.logoutTimer) {
+ clearTimeout(this.logoutTimer);
+ this.logoutTimer = null;
+ }
+
+ if (this.countdownTimer) {
+ clearInterval(this.countdownTimer);
+ this.countdownTimer = null;
+ }
+ }
+}
diff --git a/src/app/core/services/state/state.service.ts b/src/app/core/services/state/state.service.ts
deleted file mode 100644
index e8990770..00000000
--- a/src/app/core/services/state/state.service.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { HttpClient } 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";
-
-@Injectable({
- providedIn: 'root'
-})
-export class StateService {
-
- private readonly http = inject(HttpClient);
-
- getStateDataTable(query: DataTableQuery, countryId: string): Observable> {
- return this.http.post>(`${STATE_ENDPOINTS.dataTable}`, { ...query, countryId });
- }
-}
\ No newline at end of file
diff --git a/src/app/core/services/tenant-context.service.ts b/src/app/core/services/tenant-context.service.ts
new file mode 100644
index 00000000..aa9e6f62
--- /dev/null
+++ b/src/app/core/services/tenant-context.service.ts
@@ -0,0 +1,23 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject, signal } from '@angular/core';
+import { Observable, tap } from 'rxjs';
+import { API_CONFIG } from '../config/api.config';
+import { TenantContext } from '../models/context.model';
+
+@Injectable({ providedIn: 'root' })
+export class TenantContextService {
+ private readonly http = inject(HttpClient);
+ readonly tenantContext = signal(null);
+
+ loadTenantContext(): Observable {
+ return this.http.get(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`).pipe(
+ tap((context) => {
+ this.tenantContext.set(context);
+ })
+ );
+ }
+
+ clear(): void {
+ this.tenantContext.set(null);
+ }
+}
\ No newline at end of file
diff --git a/src/app/core/services/user-context.service.ts b/src/app/core/services/user-context.service.ts
new file mode 100644
index 00000000..632f1511
--- /dev/null
+++ b/src/app/core/services/user-context.service.ts
@@ -0,0 +1,32 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject, signal } from '@angular/core';
+import { map, Observable, tap } from 'rxjs';
+import { API_CONFIG } from '../config/api.config';
+import { CurrentUserContext } from '../models/context.model';
+
+@Injectable({ providedIn: 'root' })
+export class UserContextService {
+ private readonly http = inject(HttpClient);
+ readonly currentUserContext = signal(null);
+
+ loadCurrentUserProfile(): Observable {
+ return this.http.get>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}`).pipe(
+ map((response) => ({
+ id: response.id?.trim() ?? '',
+ email: response.email?.trim() ?? '',
+ displayName: response.displayName?.trim() || undefined,
+ fullName: response.fullName?.trim() || undefined,
+ tenantId: response.tenantId?.trim() || undefined,
+ roles: response.roles ?? [],
+ defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
+ })),
+ tap((profile) => {
+ this.currentUserContext.set(profile);
+ })
+ );
+ }
+
+ clear(): void {
+ this.currentUserContext.set(null);
+ }
+}
\ No newline at end of file
diff --git a/src/app/shell/routes/auth.routes.ts b/src/app/features/authentication/authentication.routes.ts
similarity index 58%
rename from src/app/shell/routes/auth.routes.ts
rename to src/app/features/authentication/authentication.routes.ts
index 95dc7296..54cb8c98 100644
--- a/src/app/shell/routes/auth.routes.ts
+++ b/src/app/features/authentication/authentication.routes.ts
@@ -5,14 +5,14 @@ export const authen: Routes = [
{
path: 'login',
canActivate: [guestGuard],
- loadComponent: () => import('../../authentication/login/login').then((m) => m.Login),
+ loadComponent: () => import('./pages/login/login').then((m) => m.Login),
},
{
path: '',
children: [
{
path: '',
- loadChildren: () => import('../../features/errors/error.routes').then((m) => m.errorRoutingModule),
+ loadChildren: () => import('../errors/error.routes').then((m) => m.errorRoutingModule),
},
],
},
diff --git a/src/app/authentication/login/login.html b/src/app/features/authentication/pages/login/login.html
similarity index 99%
rename from src/app/authentication/login/login.html
rename to src/app/features/authentication/pages/login/login.html
index 0dcd4cd7..88695cd8 100644
--- a/src/app/authentication/login/login.html
+++ b/src/app/features/authentication/pages/login/login.html
@@ -82,4 +82,4 @@
-
\ No newline at end of file
+
diff --git a/src/app/authentication/login/login.scss b/src/app/features/authentication/pages/login/login.scss
similarity index 97%
rename from src/app/authentication/login/login.scss
rename to src/app/features/authentication/pages/login/login.scss
index 11e53b4c..263aee1c 100644
--- a/src/app/authentication/login/login.scss
+++ b/src/app/features/authentication/pages/login/login.scss
@@ -12,4 +12,4 @@
margin: auto;
border-radius: 0.75rem;
}
- }
\ No newline at end of file
+ }
diff --git a/src/app/authentication/login/login.ts b/src/app/features/authentication/pages/login/login.ts
similarity index 95%
rename from src/app/authentication/login/login.ts
rename to src/app/features/authentication/pages/login/login.ts
index 0b3b9ba4..74b11327 100644
--- a/src/app/authentication/login/login.ts
+++ b/src/app/features/authentication/pages/login/login.ts
@@ -1,11 +1,11 @@
import { ChangeDetectorRef, Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
-import { AuthService } from '../../core/services/auth/auth.service';
+import { AuthService } from '../../../../core/services/auth/auth.service';
import { ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { catchError, finalize, of, switchMap, tap } from 'rxjs';
-import { AppContextService } from '../../core/services/context/app-context.service';
+import { AppContextService } from '../../../../core/services/context/app-context.service';
@Component({
selector: 'app-login',
diff --git a/src/app/features/global-masters/cities/data-access/city.endpoints.ts b/src/app/features/global-masters/cities/data-access/city.endpoints.ts
new file mode 100644
index 00000000..0c668c39
--- /dev/null
+++ b/src/app/features/global-masters/cities/data-access/city.endpoints.ts
@@ -0,0 +1,11 @@
+import { buildApiUrl } from '../../../../core/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/features/global-masters/cities/data-access/city.service.ts b/src/app/features/global-masters/cities/data-access/city.service.ts
new file mode 100644
index 00000000..dd8a49a1
--- /dev/null
+++ b/src/app/features/global-masters/cities/data-access/city.service.ts
@@ -0,0 +1,46 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import { CITY_ENDPOINTS } from './city.endpoints';
+import {
+ CityDto,
+ CreateCityRequest,
+ UpdateCityRequest
+} from '../models/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,
+ countryId: string | null = null,
+ stateId: string | null = null
+ ): Observable> {
+ let params = new HttpParams();
+ if (countryId) params = params.set('countryId', countryId);
+ if (stateId) params = params.set('stateId', stateId);
+ return this.http.post>(
+ CITY_ENDPOINTS.dataTable,
+ query,
+ { params }
+ );
+ }
+
+ 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/features/global-masters/cities/models/city.model.ts b/src/app/features/global-masters/cities/models/city.model.ts
new file mode 100644
index 00000000..19a7dbf2
--- /dev/null
+++ b/src/app/features/global-masters/cities/models/city.model.ts
@@ -0,0 +1,28 @@
+export interface CityDto {
+ id: string;
+ stateId: string;
+ name: string;
+ state:string;
+ country: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/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..280df3fd 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,170 @@
-city-list works!
+
+
+
+
+
+
+
+
+
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..f535d001 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,560 @@
-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 '../../models/city.model';
+import { CountryLookupDto } from '../../../countries/models/country.model';
+import { StateLookupDto } from '../../../states/models/state.model';
+import { TimezoneDto, TimezoneLookupDto } from '../../../timezones/models/timezone.model';
+import { CityService } from '../../../cities/data-access/city.service';
+import { CountryService } from '../../../countries/data-access/country.service';
+import { StateService } from '../../../states/data-access/state.service';
+import { TimezoneService } from '../../../timezones/data-access/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';
+import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
+
+interface CityTableRow extends DataTableRecord {
+ id: string;
+ stateId: string;
+ name: string;
+ code: string | null;
+ timezoneId: string | null;
+ isActive: boolean;
+ serialNumber: number;
+ state: string;
+ country: string;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
@Component({
selector: 'city-list',
- imports: [],
+ standalone: true,
+ imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
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 appliedCountryId = signal(null);
+ readonly appliedStateId = 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: ['', 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 resolveCountry: AutocompleteResolveValueFn =
+ value => this.countryApi.getCountryById(value).pipe(
+ map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
+ );
+ readonly displayState: AutocompleteDisplayFn = state => state.name;
+ readonly stateValue: AutocompleteValueFn = state => state.id;
+ readonly resolveState: AutocompleteResolveValueFn =
+ value => this.stateApi.getStateById(value).pipe(
+ map(state => ({ id: state.id, name: state.name, code: state.code ?? '' }))
+ );
+ 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' : 'Select a country first'
+ );
+ readonly formStatePlaceholder = computed(() =>
+ this.cityForm.controls.countryId.value ? 'Search' : 'Search'
+ );
+ readonly emptyMessage = computed(() =>
+ this.appliedCountryId() && this.appliedStateId()
+ ? 'No cities found'
+ : 'Select a country and state'
+ );
+
+ readonly emptyDescription = computed(() =>
+ this.appliedCountryId() && this.appliedStateId()
+ ? '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' : 'Update'
+ );
+ readonly loadingLabel = computed(() =>
+ this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
+ );
+
+ 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 {
+ this.loadCities(this.queryState.getQuery());
+ }
+
+ 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);
+ }
+
+ applyCityFilters(): void {
+ const countryId = this.filterForm.controls.countryId.value || null;
+ const stateId = this.filterForm.controls.stateId.value || null;
+
+ this.appliedCountryId.set(countryId);
+ this.appliedStateId.set(stateId);
+
+ this.loadCities(this.queryState.setPage({
+ pageIndex: 1,
+ pageSize: this.queryState.pageSize()
+ }));
+ }
+
+ 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 {
+ 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');
+ break;
+ case 'activate':
+ this.updateCityStatus(city, true);
+ break;
+ case 'deactivate':
+ this.updateCityStatus(city, false);
+ break;
+ }
+ }
+
+ onAddCity(): void {
+ this.modalMode.set('create');
+ this.selectedCity.set(null);
+ this.submitAttempted.set(false);
+ this.selectedFormCountry.set(null);
+ this.selectedFormState.set(null);
+ this.cityForm.enable({ emitEvent: false });
+ this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { 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.appliedStateId();
+ const countryId = this.appliedCountryId();
+ return this.cityApi.getCityDataTable(query, countryId, stateId).pipe(
+ catchError(() => {
+ this.toastr.error('Unable to load cities.');
+ this.clearGrid();
+ return of(null);
+ })
+ );
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe(response => {
+ if (!response) return;
+ const query = this.queryState.getQuery();
+ if (response.draw !== query.draw) return;
+
+ this.cities.set(response.rows.map((city, index) => ({
+ ...city,
+ serialNumber: (query.page - 1) * query.pageSize + index + 1
+ })));
+ 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.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);
+ });
+ }
+
+ 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 });
+ this.cityForm.controls.stateId.enable({ emitEvent: false });
+ });
+ }
+
+ private openExistingCity(city: CityDto, mode: 'edit'): void {
+ this.cityApi.getCityById(city.id).pipe(
+ switchMap(details => this.stateApi.getStateById(details.stateId).pipe(
+ map(stateDetails => ({ details, countryId: stateDetails.countryId }))
+ )),
+ take(1)
+ ).subscribe(({ details, countryId }) => {
+ this.selectedCity.set(details);
+ this.modalMode.set(mode);
+ this.submitAttempted.set(false);
+ this.selectedFormCountry.set(null);
+ this.selectedFormState.set(null);
+ this.cityForm.enable({ emitEvent: false });
+ this.cityForm.reset({
+ countryId,
+ 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 [data-form-control][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,
+ state: row.state,
+ country: row.country,
+ 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/data-access/country.endpoints.ts b/src/app/features/global-masters/countries/data-access/country.endpoints.ts
new file mode 100644
index 00000000..e4a7b4d5
--- /dev/null
+++ b/src/app/features/global-masters/countries/data-access/country.endpoints.ts
@@ -0,0 +1,21 @@
+import { buildApiUrl } from '../../../../core/config/api-url.util';
+
+export const COUNTRY_ENDPOINTS = {
+ dataTable: buildApiUrl('masterAdmin', '/v1/countries/datatable'),
+
+ create: buildApiUrl('masterAdmin', '/v1/countries'),
+
+ getById: (id: string) =>
+ buildApiUrl('masterAdmin', `/v1/countries/${encodeURIComponent(id)}`),
+
+ autocomplete: buildApiUrl('masterAdmin', '/v1/countries/autocomplete'),
+
+ update: (id: string) =>
+ buildApiUrl('masterAdmin', `/v1/countries/${encodeURIComponent(id)}`),
+
+ delete: (id: string) =>
+ buildApiUrl('masterAdmin', `/v1/countries/${encodeURIComponent(id)}`),
+
+ changeStatus: (id: string) =>
+ buildApiUrl('masterAdmin', `/v1/countries/${encodeURIComponent(id)}/status`),
+} as const;
diff --git a/src/app/features/global-masters/countries/data-access/country.service.ts b/src/app/features/global-masters/countries/data-access/country.service.ts
new file mode 100644
index 00000000..10d406b0
--- /dev/null
+++ b/src/app/features/global-masters/countries/data-access/country.service.ts
@@ -0,0 +1,44 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import {
+ DataTableQuery,
+ DataTableResult,
+} from '../../../../shared/components/data-table/data-table.types';
+import {
+ CountryDto,
+ CountryLookupDto,
+ CreateCountryRequest,
+ UpdateCountryRequest,
+} from '../models/country.model';
+import { COUNTRY_ENDPOINTS } from './country.endpoints';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class CountryService {
+ private readonly http = inject(HttpClient);
+
+ getCountryDataTable(query: DataTableQuery): Observable> {
+ return this.http.post>(COUNTRY_ENDPOINTS.dataTable, query);
+ }
+
+ 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/features/global-masters/countries/models/country.model.ts b/src/app/features/global-masters/countries/models/country.model.ts
new file mode 100644
index 00000000..9c4b220f
--- /dev/null
+++ b/src/app/features/global-masters/countries/models/country.model.ts
@@ -0,0 +1,31 @@
+export interface CountryDto {
+ id: string;
+ iso2: string;
+ iso3: string;
+ name: string;
+ phoneCode: string | null;
+ defaultCurrencyId: string | null;
+ isActive: boolean;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
+
+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/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..43a71e14 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,79 @@
-
-
+
+
-
-
\ 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..a46ddcf1 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,84 +1,87 @@
-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 '../../models/country.model';
+import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api';
+import { CountryService } from '../../data-access/country.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 countryModalTitle = computed(() =>
- this.countryModalMode() === 'create'
- ? 'Add Country'
- : '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'
- : 'Update Country'
- );
-
- readonly countryLoadingLabel = computed(() =>
- this.countryModalMode() === 'create'
- ? 'Saving Country...'
- : 'Updating Country...'
- );
-
- readonly countrySubmitAction = computed<'save' | 'update'>(() =>
- this.countryModalMode() === 'create'
- ? 'save'
- : 'update'
- );
-
-
+ 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: [
@@ -88,7 +91,6 @@ export class CountryList {
Validators.maxLength(150)
]
],
-
iso2: [
'',
[
@@ -96,7 +98,6 @@ export class CountryList {
Validators.pattern(/^[A-Za-z]{2}$/)
]
],
-
iso3: [
'',
[
@@ -104,106 +105,137 @@ export class CountryList {
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
-
phoneCode: [
'',
[
- Validators.maxLength(20),
- Validators.pattern(/^\+?[0-9]*$/)
+ Validators.maxLength(16),
+ Validators.pattern(/^\+?[0-9\- ]{1,15}$/)
]
],
- currency: [
- '',
- [
- Validators.maxLength(3),
- Validators.pattern(/^[A-Za-z]{3}$/)
- ]
- ],
-
-
- defaultCurrencyId:
- this.formBuilder.control(null)
+ 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(' - ');
- readonly columns = signal([
- { key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '100px' },
+ return currency.symbol?.trim()
+ ? `${baseLabel} (${currency.symbol})`
+ : baseLabel;
+ };
+ readonly currencyValue: AutocompleteValueFn = currency => currency.id;
+
+ readonly countryModalTitle = computed(() =>
+ this.countryModalMode() === 'create'
+ ? 'Add Country'
+ : 'Edit Country'
+ );
+
+ readonly countrySubmitLabel = computed(() =>
+ this.countryModalMode() === 'create'
+ ? 'Save'
+ : 'Update'
+ );
+
+ readonly countryLoadingLabel = computed(() =>
+ this.countryModalMode() === 'create'
+ ? 'Saving...'
+ : 'Updating...'
+ );
+
+ readonly countrySubmitAction = computed<'save' | 'update'>(() =>
+ this.countryModalMode() === '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: '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: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' },
{
- 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 [data-form-control][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/countries/public-api.ts b/src/app/features/global-masters/countries/public-api.ts
new file mode 100644
index 00000000..158a7781
--- /dev/null
+++ b/src/app/features/global-masters/countries/public-api.ts
@@ -0,0 +1,2 @@
+export { CountryService } from './data-access/country.service';
+export type { CountryLookupDto } from './models/country.model';
diff --git a/src/app/features/global-masters/currencies/data-access/currency.endpoints.ts b/src/app/features/global-masters/currencies/data-access/currency.endpoints.ts
new file mode 100644
index 00000000..9eb91c37
--- /dev/null
+++ b/src/app/features/global-masters/currencies/data-access/currency.endpoints.ts
@@ -0,0 +1,44 @@
+import { buildApiUrl } from '../../../../core/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/features/global-masters/currencies/data-access/currency.service.ts b/src/app/features/global-masters/currencies/data-access/currency.service.ts
new file mode 100644
index 00000000..cebb5a1c
--- /dev/null
+++ b/src/app/features/global-masters/currencies/data-access/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 { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types';
+import {
+ CreateCurrencyRequest,
+ CurrencyDto,
+ CurrencyLookupDto,
+ UpdateCurrencyRequest
+} from '../models/currency.model';
+import { CURRENCY_ENDPOINTS } from './currency.endpoints';
+
+@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/features/global-masters/currencies/models/currency.model.ts b/src/app/features/global-masters/currencies/models/currency.model.ts
new file mode 100644
index 00000000..8ea67172
--- /dev/null
+++ b/src/app/features/global-masters/currencies/models/currency.model.ts
@@ -0,0 +1,43 @@
+export interface CurrencyCountryFlag {
+ readonly iso2: string;
+}
+
+export type CurrencyIso2Value =
+ | string
+ | readonly (string | CurrencyCountryFlag)[]
+ | null;
+
+export interface CurrencyDto {
+ id: string;
+ code: string;
+ iso2: CurrencyIso2Value;
+ 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/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..27f32995
--- /dev/null
+++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html
@@ -0,0 +1,146 @@
+
+
+ @if (visibleCountries(row); as countries) {
+ @if (countries.length > 0) {
+
+ @for (country of countries; track country.iso2) {
+
+
+ @if (getFlagUrl(country.iso2); as flagUrl) {
+
+
+ } @else {
+
+ }
+
+ {{ country.iso2 }}
+
+ }
+
+ @if (remainingCountryCount(row); as remaining) {
+
+
+
+
+ @if (iso2TooltipPlacement() === 'right') {
+
+ } @else {
+
+
+ }
+
+
+
+
+
+
+
+
+ @for (country of remainingCountries(row); track country.iso2) {
+
+
+ @if (getFlagUrl(country.iso2); as flagUrl) {
+
+
+ } @else {
+
+ }
+
+
{{ country.iso2 }}
+
+ }
+
+
+
+
+
+ }
+
+ } @else {
+ —
+ }
+ }
+
+
+
+
+ {{ 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..cfeeb807
--- /dev/null
+++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts
@@ -0,0 +1,623 @@
+import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+ CdkConnectedOverlay,
+ CdkOverlayOrigin,
+ ConnectedOverlayPositionChange,
+ ConnectedPosition
+} from '@angular/cdk/overlay';
+import {
+ FormBuilder,
+ ReactiveFormsModule,
+ Validators
+} from '@angular/forms';
+import { ToastrService } from 'ngx-toastr';
+import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
+
+import {
+ CreateCurrencyRequest,
+ CurrencyCountryFlag,
+ CurrencyDto,
+ CurrencyIso2Value,
+ CurrencyModalMode,
+ UpdateCurrencyRequest
+} from '../../models/currency.model';
+import { CurrencyService } from '../../data-access/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 { Modal } from '../../../../../shared/components/modal/modal';
+import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
+import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
+
+type Iso2TooltipPlacement = 'above' | 'below' | 'left' | 'right';
+interface CurrencyTableRow extends DataTableRecord {
+ id: string;
+ code: string;
+ iso2: CurrencyIso2Value;
+ 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, CdkOverlayOrigin, CdkConnectedOverlay],
+ 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 openIso2TooltipCurrencyId = signal(null);
+ readonly iso2TooltipPlacement = signal('right');
+ readonly iso2TooltipPositions: ConnectedPosition[] = [
+ {
+ originX: 'end',
+ originY: 'center',
+ overlayX: 'start',
+ overlayY: 'center',
+ offsetX: 12
+ }
+ ];
+ private iso2TooltipCloseTimer: ReturnType | null = null;
+
+ 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'
+ : 'Update'
+ );
+
+ readonly currencyLoadingLabel = computed(() =>
+ this.currencyModalMode() === 'create'
+ ? 'Saving...'
+ : 'Updating...'
+ );
+
+ 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: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', 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 [data-form-control][aria-invalid="true"]'
+ );
+
+ firstInvalidControl?.focus();
+ firstInvalidControl?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
+ });
+ });
+ }
+
+ private toCurrencyDto(row: CurrencyTableRow): CurrencyDto {
+ return {
+ id: row.id,
+ code: row.code,
+ iso2: row.iso2,
+ name: row.name,
+ symbol: row.symbol,
+ numericCode: row.numericCode,
+ decimalDigits: row.decimalDigits,
+ isActive: row.isActive,
+ createdOn: row.createdOn,
+ modifiedOn: row.modifiedOn
+ };
+ }
+
+ visibleCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] {
+ return this.normalizeIso2Codes(row.iso2).slice(0, 1);
+ }
+
+ remainingCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] {
+ return this.normalizeIso2Codes(row.iso2).slice(1);
+ }
+
+ remainingCountryCount(row: CurrencyTableRow): number {
+ return this.remainingCountries(row).length;
+ }
+
+ openIso2Tooltip(row: CurrencyTableRow): void {
+ this.cancelIso2TooltipClose();
+ this.openIso2TooltipCurrencyId.set(row.id);
+ }
+
+ scheduleIso2TooltipClose(): void {
+ this.cancelIso2TooltipClose();
+ this.iso2TooltipCloseTimer = setTimeout(() => this.closeIso2Tooltip(), 120);
+ }
+
+ isIso2TooltipOpen(row: CurrencyTableRow): boolean {
+ return this.openIso2TooltipCurrencyId() === row.id;
+ }
+
+ iso2TooltipId(row: CurrencyTableRow): string {
+ return `currency-iso2-tooltip-${row.id}`;
+ }
+
+ closeIso2Tooltip(): void {
+ this.cancelIso2TooltipClose();
+ this.openIso2TooltipCurrencyId.set(null);
+ }
+
+ onIso2TooltipKeydown(event: KeyboardEvent): void {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ this.closeIso2Tooltip();
+ }
+ }
+
+ onIso2TooltipPositionChange(event: ConnectedOverlayPositionChange): void {
+ this.iso2TooltipPlacement.set(
+ event.connectionPair.overlayY === 'bottom' ? 'above' : 'below'
+ );
+ }
+
+ normalizeIso2Codes(value: unknown): CurrencyCountryFlag[] {
+ const items: readonly unknown[] = Array.isArray(value)
+ ? value
+ : typeof value === 'string'
+ ? value.split(/[,;|]/)
+ : [];
+ const codes = new Set();
+
+ for (const item of items) {
+ const rawCode = typeof item === 'string'
+ ? item
+ : item && typeof item === 'object' && 'iso2' in item
+ ? String(item.iso2)
+ : '';
+ const iso2 = rawCode.trim().toUpperCase();
+
+ if (/^[A-Z]{2}$/.test(iso2)) {
+ codes.add(iso2);
+ }
+ }
+
+ return [...codes].map(iso2 => ({ iso2 }));
+ }
+
+ private cancelIso2TooltipClose(): void {
+ if (this.iso2TooltipCloseTimer !== null) {
+ clearTimeout(this.iso2TooltipCloseTimer);
+ this.iso2TooltipCloseTimer = null;
+ }
+ }
+
+ getFlagUrl(value: unknown): string {
+ const code = this.normalizeIso2Codes(value)[0]?.iso2.toLowerCase();
+
+ return code && /^[a-z]{2}$/.test(code)
+ ? `https://flagcdn.com/24x18/${code}.png`
+ : '';
+ }
+ onFlagError(event: Event): void {
+ const image = event.target as HTMLImageElement;
+ image.classList.add('hidden');
+ image.nextElementSibling?.classList.remove('hidden');
+ }
+}
diff --git a/src/app/features/global-masters/currencies/public-api.ts b/src/app/features/global-masters/currencies/public-api.ts
new file mode 100644
index 00000000..ed036285
--- /dev/null
+++ b/src/app/features/global-masters/currencies/public-api.ts
@@ -0,0 +1,2 @@
+export { CurrencyService } from './data-access/currency.service';
+export type { CurrencyDto, CurrencyLookupDto } from './models/currency.model';
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/data-access/language.endpoints.ts b/src/app/features/global-masters/languages/data-access/language.endpoints.ts
new file mode 100644
index 00000000..123ad1fa
--- /dev/null
+++ b/src/app/features/global-masters/languages/data-access/language.endpoints.ts
@@ -0,0 +1,11 @@
+import { buildApiUrl } from '../../../../core/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/features/global-masters/languages/data-access/language.service.ts b/src/app/features/global-masters/languages/data-access/language.service.ts
new file mode 100644
index 00000000..d491b95f
--- /dev/null
+++ b/src/app/features/global-masters/languages/data-access/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 './language.endpoints';
+import {
+ CreateLanguageRequest,
+ LanguageDto,
+ LanguageLookupDto,
+ UpdateLanguageRequest
+} from '../models/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/features/global-masters/languages/models/language.model.ts b/src/app/features/global-masters/languages/models/language.model.ts
new file mode 100644
index 00000000..fc1f7539
--- /dev/null
+++ b/src/app/features/global-masters/languages/models/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/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..6aaa3e77
--- /dev/null
+++ b/src/app/features/global-masters/languages/pages/language-list/language-list.html
@@ -0,0 +1,67 @@
+
+
+ {{ value }}
+
+
+ {{ value }}
+
+
+
+
+
+
+ @if (modalLoading()) {
+
+
+ Loading language...
+
+ } @else {
+
+ }
+
\ No newline at end of file
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..56e47501
--- /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 '../../models/language.model';
+import { LanguageService } from '../../data-access/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' : 'Update'
+ );
+ readonly loadingLabel = computed(() =>
+ this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
+ );
+ 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 [data-form-control][aria-invalid="true"]'
+ );
+ control?.focus();
+ control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ });
+ }
+}
diff --git a/src/app/features/global-masters/languages/public-api.ts b/src/app/features/global-masters/languages/public-api.ts
new file mode 100644
index 00000000..6d9a5cf5
--- /dev/null
+++ b/src/app/features/global-masters/languages/public-api.ts
@@ -0,0 +1,2 @@
+export { LanguageService } from './data-access/language.service';
+export type { LanguageLookupDto } from './models/language.model';
diff --git a/src/app/core/end-points/state/state.endpoints.ts b/src/app/features/global-masters/states/data-access/state.endpoints.ts
similarity index 81%
rename from src/app/core/end-points/state/state.endpoints.ts
rename to src/app/features/global-masters/states/data-access/state.endpoints.ts
index afdadc65..9f32324b 100644
--- a/src/app/core/end-points/state/state.endpoints.ts
+++ b/src/app/features/global-masters/states/data-access/state.endpoints.ts
@@ -1,4 +1,4 @@
-import { buildApiUrl } from '../../config/api-url.util';
+import { buildApiUrl } from '../../../../core/config/api-url.util';
export const STATE_ENDPOINTS = {
@@ -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/features/global-masters/states/data-access/state.service.ts b/src/app/features/global-masters/states/data-access/state.service.ts
new file mode 100644
index 00000000..ae925b34
--- /dev/null
+++ b/src/app/features/global-masters/states/data-access/state.service.ts
@@ -0,0 +1,45 @@
+import { HttpClient, HttpParams } from "@angular/common/http";
+import { Injectable, inject } from "@angular/core";
+import { STATE_ENDPOINTS } from "./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.model";
+
+@Injectable({
+ providedIn: 'root'
+})
+export class StateService {
+
+ private readonly http = inject(HttpClient);
+
+ getStateDataTable(query: DataTableQuery, countryId: string | null = null): Observable> {
+ const params = countryId ? new HttpParams().set('countryId', countryId) : undefined;
+ return this.http.post>(`${STATE_ENDPOINTS.dataTable}`, query, { params });
+ }
+
+ 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/features/global-masters/states/models/state.model.ts b/src/app/features/global-masters/states/models/state.model.ts
new file mode 100644
index 00000000..384358d9
--- /dev/null
+++ b/src/app/features/global-masters/states/models/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/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..0728e910 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,104 @@
-
+
+
- (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..1c7ed70a 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,187 @@
-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, map, of, switchMap } from 'rxjs';
+
+import {
+ CountryLookupDto
+} from '../../../countries/public-api';
+import {
+ CreateStateRequest,
+ StateDto,
+ StateModalMode,
+ UpdateStateRequest
+} from '../../models/state.model';
+import { CountryService } from '../../../countries/public-api';
+import { StateService } from '../../data-access/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,
+ AutocompleteResolveValueFn,
+ 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';
+import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
+
+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, FilterCard],
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();
readonly queryState = new DataTableQueryState();
- readonly states = signal([]);
+ readonly states = signal([]);
+ readonly selectedCountryLookup = signal(null);
+ readonly selectedFormCountry = signal(null);
+ readonly selectedCountryId = signal(null);
+ readonly appliedCountryId = 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 resolveCountry: AutocompleteResolveValueFn =
+ value => this.countryApi.getCountryById(value).pipe(
+ map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
+ );
+
+ 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 emptyMessage = computed(() =>
+ this.appliedCountryId()
+ ? 'No states found'
+ : 'No records found'
+ );
+
+ readonly emptyDescription = computed(() =>
+ this.appliedCountryId()
+ ? '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'
+ : 'Update'
+ );
+
+ readonly stateLoadingLabel = computed(() =>
+ this.stateModalMode() === 'create'
+ ? 'Saving...'
+ : 'Updating...'
+ );
+
+ 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 +193,107 @@ 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;
- }
-
- // 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);
- }
+ .subscribe(countryId => {
+ this.onCountrySelected(countryId || null);
});
+
+ this.stateQueryRequests$
+ .pipe(
+ switchMap(query => {
+ const countryId = this.appliedCountryId();
+
+ 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.loadStates(this.queryState.getQuery());
+ }
+
+ loadStates(query: DataTableQuery): void {
+ this.stateQueryRequests$.next(query);
+ }
+
+ onCountryLookupSelected(country: CountryLookupDto): void {
+ this.selectedCountryLookup.set(country);
+ }
+
+ applyCountryFilter(): void {
+ const countryId = this.countryFilterForm.controls.countryId.value || null;
+ this.appliedCountryId.set(countryId);
+
+ this.loadStates(this.queryState.setPage({
+ pageIndex: 1,
+ pageSize: this.queryState.pageSize()
+ }));
+ }
+
+ onFormCountrySelected(country: CountryLookupDto): void {
+ this.selectedFormCountry.set(country);
+ }
+
+ onFormCountryCleared(): void {
+ this.selectedFormCountry.set(null);
}
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 +304,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 +347,224 @@ 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 {
+ this.stateModalMode.set('create');
+ this.selectedStateId.set(null);
+ this.selectedState.set(null);
+ this.stateSubmitAttempted.set(false);
+
+ this.selectedFormCountry.set(null);
+ 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.selectedFormCountry.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);
+ }
+
}
+ 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.selectedFormCountry.set(null);
+ this.stateForm.reset({
+ countryId: stateDetails.countryId ?? '',
+ 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.selectedFormCountry.set(null);
+ this.stateSubmitAttempted.set(false);
+ this.loadStates(this.queryState.getQuery());
+ }
+
+ private focusFirstInvalidStateControl(): void {
+ queueMicrotask(() => {
+ const firstInvalidControl =
+ this.elementRef.nativeElement.querySelector(
+ 'modal [data-form-control][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/states/public-api.ts b/src/app/features/global-masters/states/public-api.ts
new file mode 100644
index 00000000..6b7e957b
--- /dev/null
+++ b/src/app/features/global-masters/states/public-api.ts
@@ -0,0 +1,2 @@
+export { StateService } from './data-access/state.service';
+export type { StateLookupDto } from './models/state.model';
diff --git a/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts b/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts
new file mode 100644
index 00000000..6a5d5516
--- /dev/null
+++ b/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts
@@ -0,0 +1,11 @@
+import { buildApiUrl } from '../../../../core/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/features/global-masters/timezones/data-access/timezone.service.ts b/src/app/features/global-masters/timezones/data-access/timezone.service.ts
new file mode 100644
index 00000000..559982a6
--- /dev/null
+++ b/src/app/features/global-masters/timezones/data-access/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 './timezone.endpoints';
+import {
+ CreateTimezoneRequest,
+ TimezoneDto,
+ TimezoneLookupDto,
+ UpdateTimezoneRequest
+} from '../models/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/timezones/models/timezone.model.ts b/src/app/features/global-masters/timezones/models/timezone.model.ts
new file mode 100644
index 00000000..31a75ee6
--- /dev/null
+++ b/src/app/features/global-masters/timezones/models/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/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..bd22a860
--- /dev/null
+++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html
@@ -0,0 +1,137 @@
+
+
+ {{ value }}
+
+
+ {{ value }}
+
+
+
+
+
+
+ @if (modalLoading()) {
+
+
+ Loading timezone...
+
+ } @else {
+
+ }
+
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..a1ffe667
--- /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 '../../models/timezone.model';
+import { TimezoneService } from '../../data-access/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' : 'Update');
+ readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...');
+ 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 [data-form-control][aria-invalid="true"]')
+ ?.focus());
+ }
+}
diff --git a/src/app/features/global-masters/timezones/public-api.ts b/src/app/features/global-masters/timezones/public-api.ts
new file mode 100644
index 00000000..ec3675e9
--- /dev/null
+++ b/src/app/features/global-masters/timezones/public-api.ts
@@ -0,0 +1,2 @@
+export { TimezoneService } from './data-access/timezone.service';
+export type { TimezoneDto, TimezoneLookupDto } from './models/timezone.model';
diff --git a/src/app/features/organizations/organization-list/organization-list.html b/src/app/features/organizations/organization-list/organization-list.html
new file mode 100644
index 00000000..4c6f643d
--- /dev/null
+++ b/src/app/features/organizations/organization-list/organization-list.html
@@ -0,0 +1 @@
+organization-list works!
diff --git a/src/app/features/organizations/organization-list/organization-list.scss b/src/app/features/organizations/organization-list/organization-list.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/features/organizations/organization-list/organization-list.ts b/src/app/features/organizations/organization-list/organization-list.ts
new file mode 100644
index 00000000..0aa9bef6
--- /dev/null
+++ b/src/app/features/organizations/organization-list/organization-list.ts
@@ -0,0 +1,11 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'organization-list',
+ imports: [],
+ templateUrl: './organization-list.html',
+ styleUrl: './organization-list.scss',
+})
+export class OrganizationList {
+
+}
diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html
new file mode 100644
index 00000000..9def9eba
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts
new file mode 100644
index 00000000..c5de3d6f
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts
@@ -0,0 +1,119 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ input,
+ output
+} from '@angular/core';
+
+export interface OnboardingStep {
+ readonly key: string;
+ readonly label: string;
+}
+
+@Component({
+ selector: 'onboarding-stepper',
+ standalone: true,
+ imports: [],
+ templateUrl: './onboarding-stepper.html',
+ styleUrl: './onboarding-stepper.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class OnboardingStepper {
+ readonly steps = input.required();
+ readonly currentStepIndex = input.required();
+ readonly completedStepIndexes = input([]);
+
+ readonly savingDraft = input(false);
+ readonly finishing = input(false);
+ readonly navigationDisabled = input(false);
+
+ readonly stepSelected = output();
+ readonly backClicked = output();
+ readonly nextClicked = output();
+ readonly saveDraftClicked = output();
+ readonly finishClicked = output();
+ readonly cancelClicked = output();
+
+ isFirstStep(): boolean {
+ return this.currentStepIndex() === 0;
+ }
+
+ isLastStep(): boolean {
+ return this.currentStepIndex() === this.steps().length - 1;
+ }
+
+ isCurrentStep(index: number): boolean {
+ return index === this.currentStepIndex();
+ }
+
+ isCompletedStep(index: number): boolean {
+ return this.completedStepIndexes().includes(index);
+ }
+
+ isPendingStep(index: number): boolean {
+ return !this.isCurrentStep(index) && !this.isCompletedStep(index);
+ }
+
+ canOpenStep(index: number): boolean {
+ if (this.navigationDisabled()) {
+ return false;
+ }
+
+ return (
+ index < this.currentStepIndex() ||
+ this.isCompletedStep(index)
+ );
+ }
+
+ selectStep(index: number): void {
+ if (!this.canOpenStep(index)) {
+ return;
+ }
+
+ this.stepSelected.emit(index);
+ }
+
+ requestBack(): void {
+ if (this.navigationDisabled() || this.isFirstStep()) {
+ return;
+ }
+
+ this.backClicked.emit();
+ }
+
+ requestNext(): void {
+ if (this.navigationDisabled() || this.isLastStep()) {
+ return;
+ }
+
+ this.nextClicked.emit();
+ }
+
+ requestSaveDraft(): void {
+ if (this.navigationDisabled() || this.savingDraft()) {
+ return;
+ }
+
+ this.saveDraftClicked.emit();
+ }
+
+ requestFinish(): void {
+ if (
+ this.navigationDisabled() ||
+ this.finishing() ||
+ !this.isLastStep()
+ ) {
+ return;
+ }
+
+ this.finishClicked.emit();
+ }
+
+ requestCancel(): void {
+ if (this.navigationDisabled()) {
+ return;
+ }
+
+ this.cancelClicked.emit();
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/models/organization-draft.model.ts b/src/app/features/organizations/organization-onboarding/models/organization-draft.model.ts
new file mode 100644
index 00000000..0cb32e33
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/models/organization-draft.model.ts
@@ -0,0 +1,18 @@
+import {
+ OrganizationAdminValue,
+ OrganizationBasicsValue,
+ OrganizationLocalizationValue,
+ OrganizationPlanLimitsValue,
+} from './organization-onboarding.model';
+
+export interface OrganizationOnboardingDraft {
+ readonly schemaVersion: 1;
+ readonly draftId: string | null;
+ readonly currentStepIndex: number;
+ readonly completedStepIndexes: readonly number[];
+ readonly basics: Partial | null;
+ readonly localization: Partial | null;
+ readonly planLimits: Partial | null;
+ readonly admin: Partial | null;
+ readonly savedAt: string;
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts
new file mode 100644
index 00000000..7e591a24
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts
@@ -0,0 +1,119 @@
+export type OnboardingStepKey =
+ | 'basics'
+ | 'localization'
+ | 'plan-limits'
+ | 'admin-user';
+
+export interface OnboardingStepDefinition {
+ readonly key: OnboardingStepKey;
+ readonly label: string;
+}
+
+export interface OnboardingLookupValue {
+ readonly id: string;
+ readonly label: string;
+ readonly secondaryLabel?: string | null;
+}
+
+export interface CountryLookupValue extends OnboardingLookupValue {
+ readonly iso2?: string | null;
+}
+
+export interface OrganizationBasicsValue {
+ readonly organizationCode: string | null;
+ readonly organizationName: string;
+ readonly shortName: string;
+ readonly localLanguageName: string | null;
+ readonly organizationType: OnboardingLookupValue;
+ readonly industry: OnboardingLookupValue;
+ readonly registrationCountry: CountryLookupValue;
+}
+
+export type TimeFormatValue = 'TwelveHour' | 'TwentyFourHour';
+
+export type DateFormatValue =
+ | 'DD/MM/YYYY'
+ | 'MM/DD/YYYY'
+ | 'YYYY-MM-DD';
+
+export type NumberFormatValue =
+ | 'OneTwoThreeCommaFourFiveSixPointSevenEight'
+ | 'OneTwoThreePointFourFiveSixCommaSevenEight';
+
+export type FiscalYearConventionValue =
+ | 'CalendarYear'
+ | 'AprilToMarch'
+ | 'JulyToJune';
+
+export interface OrganizationLocalizationValue {
+ readonly timeZone: OnboardingLookupValue;
+ readonly currency: OnboardingLookupValue;
+ readonly defaultLanguage: OnboardingLookupValue;
+ readonly additionalLanguageIds: readonly string[];
+ readonly additionalLanguageSelections: readonly OnboardingLookupValue[];
+ readonly dateFormat: DateFormatValue;
+ readonly timeFormat: TimeFormatValue;
+ readonly numberFormat: NumberFormatValue;
+ readonly fiscalYearConvention: FiscalYearConventionValue;
+ readonly localizationDefaultsCountryId: string | null;
+}
+
+export type OrganizationLicenseType = 'Trial' | 'Paid';
+
+export interface OrganizationPlanLookupValue extends OnboardingLookupValue {
+ readonly code?: string | null;
+}
+
+export interface OrganizationPlanLimitsValue {
+ readonly subscriptionPlan: OrganizationPlanLookupValue;
+ readonly licenseType: OrganizationLicenseType;
+ readonly maximumCompanies: number;
+ readonly maximumUsers: number;
+ readonly maximumStorageGb: number;
+ readonly goLiveDate: string;
+ readonly systemAccessStartDate: string;
+ readonly systemAccessEndDate: string | null;
+ readonly limitsEditable: boolean;
+}
+
+export interface OrganizationAdminValue {
+ readonly organizationEmail: string;
+ readonly organizationPhone: string;
+ readonly administratorFullName: string;
+ readonly administratorEmail: string;
+ readonly administratorMobile: string;
+}
+
+export interface OrganizationOnboardingData {
+ readonly basics: OrganizationBasicsValue | null;
+ readonly localization: OrganizationLocalizationValue | null;
+ readonly planLimits: OrganizationPlanLimitsValue | null;
+ readonly admin: OrganizationAdminValue | null;
+}
+
+export interface CountryLocalizationDefaults {
+ readonly countryId: string;
+ readonly timeZone: OnboardingLookupValue;
+ readonly currency: OnboardingLookupValue;
+ readonly defaultLanguage: OnboardingLookupValue;
+ readonly dateFormat: DateFormatValue;
+ readonly timeFormat: TimeFormatValue;
+ readonly numberFormat: NumberFormatValue;
+ readonly fiscalYearConvention: FiscalYearConventionValue;
+}
+
+export interface OrganizationPlanDefaults {
+ readonly plan: OrganizationPlanLookupValue;
+ readonly licenseType: OrganizationLicenseType;
+ readonly maximumCompanies: number;
+ readonly maximumUsers: number;
+ readonly maximumStorageGb: number;
+ readonly limitsEditable: boolean;
+}
+
+export interface OnboardingStepForm {
+ validate(): boolean;
+ getValue(): TValue;
+ getDraftValue(): Partial;
+ patchValue(value: Partial): void;
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/models/organization-provisioning.model.ts b/src/app/features/organizations/organization-onboarding/models/organization-provisioning.model.ts
new file mode 100644
index 00000000..70ca18d3
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/models/organization-provisioning.model.ts
@@ -0,0 +1,23 @@
+import {
+ OrganizationAdminValue,
+ OrganizationBasicsValue,
+ OrganizationLocalizationValue,
+ OrganizationPlanLimitsValue,
+} from './organization-onboarding.model';
+
+export interface OrganizationProvisioningRequest {
+ readonly basics: OrganizationBasicsValue;
+ readonly localization: OrganizationLocalizationValue;
+ readonly planLimits: OrganizationPlanLimitsValue;
+ readonly admin: OrganizationAdminValue;
+}
+
+export type OrganizationProvisioningStatus =
+ | 'not-configured'
+ | 'submitted'
+ | 'failed';
+
+export interface OrganizationProvisioningResult {
+ readonly status: OrganizationProvisioningStatus;
+ readonly message: string;
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.html b/src/app/features/organizations/organization-onboarding/organization-onboarding.html
new file mode 100644
index 00000000..4c21af5f
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.html
@@ -0,0 +1,66 @@
+
+
+
+
+ Create Organization
+
+
+
+
+
+
+ @switch (currentStep().key) {
+ @case ('basics') {
+
+ }
+
+ @case ('localization') {
+
+ Localization content
+
+ }
+
+ @case ('plan-limits') {
+
+ Plan and Limits content
+
+ }
+
+ @case ('admin-user') {
+
+ Admin and User content
+
+ }
+ }
+
+
+
+
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.scss b/src/app/features/organizations/organization-onboarding/organization-onboarding.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts
new file mode 100644
index 00000000..5d18da07
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts
@@ -0,0 +1,164 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ DestroyRef,
+ inject,
+ signal
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { Router } from '@angular/router';
+import { ToastrService } from 'ngx-toastr';
+import { catchError, finalize, of } from 'rxjs';
+import {
+ OnboardingStep,
+ OnboardingStepper
+} from './components/onboarding-stepper/onboarding-stepper';
+import { OrganizationBasicsStepComponent } from './steps/organization-basics/organization-basics';
+import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service';
+import { OrganizationOnboardingService } from './services/organization-onboarding.service';
+
+@Component({
+ selector: 'app-organization-onboarding',
+ standalone: true,
+ imports: [OnboardingStepper, OrganizationBasicsStepComponent],
+ templateUrl: './organization-onboarding.html',
+ styleUrl: './organization-onboarding.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [OrganizationOnboardingStateService, OrganizationOnboardingService]
+})
+export class OrganizationOnboarding {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly router = inject(Router);
+ private readonly toastr = inject(ToastrService);
+ private readonly stateService = inject(OrganizationOnboardingStateService);
+ private readonly onboardingService = inject(OrganizationOnboardingService);
+
+ readonly steps: readonly OnboardingStep[] = this.stateService.createStepDefinitions();
+
+ readonly savingDraft = signal(false);
+ readonly finishing = signal(false);
+
+ private basicsStep?: OrganizationBasicsStepComponent;
+
+ readonly currentStepIndex = this.stateService.currentStepIndex;
+ readonly completedStepIndexes = this.stateService.completedStepIndexes;
+
+ readonly currentStep = computed(
+ () => this.steps[this.currentStepIndex()]
+ );
+
+ readonly navigationDisabled = computed(
+ () => this.savingDraft() || this.finishing()
+ );
+
+ constructor() {
+ this.onboardingService.loadDraft()
+ .pipe(
+ catchError(() => {
+ this.toastr.error('Unable to restore the onboarding draft.', 'Draft restore failed');
+ return of(null);
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe(draft => {
+ if (!draft) {
+ return;
+ }
+
+ this.stateService.restoreDraft(draft);
+ });
+ }
+
+ registerBasicsStep(component: OrganizationBasicsStepComponent): void {
+ this.basicsStep = component;
+ const basicsDraft = this.stateService.onboardingDraft().basics;
+
+ if (basicsDraft) {
+ component.patchValue(basicsDraft);
+ }
+ }
+
+ onStepSelected(index: number): void {
+ if (!this.stateService.canOpenStep(index)) {
+ return;
+ }
+
+ this.stateService.setCurrentStepIndex(index);
+ }
+
+ onBack(): void {
+ const currentIndex = this.currentStepIndex();
+
+ if (currentIndex <= 0) {
+ return;
+ }
+
+ this.stateService.setCurrentStepIndex(currentIndex - 1);
+ }
+
+ onNext(): void {
+ const currentIndex = this.currentStepIndex();
+
+ if (currentIndex >= this.steps.length - 1) {
+ return;
+ }
+
+ if (currentIndex === 0) {
+ if (!this.basicsStep?.validate()) {
+ this.stateService.unmarkStepCompleted(currentIndex);
+ return;
+ }
+
+ this.stateService.updateBasics(this.basicsStep.getValue());
+ }
+
+ this.stateService.markStepCompleted(currentIndex);
+ this.stateService.setCurrentStepIndex(currentIndex + 1);
+ }
+
+ onSaveDraft(): void {
+ if (this.savingDraft()) {
+ return;
+ }
+
+ if (this.currentStepIndex() === 0 && this.basicsStep) {
+ this.stateService.updateBasicsDraft(this.basicsStep.getDraftValue());
+ }
+
+ this.savingDraft.set(true);
+
+ this.onboardingService.saveDraft(this.stateService.buildDraft())
+ .pipe(
+ finalize(() => {
+ this.savingDraft.set(false);
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe({
+ next: () => {
+ this.toastr.success('Onboarding draft saved successfully.', 'Draft saved');
+ },
+ error: () => {
+ this.toastr.error('Unable to save the onboarding draft.', 'Draft save failed');
+ }
+ });
+ }
+
+ onFinish(): void {
+ if (this.finishing()) {
+ return;
+ }
+
+ this.finishing.set(true);
+
+ queueMicrotask(() => {
+ this.finishing.set(false);
+ });
+ }
+
+ onCancel(): void {
+ this.stateService.clear();
+ void this.router.navigate(['/configuration/organizations']);
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts b/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts
new file mode 100644
index 00000000..7ae2e8a6
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts
@@ -0,0 +1,189 @@
+import { Injectable, computed, signal } from '@angular/core';
+
+import { OrganizationOnboardingDraft } from '../models/organization-draft.model';
+import {
+ OnboardingStepDefinition,
+ OrganizationAdminValue,
+ OrganizationBasicsValue,
+ OrganizationLocalizationValue,
+ OrganizationOnboardingData,
+ OrganizationPlanLimitsValue,
+} from '../models/organization-onboarding.model';
+
+const INITIAL_DATA: OrganizationOnboardingData = {
+ basics: null,
+ localization: null,
+ planLimits: null,
+ admin: null,
+};
+
+type OnboardingDraftState = {
+ readonly basics: Partial | null;
+ readonly localization: Partial | null;
+ readonly planLimits: Partial | null;
+ readonly admin: Partial | null;
+};
+
+const INITIAL_DRAFT_STATE: OnboardingDraftState = {
+ basics: null,
+ localization: null,
+ planLimits: null,
+ admin: null,
+};
+
+@Injectable()
+export class OrganizationOnboardingStateService {
+ private readonly onboardingDataState = signal(INITIAL_DATA);
+ private readonly onboardingDraftState = signal(INITIAL_DRAFT_STATE);
+ private readonly currentStepIndexState = signal(0);
+ private readonly completedStepIndexesState = signal([]);
+ private readonly savedDraftIdState = signal(null);
+ private readonly savedAtState = signal(null);
+
+ readonly onboardingData = this.onboardingDataState.asReadonly();
+ readonly onboardingDraft = this.onboardingDraftState.asReadonly();
+ readonly currentStepIndex = this.currentStepIndexState.asReadonly();
+ readonly completedStepIndexes = this.completedStepIndexesState.asReadonly();
+ readonly savedDraftId = this.savedDraftIdState.asReadonly();
+ readonly savedAt = this.savedAtState.asReadonly();
+
+ readonly basics = computed(() => this.onboardingDataState().basics);
+ readonly localization = computed(() => this.onboardingDataState().localization);
+ readonly planLimits = computed(() => this.onboardingDataState().planLimits);
+ readonly admin = computed(() => this.onboardingDataState().admin);
+ readonly hasAnyData = computed(() => {
+ const data = this.onboardingDataState();
+ return !!(data.basics || data.localization || data.planLimits || data.admin);
+ });
+
+ setCurrentStepIndex(index: number): void {
+ this.currentStepIndexState.set(Math.max(0, index));
+ }
+
+ updateBasics(value: OrganizationBasicsValue | null): void {
+ this.onboardingDataState.update(current => ({ ...current, basics: value }));
+ this.onboardingDraftState.update(current => ({ ...current, basics: value }));
+ }
+
+ updateLocalization(value: OrganizationLocalizationValue | null): void {
+ this.onboardingDataState.update(current => ({ ...current, localization: value }));
+ this.onboardingDraftState.update(current => ({ ...current, localization: value }));
+ }
+
+ updatePlanLimits(value: OrganizationPlanLimitsValue | null): void {
+ this.onboardingDataState.update(current => ({ ...current, planLimits: value }));
+ this.onboardingDraftState.update(current => ({ ...current, planLimits: value }));
+ }
+
+ updateAdmin(value: OrganizationAdminValue | null): void {
+ this.onboardingDataState.update(current => ({ ...current, admin: value }));
+ this.onboardingDraftState.update(current => ({ ...current, admin: value }));
+ }
+
+ updateBasicsDraft(value: Partial | null): void {
+ this.onboardingDraftState.update(current => ({ ...current, basics: value }));
+ }
+
+ updateLocalizationDraft(value: Partial | null): void {
+ this.onboardingDraftState.update(current => ({ ...current, localization: value }));
+ }
+
+ updatePlanLimitsDraft(value: Partial | null): void {
+ this.onboardingDraftState.update(current => ({ ...current, planLimits: value }));
+ }
+
+ updateAdminDraft(value: Partial | null): void {
+ this.onboardingDraftState.update(current => ({ ...current, admin: value }));
+ }
+
+ markStepCompleted(index: number): void {
+ this.completedStepIndexesState.update(current =>
+ current.includes(index)
+ ? current
+ : [...current, index].sort((left, right) => left - right)
+ );
+ }
+
+ unmarkStepCompleted(index: number): void {
+ this.completedStepIndexesState.update(current => current.filter(item => item !== index));
+ }
+
+ setCompletedStepIndexes(indexes: readonly number[]): void {
+ const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0))].sort((left, right) => left - right);
+ this.completedStepIndexesState.set(uniqueIndexes);
+ }
+
+ canOpenStep(index: number): boolean {
+ return index <= this.currentStepIndexState() || this.completedStepIndexesState().includes(index);
+ }
+
+ isStepCompleted(index: number): boolean {
+ return this.completedStepIndexesState().includes(index);
+ }
+
+ buildDraft(): OrganizationOnboardingDraft {
+ const draft = this.onboardingDraftState();
+
+ return {
+ schemaVersion: 1,
+ draftId: this.savedDraftIdState(),
+ currentStepIndex: this.currentStepIndexState(),
+ completedStepIndexes: this.completedStepIndexesState(),
+ basics: draft.basics,
+ localization: draft.localization,
+ planLimits: draft.planLimits,
+ admin: draft.admin,
+ savedAt: new Date().toISOString(),
+ };
+ }
+
+ restoreDraft(draft: OrganizationOnboardingDraft): void {
+ this.onboardingDraftState.set({
+ basics: draft.basics ? { ...draft.basics } : null,
+ localization: draft.localization ? { ...draft.localization } : null,
+ planLimits: draft.planLimits ? { ...draft.planLimits } : null,
+ admin: draft.admin ? { ...draft.admin } : null,
+ });
+ this.onboardingDataState.set({
+ basics: draft.completedStepIndexes.includes(0) && draft.basics
+ ? { ...draft.basics } as OrganizationBasicsValue
+ : null,
+ localization: draft.completedStepIndexes.includes(1) && draft.localization
+ ? { ...draft.localization } as OrganizationLocalizationValue
+ : null,
+ planLimits: draft.completedStepIndexes.includes(2) && draft.planLimits
+ ? { ...draft.planLimits } as OrganizationPlanLimitsValue
+ : null,
+ admin: draft.completedStepIndexes.includes(3) && draft.admin
+ ? { ...draft.admin } as OrganizationAdminValue
+ : null,
+ });
+ this.currentStepIndexState.set(Math.max(0, draft.currentStepIndex));
+ this.setCompletedStepIndexes(draft.completedStepIndexes);
+ this.savedDraftIdState.set(draft.draftId);
+ this.savedAtState.set(draft.savedAt);
+ }
+
+ updateSavedDraftMetadata(draftId: string | null, savedAt: string): void {
+ this.savedDraftIdState.set(draftId);
+ this.savedAtState.set(savedAt);
+ }
+
+ clear(): void {
+ this.onboardingDataState.set(INITIAL_DATA);
+ this.onboardingDraftState.set(INITIAL_DRAFT_STATE);
+ this.currentStepIndexState.set(0);
+ this.completedStepIndexesState.set([]);
+ this.savedDraftIdState.set(null);
+ this.savedAtState.set(null);
+ }
+
+ createStepDefinitions(): readonly OnboardingStepDefinition[] {
+ return [
+ { key: 'basics', label: 'Basics' },
+ { key: 'localization', label: 'Localization' },
+ { key: 'plan-limits', label: 'Plan & Limits' },
+ { key: 'admin-user', label: 'Admin & User' },
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts b/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts
new file mode 100644
index 00000000..2f92c05a
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts
@@ -0,0 +1,332 @@
+import { Injectable, inject } from '@angular/core';
+import { FormSelect } from '../../../../shared/components/form/form-select/form-select';
+import { Observable, catchError, map, of, throwError } from 'rxjs';
+import {
+ CountryLookupDto,
+ CountryService,
+} from '../../../global-masters/countries/public-api';
+import {
+ CurrencyLookupDto,
+ CurrencyService,
+} from '../../../global-masters/currencies/public-api';
+import {
+ LanguageLookupDto,
+ LanguageService,
+} from '../../../global-masters/languages/public-api';
+import {
+ TimezoneLookupDto,
+ TimezoneService,
+} from '../../../global-masters/timezones/public-api';
+import { OrganizationOnboardingDraft } from '../models/organization-draft.model';
+import {
+ CountryLocalizationDefaults,
+ DateFormatValue,
+ FiscalYearConventionValue,
+ NumberFormatValue,
+ OnboardingLookupValue,
+ OrganizationLicenseType,
+ OrganizationPlanDefaults,
+ OrganizationPlanLookupValue,
+ TimeFormatValue,
+} from '../models/organization-onboarding.model';
+import {
+ OrganizationProvisioningRequest,
+ OrganizationProvisioningResult,
+} from '../models/organization-provisioning.model';
+
+const ORGANIZATION_ONBOARDING_DRAFT_KEY = 'organization-onboarding-draft-v1';
+
+const ORGANIZATION_TYPE_OPTIONS: readonly OnboardingLookupValue[] = [
+ { id: 'enterprise', label: 'Enterprise' },
+ { id: 'group', label: 'Group' },
+ { id: 'non-profit', label: 'Non Profit' },
+ { id: 'public-sector', label: 'Public Sector' },
+];
+
+const INDUSTRY_OPTIONS: readonly OnboardingLookupValue[] = [
+ { id: 'healthcare', label: 'Healthcare' },
+ { id: 'manufacturing', label: 'Manufacturing' },
+ { id: 'professional-services', label: 'Professional Services' },
+ { id: 'retail', label: 'Retail' },
+ { id: 'technology', label: 'Technology' },
+];
+
+const SUBSCRIPTION_PLAN_OPTIONS: readonly OrganizationPlanLookupValue[] = [
+ { id: 'starter', label: 'Starter', code: 'STARTER' },
+ { id: 'growth', label: 'Growth', code: 'GROWTH' },
+ { id: 'enterprise', label: 'Enterprise', code: 'ENTERPRISE' },
+];
+
+const PLAN_DEFAULTS: readonly OrganizationPlanDefaults[] = [
+ {
+ plan: { id: 'starter', label: 'Starter', code: 'STARTER' },
+ licenseType: 'Trial',
+ maximumCompanies: 1,
+ maximumUsers: 10,
+ maximumStorageGb: 10,
+ limitsEditable: true,
+ },
+ {
+ plan: { id: 'growth', label: 'Growth', code: 'GROWTH' },
+ licenseType: 'Paid',
+ maximumCompanies: 5,
+ maximumUsers: 100,
+ maximumStorageGb: 100,
+ limitsEditable: true,
+ },
+ {
+ plan: { id: 'enterprise', label: 'Enterprise', code: 'ENTERPRISE' },
+ licenseType: 'Paid',
+ maximumCompanies: 25,
+ maximumUsers: 1000,
+ maximumStorageGb: 500,
+ limitsEditable: true,
+ },
+];
+
+const DATE_FORMAT_OPTIONS: readonly FormSelect[] = [
+ // { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
+ // { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
+ // { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
+];
+
+const TIME_FORMAT_OPTIONS: readonly FormSelect[] = [
+ // { value: 'TwelveHour', label: '12 hour' },
+ // { value: 'TwentyFourHour', label: '24 hour' },
+];
+
+const NUMBER_FORMAT_OPTIONS: readonly FormSelect[] = [
+ // { value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' },
+ // { value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' },
+];
+
+const FISCAL_YEAR_OPTIONS: readonly FormSelect[] = [
+ // { value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
+ // { value: 'AprilToMarch', label: 'April - March' },
+ // { value: 'JulyToJune', label: 'July - June' },
+];
+
+const LICENSE_TYPE_OPTIONS: readonly FormSelect[] = [
+ // { value: 'Trial', label: 'Trial' },
+ // { value: 'Paid', label: 'Paid' },
+];
+
+const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
+ {
+ countryId: 'IN',
+ timeZone: { id: 'asia-kolkata', label: 'India Standard Time', secondaryLabel: 'Asia/Kolkata' },
+ currency: { id: 'inr', label: 'Indian Rupee', secondaryLabel: 'INR' },
+ defaultLanguage: { id: 'en-in', label: 'English', secondaryLabel: 'en-IN' },
+ dateFormat: 'DD/MM/YYYY',
+ timeFormat: 'TwelveHour',
+ numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
+ fiscalYearConvention: 'AprilToMarch',
+ },
+ {
+ countryId: 'US',
+ timeZone: { id: 'america-new-york', label: 'Eastern Time', secondaryLabel: 'America/New_York' },
+ currency: { id: 'usd', label: 'US Dollar', secondaryLabel: 'USD' },
+ defaultLanguage: { id: 'en-us', label: 'English', secondaryLabel: 'en-US' },
+ dateFormat: 'MM/DD/YYYY',
+ timeFormat: 'TwelveHour',
+ numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
+ fiscalYearConvention: 'CalendarYear',
+ },
+];
+
+@Injectable()
+export class OrganizationOnboardingService {
+ private readonly countryService = inject(CountryService);
+ private readonly currencyService = inject(CurrencyService);
+ private readonly languageService = inject(LanguageService);
+ private readonly timezoneService = inject(TimezoneService);
+
+ searchOrganizationTypes(term: string | null, limit = 10): Observable {
+ return of(this.filterLookupValues(ORGANIZATION_TYPE_OPTIONS, term, limit));
+ }
+
+ resolveOrganizationType(id: string): Observable {
+ return of(ORGANIZATION_TYPE_OPTIONS.find(option => option.id === id) ?? null);
+ }
+
+ searchIndustries(term: string | null, limit = 10): Observable {
+ return of(this.filterLookupValues(INDUSTRY_OPTIONS, term, limit));
+ }
+
+ resolveIndustry(id: string): Observable {
+ return of(INDUSTRY_OPTIONS.find(option => option.id === id) ?? null);
+ }
+
+ searchRegistrationCountries(term: string | null, limit = 10): Observable {
+ return this.countryService.autocomplete(term ?? '', limit);
+ }
+
+ resolveCountry(id: string): Observable {
+ return this.countryService.getCountryById(id).pipe(
+ map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
+ );
+ }
+
+ searchTimezones(term: string | null, limit = 10): Observable {
+ return this.timezoneService.autocomplete(term, limit);
+ }
+
+ resolveTimezone(id: string): Observable {
+ return this.timezoneService.getById(id).pipe(
+ map(timezone => ({ id: timezone.id, ianaId: timezone.ianaId, displayName: timezone.displayName }))
+ );
+ }
+
+ searchCurrencies(term: string | null, limit = 10): Observable {
+ return this.currencyService.autocomplete(term, limit);
+ }
+
+ resolveCurrency(id: string): Observable {
+ return this.currencyService.getCurrencyById(id).pipe(
+ map(currency => ({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol }))
+ );
+ }
+
+ searchLanguages(term: string | null, limit = 10): Observable {
+ return this.languageService.autocomplete(term, limit);
+ }
+
+ resolveLanguage(id: string): Observable {
+ return this.languageService.getById(id).pipe(
+ map(language => ({
+ id: language.id,
+ code: language.code,
+ name: language.name,
+ nativeName: language.nativeName,
+ isRightToLeft: language.isRightToLeft,
+ }))
+ );
+ }
+loadLanguageOptions(limit = 50):readonly FormSelect[] {
+ return DATE_FORMAT_OPTIONS;
+ }
+ // loadLanguageOptions(limit = 50): Observable[]> {
+ // // return this.languageService.autocomplete('', limit).pipe(
+ // // map(items => items.map(item => ({
+ // // value: item.id,
+ // // label: `${item.code} - ${item.name}`,
+ // // })))
+ // // );
+ // }
+
+ getDateFormatOptions(): readonly FormSelect[] {
+ return DATE_FORMAT_OPTIONS;
+ }
+
+ getTimeFormatOptions(): readonly FormSelect[] {
+ return TIME_FORMAT_OPTIONS;
+ }
+
+ getNumberFormatOptions(): readonly FormSelect[] {
+ return NUMBER_FORMAT_OPTIONS;
+ }
+
+ getFiscalYearOptions(): readonly FormSelect[] {
+ return FISCAL_YEAR_OPTIONS;
+ }
+
+ getLicenseTypeOptions(): readonly FormSelect[] {
+ return LICENSE_TYPE_OPTIONS;
+ }
+
+ searchSubscriptionPlans(term: string | null, limit = 10): Observable {
+ return of(this.filterLookupValues(SUBSCRIPTION_PLAN_OPTIONS, term, limit));
+ }
+
+ resolveSubscriptionPlan(id: string): Observable {
+ return of(SUBSCRIPTION_PLAN_OPTIONS.find(option => option.id === id) ?? null);
+ }
+
+ getPlanDefaults(planId: string): Observable {
+ return of(PLAN_DEFAULTS.find(item => item.plan.id === planId) ?? null);
+ }
+
+ getCountryLocalizationDefaults(countryIso2: string | null): Observable {
+ if (!countryIso2?.trim()) {
+ return of(null);
+ }
+
+ return of(LOCALIZATION_DEFAULTS.find(item => item.countryId === countryIso2.trim().toUpperCase()) ?? null);
+ }
+
+ loadDraft(): Observable {
+ try {
+ const rawDraft = sessionStorage.getItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
+
+ if (!rawDraft) {
+ return of(null);
+ }
+
+ const parsedDraft = JSON.parse(rawDraft) as Partial;
+
+ if (!this.isValidDraft(parsedDraft)) {
+ sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
+ return of(null);
+ }
+
+ return of(parsedDraft);
+ } catch (error) {
+ sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
+ return throwError(() => error);
+ }
+ }
+
+ saveDraft(draft: OrganizationOnboardingDraft): Observable {
+ try {
+ sessionStorage.setItem(ORGANIZATION_ONBOARDING_DRAFT_KEY, JSON.stringify(draft));
+ return of(void 0);
+ } catch (error) {
+ return throwError(() => error);
+ }
+ }
+
+ clearDraft(): Observable {
+ try {
+ sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
+ return of(void 0);
+ } catch (error) {
+ return throwError(() => error);
+ }
+ }
+
+ provisionOrganization(_request: OrganizationProvisioningRequest): Observable {
+ return of({
+ status: 'not-configured',
+ message: 'Provisioning is not configured yet for organization onboarding.',
+ });
+ }
+
+ private filterLookupValues(
+ source: readonly TValue[],
+ term: string | null,
+ limit: number,
+ ): readonly TValue[] {
+ const normalizedTerm = term?.trim().toLowerCase() ?? '';
+
+ return source
+ .filter(option => {
+ if (!normalizedTerm) {
+ return true;
+ }
+
+ return [option.label, option.secondaryLabel]
+ .filter((value): value is string => !!value)
+ .some(value => value.toLowerCase().includes(normalizedTerm));
+ })
+ .slice(0, Math.max(1, limit));
+ }
+
+ private isValidDraft(value: Partial): value is OrganizationOnboardingDraft {
+ return (
+ value.schemaVersion === 1 &&
+ typeof value.currentStepIndex === 'number' &&
+ Array.isArray(value.completedStepIndexes) &&
+ typeof value.savedAt === 'string'
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.html b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.html
new file mode 100644
index 00000000..61a039c5
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.html
@@ -0,0 +1,134 @@
+
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.scss b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.scss
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.scss
@@ -0,0 +1 @@
+
diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts
new file mode 100644
index 00000000..7d37ead3
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts
@@ -0,0 +1,217 @@
+import { ChangeDetectionStrategy, Component, ElementRef, inject, signal } from '@angular/core';
+import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
+import { ToastrService } from 'ngx-toastr';
+import { catchError, map, of } from 'rxjs';
+
+import {
+ CountryLookupDto,
+} from '../../../../global-masters/countries/public-api';
+import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
+import {
+ AutocompleteDisplayFn,
+ AutocompleteResolveValueFn,
+ AutocompleteSearchFn,
+ AutocompleteValueFn,
+} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
+import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
+import {
+ OnboardingLookupValue,
+ OnboardingStepForm,
+ OrganizationBasicsValue,
+} from '../../models/organization-onboarding.model';
+import { OrganizationOnboardingService } from '../../services/organization-onboarding.service';
+
+interface OrganizationBasicsFormModel {
+ readonly organizationName: string;
+ readonly shortName: string;
+ readonly localLanguageName: string;
+ readonly organizationTypeId: string | null;
+ readonly industryId: string | null;
+ readonly registrationCountryId: string | null;
+}
+
+@Component({
+ selector: 'app-organization-basics-step',
+ standalone: true,
+ imports: [ReactiveFormsModule, FormInput, Autocomplete],
+ templateUrl: './organization-basics.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrganizationBasicsStepComponent implements OnboardingStepForm {
+ private readonly formBuilder = inject(FormBuilder);
+ private readonly onboardingService = inject(OrganizationOnboardingService);
+ private readonly toastr = inject(ToastrService);
+ private readonly elementRef = inject>(ElementRef);
+
+ readonly submitAttempted = signal(false);
+ readonly organizationCodeControl = this.formBuilder.nonNullable.control('Pending generation');
+ readonly organizationTypeSelection = signal(null);
+ readonly industrySelection = signal(null);
+ readonly registrationCountrySelection = signal(null);
+
+ readonly form = this.formBuilder.group({
+ organizationName: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]),
+ shortName: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]),
+ localLanguageName: this.formBuilder.nonNullable.control('', [Validators.maxLength(150)]),
+ organizationTypeId: this.formBuilder.control(null, [Validators.required]),
+ industryId: this.formBuilder.control(null, [Validators.required]),
+ registrationCountryId: this.formBuilder.control(null, [Validators.required]),
+ });
+
+ readonly searchOrganizationTypes: AutocompleteSearchFn = (term, limit) =>
+ this.onboardingService.searchOrganizationTypes(term, limit).pipe(
+ catchError(() => {
+ this.toastr.error('Unable to load organization types.');
+ return of([]);
+ })
+ );
+
+ readonly searchIndustries: AutocompleteSearchFn = (term, limit) =>
+ this.onboardingService.searchIndustries(term, limit).pipe(
+ catchError(() => {
+ this.toastr.error('Unable to load industries.');
+ return of([]);
+ })
+ );
+
+ readonly searchCountries: AutocompleteSearchFn = (term, limit) =>
+ this.onboardingService.searchRegistrationCountries(term, limit).pipe(
+ catchError(() => {
+ this.toastr.error('Unable to load countries.');
+ return of([]);
+ })
+ );
+
+ readonly displayLookup: AutocompleteDisplayFn = item =>
+ [item.label, item.secondaryLabel].filter(Boolean).join(' - ');
+
+ readonly lookupValue: AutocompleteValueFn = item => item.id;
+
+ readonly resolveOrganizationType: AutocompleteResolveValueFn = value =>
+ this.onboardingService.resolveOrganizationType(value);
+
+ readonly resolveIndustry: AutocompleteResolveValueFn = value =>
+ this.onboardingService.resolveIndustry(value);
+
+ readonly displayCountry: AutocompleteDisplayFn = country => country.name;
+
+ readonly countryValue: AutocompleteValueFn = country => country.id;
+
+ readonly resolveCountry: AutocompleteResolveValueFn = value =>
+ this.onboardingService.resolveCountry(value);
+
+ validate(): boolean {
+ this.submitAttempted.set(true);
+
+ if (this.form.valid) {
+ return true;
+ }
+
+ this.form.markAllAsTouched();
+ this.focusFirstInvalidControl();
+ return false;
+ }
+
+ getValue(): OrganizationBasicsValue {
+ const value = this.form.getRawValue() as OrganizationBasicsFormModel;
+ const organizationType = this.organizationTypeSelection();
+ const industry = this.industrySelection();
+ const registrationCountry = this.registrationCountrySelection();
+
+ if (!organizationType || !industry || !registrationCountry) {
+ throw new Error('Organization basics selections are incomplete.');
+ }
+
+ return {
+ organizationCode: null,
+ organizationName: value.organizationName.trim(),
+ shortName: value.shortName.trim(),
+ localLanguageName: value.localLanguageName.trim() || null,
+ organizationType,
+ industry,
+ registrationCountry: {
+ id: registrationCountry.id,
+ label: registrationCountry.name,
+ iso2: registrationCountry.iso2,
+ },
+ };
+ }
+
+ getDraftValue(): Partial {
+ const value = this.form.getRawValue() as OrganizationBasicsFormModel;
+
+ return {
+ organizationCode: null,
+ organizationName: value.organizationName,
+ shortName: value.shortName,
+ localLanguageName: value.localLanguageName || null,
+ organizationType: this.organizationTypeSelection() ?? undefined,
+ industry: this.industrySelection() ?? undefined,
+ registrationCountry: this.registrationCountrySelection()
+ ? {
+ id: this.registrationCountrySelection()!.id,
+ label: this.registrationCountrySelection()!.name,
+ iso2: this.registrationCountrySelection()!.iso2,
+ }
+ : undefined,
+ };
+ }
+
+ patchValue(value: Partial): void {
+ this.form.patchValue({
+ organizationName: value.organizationName ?? '',
+ shortName: value.shortName ?? '',
+ localLanguageName: value.localLanguageName ?? '',
+ organizationTypeId: value.organizationType?.id ?? null,
+ industryId: value.industry?.id ?? null,
+ registrationCountryId: value.registrationCountry?.id ?? null,
+ }, { emitEvent: false });
+
+ this.organizationTypeSelection.set(value.organizationType ?? null);
+ this.industrySelection.set(value.industry ?? null);
+ this.registrationCountrySelection.set(
+ value.registrationCountry
+ ? {
+ id: value.registrationCountry.id,
+ iso2: value.registrationCountry.iso2 ?? '',
+ name: value.registrationCountry.label,
+ }
+ : null
+ );
+ this.submitAttempted.set(false);
+ }
+
+ onOrganizationTypeSelected(value: OnboardingLookupValue): void {
+ this.organizationTypeSelection.set(value);
+ }
+
+ onOrganizationTypeCleared(): void {
+ this.organizationTypeSelection.set(null);
+ }
+
+ onIndustrySelected(value: OnboardingLookupValue): void {
+ this.industrySelection.set(value);
+ }
+
+ onIndustryCleared(): void {
+ this.industrySelection.set(null);
+ }
+
+ onRegistrationCountrySelected(value: CountryLookupDto): void {
+ this.registrationCountrySelection.set(value);
+ }
+
+ onRegistrationCountryCleared(): void {
+ this.registrationCountrySelection.set(null);
+ }
+
+ private focusFirstInvalidControl(): void {
+ queueMicrotask(() => {
+ const invalidElement = this.elementRef.nativeElement.querySelector(
+ '.ng-invalid [data-form-control], [data-form-control].ng-invalid, .ng-invalid .ng-select-container'
+ );
+
+ invalidElement?.focus();
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.html b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.html
new file mode 100644
index 00000000..80504b6d
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.html
@@ -0,0 +1,82 @@
+
\ No newline at end of file
diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts
new file mode 100644
index 00000000..cf356394
--- /dev/null
+++ b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts
@@ -0,0 +1,409 @@
+import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, 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 { catchError, of } from 'rxjs';
+
+import {
+ CurrencyLookupDto,
+} from '../../../../global-masters/currencies/public-api';
+import {
+ LanguageLookupDto,
+} from '../../../../global-masters/languages/public-api';
+import {
+ TimezoneLookupDto,
+} from '../../../../global-masters/timezones/public-api';
+import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
+import {
+ AutocompleteDisplayFn,
+ AutocompleteResolveValueFn,
+ AutocompleteSearchFn,
+ AutocompleteValueFn,
+} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
+import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
+import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
+import {
+ CountryLocalizationDefaults,
+ DateFormatValue,
+ FiscalYearConventionValue,
+ NumberFormatValue,
+ OnboardingLookupValue,
+ OnboardingStepForm,
+ OrganizationLocalizationValue,
+ TimeFormatValue,
+} from '../../models/organization-onboarding.model';
+import { OrganizationOnboardingService } from '../../services/organization-onboarding.service';
+
+interface OrganizationLocalizationFormModel {
+ readonly timeZoneId: string | null;
+ readonly currencyId: string | null;
+ readonly defaultLanguageId: string | null;
+ readonly additionalLanguageIds: readonly string[];
+ readonly dateFormat: DateFormatValue | null;
+ readonly timeFormat: TimeFormatValue | null;
+ readonly numberFormat: NumberFormatValue | null;
+ readonly fiscalYearConvention: FiscalYearConventionValue | null;
+}
+
+@Component({
+ selector: 'app-organization-localization-step',
+ standalone: true,
+ imports: [ReactiveFormsModule, Autocomplete, FormSelect],
+ templateUrl: './organization-localization.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrganizationLocalizationStepComponent implements OnboardingStepForm {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly formBuilder = inject(FormBuilder);
+ private readonly onboardingService = inject(OrganizationOnboardingService);
+ private readonly toastr = inject(ToastrService);
+ private readonly elementRef = inject>(ElementRef);
+
+ readonly submitAttempted = signal(false);
+ readonly timeZoneSelection = signal(null);
+ readonly currencySelection = signal(null);
+ readonly defaultLanguageSelection = signal(null);
+ readonly additionalLanguageOptions = signal[]>([]);
+
+ readonly dateFormatOptions = this.onboardingService.getDateFormatOptions();
+ readonly timeFormatOptions = this.onboardingService.getTimeFormatOptions();
+ readonly numberFormatOptions = this.onboardingService.getNumberFormatOptions();
+ readonly fiscalYearOptions = this.onboardingService.getFiscalYearOptions();
+
+ readonly form = this.formBuilder.group({
+ timeZoneId: this.formBuilder.control(null, [Validators.required]),
+ currencyId: this.formBuilder.control(null, [Validators.required]),
+ defaultLanguageId: this.formBuilder.control(null, [Validators.required]),
+ additionalLanguageIds: this.formBuilder.nonNullable.control([]),
+ dateFormat: this.formBuilder.control(null, [Validators.required]),
+ timeFormat: this.formBuilder.control(null, [Validators.required]),
+ numberFormat: this.formBuilder.control(null, [Validators.required]),
+ fiscalYearConvention: this.formBuilder.control