Compare commits
2 Commits
4cb9b0181e
...
0b797b6a56
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b797b6a56 | |||
| a29cb34669 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
"autoprefixer": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div echarts [ngClass]="echartClass()" [options]="options() || {}" [id]="id()"></div>
|
||||||
@@ -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<EChartsOption>()
|
||||||
|
id = input<string>()
|
||||||
|
echartClass = input<string>()
|
||||||
|
theme = input<string>()
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
import { ContentLayout } from './shared/layouts/content-layout/content-layout';
|
import { ContentLayout } from './shell/layouts/content-layout/content-layout';
|
||||||
import { AuthenticationLayout } from './shared/layouts/authentication-layout/authentication-layout';
|
import { AuthenticationLayout } from './shell/layouts/authentication-layout/authentication-layout';
|
||||||
import { authGuard } from './core/guards/auth.guard';
|
import { authGuard } from './core/guards/auth/auth.guard';
|
||||||
|
|
||||||
export const App_Route: Route[] = [
|
export const App_Route: Route[] = [
|
||||||
{ path: '', redirectTo: 'auth/login', pathMatch: 'full' },
|
{ path: '', redirectTo: 'auth/login', pathMatch: 'full' },
|
||||||
{
|
{
|
||||||
path: 'auth',
|
path: 'auth',
|
||||||
component: AuthenticationLayout,
|
component: AuthenticationLayout,
|
||||||
loadChildren: () => import('./shared/routes/auth.routes').then((m) => m.authen),
|
loadChildren: () =>
|
||||||
|
import('./features/authentication/authentication.routes').then((m) => m.authen),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ContentLayout,
|
component: ContentLayout,
|
||||||
canActivateChild: [authGuard],
|
canActivateChild: [authGuard],
|
||||||
loadChildren: () => import('./shared/routes/content.routes').then((m) => m.content),
|
loadChildren: () => import('./shell/routes/content.routes').then((m) => m.content),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
loadComponent: () => import('./components/error/error404/error404').then((m) => m.Error404),
|
loadComponent: () => import('./features/errors/error404/error404').then((m) => m.Error404),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Component, signal, inject } from '@angular/core';
|
import { Component, signal, inject } from '@angular/core';
|
||||||
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||||
import { AppStateService } from './shared/services/app-state.service';
|
import { AppStateService } from './core/services/common/app-state.service';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
<div
|
|
||||||
class="flex justify-center authentication authentication-basic items-center h-full text-defaultsize text-defaulttextcolor">
|
|
||||||
<div class="grid grid-cols-12">
|
|
||||||
<div class="xxl:col-span-4 xl:col-span-4 lg:col-span-4 md:col-span-3 sm:col-span-2"></div>
|
|
||||||
<div class="xxl:col-span-4 xl:col-span-4 lg:col-span-4 md:col-span-6 sm:col-span-8 col-span-12">
|
|
||||||
<div class="my-[2.5rem] flex justify-center">
|
|
||||||
<a routerLink="/dashboards/crm">
|
|
||||||
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-logo">
|
|
||||||
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-dark">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="box">
|
|
||||||
<div class="box-body !p-[3rem]">
|
|
||||||
<p class="h5 font-semibold mb-2 text-center">Sign In</p>
|
|
||||||
<p class="mb-4 text-[#8c9097] dark:text-white/50 opacity-[0.7] font-normal text-center">Welcome back !
|
|
||||||
</p>
|
|
||||||
<nav aria-label="Tabs" role="tablist" aria-orientation="horizontal"
|
|
||||||
class="sm:flex rounded-lg transition p-1 ">
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div id="segment-2" role="tabpanel" aria-labelledby="segment-item-2">
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<h1 class="block text-2xl font-bold text-gray-800 dark:text-white">
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
|
|
||||||
<form [formGroup]="adminLoginForm" (ngSubmit)="login()">
|
|
||||||
<div class="text-danger">
|
|
||||||
{{ loginError }}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-12 gap-y-4">
|
|
||||||
<div class="xl:col-span-12 col-span-12">
|
|
||||||
<label for="signin-username" class="form-label text-default">User Name </label>
|
|
||||||
<input type="text" class="form-control form-control-lg w-full !rounded-md" id="signin-username"
|
|
||||||
placeholder="user name" formControlName="username" autocomplete>
|
|
||||||
</div>
|
|
||||||
<div class="xl:col-span-12 col-span-12 mb-2">
|
|
||||||
<label for="signin-password" class="form-label text-default block">Password
|
|
||||||
</label>
|
|
||||||
<a routerLink="/authentication/reset-password/basic"
|
|
||||||
class="float-end text-danger">Forget password ?</a>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="password" class="form-control form-control-lg !rounded-s-md" id="signin-password"
|
|
||||||
placeholder="password" formControlName="password" autocomplete
|
|
||||||
[type]="visibilityMap['Angular'] ? 'text' : 'password'">
|
|
||||||
<button aria-label="button" class="ti-btn ti-btn-light !rounded-s-none !mb-0" type="button"
|
|
||||||
(click)="toggleVisibility('Angular')" id="button-addon2"><i
|
|
||||||
class="{{ iconMap['Angular'] }} align-middle"></i></button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<div class="form-check !ps-0">
|
|
||||||
<input class="form-check-input" type="checkbox" value="" id="defaultCheck1" formControlName="rememberMe">
|
|
||||||
<label class="form-check-label text-[#8c9097] dark:text-white/50 font-normal"
|
|
||||||
for="defaultCheck1">
|
|
||||||
Remember password ?
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="xl:col-span-12 col-span-12 grid mt-2">
|
|
||||||
<button type="submit" [disabled]="adminLoginForm.invalid || isSubmitting"
|
|
||||||
class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium inline-flex items-center justify-center gap-2">
|
|
||||||
@if (isSubmitting) {
|
|
||||||
<span class="ti-spinner text-white" role="status" aria-label="loading"></span>
|
|
||||||
<span>Signing In...</span>
|
|
||||||
} @else {
|
|
||||||
<span>Sign In</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-[0.75rem] text-[#8c9097] dark:text-white/50 mt-4">Dont have an account? <a
|
|
||||||
routerLink="/authentication/sign-up/basic" class="text-primary">Sign Up</a></p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center my-4 authentication-barrier">
|
|
||||||
<span>OR</span>
|
|
||||||
</div>
|
|
||||||
<div class="btn-list text-center">
|
|
||||||
<button aria-label="button" type="button" class="ti-btn ti-btn-icon ti-btn-light me-[0.365rem]">
|
|
||||||
<i class="ri-facebook-line font-bold text-dark opacity-[0.7]"></i>
|
|
||||||
</button>
|
|
||||||
<button aria-label="button" type="button" class="ti-btn ti-btn-icon ti-btn-light me-[0.365rem]">
|
|
||||||
<i class="ri-google-line font-bold text-dark opacity-[0.7]"></i>
|
|
||||||
</button>
|
|
||||||
<button aria-label="button" type="button" class="ti-btn ti-btn-icon ti-btn-light">
|
|
||||||
<i class="ri-twitter-x-line font-bold text-dark opacity-[0.7]"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="xxl:col-span-4 xl:col-span-4 lg:col-span-4 md:col-span-3 sm:col-span-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export const API_HOSTS = {
|
||||||
|
identity: environment.api.identity,
|
||||||
|
masterAdmin: environment.api.masterAdmin,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ApiHost = keyof typeof API_HOSTS;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { API_HOSTS, ApiHost } from './api-host';
|
||||||
|
|
||||||
|
export function buildApiUrl(
|
||||||
|
host: ApiHost,
|
||||||
|
endpoint: string
|
||||||
|
): string {
|
||||||
|
const baseUrl = API_HOSTS[host].replace(/\/+$/, '');
|
||||||
|
const normalizedEndpoint = endpoint.startsWith('/')
|
||||||
|
? endpoint
|
||||||
|
: `/${endpoint}`;
|
||||||
|
|
||||||
|
return `${baseUrl}${normalizedEndpoint}`;
|
||||||
|
}
|
||||||
@@ -1,19 +1,9 @@
|
|||||||
import { environment } from '../../../environments/environment';
|
|
||||||
|
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
baseUrl: environment.apiBaseUrl ?? 'https://localhost:5001/api',
|
baseUrl: '/api',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
auth: '/v1/auth',
|
auth: '/auth',
|
||||||
users: '/users',
|
|
||||||
tenants: '/tenants',
|
|
||||||
currentUserProfile: '/users/me',
|
currentUserProfile: '/users/me',
|
||||||
tenantContext: '/tenants/current-context',
|
tenantContext: '/tenants/current-context',
|
||||||
permissionContext: '/users/me/permissions',
|
permissionContext: '/users/me/permissions',
|
||||||
masters: '/masters',
|
|
||||||
billing: '/billing',
|
|
||||||
localization: '/localization',
|
|
||||||
theming: '/theming',
|
|
||||||
platform: '/platform',
|
|
||||||
monitoring: '/monitoring',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { buildApiUrl } from '../../config/api-url.util';
|
||||||
|
|
||||||
|
export const AUTH_ENDPOINTS = {
|
||||||
|
login: buildApiUrl('identity', '/v1/auth/login'),
|
||||||
|
refresh: buildApiUrl('identity', '/v1/auth/refresh'),
|
||||||
|
logout: buildApiUrl('identity', '/v1/auth/logout'),
|
||||||
|
currentUser: buildApiUrl('identity', '/v1/auth/me'),
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateChildFn, Router } from '@angular/router';
|
||||||
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
|
import { TokenStorageService } from '../../services/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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
|
import { TokenStorageService } from '../../services/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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
};
|
||||||
@@ -2,20 +2,34 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { catchError, switchMap, throwError } from 'rxjs';
|
import { catchError, switchMap, throwError } from 'rxjs';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../services/auth/auth.service';
|
||||||
import { API_CONFIG } from '../config/api.config';
|
import { API_CONFIG } from '../config/api.config';
|
||||||
|
import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints';
|
||||||
|
|
||||||
const RETRY_HEADER = 'X-Auth-Retry';
|
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) => {
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
const authService = inject(AuthService);
|
const authService = inject(AuthService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
|
||||||
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
|
const isLoginRequest = isEndpointRequest(
|
||||||
const isAuthRequest = req.url.includes(authBaseUrl);
|
req.url,
|
||||||
const isRefreshRequest = req.url.includes(`${authBaseUrl}/refresh`);
|
AUTH_ENDPOINTS.login
|
||||||
|
);
|
||||||
|
|
||||||
if (isAuthRequest) {
|
const isRefreshRequest = isEndpointRequest(
|
||||||
|
req.url,
|
||||||
|
AUTH_ENDPOINTS.refresh
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoginRequest || isRefreshRequest) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,31 @@ import { ToastrService } from 'ngx-toastr';
|
|||||||
import { catchError, throwError } from 'rxjs';
|
import { catchError, throwError } from 'rxjs';
|
||||||
import { API_CONFIG } from '../config/api.config';
|
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<string, unknown>;
|
||||||
|
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) => {
|
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
const toastr = inject(ToastrService);
|
const toastr = inject(ToastrService);
|
||||||
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
|
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
|
||||||
@@ -13,7 +38,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
return next(req).pipe(
|
return next(req).pipe(
|
||||||
catchError((error: HttpErrorResponse) => {
|
catchError((error: HttpErrorResponse) => {
|
||||||
if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
|
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');
|
toastr.error(message, 'Request failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HttpInterceptorFn } from '@angular/common/http';
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { finalize } from 'rxjs';
|
import { finalize } from 'rxjs';
|
||||||
import { LoadingService } from '../services/loading.service';
|
import { LoadingService } from '../services/common/loading.service';
|
||||||
|
|
||||||
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
|
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
const loadingService = inject(LoadingService);
|
const loadingService = inject(LoadingService);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemDetails {
|
||||||
|
title?: string;
|
||||||
|
status?: number;
|
||||||
|
detail?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { UserProfile } from '../auth/auth.model';
|
||||||
|
import { Menu } from '../../services/common/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;
|
||||||
|
}
|
||||||
@@ -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/auth.model';
|
||||||
|
import { TokenStorageService } from './token-storage.service';
|
||||||
|
import { AppContextService } from '../../services/context/app-context.service';
|
||||||
|
import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints';
|
||||||
|
|
||||||
|
@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<UserProfile | null>(null);
|
||||||
|
readonly user$ = this.userSubject.asObservable();
|
||||||
|
readonly currentUserSignal = signal<UserProfile | null>(null);
|
||||||
|
private readonly accessTokenSignal = signal<string | null>(null);
|
||||||
|
readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
|
||||||
|
|
||||||
|
private refreshRequest$: Observable<LoginResponse> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.restoreAuthState();
|
||||||
|
}
|
||||||
|
|
||||||
|
login(payload: LoginRequest, rememberMe: boolean): Observable<LoginResponse> {
|
||||||
|
return this.http.post<LoginResponse>(`${AUTH_ENDPOINTS.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<LoginResponse> {
|
||||||
|
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<LoginResponse>(`${AUTH_ENDPOINTS.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof setTimeout> | null = null;
|
||||||
|
private logoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private countdownTimer: ReturnType<typeof setInterval> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { LoginResponse, UserProfile } from '../../models/auth/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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { DOCUMENT, ElementRef, inject, Injectable, Renderer2 } from '@angular/core';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
interface StateType {
|
||||||
|
direction: string;
|
||||||
|
theme: string;
|
||||||
|
navigationStyles: string, // vertical, horizontal
|
||||||
|
menuStyles: string, // menu-click, menu-hover, icon-click, icon-hover
|
||||||
|
layoutStyles: string, // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
|
||||||
|
pageStyles: string, // regular, classic, modern
|
||||||
|
widthStyles: string, // fullwidth, boxed
|
||||||
|
menuPosition: string, // fixed, scrollable
|
||||||
|
headerPosition: string, // fixed, scrollable
|
||||||
|
menuColor: string, // light, dark, color, gradient, transparent
|
||||||
|
headerColor: string, // light, dark, color, gradient, transparent
|
||||||
|
themePrimary: string, // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
|
||||||
|
themeBackground: string,
|
||||||
|
backgroundImage: string,
|
||||||
|
};
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AppStateService {
|
||||||
|
private readonly localStorageKey = 'Ynex-ng'; // Customize this key
|
||||||
|
private initialState: StateType = {
|
||||||
|
theme: 'light', // light, dark
|
||||||
|
direction: 'ltr', // ltr, rtl
|
||||||
|
navigationStyles: 'vertical', // vertical, horizontal
|
||||||
|
menuStyles: '', // menu-click, menu-hover, icon-click, icon-hover
|
||||||
|
layoutStyles: 'default', // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
|
||||||
|
pageStyles: 'regular', // regular, classic, modern
|
||||||
|
widthStyles: 'fullwidth', // fullwidth, boxed
|
||||||
|
menuPosition: 'fixed', // fixed, scrollable
|
||||||
|
headerPosition: 'fixed', // fixed, scrollable
|
||||||
|
menuColor: 'dark', // light, dark, color, gradient, transparent
|
||||||
|
headerColor: 'light', // light, dark, color, gradient, transparent
|
||||||
|
themePrimary: '', // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
|
||||||
|
themeBackground: '',
|
||||||
|
backgroundImage: '', // bgimg1, bgimg2, bgimg3, bgimg4, bgimg5
|
||||||
|
} // Store initial state
|
||||||
|
private stateSubject = new BehaviorSubject<StateType>(this.initialState); // Use any for initial null value
|
||||||
|
state$ = this.stateSubject.asObservable();
|
||||||
|
private document = inject(DOCUMENT);
|
||||||
|
navigationStyles: any;
|
||||||
|
private html = this.document.documentElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
const initialState: StateType = this.getInitialStateFromLocalStorage();
|
||||||
|
// this.initializeState();
|
||||||
|
this.stateSubject.next(initialState);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitialStateFromLocalStorage(): StateType {
|
||||||
|
try {
|
||||||
|
const storedState = localStorage.getItem(this.localStorageKey);
|
||||||
|
if (storedState) {
|
||||||
|
return JSON.parse(storedState);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving initial state from local storage:', error);
|
||||||
|
}
|
||||||
|
return this.initialState;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
getupdateState() {
|
||||||
|
const currentState = this.stateSubject.getValue();
|
||||||
|
return currentState
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(newState?: Partial<any>) { // Use any for partial updates
|
||||||
|
const currentState = this.stateSubject.getValue(); // Get current state
|
||||||
|
|
||||||
|
if (!currentState) {
|
||||||
|
|
||||||
|
// Handle initial update case (no state emitted yet)
|
||||||
|
this.updateStateAndEmit(newState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newState) {
|
||||||
|
|
||||||
|
const updatedState = { ...currentState, ...newState }; // Merge updates
|
||||||
|
this.updateStateAndEmit(updatedState); // Update and emit combined state
|
||||||
|
} else {
|
||||||
|
this.updateStateAndEmit(currentState);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private state: { [key: string]: any } = {};
|
||||||
|
getState(menuStyles: string): any {
|
||||||
|
return this.state[menuStyles];
|
||||||
|
}
|
||||||
|
private applyThemeBackgroundSpecificChanges(background: any) {
|
||||||
|
|
||||||
|
this.html?.style.setProperty('--color-bodybg', background.main);
|
||||||
|
this.html?.style.setProperty('--color-bodybg2', background.secondary);
|
||||||
|
this.html?.style.setProperty('--color-light', background.accent);
|
||||||
|
this.html?.style.setProperty('--color-formcontrolbg', `rgba(${background.accent})`);
|
||||||
|
this.html?.style.setProperty('--color-inputborder', background.overlay);
|
||||||
|
this.html?.style.setProperty('--color-gray3', background.primary);
|
||||||
|
this.applythemeSpecificChanges(background.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private applyDirectionSpecificChanges(direction: string) {
|
||||||
|
|
||||||
|
|
||||||
|
this.html?.setAttribute('dir', direction);
|
||||||
|
|
||||||
|
}
|
||||||
|
private applythemeSpecificChanges(theme: string) {
|
||||||
|
|
||||||
|
|
||||||
|
this.html?.setAttribute('class', theme); //setting theme style
|
||||||
|
this.html?.setAttribute('data-header-styles', theme); //setting header style
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private applyNavigationStylesSpecificChanges(navigationStyles: string) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-nav-layout', navigationStyles);
|
||||||
|
if (navigationStyles == 'horizontal') {
|
||||||
|
this.html?.setAttribute('data-nav-style', 'menu-click');
|
||||||
|
this.html?.removeAttribute('data-vertical-style');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private applyMenuStylesSpecificChanges(menuStyles: string) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-nav-style', menuStyles);
|
||||||
|
this.html?.setAttribute('data-toggled', menuStyles + '-closed');
|
||||||
|
this.html?.removeAttribute('data-vertical-style');
|
||||||
|
}
|
||||||
|
private applyLayoutStylesSpecificChanges(layoutStyles: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-vertical-style', layoutStyles);
|
||||||
|
this.html?.removeAttribute('data-nav-style');
|
||||||
|
switch (layoutStyles) {
|
||||||
|
case 'default':
|
||||||
|
this.html?.setAttribute('data-vertical-style', 'overlay');
|
||||||
|
this.html?.setAttribute('data-toggled', '');
|
||||||
|
break;
|
||||||
|
case 'closed':
|
||||||
|
this.html?.setAttribute('data-toggled', 'close-menu-close');
|
||||||
|
break;
|
||||||
|
case 'icontext':
|
||||||
|
this.html?.setAttribute('data-toggled', 'icon-text-close');
|
||||||
|
break;
|
||||||
|
case 'overlay':
|
||||||
|
this.html?.setAttribute('data-toggled', 'icon-overlay-close');
|
||||||
|
break;
|
||||||
|
case 'detached':
|
||||||
|
this.html?.setAttribute('data-toggled', 'detached-close');
|
||||||
|
break;
|
||||||
|
case 'doublemenu':
|
||||||
|
this.html?.setAttribute('data-toggled', 'double-menu-open');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (layoutStyles === 'icon-text') {
|
||||||
|
this.html?.setAttribute('icon-text', 'open');
|
||||||
|
} else {
|
||||||
|
// If not 'icon-text', remove the icon-text attribute
|
||||||
|
this.html?.removeAttribute('icon-text');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private applypageStylesSpecificChanges(pageStyles: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-page-style', pageStyles);
|
||||||
|
|
||||||
|
const slideRight = document.querySelector('.slide-right') as HTMLElement | null;
|
||||||
|
if (slideRight) {
|
||||||
|
// If the element exists, toggle the 'd-none' class
|
||||||
|
if (slideRight.classList.contains('d-none')) {
|
||||||
|
slideRight.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
slideRight.classList.add('d-none');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the element does not exist (is null), create a safe fallback by adding 'd-none'
|
||||||
|
const dummySlideRight = document.createElement('div');
|
||||||
|
dummySlideRight.classList.add('slide-right', 'd-none'); // Add classes to the new element
|
||||||
|
document.body.appendChild(dummySlideRight); // Append it to the DOM as a fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private applywidthStylesSpecificChanges(widthStyles: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-width', widthStyles);
|
||||||
|
}
|
||||||
|
private applymenuPositionSpecificChanges(menuPosition: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-menu-position', menuPosition);
|
||||||
|
}
|
||||||
|
private applyheaderPositionSpecificChanges(headerPosition: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-header-position', headerPosition);
|
||||||
|
}
|
||||||
|
private applyheaderColorSpecificChanges(headerColor: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-header-styles', headerColor);
|
||||||
|
}
|
||||||
|
private applymenuColorSpecificChanges(menuColor: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('data-menu-styles', menuColor);
|
||||||
|
}
|
||||||
|
private applyPrimarySpecificChanges(primary: string) {
|
||||||
|
|
||||||
|
this.html?.style.setProperty('--color-primaryrgb', primary);
|
||||||
|
this.html?.style.setProperty('--color-primary', primary);
|
||||||
|
}
|
||||||
|
private applybackgroundImageSpecificChanges(backgroundImage: string) {
|
||||||
|
|
||||||
|
this.html?.setAttribute('bg-img', backgroundImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public applyReset() {
|
||||||
|
|
||||||
|
if (this.html) {
|
||||||
|
this.html?.style.removeProperty('--color-bodybg');
|
||||||
|
this.html?.style.removeProperty('--color-gray3');
|
||||||
|
this.html?.style.removeProperty('--color-bodybg2');
|
||||||
|
this.html?.style.removeProperty('--color-light');
|
||||||
|
this.html?.style.removeProperty('--color-formcontrolbg');
|
||||||
|
this.html?.style.removeProperty('--color-inputborder');
|
||||||
|
this.html?.style.removeProperty('--color-primary');
|
||||||
|
this.html?.style.removeProperty('--color-primaryrgb');
|
||||||
|
|
||||||
|
}
|
||||||
|
this.html?.removeAttribute('bg-img');
|
||||||
|
this.html?.setAttribute('data-vertical-style', 'overlay');
|
||||||
|
this.stateSubject.next(this.initialState);
|
||||||
|
this.updateStateAndEmit(this.initialState);
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
if (window.innerWidth <= 992) {
|
||||||
|
this.html?.setAttribute('data-toggled', 'close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStateAndEmit(state: any) {
|
||||||
|
// Conditional logic based on direction changes
|
||||||
|
|
||||||
|
const currentState = this.stateSubject.getValue(); // Get current state
|
||||||
|
// Conditional logic based on theme changes
|
||||||
|
if (state['theme']) {
|
||||||
|
|
||||||
|
this.applythemeSpecificChanges(state['theme']);
|
||||||
|
}
|
||||||
|
if (state['direction']) {
|
||||||
|
|
||||||
|
this.applyDirectionSpecificChanges(state['direction']);
|
||||||
|
}
|
||||||
|
// Conditional logic based on theme changes
|
||||||
|
if (state['navigationStyles']) {
|
||||||
|
this.applyNavigationStylesSpecificChanges(state['navigationStyles']);
|
||||||
|
}
|
||||||
|
// Conditional logic based on theme changes
|
||||||
|
if (state['menuStyles'] && !state['layoutStyles']) {
|
||||||
|
this.applyMenuStylesSpecificChanges(state['menuStyles']);
|
||||||
|
}
|
||||||
|
if (state['layoutStyles'] && !state['menuStyles']) {
|
||||||
|
this.applyLayoutStylesSpecificChanges(state['layoutStyles']);
|
||||||
|
}
|
||||||
|
if (state['pageStyles']) {
|
||||||
|
this.applypageStylesSpecificChanges(state['pageStyles']);
|
||||||
|
}
|
||||||
|
if (state['widthStyles']) {
|
||||||
|
this.applywidthStylesSpecificChanges(state['widthStyles']);
|
||||||
|
}
|
||||||
|
if (state['menuPosition']) {
|
||||||
|
this.applymenuPositionSpecificChanges(state['menuPosition']);
|
||||||
|
}
|
||||||
|
if (state['headerPosition']) {
|
||||||
|
this.applyheaderPositionSpecificChanges(state['headerPosition']);
|
||||||
|
}
|
||||||
|
if (state['themePrimary']) {
|
||||||
|
this.applyPrimarySpecificChanges(state['themePrimary']);
|
||||||
|
}
|
||||||
|
if (state['themeBackground']) {
|
||||||
|
this.applyThemeBackgroundSpecificChanges(state['themeBackground']);
|
||||||
|
}
|
||||||
|
if (state['headerColor']) {
|
||||||
|
this.applyheaderColorSpecificChanges(state['headerColor']);
|
||||||
|
}
|
||||||
|
if (state['menuColor']) {
|
||||||
|
this.applymenuColorSpecificChanges(state['menuColor']);
|
||||||
|
}
|
||||||
|
if (state['backgroundImage']) {
|
||||||
|
this.applybackgroundImageSpecificChanges(state['backgroundImage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stateSubject.next(state);
|
||||||
|
this.updateLocalStorage(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLocalStorage(state: any) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.localStorageKey, JSON.stringify(state));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving state to local storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { API_CONFIG } from '../../config/api.config';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BaseApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
protected get<T>(resource: string, params?: Record<string, string | number | boolean>): Observable<T> {
|
||||||
|
let httpParams = new HttpParams();
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
httpParams = httpParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<T>(`${API_CONFIG.baseUrl}${resource}`, { params: httpParams });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected post<T, R = unknown>(resource: string, body: T): Observable<R> {
|
||||||
|
return this.http.post<R>(`${API_CONFIG.baseUrl}${resource}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected put<T, R = unknown>(resource: string, body: T): Observable<R> {
|
||||||
|
return this.http.put<R>(`${API_CONFIG.baseUrl}${resource}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected delete<R = unknown>(resource: string): Observable<R> {
|
||||||
|
return this.http.delete<R>(`${API_CONFIG.baseUrl}${resource}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { AngularFireModule } from '@angular/fire/compat';
|
||||||
|
import { AngularFirestoreModule } from '@angular/fire/compat/firestore';
|
||||||
|
import { AngularFireDatabaseModule } from '@angular/fire/compat/database';
|
||||||
|
import { AngularFireAuthModule } from '@angular/fire/compat/auth';
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class FirebaseService {
|
||||||
|
constructor() {
|
||||||
|
AngularFireModule.initializeApp(environment.firebase);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFirestore() {
|
||||||
|
return AngularFirestoreModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDatabase() {
|
||||||
|
return AngularFireDatabaseModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuth() {
|
||||||
|
return AngularFireAuthModule;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { MenuContext } from '../../models/context/context.model';
|
||||||
|
|
||||||
|
export const SAAS_MENU_DATA: MenuContext = {
|
||||||
|
defaultLandingPage: '/dashboards/crm',
|
||||||
|
items: [
|
||||||
|
{ headTitle: 'MAIN' },
|
||||||
|
{
|
||||||
|
title: 'Dashboards',
|
||||||
|
icon: '<i class="bx bx-home side-menu__icon"></i>',
|
||||||
|
type: 'sub',
|
||||||
|
active: false,
|
||||||
|
selected: false,
|
||||||
|
dirchange: false,
|
||||||
|
children: [
|
||||||
|
{ path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ headTitle: 'SAAS ADMIN' },
|
||||||
|
{
|
||||||
|
title: 'Management',
|
||||||
|
icon: '<i class="bx bx-buildings side-menu__icon"></i>',
|
||||||
|
type: 'sub',
|
||||||
|
active: false,
|
||||||
|
selected: false,
|
||||||
|
dirchange: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'Global Master',
|
||||||
|
type: 'sub',
|
||||||
|
active: false,
|
||||||
|
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',
|
||||||
|
active: false,
|
||||||
|
selected: false,
|
||||||
|
dirchange: false,
|
||||||
|
children: [
|
||||||
|
|
||||||
|
{ 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: '<i class="bx bx-line-chart side-menu__icon"></i>',
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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<MenuService['getNavigationMenu']>, path: string): boolean {
|
||||||
|
return items.some(
|
||||||
|
(item) =>
|
||||||
|
item.path === path ||
|
||||||
|
(item.children ? hasPath(item.children, path) : false) ||
|
||||||
|
(item.children2 ? hasPath(item.children2, path) : false),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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<MenuContext | null>(null);
|
||||||
|
|
||||||
|
loadMenu(): Observable<MenuContext> {
|
||||||
|
const context = this.cloneMenuContext(SAAS_MENU_DATA);
|
||||||
|
context.items = this.filterAuthorizedItems(context.items);
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable, OnDestroy } from '@angular/core';
|
||||||
|
import { Subject, BehaviorSubject, fromEvent } from 'rxjs';
|
||||||
|
import { takeUntil, debounceTime } from 'rxjs/operators';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
// Menu
|
||||||
|
export interface Menu {
|
||||||
|
headTitle?: string;
|
||||||
|
headTitle2?: string;
|
||||||
|
path?: string;
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
type?: string;
|
||||||
|
badgeValue?: string;
|
||||||
|
badgeClass?: string;
|
||||||
|
badgeText?: string;
|
||||||
|
active?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
bookmark?: boolean;
|
||||||
|
children?: Menu[];
|
||||||
|
children2?: Menu[];
|
||||||
|
Menusub?: boolean;
|
||||||
|
target?: boolean;
|
||||||
|
menutype?: string,
|
||||||
|
dirchange?: boolean,
|
||||||
|
nochild?: any
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class NavService implements OnDestroy {
|
||||||
|
private unsubscriber: Subject<any> = new Subject();
|
||||||
|
public screenWidth: BehaviorSubject<number> = new BehaviorSubject(
|
||||||
|
window.innerWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search Box
|
||||||
|
public search = false;
|
||||||
|
|
||||||
|
// Language
|
||||||
|
public language = false;
|
||||||
|
|
||||||
|
// Mega Menu
|
||||||
|
public megaMenu = false;
|
||||||
|
public levelMenu = false;
|
||||||
|
public megaMenuColapse: boolean = window.innerWidth < 1199 ? true : false;
|
||||||
|
|
||||||
|
// Collapse Sidebar
|
||||||
|
public collapseSidebar: boolean = window.innerWidth < 991 ? true : false;
|
||||||
|
|
||||||
|
// For Horizontal Layout Mobile
|
||||||
|
public horizontal: boolean = window.innerWidth < 991 ? false : true;
|
||||||
|
|
||||||
|
// Full screen
|
||||||
|
public fullScreen = false;
|
||||||
|
active: any;
|
||||||
|
|
||||||
|
constructor(private router: Router) {
|
||||||
|
this.setScreenWidth(window.innerWidth);
|
||||||
|
fromEvent(window, 'resize')
|
||||||
|
.pipe(debounceTime(1000), takeUntil(this.unsubscriber))
|
||||||
|
.subscribe((evt: any) => {
|
||||||
|
this.setScreenWidth(evt.target.innerWidth);
|
||||||
|
if (evt.target.innerWidth < 991) {
|
||||||
|
this.collapseSidebar = true;
|
||||||
|
this.megaMenu = false;
|
||||||
|
this.levelMenu = false;
|
||||||
|
}
|
||||||
|
if (evt.target.innerWidth < 1199) {
|
||||||
|
this.megaMenuColapse = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (window.innerWidth < 991) {
|
||||||
|
// Detect Route change sidebar close
|
||||||
|
this.router.events.subscribe((event) => {
|
||||||
|
this.collapseSidebar = true;
|
||||||
|
this.megaMenu = false;
|
||||||
|
this.levelMenu = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.unsubscriber.next;
|
||||||
|
this.unsubscriber.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setScreenWidth(width: number): void {
|
||||||
|
this.screenWidth.next(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
items = new BehaviorSubject<Menu[]>([]);
|
||||||
|
|
||||||
|
setMenuItems(menuItems: Menu[]): void {
|
||||||
|
this.items.next(menuItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMenuItems(): void {
|
||||||
|
this.items.next([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { map, Observable, tap } from 'rxjs';
|
||||||
|
import { PermissionContext } from '../../models/context/context.model';
|
||||||
|
|
||||||
|
interface PermissionResponse {
|
||||||
|
roles?: string[];
|
||||||
|
permissions?: string[];
|
||||||
|
defaultLandingPage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PermissionService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
readonly permissionContext = signal<PermissionContext | null>(null);
|
||||||
|
|
||||||
|
loadPermissions(): Observable<PermissionContext> {
|
||||||
|
return this.http.get<PermissionResponse>(``).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/context.model';
|
||||||
|
import { MenuService } from '../common/menu.service';
|
||||||
|
import { PermissionService } from '../common/permission.service';
|
||||||
|
import { TenantContextService } from './tenant-context.service';
|
||||||
|
import { UserContextService } from './user-context.service';
|
||||||
|
import { NavService } from '../common/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<AppContextState> | null = null;
|
||||||
|
private menuLoadRequest$: Observable<MenuContext> | null = null;
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
ensureMenuInitialized(forceReload = false): Observable<MenuContext> {
|
||||||
|
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<AppContextState> {
|
||||||
|
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<AppContextState> {
|
||||||
|
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: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { Observable, tap } from 'rxjs';
|
||||||
|
import { TenantContext } from '../../models/context/context.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TenantContextService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
readonly tenantContext = signal<TenantContext | null>(null);
|
||||||
|
|
||||||
|
loadTenantContext(): Observable<TenantContext> {
|
||||||
|
return this.http.get<TenantContext>(``).pipe(
|
||||||
|
tap((context) => {
|
||||||
|
this.tenantContext.set(context);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.tenantContext.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { map, Observable, tap } from 'rxjs';
|
||||||
|
import { CurrentUserContext } from '../../models/context/context.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserContextService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
readonly currentUserContext = signal<CurrentUserContext | null>(null);
|
||||||
|
|
||||||
|
loadCurrentUserProfile(): Observable<CurrentUserContext> {
|
||||||
|
return this.http.get<Partial<CurrentUserContext>>(``).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { guestGuard } from '../../core/guards/auth/guest.guard';
|
||||||
|
|
||||||
|
export const authen: Routes = [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
canActivate: [guestGuard],
|
||||||
|
loadComponent: () => import('./pages/login/login').then((m) => m.Login),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadChildren: () => import('../errors/error.routes').then((m) => m.errorRoutingModule),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<div
|
||||||
|
class="grid grid-cols-12 h-screen min-h-screen overflow-hidden bg-bodybg dark:bg-bodybg text-defaulttextcolor text-defaultsize">
|
||||||
|
<div class="hidden xl:block xl:col-span-6 h-screen"> <img src="./assets/images/authentication/auth-cover.png"
|
||||||
|
class="w-full h-full object-cover object-left" alt="login cover" /> </div>
|
||||||
|
<div class="xl:col-span-6 col-span-12 min-h-[100dvh] flex items-center justify-center px-6 bg-bodybg dark:bg-bodybg">
|
||||||
|
<div class="authentication authentication-basic w-full max-w-[420px]">
|
||||||
|
|
||||||
|
<div class="my-[2.5rem] flex justify-center">
|
||||||
|
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-logo">
|
||||||
|
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-dark">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box !mb-0">
|
||||||
|
<div class="box-body !p-[3rem]">
|
||||||
|
<p class="h5 font-semibold mb-2 text-center">Sign In</p>
|
||||||
|
<p class="mb-4 text-[#8c9097] dark:text-white/50 opacity-[0.7] font-normal text-center">
|
||||||
|
Welcome back !
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form [formGroup]="adminLoginForm" (ngSubmit)="login()">
|
||||||
|
@if (loginError) {
|
||||||
|
<div class="text-danger mb-3">{{ loginError }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-12 gap-y-4">
|
||||||
|
<div class="xl:col-span-12 col-span-12">
|
||||||
|
<label for="signin-username" class="form-label text-default">User Name</label>
|
||||||
|
<input type="text" class="form-control form-control-lg w-full !rounded-md"
|
||||||
|
id="signin-username" placeholder="user name" formControlName="username"
|
||||||
|
autocomplete="username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="xl:col-span-12 col-span-12 mb-2">
|
||||||
|
<label for="signin-password" class="form-label text-default block">Password</label>
|
||||||
|
|
||||||
|
<a routerLink="/authentication/reset-password/basic"
|
||||||
|
class="ltr:float-right rtl:float-left text-danger">
|
||||||
|
Forgot password ?
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<input class="form-control form-control-lg !rounded-s-md" id="signin-password"
|
||||||
|
placeholder="password" formControlName="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
[type]="visibilityMap['Angular'] ? 'text' : 'password'">
|
||||||
|
|
||||||
|
<button aria-label="button" class="ti-btn ti-btn-light !rounded-s-none !mb-0"
|
||||||
|
type="button" (click)="toggleVisibility('Angular')" id="button-addon2">
|
||||||
|
<i class="{{ iconMap['Angular'] }} align-middle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="form-check !ps-0">
|
||||||
|
<input class="form-check-input" type="checkbox" id="defaultCheck1"
|
||||||
|
formControlName="rememberMe">
|
||||||
|
|
||||||
|
<label class="form-check-label text-[#8c9097] dark:text-white/50 font-normal"
|
||||||
|
for="defaultCheck1">
|
||||||
|
Remember password ?
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="xl:col-span-12 col-span-12 grid mt-2">
|
||||||
|
<button type="submit" [disabled]="adminLoginForm.invalid || isSubmitting"
|
||||||
|
class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium inline-flex items-center justify-center gap-2">
|
||||||
|
|
||||||
|
<span [style.display]="isSubmitting ? '' : 'none'" class="ti-spinner text-white"
|
||||||
|
role="status" aria-label="loading"></span>
|
||||||
|
<span [style.display]="isSubmitting ? '' : 'none'">Signing In...</span>
|
||||||
|
<span [style.display]="isSubmitting ? 'none' : ''">Sign In</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+1
-1
@@ -12,4 +12,4 @@
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+26
-18
@@ -1,11 +1,11 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { ChangeDetectorRef, Component, inject } from '@angular/core';
|
||||||
import { FormBuilder, Validators } from '@angular/forms';
|
import { FormBuilder, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { finalize, switchMap, tap } from 'rxjs';
|
import { catchError, finalize, of, switchMap, tap } from 'rxjs';
|
||||||
import { AppContextService } from '../../core/services/app-context.service';
|
import { AppContextService } from '../../../../core/services/context/app-context.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -17,6 +17,7 @@ import { AppContextService } from '../../core/services/app-context.service';
|
|||||||
})
|
})
|
||||||
export class Login {
|
export class Login {
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly cdr = inject(ChangeDetectorRef);
|
||||||
private readonly fallbackRoute = '/dashboards/crm';
|
private readonly fallbackRoute = '/dashboards/crm';
|
||||||
|
|
||||||
public readonly adminLoginForm = this.formBuilder.nonNullable.group({
|
public readonly adminLoginForm = this.formBuilder.nonNullable.group({
|
||||||
@@ -39,7 +40,7 @@ export class Login {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private toastr: ToastrService
|
private toastr: ToastrService
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
login() {
|
login() {
|
||||||
this.loginError = '';
|
this.loginError = '';
|
||||||
@@ -49,40 +50,47 @@ export class Login {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if login is already in progress, prevent multiple submissions
|
|
||||||
if (this.isSubmitting) {
|
if (this.isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
let loginSucceeded = false;
|
|
||||||
|
|
||||||
const { username, password, rememberMe } = this.adminLoginForm.getRawValue();
|
const { username, password, rememberMe } = this.adminLoginForm.getRawValue();
|
||||||
|
|
||||||
this.authservice.login({ email: username, password }, !!rememberMe)
|
this.authservice.login({ email: username, password }, !!rememberMe)
|
||||||
.pipe(
|
.pipe(
|
||||||
tap(() => {
|
switchMap(() =>
|
||||||
loginSucceeded = true;
|
this.appContextService.ensureMenuInitialized(true).pipe(
|
||||||
}),
|
catchError(() => {
|
||||||
switchMap(() => this.appContextService.ensureMenuInitialized(true)),
|
this.authservice.logout();
|
||||||
|
this.loginError = 'Unable to load application context.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
if (this.loginError) {
|
||||||
|
this.toastr.error(this.loginError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void this.router.navigateByUrl(this.getSafeReturnUrl());
|
void this.router.navigateByUrl(this.getSafeReturnUrl());
|
||||||
this.toastr.success('Login successful', username);
|
this.toastr.success('Login successful', username);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (loginSucceeded) {
|
this.loginError =
|
||||||
this.authservice.logout();
|
error?.error?.detail ||
|
||||||
this.loginError = 'Unable to load application context.';
|
error?.error?.message ||
|
||||||
} else {
|
'Invalid email or password';
|
||||||
this.loginError = error?.error?.detail || 'Invalid details';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.toastr.error(this.loginError, username);
|
this.toastr.error(this.loginError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome back,
|
||||||
|
Json Taylor !</p>
|
||||||
|
<p class="font-normal text-[#8c9097] dark:text-white/50 text-[0.813rem]">Track your sales activity, leads and deals
|
||||||
|
here.</p>
|
||||||
|
</div>
|
||||||
|
<div class="btn-list md:mt-0 mt-2">
|
||||||
|
<button type="button"
|
||||||
|
class="ti-btn bg-primary text-white btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
|
||||||
|
<i class="ri-filter-3-fill inline-block"></i>Filters
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="ti-btn ti-btn-outline-secondary btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
|
||||||
|
<i class="ri-upload-cloud-line inline-block"></i>Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-12 gap-x-6">
|
||||||
|
<div class="2xl:col-span-9 xl:col-span-12 col-span-12">
|
||||||
|
<div class="grid grid-cols-12 gap-x-6">
|
||||||
|
<div class="2xl:col-span-4 xl:col-span-4 col-span-12">
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
|
||||||
|
<div class="box crm-highlight-card">
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-[1.125rem] text-white mb-2">Your target is incomplete</div>
|
||||||
|
<span class="block text-[0.75rem] text-white"><span class="opacity-[0.7]">You have
|
||||||
|
completed</span>
|
||||||
|
<span class="font-semibold text-warning"> 48%</span> <span class="opacity-[0.7]"> of the given
|
||||||
|
target, you can also check your status</span>.</span>
|
||||||
|
<span class="block font-semibold mt-[0.125rem]"><a class="text-white text-[0.813rem]"
|
||||||
|
href="javascript:void(0);"><u>Click
|
||||||
|
here</u></a></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<spk-apexcharts id="crm-main" [chartOptions]="YourtargetisincompleteChart" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header flex justify-between">
|
||||||
|
<div class="box-title">
|
||||||
|
Top Deals
|
||||||
|
</div>
|
||||||
|
<div class="hs-dropdown ti-dropdown">
|
||||||
|
<a aria-label="anchor" href="javascript:void(0);"
|
||||||
|
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="fe fe-more-vertical text-[0.8rem]"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Week</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Month</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Year</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<ul class="list-none crm-top-deals mb-0">
|
||||||
|
@for (deal of topDeals; track $index) {
|
||||||
|
<li [class]="!$last ?'mb-[0.9rem]':''">
|
||||||
|
<div class="flex items-start flex-wrap">
|
||||||
|
|
||||||
|
<div class="me-2">
|
||||||
|
<span class="inline-flex items-center justify-center">
|
||||||
|
@if (deal.avatarImg) {
|
||||||
|
<img [src]="deal.avatarImg" [alt]="deal.name"
|
||||||
|
class="w-[1.75rem] h-[1.75rem] leading-[1.75rem] rounded-full">
|
||||||
|
} @else {
|
||||||
|
<span
|
||||||
|
[class]="`inline-flex items-center justify-center !w-[1.75rem] !h-[1.75rem] leading-[1.75rem] text-[0.65rem] rounded-full font-semibold bg-${deal.color}/10 text-${deal.color}`">
|
||||||
|
{{ deal.initials }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow">
|
||||||
|
<p class="font-semibold mb-[1.4px] text-[0.813rem]">
|
||||||
|
{{ deal.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-[#8c9097] dark:text-white/50 text-[0.75rem]">
|
||||||
|
{{ deal.email }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-semibold text-[0.9375rem]">
|
||||||
|
{{ deal.amount }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header justify-between">
|
||||||
|
<div class="box-title">Profit Earned</div>
|
||||||
|
<div class="hs-dropdown ti-dropdown">
|
||||||
|
<a href="javascript:void(0);" class="px-2 font-normal text-[0.75rem] text-[#8c9097] dark:text-white/50"
|
||||||
|
aria-expanded="false">
|
||||||
|
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Today</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">This Week</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Last Week</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-body !py-0 !ps-0">
|
||||||
|
<spk-apexcharts [chartOptions]="profitEarnedChart" id="crm-profits-earned" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="2xl:col-span-8 xl:col-span-8 col-span-12">
|
||||||
|
<div class="grid grid-cols-12 gap-x-6">
|
||||||
|
@for (item of crmCards; track $index) {
|
||||||
|
<div class="2xl:col-span-6 xl:col-span-6 col-span-12">
|
||||||
|
<div class="box overflow-hidden">
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="flex items-top justify-between">
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
[class]="`!text-[0.8rem] !w-[2.5rem] !h-[2.5rem] !leading-[2.5rem] !rounded-full inline-flex items-center justify-center bg-${item.color}`">
|
||||||
|
<i [class]="`${item.icon} text-[1rem] text-white`"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow ms-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p class="text-[#8c9097] dark:text-white/50 text-[0.813rem] mb-0">{{item.title}}</p>
|
||||||
|
<h4 class="font-semibold text-[1.5rem] !mb-2 ">{{item.number}}</h4>
|
||||||
|
</div>
|
||||||
|
<spk-apexcharts [id]="item.chartId" [chartOptions]="item.chartoptions" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between !mt-1">
|
||||||
|
<div>
|
||||||
|
<a [class]="`text-${item.color} text-[0.813rem]`" href="javascript:void(0);">View All<i
|
||||||
|
class="ti ti-arrow-narrow-right ms-2 font-semibold inline-block"></i></a>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<p [class]="`mb-0 ${item.percentageColor} text-[0.813rem] font-semibold`">{{item.percentage}}
|
||||||
|
</p>
|
||||||
|
<p class="text-[#8c9097] dark:text-white/50 opacity-[0.7] text-[0.6875rem]">this month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header !gap-0 !m-0 justify-between">
|
||||||
|
<div class="box-title">
|
||||||
|
Revenue Analytics
|
||||||
|
</div>
|
||||||
|
<div class="hs-dropdown ti-dropdown">
|
||||||
|
<a href="javascript:void(0);"
|
||||||
|
class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50" aria-expanded="false">
|
||||||
|
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Today</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">This Week</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Last Week</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-body !py-5">
|
||||||
|
<spk-apexcharts [id]="'crm-revenue-analytics'" [chartOptions]="RevenueAnalytics" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
|
||||||
|
<div class="box custom-card">
|
||||||
|
<div class="box-header justify-between">
|
||||||
|
<div class="box-title">
|
||||||
|
Deals Statistics
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<input class="ti-form-control form-control-sm" type="text" placeholder="Search Here"
|
||||||
|
aria-label=".form-control-sm example">
|
||||||
|
</div>
|
||||||
|
<div class="hs-dropdown ti-dropdown">
|
||||||
|
<a href="javascript:void(0);"
|
||||||
|
class="ti-btn ti-btn-primary !bg-primary !text-white !py-1 !px-2 !text-[0.75rem] !m-0 !gap-0 !font-medium"
|
||||||
|
aria-expanded="false">
|
||||||
|
Sort By<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">New</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Popular</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Relevant</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<spk-reusable-tables [columns]="SalesRepData.columns" [showCheckbox]="true"
|
||||||
|
tableClass="table min-w-full whitespace-nowrap table-hover border table-bordered">
|
||||||
|
|
||||||
|
@for (row of SalesRepData.rows; track row.id) {
|
||||||
|
<tr class="border !border-defaultborder border-solid hover:bg-gray-100 dark:!border-defaultborder/10 dark:hover:!bg-light">
|
||||||
|
|
||||||
|
<td scope="row" class="">
|
||||||
|
<input class="form-check-input" type="checkbox" [checked]="row.checked" aria-label="...">
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center font-semibold">
|
||||||
|
<span class="!me-2 inline-flex justify-center items-center">
|
||||||
|
<img [src]="row.img" alt="img" class="w-[1.75rem] h-[1.75rem] rounded-full">
|
||||||
|
</span>
|
||||||
|
{{ row.name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{{ row.category }}</td>
|
||||||
|
|
||||||
|
<td>{{ row.mail }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
[class]="'inline-flex text-' + row.locationClass + ' !py-[0.15rem] !px-[0.45rem] rounded-sm !font-semibold !text-[0.75em] bg-' + row.locationClass + '/10'">
|
||||||
|
{{ row.location }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{{ row.date }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-row items-center !gap-2 text-[0.9375rem]">
|
||||||
|
<a aria-label="anchor" href="javascript:void(0);"
|
||||||
|
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-success/10 text-success hover:bg-success hover:!text-white hover:!border-success">
|
||||||
|
<i class="ri-download-2-line"></i>
|
||||||
|
</a>
|
||||||
|
<a aria-label="anchor" href="javascript:void(0);"
|
||||||
|
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-primary/10 text-primary hover:bg-primary hover:!text-white hover:!border-primary">
|
||||||
|
<i class="ri-edit-line"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</spk-reusable-tables>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-footer">
|
||||||
|
<div class="sm:flex items-center">
|
||||||
|
<div class="text-defaulttextcolor dark:text-defaulttextcolor/70">
|
||||||
|
Showing 5 Entries <i class="bi bi-arrow-right ms-2 font-semibold"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<nav aria-label="Page navigation" class="pagination-style-4">
|
||||||
|
<ul class="ti-pagination mb-0">
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a class="page-link" href="javascript:void(0);">
|
||||||
|
Prev
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item"><a class="page-link active" href="javascript:void(0);">1</a></li>
|
||||||
|
<li class="page-item"><a class="page-link" href="javascript:void(0);">2</a></li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link !text-primary" href="javascript:void(0);">
|
||||||
|
next
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="2xl:col-span-3 xl:col-span-12 col-span-12">
|
||||||
|
<div class="grid grid-cols-12 gap-x-6">
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header justify-between">
|
||||||
|
<div class="box-title">
|
||||||
|
Leads By Source
|
||||||
|
</div>
|
||||||
|
<div class="hs-dropdown ti-dropdown">
|
||||||
|
<a aria-label="anchor" href="javascript:void(0);"
|
||||||
|
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] ! !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="fe fe-more-vertical text-[0.8rem]"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Week</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Month</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Year</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-body !p-6 mt-4">
|
||||||
|
|
||||||
|
<div class="leads-source-chart flex items-center justify-center">
|
||||||
|
|
||||||
|
<spk-chartjs [id]="'leads-source'" [class]="'chartjs-chart w-full'" [height]="250" [chartjs]="LeadsBySourceChart" />
|
||||||
|
|
||||||
|
<div class="lead-source-value ">
|
||||||
|
<span class="block text-[0.875rem] ">Total</span>
|
||||||
|
<span class="block text-[1.5625rem] font-bold">4,145</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 mt-4 border-t border-dashed border-defaultborder dark:border-defaultborder/10">
|
||||||
|
|
||||||
|
@for (item of deviceLeads; track item.label) {
|
||||||
|
<div class="col !p-0">
|
||||||
|
<div [class]="item.containerClass">
|
||||||
|
|
||||||
|
<span class="text-[#8c9097] dark:text-white/50 text-[0.75rem] mb-1 crm-lead-legend inline-block"
|
||||||
|
[class]="item.legendClass">
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-[1rem] font-semibold">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header justify-between">
|
||||||
|
<div class="box-title">
|
||||||
|
Deals Status
|
||||||
|
</div>
|
||||||
|
<div class="hs-dropdown ti-dropdown">
|
||||||
|
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
|
||||||
|
aria-expanded="false">
|
||||||
|
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Today</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">This Week</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Last Week</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="flex items-center mb-[0.8rem]">
|
||||||
|
<h4 class="font-bold mb-0 text-[1.5rem] ">4,289</h4>
|
||||||
|
<div class="ms-2">
|
||||||
|
<span
|
||||||
|
class="py-[0.18rem] px-[0.45rem] rounded-sm text-success !font-medium !text-[0.75em] bg-success/10">1.02<i
|
||||||
|
class="ri-arrow-up-s-fill align-mmiddle ms-1"></i></span>
|
||||||
|
<span class="text-[#8c9097] dark:text-white/50 text-[0.813rem] ms-1">compared to last week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full h-[0.3125rem] mb-6 rounded-full overflow-hidden">
|
||||||
|
<div class="flex flex-col justify-center rounded-s-[0.625rem] overflow-hidden bg-primary w-[21%]"
|
||||||
|
aria-valuenow="21" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-info w-[26%]" aria-valuenow="26"
|
||||||
|
aria-valuemin="0" aria-valuemax="100">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-warning w-[35%]"
|
||||||
|
aria-valuenow="35" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center rounded-e-[0.625rem] overflow-hidden bg-success w-[18%]"
|
||||||
|
aria-valuenow="18" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="list-none mb-0 pt-2 crm-deals-status">
|
||||||
|
|
||||||
|
@for (deal of dealStats; track deal.title) {
|
||||||
|
<li [class]="deal.liClass">
|
||||||
|
<div class="flex items-center text-[0.813rem] justify-between">
|
||||||
|
<div>{{ deal.title }}</div>
|
||||||
|
|
||||||
|
<div class="text-[0.75rem] text-[#8c9097] dark:text-white/50">
|
||||||
|
{{ deal.count }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header justify-between">
|
||||||
|
<div class="box-title">
|
||||||
|
Recent Activity
|
||||||
|
</div>
|
||||||
|
<div class="hs-dropdown ti-dropdown">
|
||||||
|
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
|
||||||
|
aria-expanded="false">
|
||||||
|
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Today</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">This Week</a></li>
|
||||||
|
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
|
||||||
|
href="javascript:void(0);">Last Week</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<div>
|
||||||
|
<ul class="list-none mb-0 crm-recent-activity">
|
||||||
|
@for (item of recentActivity; track $index) {
|
||||||
|
<li class="crm-recent-activity-content text-defaultsize">
|
||||||
|
<div class="flex items-start">
|
||||||
|
|
||||||
|
<div class="me-4">
|
||||||
|
<span
|
||||||
|
class="w-[1.25rem] h-[1.25rem] inline-flex items-center justify-center font-medium leading-[1.25rem] text-[0.65rem] rounded-full"
|
||||||
|
[class]="item.statusClass">
|
||||||
|
<i class="bi bi-circle-fill text-[0.5rem]"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="crm-timeline-content">
|
||||||
|
<span [class.font-semibold]="item.boldMain">{{ item.mainText }}</span>
|
||||||
|
|
||||||
|
@if (item.linkText) {
|
||||||
|
<a href="javascript:void(0);" [class]="item.linkClass">{{ item.linkText }}</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.boldText) {
|
||||||
|
<span class="font-semibold">{{ item.boldText }}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.highlightText) {
|
||||||
|
<span [class]="item.highlightClass">{{ item.highlightText }}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.tagText) {
|
||||||
|
<span [class]="item.tagClass">{{ item.tagText }}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.afterText) {
|
||||||
|
<span>{{ item.afterText }}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.showAddIcon) {
|
||||||
|
<span
|
||||||
|
class="w-[1.25rem] h-[1.25rem] leading-[1.25rem] text-[0.65rem] inline-flex items-center justify-center font-medium bg-purple/10 rounded-full ms-1">
|
||||||
|
<i class="ri-add-fill text-purple text-[0.75rem]"></i>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.showCheckIcon) {
|
||||||
|
<i class="ri-checkbox-circle-line text-success text-[1rem] align-middle ms-1"></i>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.subText) {
|
||||||
|
<span class="block text-[0.75rem] text-[#8c9097] dark:text-white/50">
|
||||||
|
{{ item.subText }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow text-end">
|
||||||
|
<span class="block text-[#8c9097] dark:text-white/50 text-[0.6875rem] opacity-[0.7]">
|
||||||
|
{{ item.time }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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'
|
||||||
|
},
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="page error-bg dark:!bg-bodybg" id="particles-js">
|
||||||
|
<!-- Start::error-page -->
|
||||||
|
<spk-particles id="tsparticles" [options]="particlesOptions" />
|
||||||
|
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="container text-defaulttextcolo dark:text-defaulttextcolor/70r text-defaultsize">
|
||||||
|
<div class="text-center p-5 my-auto">
|
||||||
|
<div class="flex items-center justify-center h-full !text-defaulttextcolor">
|
||||||
|
<div class="xl:col-span-3"></div>
|
||||||
|
<div class="xl:col-span-6 col-span-12">
|
||||||
|
<p class="error-text sm:mb-0 mb-2">404</p>
|
||||||
|
<p class="text-[1.125rem] font-semibold mb-4">Oops 😭,The page you are looking for is not available.</p>
|
||||||
|
<div class="flex justify-center items-center mb-[3rem]">
|
||||||
|
<div class="xl:col-span-6 w-[50%]">
|
||||||
|
<p class="mb-0 opacity-[0.7]">We are sorry for the inconvenience,The page you are trying to access has
|
||||||
|
been removed or never been existed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a routerLink="/dashboards/crm" class="ti-btn bg-primary text-white font-semibold"><i
|
||||||
|
class="ri-arrow-left-line align-middle inline-block"></i>BACK TO HOME</a>
|
||||||
|
</div>
|
||||||
|
<div class="xl:col-span-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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<DataTableResult<CityDto>> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (countryId) params = params.set('countryId', countryId);
|
||||||
|
if (stateId) params = params.set('stateId', stateId);
|
||||||
|
return this.http.post<DataTableResult<CityDto>>(
|
||||||
|
CITY_ENDPOINTS.dataTable,
|
||||||
|
query,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCity(request: CreateCityRequest): Observable<CityDto> {
|
||||||
|
return this.http.post<CityDto>(CITY_ENDPOINTS.create, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCity(id: string, request: UpdateCityRequest): Observable<CityDto> {
|
||||||
|
return this.http.put<CityDto>(CITY_ENDPOINTS.update(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCityById(id: string): Observable<CityDto> {
|
||||||
|
return this.http.get<CityDto>(CITY_ENDPOINTS.getById(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<!-- Start::row-1 -->
|
||||||
|
<div class="grid grid-cols-12 gap-6">
|
||||||
|
<div class="xl:col-span-12 col-span-12">
|
||||||
|
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||||
|
<form [formGroup]="filterForm" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||||
|
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="city-country-filter"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Search country"
|
||||||
|
[searchFn]="searchCountries"
|
||||||
|
[displayWith]="displayCountry"
|
||||||
|
[valueWith]="countryValue"
|
||||||
|
[selectedItem]="selectedCountry()"
|
||||||
|
[minSearchLength]="1"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
|
[hideValidation]="true"
|
||||||
|
wrapperClass="!mb-0 w-full"
|
||||||
|
(itemSelected)="onFilterCountrySelected($event)"
|
||||||
|
(cleared)="onFilterCountryCleared()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="stateId"
|
||||||
|
inputId="city-state-filter"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="State"
|
||||||
|
[placeholder]="filterStatePlaceholder()"
|
||||||
|
[searchFn]="searchFilterStates"
|
||||||
|
[displayWith]="displayState"
|
||||||
|
[valueWith]="stateValue"
|
||||||
|
[selectedItem]="selectedFilterState()"
|
||||||
|
[minSearchLength]="1"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
|
[disabled]="!selectedCountryId()"
|
||||||
|
[hideValidation]="true"
|
||||||
|
wrapperClass="!mb-0 w-full"
|
||||||
|
(itemSelected)="onFilterStateSelected($event)"
|
||||||
|
(cleared)="onFilterStateCleared()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 sm:col-span-2 lg:col-span-1">
|
||||||
|
<button type="button" class="ti-btn ti-btn-sm ti-btn-primary-full !mb-0 flex min-h-8 w-full items-center justify-center gap-1.5 !px-3 md:!w-auto" (click)="applyCityFilters()">
|
||||||
|
<i class="ti ti-filter" aria-hidden="true"></i>
|
||||||
|
<span>Filter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</app-filter-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- End::row-1 -->
|
||||||
|
|
||||||
|
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
|
||||||
|
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||||
|
tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
|
||||||
|
[showAddButton]="true" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||||
|
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
|
||||||
|
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||||
|
(actionClicked)="onActionClick($event)" toolTip="Add City" />
|
||||||
|
|
||||||
|
<modal [open]="showCityModal()" [title]="modalTitle()" size="lg"
|
||||||
|
[submitAction]="modalMode() === 'create' ? 'save' : 'update'" [submitLabel]="submitLabel()"
|
||||||
|
[loadingLabel]="loadingLabel()" [loading]="saving()"
|
||||||
|
(closed)="closeCityModal()" (submitted)="saveCity()">
|
||||||
|
<form [formGroup]="cityForm" (ngSubmit)="saveCity()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="city-country"
|
||||||
|
variant="floating"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Search"
|
||||||
|
[searchFn]="searchCountries"
|
||||||
|
[displayWith]="displayCountry"
|
||||||
|
[valueWith]="countryValue"
|
||||||
|
[resolveValueFn]="resolveCountry"
|
||||||
|
[selectedItem]="selectedFormCountry()"
|
||||||
|
[minSearchLength]="1"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="modalMode() !== 'create'"
|
||||||
|
[validationMessages]="{ required: 'Country is required.' }"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
wrapperClass="w-full"
|
||||||
|
(itemSelected)="onFormCountrySelected($event)"
|
||||||
|
(cleared)="onFormCountryCleared()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="stateId"
|
||||||
|
inputId="city-state"
|
||||||
|
variant="floating"
|
||||||
|
label="State"
|
||||||
|
[placeholder]="formStatePlaceholder()"
|
||||||
|
[help]="'Select a country first'"
|
||||||
|
[searchFn]="searchFormStates"
|
||||||
|
[displayWith]="displayState"
|
||||||
|
[valueWith]="stateValue"
|
||||||
|
[resolveValueFn]="resolveState"
|
||||||
|
[selectedItem]="selectedFormState()"
|
||||||
|
[minSearchLength]="1"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="modalMode() !== 'create'"
|
||||||
|
[validationMessages]="{ required: 'State is required.' }"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
wrapperClass="w-full"
|
||||||
|
(itemSelected)="onFormStateSelected($event)"
|
||||||
|
(cleared)="onFormStateCleared()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="name" inputId="city-name" label="City Name" variant="floating"
|
||||||
|
placeholder="Name" autocomplete="off" [required]="true"
|
||||||
|
[maxLength]="150" [validationMessages]="{
|
||||||
|
required: 'City Name is required.',
|
||||||
|
maxlength: 'City Name cannot exceed 150 characters.'
|
||||||
|
}" [submitAttempted]="submitAttempted()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="code" inputId="city-code" label="City Code" variant="floating"
|
||||||
|
placeholder="Code" autocomplete="off" [required]="true"
|
||||||
|
[maxLength]="16" [validationMessages]="{
|
||||||
|
required: 'City Code is required.',
|
||||||
|
maxlength: 'City Code cannot exceed 16 characters.',
|
||||||
|
pattern: 'City Code can contain letters, numbers, hyphens, and underscores only.'
|
||||||
|
}" [submitAttempted]="submitAttempted()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="timezoneId"
|
||||||
|
inputId="city-timezone"
|
||||||
|
variant="floating"
|
||||||
|
label="Timezone"
|
||||||
|
placeholder="Search"
|
||||||
|
[searchFn]="searchTimezones"
|
||||||
|
[displayWith]="displayTimezone"
|
||||||
|
[valueWith]="timezoneValue"
|
||||||
|
[resolveValueFn]="resolveTimezone"
|
||||||
|
[minSearchLength]="2"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="10"
|
||||||
|
emptyText="No timezones found"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modal>
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
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',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
|
||||||
|
templateUrl: './city-list.html',
|
||||||
|
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<HTMLElement>>(ElementRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly cityQueryRequests$ = new Subject<DataTableQuery>();
|
||||||
|
|
||||||
|
readonly queryState = new DataTableQueryState();
|
||||||
|
readonly cities = signal<CityTableRow[]>([]);
|
||||||
|
readonly selectedCountryId = signal<string | null>(null);
|
||||||
|
readonly selectedStateId = signal<string | null>(null);
|
||||||
|
readonly appliedCountryId = signal<string | null>(null);
|
||||||
|
readonly appliedStateId = signal<string | null>(null);
|
||||||
|
readonly selectedCountry = signal<CountryLookupDto | null>(null);
|
||||||
|
readonly selectedFilterState = signal<StateLookupDto | null>(null);
|
||||||
|
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||||
|
readonly selectedFormState = signal<StateLookupDto | null>(null);
|
||||||
|
readonly totalRecords = signal(0);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly showCityModal = signal(false);
|
||||||
|
readonly modalMode = signal<CityModalMode>('create');
|
||||||
|
readonly selectedCity = signal<CityDto | null>(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<string | null>(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> =
|
||||||
|
(term, limit) => this.timezoneApi.autocomplete(term, limit);
|
||||||
|
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> =
|
||||||
|
(term, limit) => this.countryApi.autocomplete(term, limit).pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.toastr.error('Unable to load countries.');
|
||||||
|
return of<CountryLookupDto[]>([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
readonly searchFilterStates: AutocompleteSearchFn<StateLookupDto> =
|
||||||
|
(term, limit) => {
|
||||||
|
const countryId = this.selectedCountryId();
|
||||||
|
if (!countryId) return of<StateLookupDto[]>([]);
|
||||||
|
return this.stateApi.autocomplete(countryId, term, limit).pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.toastr.error('Unable to load states.');
|
||||||
|
return of<StateLookupDto[]>([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
readonly searchFormStates: AutocompleteSearchFn<StateLookupDto> =
|
||||||
|
(term, limit) => {
|
||||||
|
const countryId = this.cityForm.controls.countryId.value;
|
||||||
|
if (!countryId) return of<StateLookupDto[]>([]);
|
||||||
|
return this.stateApi.autocomplete(countryId, term, limit).pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.toastr.error('Unable to load states.');
|
||||||
|
return of<StateLookupDto[]>([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
|
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
|
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||||
|
value => this.countryApi.getCountryById(value).pipe(
|
||||||
|
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||||
|
);
|
||||||
|
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
|
||||||
|
readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
|
||||||
|
readonly resolveState: AutocompleteResolveValueFn<StateLookupDto, string> =
|
||||||
|
value => this.stateApi.getStateById(value).pipe(
|
||||||
|
map(state => ({ id: state.id, name: state.name, code: state.code ?? '' }))
|
||||||
|
);
|
||||||
|
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> =
|
||||||
|
timezone => `${timezone.ianaId} — ${timezone.displayName}`;
|
||||||
|
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
|
||||||
|
timezone => timezone.id;
|
||||||
|
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
|
||||||
|
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<DataTableColumn<CityTableRow>[]>([
|
||||||
|
{ 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<DataTableAction<CityTableRow>[]>([
|
||||||
|
// { 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<CityTableRow>): 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<HTMLElement>(
|
||||||
|
'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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<DataTableResult<CountryDto>> {
|
||||||
|
return this.http.post<DataTableResult<CountryDto>>(COUNTRY_ENDPOINTS.dataTable, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCountry(request: CreateCountryRequest): Observable<CountryDto> {
|
||||||
|
return this.http.post<CountryDto>(COUNTRY_ENDPOINTS.create, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCountry(id: string, request: UpdateCountryRequest): Observable<CountryDto> {
|
||||||
|
return this.http.put<CountryDto>(COUNTRY_ENDPOINTS.update(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCountryById(id: string): Observable<CountryDto> {
|
||||||
|
return this.http.get<CountryDto>(COUNTRY_ENDPOINTS.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
autocomplete(term = '', limit = 50): Observable<CountryLookupDto[]> {
|
||||||
|
return this.http.get<CountryLookupDto[]>(COUNTRY_ENDPOINTS.autocomplete, {
|
||||||
|
params: new HttpParams().set('term', term).set('limit', limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<app-data-table [columns]="columns()" [rows]="countries()" [actions]="actions()"
|
||||||
|
(addClicked)="onAddCountry()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
||||||
|
[pageSize]="queryState.pageSize()" tableTitle="Countries" buttonTitle="Add"
|
||||||
|
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search countries..." [searchDebounceTime]="300"
|
||||||
|
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||||
|
(actionClicked)="onActionClick($event)" toolTip="Add Country">
|
||||||
|
<ng-template appDataTableCell="name" let-row let-value="value">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if (getFlagUrl(row.iso2); as flagUrl) {
|
||||||
|
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
|
||||||
|
(error)="onFlagError($event)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</app-data-table>
|
||||||
|
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Delete Country"
|
||||||
|
text="Do you really want to delete this country?"
|
||||||
|
confirmButtonText="Delete"
|
||||||
|
cancelButtonText="Cancel"
|
||||||
|
(confirmed)="onDeleteConfirmed()"
|
||||||
|
(cancelled)="onDeleteCancelled()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<modal [open]="showCountryModal()" [title]="countryModalTitle()" size="md"
|
||||||
|
[submitAction]="countrySubmitAction()" [submitLabel]="countrySubmitLabel()" [loadingLabel]="countryLoadingLabel()"
|
||||||
|
[loading]="saving()" (closed)="closeCountryModal()"
|
||||||
|
(submitted)="saveCountry()">
|
||||||
|
<form [formGroup]="countryForm" (ngSubmit)="saveCountry()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-6">
|
||||||
|
<app-form-input formControlName="name" inputId="country-name" label="Country Name" variant="floating"
|
||||||
|
placeholder="Name" autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
|
||||||
|
required: 'Country Name is required.',
|
||||||
|
maxlength: 'Country Name cannot exceed 150 characters.'
|
||||||
|
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="defaultCurrencyId"
|
||||||
|
inputId="country-default-currency-id"
|
||||||
|
variant="floating"
|
||||||
|
label="Currency"
|
||||||
|
placeholder="e.g.: USD"
|
||||||
|
[searchFn]="searchCurrencies"
|
||||||
|
[displayWith]="displayCurrency"
|
||||||
|
[valueWith]="currencyValue"
|
||||||
|
[selectedItem]="selectedCurrency()"
|
||||||
|
[minSearchLength]="1"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="10"
|
||||||
|
[clearable]="true"
|
||||||
|
emptyText="No currencies found"
|
||||||
|
typeToSearchText="Type to search currencies"
|
||||||
|
[submitAttempted]="countrySubmitAttempted()"
|
||||||
|
wrapperClass="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="iso2" inputId="country-iso2" label="ISO2 Code" placeholder="e.g.: IN" variant="floating"
|
||||||
|
autocomplete="off" [required]="true" [minLength]="2" [maxLength]="2"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'ISO2 Code is required.',
|
||||||
|
minlength: 'ISO2 Code must contain exactly 2 letters.',
|
||||||
|
maxlength: 'ISO2 Code must contain exactly 2 letters.',
|
||||||
|
pattern: 'ISO2 Code can contain letters only.'
|
||||||
|
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="iso3" inputId="country-iso3" label="ISO3 Code" placeholder="e.g.: IND" variant="floating"
|
||||||
|
autocomplete="off" [required]="true" [minLength]="3" [maxLength]="3"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'ISO3 Code is required.',
|
||||||
|
minlength: 'ISO3 Code must contain exactly 3 letters.',
|
||||||
|
maxlength: 'ISO3 Code must contain exactly 3 letters.',
|
||||||
|
pattern: 'ISO3 Code can contain letters only.'
|
||||||
|
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="phoneCode" inputId="country-phone-code" label="Phone Code" type="tel" variant="floating"
|
||||||
|
inputMode="tel" placeholder="e.g.: +91" autocomplete="off" [maxLength]="16" [validationMessages]="{
|
||||||
|
maxlength: 'Phone Code cannot exceed 16 characters.',
|
||||||
|
pattern: 'Phone Code can contain an optional plus sign, digits, hyphens, and spaces only.'
|
||||||
|
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modal>
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
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 {
|
||||||
|
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 { 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, FormInput, Autocomplete, ConfirmDialog],
|
||||||
|
templateUrl: './country-list.html',
|
||||||
|
styleUrl: './country-list.scss',
|
||||||
|
})
|
||||||
|
export class CountryList {
|
||||||
|
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<HTMLElement>>(ElementRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly countryQueryRequests$ = new Subject<DataTableQuery>();
|
||||||
|
|
||||||
|
readonly queryState = new DataTableQueryState();
|
||||||
|
|
||||||
|
readonly countries = signal<CountryTableRow[]>([]);
|
||||||
|
readonly totalRecords = signal(0);
|
||||||
|
readonly filteredRecords = signal(0);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
|
||||||
|
readonly showCountryModal = signal(false);
|
||||||
|
readonly countryModalMode = signal<CountryModalMode>('create');
|
||||||
|
readonly selectedCountryId = signal<string | null>(null);
|
||||||
|
readonly selectedCountry = signal<CountryDto | null>(null);
|
||||||
|
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
|
||||||
|
readonly countrySubmitAttempted = signal(false);
|
||||||
|
readonly pendingDeleteCountry = signal<CountryDto | null>(null);
|
||||||
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
|
|
||||||
|
readonly countryForm = this.formBuilder.nonNullable.group({
|
||||||
|
name: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.maxLength(150)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
iso2: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.pattern(/^[A-Za-z]{2}$/)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
iso3: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.pattern(/^[A-Za-z]{3}$/)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
phoneCode: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.maxLength(16),
|
||||||
|
Validators.pattern(/^\+?[0-9\- ]{1,15}$/)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultCurrencyId: this.formBuilder.control<string | null>(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> =
|
||||||
|
(term, limit) => this.currencyApi.autocomplete(term, limit);
|
||||||
|
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => {
|
||||||
|
const baseLabel = [currency.code, currency.name].filter(Boolean).join(' - ');
|
||||||
|
|
||||||
|
return currency.symbol?.trim()
|
||||||
|
? `${baseLabel} (${currency.symbol})`
|
||||||
|
: baseLabel;
|
||||||
|
};
|
||||||
|
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = 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<DataTableColumn<CountryTableRow>[]>([
|
||||||
|
{ 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 =>
|
||||||
|
value === true
|
||||||
|
? 'badge bg-success/10 text-success'
|
||||||
|
: 'badge bg-danger/10 text-danger',
|
||||||
|
formatter: value => value ? 'Active' : 'Inactive'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly actions = signal<DataTableAction<CountryTableRow>[]>([
|
||||||
|
{
|
||||||
|
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.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.countryQueryRequests$.next(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearch(value: string): void {
|
||||||
|
const query = this.queryState.setSearch(value.trim());
|
||||||
|
this.loadCountries(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event: DataTablePageEvent): void {
|
||||||
|
const query = this.queryState.setPage(event);
|
||||||
|
this.loadCountries(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSortChange(event: DataTableSortEvent): void {
|
||||||
|
const query = this.queryState.setSort(event);
|
||||||
|
this.loadCountries(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRefresh(): void {
|
||||||
|
const currentQuery = this.queryState.getQuery();
|
||||||
|
|
||||||
|
this.loadCountries({
|
||||||
|
...currentQuery,
|
||||||
|
draw: currentQuery.draw + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onReset(): void {
|
||||||
|
const query = this.queryState.reset();
|
||||||
|
this.loadCountries(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteConfirmed(): void {
|
||||||
|
const country = this.pendingDeleteCountry();
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingDeleteCountry.set(null);
|
||||||
|
this.deleteCountry(country);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteCancelled(): void {
|
||||||
|
this.pendingDeleteCountry.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionClick(event: DataTableActionEvent<CountryTableRow>): void {
|
||||||
|
const country = this.toCountryDto(event.row);
|
||||||
|
|
||||||
|
switch (event.action.type) {
|
||||||
|
case 'view':
|
||||||
|
this.viewCountry(country);
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
this.openEditCountry(country);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
this.requestDeleteCountry(country);
|
||||||
|
break;
|
||||||
|
case 'activate':
|
||||||
|
this.activateCountry(country);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '',
|
||||||
|
iso2: '',
|
||||||
|
iso3: '',
|
||||||
|
phoneCode: '',
|
||||||
|
defaultCurrencyId: null
|
||||||
|
});
|
||||||
|
this.resetCountryFormState();
|
||||||
|
|
||||||
|
this.showCountryModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCountryModal(): void {
|
||||||
|
if (this.saving()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryId = this.selectedCountryId();
|
||||||
|
|
||||||
|
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.countryApi
|
||||||
|
.getCountryById(country.id)
|
||||||
|
.pipe(
|
||||||
|
switchMap(countryDetails => {
|
||||||
|
const currencyId = countryDetails.defaultCurrencyId;
|
||||||
|
|
||||||
|
if (!currencyId) {
|
||||||
|
return of({ countryDetails, currency: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
this.showCountryModal.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getFlagUrl(iso2: string | null | undefined): string {
|
||||||
|
const code = iso2?.trim().toLowerCase();
|
||||||
|
|
||||||
|
return code && /^[a-z]{2}$/.test(code)
|
||||||
|
? `https://flagcdn.com/24x18/${code}.png`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onFlagError(event: Event): void {
|
||||||
|
const image = event.target as HTMLImageElement;
|
||||||
|
image.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLElement>(
|
||||||
|
'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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CountryService } from './data-access/country.service';
|
||||||
|
export type { CountryLookupDto } from './models/country.model';
|
||||||
@@ -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;
|
||||||
@@ -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<DataTableResult<CurrencyDto>> {
|
||||||
|
return this.http.post<DataTableResult<CurrencyDto>>(CURRENCY_ENDPOINTS.dataTable, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCurrency(request: CreateCurrencyRequest): Observable<CurrencyDto> {
|
||||||
|
return this.http.post<CurrencyDto>(CURRENCY_ENDPOINTS.create, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCurrency(id: string, request: UpdateCurrencyRequest): Observable<CurrencyDto> {
|
||||||
|
return this.http.put<CurrencyDto>(CURRENCY_ENDPOINTS.update(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrencyById(id: string): Observable<CurrencyDto> {
|
||||||
|
return this.http.get<CurrencyDto>(CURRENCY_ENDPOINTS.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
autocomplete(term: string | null, limit = 10): Observable<readonly CurrencyLookupDto[]> {
|
||||||
|
let params = new HttpParams().set('limit', limit);
|
||||||
|
const normalizedTerm = term?.trim();
|
||||||
|
|
||||||
|
if (normalizedTerm) {
|
||||||
|
params = params.set('term', normalizedTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<readonly CurrencyLookupDto[]>(CURRENCY_ENDPOINTS.autocomplete, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<app-data-table [columns]="columns()" [rows]="currencies()" [actions]="actions()" [totalRecords]="totalRecords()"
|
||||||
|
[pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||||
|
tableTitle="Currency Management" buttonTitle="Add" [showSearch]="true" [showAddButton]="true"
|
||||||
|
searchPlaceholder="Search currencies..." [searchDebounceTime]="300" toolTip="Add Currency"
|
||||||
|
(addClicked)="onAddCurrency()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||||
|
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)">
|
||||||
|
<ng-template appDataTableCell="iso2" let-row>
|
||||||
|
@if (visibleCountries(row); as countries) {
|
||||||
|
@if (countries.length > 0) {
|
||||||
|
<div class="inline-flex max-w-full items-center gap-2 whitespace-nowrap" (click)="$event.stopPropagation()">
|
||||||
|
@for (country of countries; track country.iso2) {
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm bg-light/60 px-1.5 py-1 text-[0.6875rem] font-semibold text-defaulttextcolor dark:bg-white/10 dark:text-white/80">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-[18px] w-6 items-center justify-center rounded-sm bg-light text-textmuted dark:bg-black/20">
|
||||||
|
@if (getFlagUrl(country.iso2); as flagUrl) {
|
||||||
|
<img [src]="flagUrl" [alt]="country.iso2 + ' flag'" loading="lazy"
|
||||||
|
class="h-[18px] w-6 rounded-sm object-contain" (error)="onFlagError($event)" />
|
||||||
|
<i class="ti ti-flag hidden text-[0.6875rem]" aria-hidden="true"></i>
|
||||||
|
} @else {
|
||||||
|
<i class="ti ti-flag text-[0.6875rem]" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>{{ country.iso2 }}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (remainingCountryCount(row); as remaining) {
|
||||||
|
<button type="button" cdkOverlayOrigin #iso2TooltipOrigin="cdkOverlayOrigin"
|
||||||
|
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-[0.6875rem] font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||||
|
[attr.aria-label]="'Show ' + remaining + ' more ISO2 codes for ' + row.name"
|
||||||
|
[attr.aria-describedby]="iso2TooltipId(row)" (mouseenter)="openIso2Tooltip(row)"
|
||||||
|
(mouseleave)="scheduleIso2TooltipClose()" (focus)="openIso2Tooltip(row)"
|
||||||
|
(blur)="scheduleIso2TooltipClose()" (keydown)="onIso2TooltipKeydown($event)">
|
||||||
|
+{{ remaining }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="iso2TooltipOrigin"
|
||||||
|
[cdkConnectedOverlayOpen]="isIso2TooltipOpen(row)" [cdkConnectedOverlayPositions]="iso2TooltipPositions"
|
||||||
|
[cdkConnectedOverlayViewportMargin]="8" [cdkConnectedOverlayPush]="true" (detach)="closeIso2Tooltip()">
|
||||||
|
<div [id]="iso2TooltipId(row)" role="tooltip"
|
||||||
|
class="relative w-35 max-w-[calc(100vw-16px)] overflow-visible rounded-lg border border-defaultborder bg-white shadow-2xl dark:border-white/10 dark:bg-bodybg"
|
||||||
|
(click)="$event.stopPropagation()" (mouseenter)="openIso2Tooltip(row)"
|
||||||
|
(mouseleave)="scheduleIso2TooltipClose()">
|
||||||
|
@if (iso2TooltipPlacement() === 'right') {
|
||||||
|
<span aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute left-[-6px] top-1/2 z-10 size-3 -translate-y-1/2 rotate-45 border-b border-l border-defaultborder bg-white dark:border-white/10 dark:bg-bodybg"></span>
|
||||||
|
} @else {
|
||||||
|
<span aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute left-1/2 top-[-6px] z-10 size-3 -translate-x-1/2 rotate-45 border-l border-t border-primary bg-primary">
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="relative z-20 overflow-hidden rounded-lg">
|
||||||
|
<div class="flex shrink-0 items-center justify-between bg-primary px-3 py-2 text-white">
|
||||||
|
<!--<span class="text-xs font-semibold">
|
||||||
|
Associated Countries
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="inline-flex min-w-5 items-center justify-center rounded-full bg-white/20 px-1.5 py-0.5 text-[0.625rem] font-semibold text-white">
|
||||||
|
{{ remainingCountryCount(row) }}
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-scrollbar-width max-h-48 overflow-y-auto overscroll-contain bg-white p-2 dark:bg-bodybg"
|
||||||
|
(wheel)="$event.stopPropagation()">
|
||||||
|
<div class="grid grid-cols-1 gap-1">
|
||||||
|
@for (country of remainingCountries(row); track country.iso2) {
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs font-semibold text-defaulttextcolor transition-colors hover:bg-primary/10 dark:text-white/80">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-[18px] w-6 shrink-0 items-center justify-center rounded-sm bg-light text-textmuted dark:bg-black/20">
|
||||||
|
@if (getFlagUrl(country.iso2); as flagUrl) {
|
||||||
|
<img [src]="flagUrl" [alt]="country.iso2 + ' flag'" loading="lazy"
|
||||||
|
class="h-[18px] w-6 shrink-0 rounded-sm object-cover"
|
||||||
|
(error)="onFlagError($event)" />
|
||||||
|
<i class="ti ti-flag hidden text-[0.6875rem]" aria-hidden="true"></i>
|
||||||
|
} @else {
|
||||||
|
<i class="ti ti-flag text-[0.6875rem]" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>{{ country.iso2 }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-textmuted" aria-label="No ISO2 codes">—</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template appDataTableCell="code" let-value="value">
|
||||||
|
<span class="badge bg-primary/10 text-primary">
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
</ng-template></app-data-table>
|
||||||
|
|
||||||
|
<app-confirm-dialog title="Delete Currency" text="Do you really want to delete this currency?"
|
||||||
|
confirmButtonText="Delete" cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()"
|
||||||
|
(cancelled)="onDeleteCancelled()" />
|
||||||
|
|
||||||
|
<modal class="modern-modal" [open]="showCurrencyModal()" [title]="currencyModalTitle()" size="md"
|
||||||
|
[submitAction]="currencySubmitAction()" [submitLabel]="currencySubmitLabel()"
|
||||||
|
[loadingLabel]="currencyLoadingLabel()" [loading]="saving()" (closed)="closeCurrencyModal()"
|
||||||
|
(submitted)="saveCurrency()">
|
||||||
|
<form [formGroup]="currencyForm" (ngSubmit)="saveCurrency()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<app-form-input formControlName="name" inputId="currency-name" label="Currency Name" autocomplete="off"
|
||||||
|
variant="floating" [required]="true" [maxLength]="100" [submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Currency Name is required.', maxlength: 'Currency Name cannot exceed 100 characters.' }" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<app-form-input formControlName="code" inputId="currency-code" label="Currency Code" autocomplete="off"
|
||||||
|
variant="floating" [required]="true" [minLength]="3" [maxLength]="3" pattern="[A-Za-z]{3}"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Currency Code is required.', minlength: 'Currency Code must contain exactly 3 letters.', maxlength: 'Currency Code must contain exactly 3 letters.', pattern: 'Currency Code can contain letters only.' }" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<app-form-input formControlName="symbol" inputId="currency-symbol" label="Currency Symbol"
|
||||||
|
autocomplete="off" variant="floating" [required]="true" [maxLength]="8"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Currency Symbol is required.', maxlength: 'Currency Symbol cannot exceed 8 characters.' }" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<app-form-input formControlName="numericCode" inputId="currency-numeric-code" label="Numeric Code"
|
||||||
|
type="number" inputMode="numeric" autocomplete="off" variant="floating" [required]="true" [min]="1"
|
||||||
|
[max]="999" [step]="1" [submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Numeric Code is required.', min: 'Numeric Code must be at least 1.', max: 'Numeric Code cannot exceed 999.' }" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<app-form-input formControlName="decimalDigits" inputId="currency-decimal-digits" label="Decimal Digits"
|
||||||
|
type="number" inputMode="numeric" autocomplete="off" variant="floating" [required]="true" [min]="0"
|
||||||
|
[max]="4" [step]="1" [submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Decimal Digits is required.', min: 'Decimal Digits cannot be less than 0.', max: 'Decimal Digits cannot exceed 4.' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modal>
|
||||||
@@ -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<HTMLElement>>(ElementRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly currencyQueryRequests$ = new Subject<DataTableQuery>();
|
||||||
|
|
||||||
|
readonly queryState = new DataTableQueryState();
|
||||||
|
|
||||||
|
readonly currencies = signal<CurrencyTableRow[]>([]);
|
||||||
|
readonly totalRecords = signal(0);
|
||||||
|
readonly filteredRecords = signal(0);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
|
||||||
|
readonly showCurrencyModal = signal(false);
|
||||||
|
readonly currencyModalMode = signal<CurrencyModalMode>('create');
|
||||||
|
readonly selectedCurrencyId = signal<string | null>(null);
|
||||||
|
readonly selectedCurrency = signal<CurrencyDto | null>(null);
|
||||||
|
readonly currencySubmitAttempted = signal(false);
|
||||||
|
readonly pendingDeleteCurrency = signal<CurrencyDto | null>(null);
|
||||||
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
|
readonly openIso2TooltipCurrencyId = signal<string | null>(null);
|
||||||
|
readonly iso2TooltipPlacement = signal<Iso2TooltipPlacement>('right');
|
||||||
|
readonly iso2TooltipPositions: ConnectedPosition[] = [
|
||||||
|
{
|
||||||
|
originX: 'end',
|
||||||
|
originY: 'center',
|
||||||
|
overlayX: 'start',
|
||||||
|
overlayY: 'center',
|
||||||
|
offsetX: 12
|
||||||
|
}
|
||||||
|
];
|
||||||
|
private iso2TooltipCloseTimer: ReturnType<typeof setTimeout> | 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<DataTableColumn<CurrencyTableRow>[]>([
|
||||||
|
{ 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<DataTableAction<CurrencyTableRow>[]>([
|
||||||
|
{
|
||||||
|
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<CurrencyTableRow>): 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<HTMLElement>(
|
||||||
|
'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<string>();
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CurrencyService } from './data-access/currency.service';
|
||||||
|
export type { CurrencyDto, CurrencyLookupDto } from './models/currency.model';
|
||||||
@@ -2,8 +2,33 @@ import { Routes } from '@angular/router';
|
|||||||
|
|
||||||
export const globalMastersRoutes: Routes = [
|
export const globalMastersRoutes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: 'countries',
|
||||||
loadComponent: () => import('./pages/master-list/master-list').then((m) => m.MasterList),
|
loadComponent: () => import('./countries/pages/country-list/country-list').then((m) => m.CountryList),
|
||||||
data: { childTitle: 'Global Masters', 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: 'Global Master', subParentTitle: 'Configuration' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cities',
|
||||||
|
loadComponent: () => import('./cities/pages/city-list/city-list').then((m) => m.CityList),
|
||||||
|
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' },
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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<DataTableResult<LanguageDto>> {
|
||||||
|
return this.http.post<DataTableResult<LanguageDto>>(LANGUAGE_ENDPOINTS.dataTable, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Observable<LanguageDto> {
|
||||||
|
return this.http.get<LanguageDto>(LANGUAGE_ENDPOINTS.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
create(request: CreateLanguageRequest): Observable<LanguageDto> {
|
||||||
|
return this.http.post<LanguageDto>(LANGUAGE_ENDPOINTS.create, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, request: UpdateLanguageRequest): Observable<LanguageDto> {
|
||||||
|
return this.http.put<LanguageDto>(LANGUAGE_ENDPOINTS.update(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
autocomplete(
|
||||||
|
term: string | null,
|
||||||
|
limit = 10
|
||||||
|
): Observable<readonly LanguageLookupDto[]> {
|
||||||
|
let params = new HttpParams().set('limit', limit);
|
||||||
|
if (term !== null) {
|
||||||
|
params = params.set('term', term);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<readonly LanguageLookupDto[]>(LANGUAGE_ENDPOINTS.autocomplete, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<app-data-table [columns]="columns()" [rows]="languages()" [actions]="actions()" [totalRecords]="totalRecords()"
|
||||||
|
[pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()" tableTitle="Languages" buttonTitle="Add"
|
||||||
|
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search languages..." [searchDebounceTime]="300"
|
||||||
|
toolTip="Add Language" (addClicked)="onAddLanguage()" (searchChanged)="onSearch($event)"
|
||||||
|
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)">
|
||||||
|
<ng-template appDataTableCell="name" let-value="value">
|
||||||
|
<span class="font-semibold">{{ value }}</span>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appDataTableCell="code" let-value="value">
|
||||||
|
<span class="badge bg-primary/10 text-primary">{{ value }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</app-data-table>
|
||||||
|
|
||||||
|
<app-confirm-dialog title="Delete Language" text="Do you really want to delete this language?"
|
||||||
|
confirmButtonText="Delete" cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()"
|
||||||
|
(cancelled)="onDeleteCancelled()" />
|
||||||
|
|
||||||
|
<modal [open]="showModal()" [title]="modalTitle()" size="md" [submitAction]="submitAction()"
|
||||||
|
[submitLabel]="submitLabel()" [loadingLabel]="loadingLabel()" [loading]="saving() || modalLoading()"
|
||||||
|
(closed)="closeModal()" (submitted)="saveLanguage()">
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
|
||||||
|
<span class="ms-2">Loading language...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="languageForm" (ngSubmit)="saveLanguage()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="name" inputId="language-name" variant="floating" label="Language Name"
|
||||||
|
placeholder="e.g.: English" autocomplete="off" [required]="true" [maxLength]="100"
|
||||||
|
[submitAttempted]="submitAttempted()" [validationMessages]="{
|
||||||
|
required: 'Language Name is required.',
|
||||||
|
maxlength: 'Language Name cannot exceed 100 characters.',
|
||||||
|
pattern: 'Language Name cannot contain only whitespace.'
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="code" inputId="language-code" variant="floating" label="Language Code"
|
||||||
|
placeholder="e.g.: en-US" autocomplete="off" [required]="true" [maxLength]="35"
|
||||||
|
[submitAttempted]="submitAttempted()" [validationMessages]="{
|
||||||
|
required: 'Language Code is required.',
|
||||||
|
maxlength: 'Language Code cannot exceed 35 characters.',
|
||||||
|
pattern: 'Use a valid language code, for example en-US or hi-IN.'
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="nativeName" inputId="language-native-name" variant="floating"
|
||||||
|
label="Native Name" placeholder="e.g.: English" autocomplete="off" [required]="true" [maxLength]="100"
|
||||||
|
[submitAttempted]="submitAttempted()" [validationMessages]="{
|
||||||
|
required: 'Native Name is required.',
|
||||||
|
maxlength: 'Native Name cannot exceed 100 characters.',
|
||||||
|
pattern: 'Native Name cannot contain only whitespace.'
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6 flex items-center pt-3">
|
||||||
|
<label for="language-rtl" class="inline-flex cursor-pointer items-center gap-2">
|
||||||
|
<input id="language-rtl" type="checkbox" formControlName="isRightToLeft" class="form-check-input" />
|
||||||
|
<span>Right To Left Language</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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<HTMLElement>>(ElementRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly queryRequests$ = new Subject<DataTableQuery>();
|
||||||
|
|
||||||
|
readonly queryState = new DataTableQueryState();
|
||||||
|
readonly languages = signal<LanguageTableRow[]>([]);
|
||||||
|
readonly totalRecords = signal(0);
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly statusChangingId = signal<string | null>(null);
|
||||||
|
readonly showModal = signal(false);
|
||||||
|
readonly modalMode = signal<LanguageModalMode>('create');
|
||||||
|
readonly selectedLanguageId = signal<string | null>(null);
|
||||||
|
readonly selectedLanguage = signal<LanguageDto | null>(null);
|
||||||
|
readonly submitAttempted = signal(false);
|
||||||
|
readonly pendingDeleteLanguageId = signal<string | null>(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<DataTableColumn<LanguageTableRow>[]>([
|
||||||
|
{ 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<DataTableAction<LanguageTableRow>[]>([
|
||||||
|
{
|
||||||
|
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<LanguageTableRow>): 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<HTMLElement>(
|
||||||
|
'modal [data-form-control][aria-invalid="true"]'
|
||||||
|
);
|
||||||
|
control?.focus();
|
||||||
|
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { LanguageService } from './data-access/language.service';
|
||||||
|
export type { LanguageLookupDto } from './models/language.model';
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<div class="space-y-6">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Global masters</h3>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Reference landing page for the common master configuration pattern.</p>
|
|
||||||
</div>
|
|
||||||
<a routerLink="/dashboards/crm" class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white">Back to dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white">Country</h4>
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Reference CRUD screen to be implemented first.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white">Currency</h4>
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Reuses the same configurable master pattern.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white">Language</h4>
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Shared master configuration with filters and forms.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.space-y-6 {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-master-list',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, RouterLink],
|
|
||||||
templateUrl: './master-list.html',
|
|
||||||
styleUrl: './master-list.scss',
|
|
||||||
})
|
|
||||||
export class MasterList {}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { buildApiUrl } from '../../../../core/config/api-url.util';
|
||||||
|
|
||||||
|
|
||||||
|
export const STATE_ENDPOINTS = {
|
||||||
|
dataTable: buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
'/v1/states/datatable'
|
||||||
|
),
|
||||||
|
|
||||||
|
create: buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
'/v1/states'
|
||||||
|
),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
`/v1/states/${encodeURIComponent(id)}`
|
||||||
|
),
|
||||||
|
|
||||||
|
autocomplete: buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
'/v1/states/autocomplete'
|
||||||
|
),
|
||||||
|
|
||||||
|
update: (id: string) =>
|
||||||
|
buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
`/v1/states/${encodeURIComponent(id)}`
|
||||||
|
),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
`/v1/states/${encodeURIComponent(id)}`
|
||||||
|
),
|
||||||
|
|
||||||
|
changeStatus: (id: string) =>
|
||||||
|
buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
`/v1/states/${encodeURIComponent(id)}/status`
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
@@ -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<DataTableResult<StateDto>> {
|
||||||
|
const params = countryId ? new HttpParams().set('countryId', countryId) : undefined;
|
||||||
|
return this.http.post<DataTableResult<StateDto>>(`${STATE_ENDPOINTS.dataTable}`, query, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
createState(request: CreateStateRequest): Observable<StateDto> {
|
||||||
|
return this.http.post<StateDto>(STATE_ENDPOINTS.create, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(id: string, request: UpdateStateRequest): Observable<StateDto> {
|
||||||
|
return this.http.put<StateDto>(STATE_ENDPOINTS.update(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateById(id: string): Observable<StateDto> {
|
||||||
|
return this.http.get<StateDto>(STATE_ENDPOINTS.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
autocomplete(countryId: string, term = '', limit = 50): Observable<StateLookupDto[]> {
|
||||||
|
return this.http.get<StateLookupDto[]>(STATE_ENDPOINTS.autocomplete, {
|
||||||
|
params: new HttpParams()
|
||||||
|
.set('countryId', countryId)
|
||||||
|
.set('term', term)
|
||||||
|
.set('limit', limit)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<div class="grid grid-cols-12 gap-6">
|
||||||
|
<div class="xl:col-span-12 col-span-12">
|
||||||
|
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||||
|
<form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||||
|
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="state-country-filter"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Search"
|
||||||
|
[searchFn]="searchCountries"
|
||||||
|
[displayWith]="displayCountry"
|
||||||
|
[valueWith]="countryValue"
|
||||||
|
[selectedItem]="selectedCountryLookup()"
|
||||||
|
[minSearchLength]="1"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
|
[hideValidation]="true"
|
||||||
|
wrapperClass="!mb-0 w-full"
|
||||||
|
(itemSelected)="onCountryLookupSelected($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
||||||
|
<button type="button" class="ti-btn ti-btn-sm ti-btn-primary-full !mb-0 flex min-h-8 w-full items-center justify-center gap-1.5 !px-3 md:!w-auto" (click)="applyCountryFilter()">
|
||||||
|
<i class="ti ti-filter" aria-hidden="true"></i>
|
||||||
|
<span>Filter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</app-filter-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
|
||||||
|
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||||
|
tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
||||||
|
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
|
||||||
|
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||||
|
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||||
|
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
|
||||||
|
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Delete State"
|
||||||
|
text="Do you really want to delete this state?"
|
||||||
|
confirmButtonText="Delete"
|
||||||
|
cancelButtonText="Cancel"
|
||||||
|
(confirmed)="onDeleteConfirmed()"
|
||||||
|
(cancelled)="onDeleteCancelled()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<modal [open]="showStateModal()" [title]="stateModalTitle()" size="md" [submitAction]="stateSubmitAction()"
|
||||||
|
[submitLabel]="stateSubmitLabel()" [loadingLabel]="stateLoadingLabel()" [loading]="saving()"
|
||||||
|
(closed)="closeStateModal()" (submitted)="saveState()">
|
||||||
|
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="state-country"
|
||||||
|
variant="floating"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Search"
|
||||||
|
[searchFn]="searchCountries"
|
||||||
|
[displayWith]="displayCountry"
|
||||||
|
[valueWith]="countryValue"
|
||||||
|
[resolveValueFn]="resolveCountry"
|
||||||
|
[selectedItem]="selectedFormCountry()"
|
||||||
|
[minSearchLength]="1"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="stateModalMode() === 'create'"
|
||||||
|
[required]="true"
|
||||||
|
[clearable]="true"
|
||||||
|
[readonly]="stateModalMode() !== 'create'"
|
||||||
|
[validationMessages]="{ required: 'Country is required.' }"
|
||||||
|
[submitAttempted]="stateSubmitAttempted()"
|
||||||
|
wrapperClass="w-full"
|
||||||
|
(itemSelected)="onFormCountrySelected($event)"
|
||||||
|
(cleared)="onFormCountryCleared()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name" variant="floating"
|
||||||
|
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
|
||||||
|
required: 'State Name is required.',
|
||||||
|
maxlength: 'State Name cannot exceed 150 characters.'
|
||||||
|
}" [submitAttempted]="stateSubmitAttempted()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="code" inputId="state-code" label="State Code" placeholder="e.g.: CA" variant="floating"
|
||||||
|
autocomplete="off" [required]="true" [maxLength]="16" [validationMessages]="{
|
||||||
|
required: 'State Code is required.',
|
||||||
|
maxlength: 'State Code cannot exceed 16 characters.',
|
||||||
|
pattern: 'State Code can contain letters, numbers, hyphens, and underscores only.'
|
||||||
|
}" [submitAttempted]="stateSubmitAttempted()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modal>
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
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 {
|
||||||
|
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 { 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',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard],
|
||||||
|
templateUrl: './state-list.html',
|
||||||
|
styleUrl: './state-list.scss',
|
||||||
|
})
|
||||||
|
export class StateList {
|
||||||
|
|
||||||
|
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<HTMLElement>>(ElementRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly stateQueryRequests$ = new Subject<DataTableQuery>();
|
||||||
|
|
||||||
|
readonly queryState = new DataTableQueryState();
|
||||||
|
|
||||||
|
readonly states = signal<StateTableRow[]>([]);
|
||||||
|
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||||
|
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||||
|
readonly selectedCountryId = signal<string | null>(null);
|
||||||
|
readonly appliedCountryId = signal<string | null>(null);
|
||||||
|
readonly totalRecords = signal(0);
|
||||||
|
readonly filteredRecords = signal(0);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
|
||||||
|
readonly showStateModal = signal(false);
|
||||||
|
readonly stateModalMode = signal<StateModalMode>('create');
|
||||||
|
readonly selectedStateId = signal<string | null>(null);
|
||||||
|
readonly selectedState = signal<StateDto | null>(null);
|
||||||
|
readonly stateSubmitAttempted = signal(false);
|
||||||
|
readonly pendingDeleteState = signal<StateDto | null>(null);
|
||||||
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
|
|
||||||
|
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
||||||
|
countryId: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> =
|
||||||
|
(term, limit) => this.countryApi.autocomplete(term, limit);
|
||||||
|
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
|
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
|
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||||
|
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<DataTableColumn<StateTableRow>[]>([
|
||||||
|
{ 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',
|
||||||
|
width: '100px',
|
||||||
|
formatter: value => value ? 'Active' : 'Inactive'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly actions = signal<DataTableAction<StateTableRow>[]>([
|
||||||
|
{
|
||||||
|
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.countryFilterForm.controls.countryId.valueChanges
|
||||||
|
.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event: DataTablePageEvent): void {
|
||||||
|
const query = this.queryState.setPage(event);
|
||||||
|
this.loadStates(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSortChange(event: DataTableSortEvent): void {
|
||||||
|
const query = this.queryState.setSort(event);
|
||||||
|
this.loadStates(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRefresh(): void {
|
||||||
|
const currentQuery = this.queryState.getQuery();
|
||||||
|
|
||||||
|
const query: DataTableQuery = {
|
||||||
|
...currentQuery,
|
||||||
|
draw: currentQuery.draw + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
this.loadStates(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
onReset(): void {
|
||||||
|
const query = this.queryState.reset();
|
||||||
|
this.loadStates(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<StateTableRow>): void {
|
||||||
|
const action = event.action.type;
|
||||||
|
const state = this.toStateDto(event.row);
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'view':
|
||||||
|
this.viewState(state);
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
this.openEditState(state);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
this.requestDeleteState(state);
|
||||||
|
break;
|
||||||
|
case 'activate':
|
||||||
|
this.activateState(state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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<HTMLElement>(
|
||||||
|
'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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { StateService } from './data-access/state.service';
|
||||||
|
export type { StateLookupDto } from './models/state.model';
|
||||||
@@ -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;
|
||||||
@@ -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<DataTableResult<TimezoneDto>> {
|
||||||
|
return this.http.post<DataTableResult<TimezoneDto>>(TIMEZONE_ENDPOINTS.dataTable, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Observable<TimezoneDto> {
|
||||||
|
return this.http.get<TimezoneDto>(TIMEZONE_ENDPOINTS.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
create(request: CreateTimezoneRequest): Observable<TimezoneDto> {
|
||||||
|
return this.http.post<TimezoneDto>(TIMEZONE_ENDPOINTS.create, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, request: UpdateTimezoneRequest): Observable<TimezoneDto> {
|
||||||
|
return this.http.put<TimezoneDto>(TIMEZONE_ENDPOINTS.update(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
autocomplete(term: string | null, limit = 10): Observable<readonly TimezoneLookupDto[]> {
|
||||||
|
const normalizedTerm = term?.trim() || null;
|
||||||
|
let params = new HttpParams().set('limit', limit);
|
||||||
|
if (normalizedTerm !== null) {
|
||||||
|
params = params.set('term', normalizedTerm);
|
||||||
|
}
|
||||||
|
return this.http.get<readonly TimezoneLookupDto[]>(TIMEZONE_ENDPOINTS.autocomplete, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<app-data-table
|
||||||
|
[columns]="columns()"
|
||||||
|
[rows]="timezones()"
|
||||||
|
[actions]="actions()"
|
||||||
|
[totalRecords]="totalRecords()"
|
||||||
|
[pageIndex]="queryState.pageIndex()"
|
||||||
|
[pageSize]="queryState.pageSize()"
|
||||||
|
tableTitle="Timezones"
|
||||||
|
buttonTitle="Add"
|
||||||
|
[showSearch]="true"
|
||||||
|
[showAddButton]="true"
|
||||||
|
searchPlaceholder="Search timezones..."
|
||||||
|
[searchDebounceTime]="300"
|
||||||
|
toolTip="Add Timezone"
|
||||||
|
(addClicked)="onAddTimezone()"
|
||||||
|
(searchChanged)="onSearch($event)"
|
||||||
|
(pageChanged)="onPageChange($event)"
|
||||||
|
(sortChanged)="onSortChange($event)"
|
||||||
|
(actionClicked)="onActionClick($event)"
|
||||||
|
>
|
||||||
|
<ng-template appDataTableCell="ianaId" let-value="value">
|
||||||
|
<span class="block max-w-72 truncate font-semibold" title="{{ value }}">{{ value }}</span>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appDataTableCell="displayName" let-value="value">
|
||||||
|
<span class="block max-w-72 truncate" title="{{ value }}">{{ value }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</app-data-table>
|
||||||
|
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Delete Timezone"
|
||||||
|
text="Do you really want to delete this timezone?"
|
||||||
|
confirmButtonText="Delete"
|
||||||
|
cancelButtonText="Cancel"
|
||||||
|
(confirmed)="onDeleteConfirmed()"
|
||||||
|
(cancelled)="onDeleteCancelled()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<modal
|
||||||
|
[open]="showModal()"
|
||||||
|
[title]="modalTitle()"
|
||||||
|
size="md"
|
||||||
|
[submitAction]="submitAction()"
|
||||||
|
[submitLabel]="submitLabel()"
|
||||||
|
[loadingLabel]="loadingLabel()"
|
||||||
|
[loading]="saving() || modalLoading()"
|
||||||
|
[showSubmitButton]="!isViewMode()"
|
||||||
|
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||||
|
(closed)="closeModal()"
|
||||||
|
(submitted)="saveTimezone()"
|
||||||
|
>
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
|
||||||
|
<span class="ms-2">Loading timezone...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="timezoneForm" (ngSubmit)="saveTimezone()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="ianaId"
|
||||||
|
inputId="timezone-iana-id"
|
||||||
|
variant="floating"
|
||||||
|
label="IANA Timezone ID"
|
||||||
|
placeholder="Id"
|
||||||
|
help="e.g.: Asia/Kolkata"
|
||||||
|
autocomplete="off"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="64"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'Timezone ID is required.',
|
||||||
|
maxlength: 'Timezone ID must be 64 characters or fewer.',
|
||||||
|
pattern: 'Use a valid IANA timezone ID, for example Asia/Kolkata or America/New_York.'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="displayName"
|
||||||
|
inputId="timezone-display-name"
|
||||||
|
variant="floating"
|
||||||
|
label="Display Name"
|
||||||
|
placeholder="Name"
|
||||||
|
help="e.g.: India Standard Time"
|
||||||
|
autocomplete="off"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="128"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'Timezone display name is required.',
|
||||||
|
maxlength: 'Timezone display name must be 128 characters or fewer.',
|
||||||
|
pattern: 'Timezone display name cannot contain only whitespace.'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="utcOffsetMinutes"
|
||||||
|
inputId="timezone-utc-offset"
|
||||||
|
variant="floating"
|
||||||
|
label="UTC Offset (minutes)"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Minutes"
|
||||||
|
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[min]="-720"
|
||||||
|
[max]="840"
|
||||||
|
[step]="1"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'UTC offset is required.',
|
||||||
|
min: 'UTC offset must be between -12:00 and +14:00.',
|
||||||
|
max: 'UTC offset must be between -12:00 and +14:00.'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (isViewMode() && selectedTimezone(); as timezone) {
|
||||||
|
<div class="col-span-12 md:col-span-6 pt-1">
|
||||||
|
<span class="block text-sm text-textmuted">Formatted UTC Offset</span>
|
||||||
|
<span class="mt-2 block font-semibold">{{ formatUtcOffset(timezone.utcOffsetMinutes) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<span class="block text-sm text-textmuted">Status</span>
|
||||||
|
<span class="badge mt-2" [class.bg-success]="timezone.isActive" [class.bg-danger]="!timezone.isActive">
|
||||||
|
{{ timezone.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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<HTMLElement>>(ElementRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly queryRequests$ = new Subject<DataTableQuery>();
|
||||||
|
|
||||||
|
readonly queryState = new DataTableQueryState();
|
||||||
|
readonly timezones = signal<TimezoneTableRow[]>([]);
|
||||||
|
readonly totalRecords = signal(0);
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly statusChangingId = signal<string | null>(null);
|
||||||
|
readonly showModal = signal(false);
|
||||||
|
readonly modalMode = signal<TimezoneModalMode>('create');
|
||||||
|
readonly selectedTimezoneId = signal<string | null>(null);
|
||||||
|
readonly selectedTimezone = signal<TimezoneDto | null>(null);
|
||||||
|
readonly submitAttempted = signal(false);
|
||||||
|
readonly pendingDeleteTimezoneId = signal<string | null>(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<DataTableColumn<TimezoneTableRow>[]>([
|
||||||
|
{ 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<DataTableAction<TimezoneTableRow>[]>([
|
||||||
|
{
|
||||||
|
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<TimezoneTableRow>): 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<HTMLElement>('modal [data-form-control][aria-invalid="true"]')
|
||||||
|
?.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { TimezoneService } from './data-access/timezone.service';
|
||||||
|
export type { TimezoneDto, TimezoneLookupDto } from './models/timezone.model';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>organization-list works!</p>
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user