Merge pull request 'feat: add Master Admin application functionality' (#3) from feature/onboarding-workflow-sc into main
Reviewed-on: sc/syscom-master-admin-ui#3 Reviewed-by: Kunal <kp.workspace17@gmail.com>
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ export const App_Route: Route[] = [
|
||||
{
|
||||
path: 'auth',
|
||||
component: AuthenticationLayout,
|
||||
loadChildren: () => import('./shell/routes/auth.routes').then((m) => m.authen),
|
||||
loadChildren: () =>
|
||||
import('./features/authentication/authentication.routes').then((m) => m.authen),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -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,133 @@
|
||||
import { computed, Injectable, inject, signal } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model';
|
||||
import { API_CONFIG } from '../config/api.config';
|
||||
import { TokenStorageService } from './token-storage.service';
|
||||
import { AppContextService } from '../services/app-context.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly tokenStorage = inject(TokenStorageService);
|
||||
private readonly appContextService = inject(AppContextService);
|
||||
|
||||
private readonly userSubject = new BehaviorSubject<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>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/login`, payload).pipe(
|
||||
map((response) => {
|
||||
const user = this.normalizeUserProfile(response);
|
||||
this.tokenStorage.saveAuth(response, rememberMe);
|
||||
this.setAuthState(user, this.tokenStorage.getAccessToken());
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
refreshAccessToken(): Observable<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>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe(
|
||||
map((response) => {
|
||||
const user = this.normalizeUserProfile(response, this.currentUserSignal());
|
||||
const storageType = this.tokenStorage.getStorageType();
|
||||
const rememberMe = storageType === 'local';
|
||||
this.tokenStorage.saveAuth(response, rememberMe);
|
||||
this.setAuthState(user, this.tokenStorage.getAccessToken());
|
||||
return response;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.logout();
|
||||
return throwError(() => error);
|
||||
}),
|
||||
finalize(() => {
|
||||
this.refreshRequest$ = null;
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
return this.refreshRequest$;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.tokenStorage.clearAuth();
|
||||
this.appContextService.clearContext();
|
||||
this.setAuthState(null, null);
|
||||
this.refreshRequest$ = null;
|
||||
}
|
||||
|
||||
get accessToken(): string | null {
|
||||
return this.tokenStorage.getAccessToken();
|
||||
}
|
||||
|
||||
get refreshToken(): string | null {
|
||||
return this.tokenStorage.getRefreshToken();
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return this.isAuthenticatedSignal();
|
||||
}
|
||||
|
||||
get isLoggedIn(): boolean {
|
||||
return this.isAuthenticatedSignal();
|
||||
}
|
||||
|
||||
get currentUser(): UserProfile | null {
|
||||
return this.currentUserSignal();
|
||||
}
|
||||
|
||||
private restoreAuthState(): void {
|
||||
const storedUser = this.tokenStorage.getUser();
|
||||
const storedAccessToken = this.tokenStorage.getAccessToken();
|
||||
|
||||
if (storedUser && storedAccessToken) {
|
||||
this.setAuthState(storedUser, storedAccessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setAuthState(null, storedAccessToken ?? null);
|
||||
}
|
||||
|
||||
private setAuthState(user: UserProfile | null, token: string | null): void {
|
||||
this.userSubject.next(user);
|
||||
this.currentUserSignal.set(user);
|
||||
this.accessTokenSignal.set(token);
|
||||
}
|
||||
|
||||
private normalizeUserProfile(response: LoginResponse, fallbackUser: UserProfile | null = null): UserProfile | null {
|
||||
if (response.user) {
|
||||
return response.user;
|
||||
}
|
||||
|
||||
const userId = response.userId?.trim();
|
||||
const email = response.email?.trim();
|
||||
if (userId || email) {
|
||||
return {
|
||||
id: userId ?? fallbackUser?.id ?? '',
|
||||
email: email ?? fallbackUser?.email ?? '',
|
||||
roles: response.roles,
|
||||
};
|
||||
}
|
||||
|
||||
return fallbackUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { LoginResponse, UserProfile } from '../models/auth.model';
|
||||
|
||||
type StorageType = 'local' | 'session';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TokenStorageService {
|
||||
private readonly accessTokenKey = 'master-admin-access-token';
|
||||
private readonly refreshTokenKey = 'master-admin-refresh-token';
|
||||
private readonly accessTokenExpiresOnKey = 'master-admin-access-token-expires-on';
|
||||
private readonly refreshTokenExpiresOnKey = 'master-admin-refresh-token-expires-on';
|
||||
private readonly userKey = 'master-admin-user';
|
||||
|
||||
saveAuth(response: LoginResponse, rememberMe: boolean): void {
|
||||
this.clearAuth();
|
||||
const selectedStorage = rememberMe ? localStorage : sessionStorage;
|
||||
|
||||
if (response.accessToken) {
|
||||
selectedStorage.setItem(this.accessTokenKey, response.accessToken);
|
||||
}
|
||||
|
||||
if (response.refreshToken) {
|
||||
selectedStorage.setItem(this.refreshTokenKey, response.refreshToken);
|
||||
}
|
||||
|
||||
if (response.accessTokenExpiresOn) {
|
||||
selectedStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn);
|
||||
}
|
||||
|
||||
if (response.refreshTokenExpiresOn) {
|
||||
selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn);
|
||||
}
|
||||
|
||||
const user = this.buildUserProfile(response);
|
||||
if (user) {
|
||||
selectedStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
}
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
this.removeFromStorage(localStorage);
|
||||
this.removeFromStorage(sessionStorage);
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.getByPriority(this.accessTokenKey);
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.getByPriority(this.refreshTokenKey);
|
||||
}
|
||||
|
||||
getAccessTokenExpiresOn(): string | null {
|
||||
return this.getByPriority(this.accessTokenExpiresOnKey);
|
||||
}
|
||||
|
||||
getRefreshTokenExpiresOn(): string | null {
|
||||
return this.getByPriority(this.refreshTokenExpiresOnKey);
|
||||
}
|
||||
|
||||
getUser(): UserProfile | null {
|
||||
const raw = this.getByPriority(this.userKey);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as UserProfile;
|
||||
} catch {
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getStorageType(): 'local' | 'session' | null {
|
||||
if (this.hasAnyAuthKey(sessionStorage)) {
|
||||
return 'session';
|
||||
}
|
||||
|
||||
if (this.hasAnyAuthKey(localStorage)) {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
isAccessTokenExpired(): boolean {
|
||||
return this.isExpired(this.getAccessTokenExpiresOn());
|
||||
}
|
||||
|
||||
isRefreshTokenExpired(): boolean {
|
||||
return this.isExpired(this.getRefreshTokenExpiresOn());
|
||||
}
|
||||
|
||||
private getByPriority(key: string): string | null {
|
||||
const fromSession = sessionStorage.getItem(key);
|
||||
if (fromSession !== null) {
|
||||
return fromSession;
|
||||
}
|
||||
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
private hasAnyAuthKey(storage: Storage): boolean {
|
||||
return [
|
||||
this.accessTokenKey,
|
||||
this.refreshTokenKey,
|
||||
this.accessTokenExpiresOnKey,
|
||||
this.refreshTokenExpiresOnKey,
|
||||
this.userKey,
|
||||
].some((key) => storage.getItem(key) !== null);
|
||||
}
|
||||
|
||||
private removeFromStorage(storage: Storage): void {
|
||||
storage.removeItem(this.accessTokenKey);
|
||||
storage.removeItem(this.refreshTokenKey);
|
||||
storage.removeItem(this.accessTokenExpiresOnKey);
|
||||
storage.removeItem(this.refreshTokenExpiresOnKey);
|
||||
storage.removeItem(this.userKey);
|
||||
}
|
||||
|
||||
private isExpired(expiresOn: string | null): boolean {
|
||||
debugger;
|
||||
if (!expiresOn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsedExpiry = Date.parse(expiresOn);
|
||||
|
||||
if (Number.isNaN(parsedExpiry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return parsedExpiry <= Date.now();
|
||||
}
|
||||
private buildUserProfile(response: LoginResponse): UserProfile | null {
|
||||
if (response.user) {
|
||||
return response.user;
|
||||
}
|
||||
|
||||
if (response.userId || response.email) {
|
||||
return {
|
||||
id: response.userId ?? '',
|
||||
email: response.email ?? '',
|
||||
roles: response.roles,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
export const API_CONFIG = {
|
||||
baseUrl: '/api',
|
||||
endpoints: {
|
||||
auth: '/auth'
|
||||
}
|
||||
auth: '/auth',
|
||||
currentUserProfile: '/users/me',
|
||||
tenantContext: '/tenants/current-context',
|
||||
permissionContext: '/users/me/permissions',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateChildFn, Router } from '@angular/router';
|
||||
import { catchError, map, of } from 'rxjs';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { TokenStorageService } from '../auth/token-storage.service';
|
||||
|
||||
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const tokenStorage = inject(TokenStorageService);
|
||||
const router = inject(Router);
|
||||
|
||||
const loginUrlTree = router.createUrlTree(['/auth/login'], {
|
||||
queryParams: { returnUrl: state.url },
|
||||
});
|
||||
|
||||
if (!authService.accessToken || !authService.currentUser) {
|
||||
authService.logout();
|
||||
return loginUrlTree;
|
||||
}
|
||||
|
||||
if (!tokenStorage.isAccessTokenExpired()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
|
||||
authService.logout();
|
||||
return loginUrlTree;
|
||||
}
|
||||
|
||||
return authService.refreshAccessToken().pipe(
|
||||
map(() => true),
|
||||
catchError(() => {
|
||||
authService.logout();
|
||||
return of(loginUrlTree);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { catchError, map, of } from 'rxjs';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { TokenStorageService } from '../auth/token-storage.service';
|
||||
|
||||
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
|
||||
|
||||
function resolveSafeReturnUrl(returnUrl: string | null): string {
|
||||
const candidate = returnUrl?.trim();
|
||||
if (!candidate) {
|
||||
return DEFAULT_AUTHENTICATED_REDIRECT;
|
||||
}
|
||||
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
const isSafeInternal =
|
||||
candidate.startsWith('/') &&
|
||||
!candidate.startsWith('//') &&
|
||||
!lowerCandidate.includes('http://') &&
|
||||
!lowerCandidate.includes('https://');
|
||||
|
||||
return isSafeInternal ? candidate : DEFAULT_AUTHENTICATED_REDIRECT;
|
||||
}
|
||||
|
||||
export const guestGuard: CanActivateFn = (route) => {
|
||||
const authService = inject(AuthService);
|
||||
const tokenStorage = inject(TokenStorageService);
|
||||
const router = inject(Router);
|
||||
|
||||
const targetUrl = resolveSafeReturnUrl(route.queryParamMap.get('returnUrl'));
|
||||
const targetUrlTree = router.createUrlTree([targetUrl]);
|
||||
|
||||
if (!authService.accessToken || !authService.currentUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!tokenStorage.isAccessTokenExpired()) {
|
||||
return targetUrlTree;
|
||||
}
|
||||
|
||||
if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
|
||||
authService.logout();
|
||||
return true;
|
||||
}
|
||||
|
||||
return authService.refreshAccessToken().pipe(
|
||||
map(() => targetUrlTree),
|
||||
catchError(() => {
|
||||
authService.logout();
|
||||
return of(true);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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.model';
|
||||
import { Menu } from '../../shared/services/nav.service';
|
||||
|
||||
export interface CurrentUserContext extends UserProfile {
|
||||
fullName?: string;
|
||||
defaultLandingPage?: string;
|
||||
}
|
||||
|
||||
export interface TenantContext {
|
||||
tenantId?: string;
|
||||
companyId?: string;
|
||||
companyName?: string;
|
||||
tenantName?: string;
|
||||
defaultLandingPage?: string;
|
||||
}
|
||||
|
||||
export interface PermissionContext {
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
defaultLandingPage?: string;
|
||||
}
|
||||
|
||||
export interface MenuContext {
|
||||
items: Menu[];
|
||||
defaultLandingPage?: string;
|
||||
}
|
||||
|
||||
export interface AppContextState {
|
||||
user: CurrentUserContext;
|
||||
tenant: TenantContext;
|
||||
permissions: PermissionContext;
|
||||
menu: MenuContext;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { finalize, forkJoin, Observable, of, shareReplay, tap } from 'rxjs';
|
||||
import { AppContextState, MenuContext } from '../models/context.model';
|
||||
import { MenuService } from './menu.service';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { TenantContextService } from './tenant-context.service';
|
||||
import { UserContextService } from './user-context.service';
|
||||
import { NavService } from '../../shared/services/nav.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppContextService {
|
||||
private readonly userContextService = inject(UserContextService);
|
||||
private readonly tenantContextService = inject(TenantContextService);
|
||||
private readonly permissionService = inject(PermissionService);
|
||||
private readonly menuService = inject(MenuService);
|
||||
private readonly navService = inject(NavService);
|
||||
|
||||
private loadRequest$: Observable<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,34 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { API_CONFIG } from '../config/api.config';
|
||||
import { ApiResponse } from '../models/api-response.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BaseApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
protected get<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,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,66 @@
|
||||
import { MenuContext } from '../models/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: [
|
||||
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
|
||||
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
|
||||
{
|
||||
title: 'Configuration',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/global-masters', title: 'Global Masters', type: 'link', dirchange: false },
|
||||
{ path: '/localization', title: 'Localization', type: 'link', dirchange: false },
|
||||
{
|
||||
title: 'Branding',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/theming', title: 'Theming', type: 'link', dirchange: false },
|
||||
{ path: '/platform', title: 'Platform', type: 'link', dirchange: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operations',
|
||||
icon: '<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,42 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable, of, tap } from 'rxjs';
|
||||
import { MenuContext } from '../models/context.model';
|
||||
import { SAAS_MENU_DATA } from './menu.data';
|
||||
import { Menu } from '../../shared/services/nav.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MenuService {
|
||||
readonly menuContext = signal<MenuContext | null>(null);
|
||||
|
||||
loadMenu(): Observable<MenuContext> {
|
||||
const context = this.cloneMenuContext(SAAS_MENU_DATA);
|
||||
return of(context).pipe(
|
||||
tap((menuContext) => {
|
||||
this.menuContext.set(menuContext);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getNavigationMenu(): Menu[] {
|
||||
return this.cloneMenuItems(this.menuContext()?.items ?? []);
|
||||
}
|
||||
|
||||
getDefaultLandingPage(): string | null {
|
||||
return this.menuContext()?.defaultLandingPage ?? null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.menuContext.set(null);
|
||||
}
|
||||
|
||||
private cloneMenuContext(context: MenuContext): MenuContext {
|
||||
return {
|
||||
defaultLandingPage: context.defaultLandingPage,
|
||||
items: this.cloneMenuItems(context.items),
|
||||
};
|
||||
}
|
||||
|
||||
private cloneMenuItems(items: Menu[]): Menu[] {
|
||||
return JSON.parse(JSON.stringify(items)) as Menu[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { map, Observable, tap } from 'rxjs';
|
||||
import { API_CONFIG } from '../config/api.config';
|
||||
import { PermissionContext } from '../models/context.model';
|
||||
|
||||
interface PermissionResponse {
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
defaultLandingPage?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PermissionService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly permissionContext = signal<PermissionContext | null>(null);
|
||||
|
||||
loadPermissions(): Observable<PermissionContext> {
|
||||
return this.http.get<PermissionResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}`).pipe(
|
||||
map((response) => ({
|
||||
roles: response.roles ?? [],
|
||||
permissions: response.permissions ?? [],
|
||||
defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
|
||||
})),
|
||||
tap((context) => {
|
||||
this.permissionContext.set(context);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.permissionContext.set(null);
|
||||
}
|
||||
}
|
||||
@@ -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,23 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { API_CONFIG } from '../config/api.config';
|
||||
import { TenantContext } from '../models/context.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TenantContextService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly tenantContext = signal<TenantContext | null>(null);
|
||||
|
||||
loadTenantContext(): Observable<TenantContext> {
|
||||
return this.http.get<TenantContext>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`).pipe(
|
||||
tap((context) => {
|
||||
this.tenantContext.set(context);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tenantContext.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { map, Observable, tap } from 'rxjs';
|
||||
import { API_CONFIG } from '../config/api.config';
|
||||
import { CurrentUserContext } from '../models/context.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserContextService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly currentUserContext = signal<CurrentUserContext | null>(null);
|
||||
|
||||
loadCurrentUserProfile(): Observable<CurrentUserContext> {
|
||||
return this.http.get<Partial<CurrentUserContext>>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}`).pipe(
|
||||
map((response) => ({
|
||||
id: response.id?.trim() ?? '',
|
||||
email: response.email?.trim() ?? '',
|
||||
displayName: response.displayName?.trim() || undefined,
|
||||
fullName: response.fullName?.trim() || undefined,
|
||||
tenantId: response.tenantId?.trim() || undefined,
|
||||
roles: response.roles ?? [],
|
||||
defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
|
||||
})),
|
||||
tap((profile) => {
|
||||
this.currentUserContext.set(profile);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.currentUserContext.set(null);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -5,14 +5,14 @@ export const authen: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [guestGuard],
|
||||
loadComponent: () => import('../../authentication/login/login').then((m) => m.Login),
|
||||
loadComponent: () => import('./pages/login/login').then((m) => m.Login),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () => import('../../features/errors/error.routes').then((m) => m.errorRoutingModule),
|
||||
loadChildren: () => import('../errors/error.routes').then((m) => m.errorRoutingModule),
|
||||
},
|
||||
],
|
||||
},
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { ChangeDetectorRef, Component, inject } from '@angular/core';
|
||||
import { FormBuilder, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth/auth.service';
|
||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError, finalize, of, switchMap, tap } from 'rxjs';
|
||||
import { AppContextService } from '../../core/services/context/app-context.service';
|
||||
import { AppContextService } from '../../../../core/services/context/app-context.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -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';
|
||||
@@ -1,14 +1,14 @@
|
||||
<!-- Start::row-1 -->
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<app-filter-card title="Location Selection" bodyClass="p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
|
||||
<form [formGroup]="filterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<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"
|
||||
@@ -19,7 +19,6 @@
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideLabel]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onFilterCountrySelected($event)"
|
||||
@@ -27,10 +26,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<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"
|
||||
@@ -42,15 +43,19 @@
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[disabled]="!selectedCountryId()"
|
||||
[hideLabel]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onFilterStateSelected($event)"
|
||||
(cleared)="onFilterStateCleared()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
@@ -58,7 +63,7 @@
|
||||
|
||||
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
|
||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
|
||||
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)"
|
||||
@@ -74,6 +79,7 @@
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="city-country"
|
||||
variant="floating"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
@@ -99,6 +105,7 @@
|
||||
<app-autocomplete
|
||||
formControlName="stateId"
|
||||
inputId="city-state"
|
||||
variant="floating"
|
||||
label="State"
|
||||
[placeholder]="formStatePlaceholder()"
|
||||
[help]="'Select a country first'"
|
||||
@@ -112,7 +119,6 @@
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[required]="true"
|
||||
[disabled]="!cityForm.controls.countryId.value"
|
||||
[readonly]="modalMode() !== 'create'"
|
||||
[validationMessages]="{ required: 'State is required.' }"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
@@ -123,7 +129,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="name" inputId="city-name" label="City Name"
|
||||
<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.',
|
||||
@@ -132,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="code" inputId="city-code" label="City Code"
|
||||
<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.',
|
||||
@@ -145,6 +151,7 @@
|
||||
<app-autocomplete
|
||||
formControlName="timezoneId"
|
||||
inputId="city-timezone"
|
||||
variant="floating"
|
||||
label="Timezone"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchTimezones"
|
||||
|
||||
@@ -19,14 +19,14 @@ import {
|
||||
CityModalMode,
|
||||
CreateCityRequest,
|
||||
UpdateCityRequest
|
||||
} from '../../../../../core/models/city/city.model';
|
||||
import { CountryLookupDto } from '../../../../../core/models/country/country.model';
|
||||
import { StateLookupDto } from '../../../../../core/models/state/state.model';
|
||||
import { TimezoneDto, TimezoneLookupDto } from '../../../../../core/models/timezone/timezone.model';
|
||||
import { CityService } from '../../../../../core/services/city/city.service';
|
||||
import { CountryService } from '../../../../../core/services/country/country.service';
|
||||
import { StateService } from '../../../../../core/services/state/state.service';
|
||||
import { TimezoneService } from '../../../../../core/services/timezone/timezone.service';
|
||||
} 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 {
|
||||
@@ -57,8 +57,8 @@ interface CityTableRow extends DataTableRecord {
|
||||
timezoneId: string | null;
|
||||
isActive: boolean;
|
||||
serialNumber: number;
|
||||
stateName: string;
|
||||
countryName: string;
|
||||
state: string;
|
||||
country: string;
|
||||
createdOn?: string;
|
||||
modifiedOn?: string | null;
|
||||
}
|
||||
@@ -85,6 +85,8 @@ export class CityList {
|
||||
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);
|
||||
@@ -103,7 +105,7 @@ export class CityList {
|
||||
|
||||
readonly cityForm = this.formBuilder.nonNullable.group({
|
||||
countryId: ['', Validators.required],
|
||||
stateId: [{ value: '', disabled: true }, Validators.required],
|
||||
stateId: ['', Validators.required],
|
||||
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||
code: [
|
||||
'',
|
||||
@@ -172,13 +174,13 @@ export class CityList {
|
||||
this.cityForm.controls.countryId.value ? 'Search' : 'Search'
|
||||
);
|
||||
readonly emptyMessage = computed(() =>
|
||||
this.selectedCountryId() && this.selectedStateId()
|
||||
this.appliedCountryId() && this.appliedStateId()
|
||||
? 'No cities found'
|
||||
: 'Select a country and state'
|
||||
);
|
||||
|
||||
readonly emptyDescription = computed(() =>
|
||||
this.selectedCountryId() && this.selectedStateId()
|
||||
this.appliedCountryId() && this.appliedStateId()
|
||||
? 'There are no cities available for the selected state.'
|
||||
: 'Choose a country and state to view available cities.'
|
||||
);
|
||||
@@ -214,7 +216,7 @@ export class CityList {
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<CityTableRow>[]>([
|
||||
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
||||
// { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
type: 'deactivate',
|
||||
@@ -238,7 +240,9 @@ export class CityList {
|
||||
this.configureFormChanges();
|
||||
}
|
||||
|
||||
ngOnInit(): void {}
|
||||
ngOnInit(): void {
|
||||
this.loadCities(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
onFilterCountrySelected(country: CountryLookupDto): void {
|
||||
this.selectedCountry.set(country);
|
||||
@@ -256,6 +260,19 @@ export class CityList {
|
||||
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);
|
||||
}
|
||||
@@ -273,10 +290,6 @@ export class CityList {
|
||||
}
|
||||
|
||||
loadCities(query: DataTableQuery): void {
|
||||
if (!this.selectedStateId()) {
|
||||
this.clearGrid();
|
||||
return;
|
||||
}
|
||||
this.cityQueryRequests$.next(query);
|
||||
}
|
||||
|
||||
@@ -315,7 +328,6 @@ export class CityList {
|
||||
this.selectedFormState.set(null);
|
||||
this.cityForm.enable({ emitEvent: false });
|
||||
this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false });
|
||||
this.cityForm.controls.stateId.disable({ emitEvent: false });
|
||||
this.resetFormState();
|
||||
this.showCityModal.set(true);
|
||||
}
|
||||
@@ -368,16 +380,15 @@ export class CityList {
|
||||
private configureCityQueries(): void {
|
||||
this.cityQueryRequests$.pipe(
|
||||
switchMap(query => {
|
||||
const stateId = this.selectedStateId();
|
||||
return stateId
|
||||
? this.cityApi.getCityDataTable(query, stateId).pipe(
|
||||
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);
|
||||
})
|
||||
)
|
||||
: of(null);
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(response => {
|
||||
@@ -385,13 +396,9 @@ export class CityList {
|
||||
const query = this.queryState.getQuery();
|
||||
if (response.draw !== query.draw) return;
|
||||
|
||||
const stateName = this.selectedFilterState()?.name ?? '—';
|
||||
const countryName = this.selectedCountry()?.name ?? '—';
|
||||
this.cities.set(response.rows.map((city, index) => ({
|
||||
...city,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1,
|
||||
stateName,
|
||||
countryName
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
})));
|
||||
this.totalRecords.set(response.filtered);
|
||||
});
|
||||
@@ -415,8 +422,6 @@ export class CityList {
|
||||
? this.filterForm.controls.stateId.enable({ emitEvent: false })
|
||||
: this.filterForm.controls.stateId.disable({ emitEvent: false });
|
||||
|
||||
this.clearGrid();
|
||||
this.queryState.reset();
|
||||
});
|
||||
|
||||
this.filterForm.controls.stateId.valueChanges.pipe(
|
||||
@@ -428,9 +433,6 @@ export class CityList {
|
||||
}
|
||||
|
||||
this.selectedStateId.set(stateId || null);
|
||||
this.clearGrid();
|
||||
const query = this.queryState.reset();
|
||||
if (stateId) this.loadCities(query);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -447,9 +449,7 @@ export class CityList {
|
||||
|
||||
this.selectedFormState.set(null);
|
||||
this.cityForm.controls.stateId.reset('', { emitEvent: false });
|
||||
countryId
|
||||
? this.cityForm.controls.stateId.enable({ emitEvent: false })
|
||||
: this.cityForm.controls.stateId.disable({ emitEvent: false });
|
||||
this.cityForm.controls.stateId.enable({ emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -473,8 +473,8 @@ export class CityList {
|
||||
code: details.code ?? '',
|
||||
timezoneId: details.timezoneId
|
||||
}, { emitEvent: false });
|
||||
this.cityForm.controls.countryId.disable({ emitEvent: false });
|
||||
this.cityForm.controls.stateId.disable({ emitEvent: false });
|
||||
// this.cityForm.controls.countryId.disable({ emitEvent: false });
|
||||
// this.cityForm.controls.stateId.disable({ emitEvent: false });
|
||||
this.resetFormState();
|
||||
this.showCityModal.set(true);
|
||||
});
|
||||
@@ -528,7 +528,7 @@ export class CityList {
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
control?.focus();
|
||||
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
@@ -540,6 +540,8 @@ export class CityList {
|
||||
id: row.id,
|
||||
stateId: row.stateId,
|
||||
name: row.name,
|
||||
state: row.state,
|
||||
country: row.country,
|
||||
code: row.code,
|
||||
timezoneId: row.timezoneId,
|
||||
isActive: row.isActive,
|
||||
|
||||
@@ -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';
|
||||
@@ -1,6 +1,6 @@
|
||||
<app-data-table [columns]="columns()" [rows]="countries()" [actions]="actions()"
|
||||
(addClicked)="onAddCountry()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()" [pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Countries" buttonTitle="Add"
|
||||
[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">
|
||||
@@ -34,7 +34,7 @@
|
||||
<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"
|
||||
<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.'
|
||||
@@ -44,6 +44,7 @@
|
||||
<app-autocomplete
|
||||
formControlName="defaultCurrencyId"
|
||||
inputId="country-default-currency-id"
|
||||
variant="floating"
|
||||
label="Currency"
|
||||
placeholder="e.g.: USD"
|
||||
[searchFn]="searchCurrencies"
|
||||
@@ -62,7 +63,7 @@
|
||||
</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"
|
||||
<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.',
|
||||
@@ -73,7 +74,7 @@
|
||||
</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"
|
||||
<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.',
|
||||
@@ -84,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="phoneCode" inputId="country-phone-code" label="Phone Code" type="tel"
|
||||
<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.'
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
CountryModalMode,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest
|
||||
} from '../../../../../core/models/country/country.model';
|
||||
import { CurrencyLookupDto } from '../../../../../core/models/currency/currency.model';
|
||||
import { CountryService } from '../../../../../core/services/country/country.service';
|
||||
import { CurrencyService } from '../../../../../core/services/currency/currency.service';
|
||||
} 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,
|
||||
@@ -525,7 +524,7 @@ export class CountryList {
|
||||
queueMicrotask(() => {
|
||||
const firstInvalidControl =
|
||||
this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
|
||||
firstInvalidControl?.focus();
|
||||
|
||||
@@ -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';
|
||||
+122
-139
@@ -1,162 +1,145 @@
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="currencies()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]"
|
||||
tableTitle="Currencies"
|
||||
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="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)" />
|
||||
<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>
|
||||
}
|
||||
|
||||
<span class="font-semibold">
|
||||
{{ value }}
|
||||
@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>
|
||||
</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()"
|
||||
/>
|
||||
<app-confirm-dialog title="Delete Currency" text="Do you really want to delete this currency?"
|
||||
confirmButtonText="Delete" cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()" />
|
||||
|
||||
<modal
|
||||
[open]="showCurrencyModal()"
|
||||
[title]="currencyModalTitle()"
|
||||
size="md"
|
||||
[submitAction]="currencySubmitAction()"
|
||||
[submitLabel]="currencySubmitLabel()"
|
||||
[loadingLabel]="currencyLoadingLabel()"
|
||||
[loading]="saving()"
|
||||
(closed)="closeCurrencyModal()"
|
||||
(submitted)="saveCurrency()"
|
||||
>
|
||||
<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-6">
|
||||
<app-form-input
|
||||
formControlName="name"
|
||||
inputId="currency-name"
|
||||
label="Currency Name"
|
||||
placeholder="Name"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="100"
|
||||
[validationMessages]="{
|
||||
required: 'Currency Name is required.',
|
||||
maxlength: 'Currency Name cannot exceed 100 characters.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
<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-6">
|
||||
<app-form-input
|
||||
formControlName="code"
|
||||
inputId="currency-code"
|
||||
label="Currency Code"
|
||||
placeholder="e.g.: AED"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[minLength]="3"
|
||||
[maxLength]="3"
|
||||
[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 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-6">
|
||||
<app-form-input
|
||||
formControlName="symbol"
|
||||
inputId="currency-symbol"
|
||||
label="Currency Symbol"
|
||||
placeholder="e.g.: د.إ"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="8"
|
||||
[validationMessages]="{
|
||||
required: 'Currency Symbol is required.',
|
||||
maxlength: 'Currency Symbol cannot exceed 8 characters.'
|
||||
}"
|
||||
<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-6">
|
||||
<app-form-input
|
||||
formControlName="numericCode"
|
||||
inputId="currency-numeric-code"
|
||||
label="Numeric Code"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g.: 784"
|
||||
autocomplete="off"
|
||||
[required]="false"
|
||||
[min]="1"
|
||||
[max]="999"
|
||||
[step]="1"
|
||||
[validationMessages]="{
|
||||
required: 'Numeric Code is required.',
|
||||
min: 'Numeric Code must be at least 1.',
|
||||
max: 'Numeric Code cannot exceed 999.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
<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-6">
|
||||
<app-form-input
|
||||
formControlName="decimalDigits"
|
||||
inputId="currency-decimal-digits"
|
||||
label="Decimal Digits"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g.: 2"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[min]="0"
|
||||
[max]="4"
|
||||
[step]="1"
|
||||
[validationMessages]="{
|
||||
required: 'Decimal Digits is required.',
|
||||
min: 'Decimal Digits cannot be less than 0.',
|
||||
max: 'Decimal Digits cannot exceed 4.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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,
|
||||
@@ -10,11 +16,13 @@ import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
|
||||
|
||||
import {
|
||||
CreateCurrencyRequest,
|
||||
CurrencyCountryFlag,
|
||||
CurrencyDto,
|
||||
CurrencyIso2Value,
|
||||
CurrencyModalMode,
|
||||
UpdateCurrencyRequest
|
||||
} from '../../../../../core/models/currency/currency.model';
|
||||
import { CurrencyService } from '../../../../../core/services/currency/currency.service';
|
||||
} from '../../models/currency.model';
|
||||
import { CurrencyService } from '../../data-access/currency.service';
|
||||
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
|
||||
import {
|
||||
DataTableAction,
|
||||
@@ -27,14 +35,15 @@ import {
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
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: string;
|
||||
iso2: CurrencyIso2Value;
|
||||
name: string;
|
||||
symbol: string;
|
||||
numericCode: number;
|
||||
@@ -48,7 +57,7 @@ interface CurrencyTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'currency-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
|
||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog, CdkOverlayOrigin, CdkConnectedOverlay],
|
||||
templateUrl: './currency-list.html',
|
||||
styleUrl: './currency-list.scss',
|
||||
})
|
||||
@@ -74,6 +83,18 @@ export class CurrencyList {
|
||||
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: [
|
||||
@@ -144,7 +165,7 @@ export class CurrencyList {
|
||||
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 },
|
||||
{ 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 },
|
||||
@@ -390,7 +411,7 @@ export class CurrencyList {
|
||||
name: currencyDetails.name ?? '',
|
||||
code: currencyDetails.code ?? '',
|
||||
symbol: currencyDetails.symbol ?? '',
|
||||
numericCode: currencyDetails.numericCode ?? 0,
|
||||
numericCode: currencyDetails.numericCode ?? '0',
|
||||
decimalDigits: currencyDetails.decimalDigits ?? 2
|
||||
});
|
||||
this.resetCurrencyFormState();
|
||||
@@ -482,7 +503,7 @@ export class CurrencyList {
|
||||
queueMicrotask(() => {
|
||||
const firstInvalidControl =
|
||||
this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
|
||||
firstInvalidControl?.focus();
|
||||
@@ -497,7 +518,7 @@ export class CurrencyList {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
iso2: row.iso2?.toLowerCase() ?? '',
|
||||
iso2: row.iso2,
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
numericCode: row.numericCode,
|
||||
@@ -508,8 +529,87 @@ export class CurrencyList {
|
||||
};
|
||||
}
|
||||
|
||||
getFlagUrl(iso2: string | null | undefined): string {
|
||||
const code = iso2?.trim().toLowerCase();
|
||||
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`
|
||||
@@ -517,6 +617,7 @@ export class CurrencyList {
|
||||
}
|
||||
onFlagError(event: Event): void {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.style.display = 'none';
|
||||
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';
|
||||
@@ -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';
|
||||
@@ -1,24 +1,8 @@
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="languages()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]"
|
||||
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)"
|
||||
>
|
||||
<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>
|
||||
@@ -27,26 +11,13 @@
|
||||
</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()"
|
||||
/>
|
||||
<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()"
|
||||
>
|
||||
<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>
|
||||
@@ -56,64 +27,37 @@
|
||||
<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"
|
||||
label="Language Name"
|
||||
placeholder="e.g.: English"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="100"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
<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"
|
||||
label="Language Code"
|
||||
placeholder="e.g.: en-US"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="35"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
<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"
|
||||
label="Native Name"
|
||||
placeholder="e.g.: English"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="100"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
<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-7">
|
||||
|
||||
|
||||
<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"
|
||||
/>
|
||||
<input id="language-rtl" type="checkbox" formControlName="isRightToLeft" class="form-check-input" />
|
||||
<span>Right To Left Language</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
LanguageDto,
|
||||
LanguageModalMode,
|
||||
UpdateLanguageRequest
|
||||
} from '../../../../../core/models/language/language.model';
|
||||
import { LanguageService } from '../../../../../core/services/language/language.service';
|
||||
} 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 {
|
||||
@@ -347,7 +347,7 @@ export class LanguageList {
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
|
||||
'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
-1
@@ -1,4 +1,4 @@
|
||||
import { buildApiUrl } from '../../config/api-url.util';
|
||||
import { buildApiUrl } from '../../../../core/config/api-url.util';
|
||||
|
||||
|
||||
export const STATE_ENDPOINTS = {
|
||||
@@ -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';
|
||||
@@ -1,12 +1,13 @@
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<app-filter-card title="Country Selection" bodyClass="p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<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"
|
||||
@@ -17,21 +18,25 @@
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideLabel]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onCountryLookupSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<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()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
||||
tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
||||
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
|
||||
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||
@@ -55,6 +60,7 @@
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="state-country"
|
||||
variant="floating"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
@@ -78,7 +84,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name"
|
||||
<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.'
|
||||
@@ -86,7 +92,7 @@
|
||||
</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"
|
||||
<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.',
|
||||
|
||||
@@ -10,15 +10,15 @@ import { Subject, catchError, distinctUntilChanged, finalize, map, of, switchMap
|
||||
|
||||
import {
|
||||
CountryLookupDto
|
||||
} from '../../../../../core/models/country/country.model';
|
||||
} from '../../../countries/public-api';
|
||||
import {
|
||||
CreateStateRequest,
|
||||
StateDto,
|
||||
StateModalMode,
|
||||
UpdateStateRequest
|
||||
} from '../../../../../core/models/state/state.model';
|
||||
import { CountryService } from '../../../../../core/services/country/country.service';
|
||||
import { StateService } from '../../../../../core/services/state/state.service';
|
||||
} 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,
|
||||
@@ -69,7 +69,6 @@ export class StateList {
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly stateQueryRequests$ = new Subject<DataTableQuery>();
|
||||
private readonly defaultCountrySelectionApplied = signal(false);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
|
||||
@@ -77,6 +76,7 @@ export class StateList {
|
||||
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);
|
||||
@@ -127,13 +127,13 @@ export class StateList {
|
||||
});
|
||||
|
||||
readonly emptyMessage = computed(() =>
|
||||
this.selectedCountryId()
|
||||
this.appliedCountryId()
|
||||
? 'No states found'
|
||||
: 'No records found'
|
||||
);
|
||||
|
||||
readonly emptyDescription = computed(() =>
|
||||
this.selectedCountryId()
|
||||
this.appliedCountryId()
|
||||
? 'There are no states available for the selected country.'
|
||||
: 'There is currently no data to display.'
|
||||
);
|
||||
@@ -217,11 +217,7 @@ export class StateList {
|
||||
this.stateQueryRequests$
|
||||
.pipe(
|
||||
switchMap(query => {
|
||||
const countryId = this.selectedCountryId();
|
||||
|
||||
if (!countryId) {
|
||||
return of(null);
|
||||
}
|
||||
const countryId = this.appliedCountryId();
|
||||
|
||||
return this.stateApi.getStateDataTable(query, countryId).pipe(
|
||||
catchError(() => {
|
||||
@@ -256,15 +252,10 @@ export class StateList {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.applyInitialDefaultCountry();
|
||||
this.loadStates(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
loadStates(query: DataTableQuery): void {
|
||||
if (!this.selectedCountryId()) {
|
||||
this.clearStateGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateQueryRequests$.next(query);
|
||||
}
|
||||
|
||||
@@ -272,6 +263,16 @@ export class StateList {
|
||||
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);
|
||||
}
|
||||
@@ -431,44 +432,6 @@ export class StateList {
|
||||
this.selectedCountryLookup.set(null);
|
||||
}
|
||||
|
||||
this.clearStateGrid();
|
||||
|
||||
const query = this.queryState.reset();
|
||||
|
||||
if (countryId) {
|
||||
this.loadStates(query);
|
||||
}
|
||||
}
|
||||
|
||||
private applyInitialDefaultCountry(): void {
|
||||
if (
|
||||
this.defaultCountrySelectionApplied() ||
|
||||
this.selectedCountryId()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultCountrySelectionApplied.set(true);
|
||||
|
||||
this.countryApi.autocomplete('AE', 50).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load countries.');
|
||||
return of<CountryLookupDto[]>([]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(countries => {
|
||||
const uae = countries.find(country =>
|
||||
country.iso2.trim().toUpperCase() === 'AE'
|
||||
);
|
||||
|
||||
if (!uae) {
|
||||
this.clearStateGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedCountryLookup.set(uae);
|
||||
this.countryFilterForm.controls.countryId.setValue(uae.id);
|
||||
});
|
||||
}
|
||||
|
||||
private clearStateGrid(): void {
|
||||
@@ -582,7 +545,7 @@ export class StateList {
|
||||
queueMicrotask(() => {
|
||||
const firstInvalidControl =
|
||||
this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
|
||||
firstInvalidControl?.focus();
|
||||
|
||||
@@ -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';
|
||||
@@ -5,7 +5,6 @@
|
||||
[totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]"
|
||||
tableTitle="Timezones"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
@@ -61,6 +60,7 @@
|
||||
<app-form-input
|
||||
formControlName="ianaId"
|
||||
inputId="timezone-iana-id"
|
||||
variant="floating"
|
||||
label="IANA Timezone ID"
|
||||
placeholder="Id"
|
||||
help="e.g.: Asia/Kolkata"
|
||||
@@ -80,6 +80,7 @@
|
||||
<app-form-input
|
||||
formControlName="displayName"
|
||||
inputId="timezone-display-name"
|
||||
variant="floating"
|
||||
label="Display Name"
|
||||
placeholder="Name"
|
||||
help="e.g.: India Standard Time"
|
||||
@@ -99,6 +100,7 @@
|
||||
<app-form-input
|
||||
formControlName="utcOffsetMinutes"
|
||||
inputId="timezone-utc-offset"
|
||||
variant="floating"
|
||||
label="UTC Offset (minutes)"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
TimezoneDto,
|
||||
TimezoneModalMode,
|
||||
UpdateTimezoneRequest
|
||||
} from '../../../../../core/models/timezone/timezone.model';
|
||||
import { TimezoneService } from '../../../../../core/services/timezone/timezone.service';
|
||||
} 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 {
|
||||
@@ -342,7 +342,7 @@ export class TimezoneList implements OnInit {
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => this.elementRef.nativeElement
|
||||
.querySelector<HTMLElement>('modal .form-control.is-invalid, modal [aria-invalid="true"]')
|
||||
.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 {
|
||||
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
<div class="w-full">
|
||||
<!-- Stepper Nav -->
|
||||
<nav aria-label="Organization onboarding progress">
|
||||
<ul
|
||||
class="relative flex-row gap-x-2 space-y-4
|
||||
sm:flex sm:space-y-0"
|
||||
>
|
||||
@for (step of steps(); track step.key; let index = $index) {
|
||||
<li
|
||||
class="group flex flex-1 basis-0 shrink items-center gap-x-2"
|
||||
[class.active]="isCurrentStep(index)"
|
||||
[class.success]="isCompletedStep(index)"
|
||||
[attr.data-hs-stepper-nav-item]="
|
||||
'{"index":' + (index + 1) + '}'
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex min-h-7 min-w-7 items-center
|
||||
text-start text-xs align-middle
|
||||
focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||
[class.cursor-pointer]="canOpenStep(index)"
|
||||
[class.cursor-default]="!canOpenStep(index)"
|
||||
[attr.aria-current]="
|
||||
isCurrentStep(index) ? 'step' : null
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
canOpenStep(index) ? null : true
|
||||
"
|
||||
(click)="selectStep(index)"
|
||||
>
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center
|
||||
rounded-full bg-gray-100 font-medium text-gray-800
|
||||
group-focus:bg-gray-200 dark:bg-bodybg dark:text-white
|
||||
dark:group-focus:bg-gray-600
|
||||
hs-stepper-active:!bg-primary
|
||||
hs-stepper-active:!text-white
|
||||
hs-stepper-success:!bg-primary
|
||||
hs-stepper-success:!text-white
|
||||
hs-stepper-completed:!bg-success
|
||||
hs-stepper-completed:group-focus:!bg-success"
|
||||
>
|
||||
<span
|
||||
class="hs-stepper-success:hidden
|
||||
hs-stepper-completed:hidden"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
|
||||
<svg
|
||||
class="hidden size-3 shrink-0 hs-stepper-success:block"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
|
||||
<span class="sr-only">
|
||||
@if (isCompletedStep(index)) {
|
||||
Completed
|
||||
} @else if (isCurrentStep(index)) {
|
||||
Current step
|
||||
} @else {
|
||||
Pending
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="ms-2 whitespace-nowrap text-sm font-medium
|
||||
text-gray-800 dark:text-white
|
||||
hs-stepper-active:!text-primary
|
||||
hs-stepper-success:!text-primary
|
||||
hs-stepper-completed:!text-success"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (index < steps().length - 1) {
|
||||
<div
|
||||
class="hidden h-px w-full flex-1 bg-gray-200
|
||||
dark:bg-bodybg sm:block
|
||||
hs-stepper-success:!bg-primary
|
||||
hs-stepper-completed:!bg-success"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- End Stepper Nav -->
|
||||
|
||||
<!-- Stepper Content -->
|
||||
<div class="mt-5 sm:mt-8">
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-gray-200
|
||||
bg-gray-50 p-6 dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
|
||||
<!-- Button Group -->
|
||||
<footer
|
||||
class="mt-5 flex flex-col-reverse gap-3
|
||||
sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<!-- Cancel or Back -->
|
||||
<div class="flex w-full sm:w-auto">
|
||||
@if (isFirstStep()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-light h-10 w-full justify-center
|
||||
gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="navigationDisabled()"
|
||||
(click)="requestCancel()"
|
||||
>
|
||||
<i class="ri-close-line" aria-hidden="true"></i>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-light h-10 w-full justify-center
|
||||
gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="navigationDisabled()"
|
||||
(click)="requestBack()"
|
||||
>
|
||||
<i
|
||||
class="ri-arrow-left-s-line rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
|
||||
<span>Back</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Save and Next/Finish -->
|
||||
<div
|
||||
class="flex w-full flex-col gap-2
|
||||
sm:w-auto sm:flex-row"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-outline-primary h-10 w-full
|
||||
justify-center gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="
|
||||
navigationDisabled() ||
|
||||
savingDraft() ||
|
||||
finishing()
|
||||
"
|
||||
(click)="requestSaveDraft()"
|
||||
>
|
||||
@if (savingDraft()) {
|
||||
<span
|
||||
class="size-4 animate-spin rounded-full border-2
|
||||
border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<span>Saving...</span>
|
||||
} @else {
|
||||
<i class="ri-save-line" aria-hidden="true"></i>
|
||||
<span>Save Draft</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (!isLastStep()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-primary-full h-10 w-full
|
||||
justify-center gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="
|
||||
navigationDisabled() ||
|
||||
savingDraft() ||
|
||||
finishing()
|
||||
"
|
||||
(click)="requestNext()"
|
||||
>
|
||||
<span>Next</span>
|
||||
|
||||
<i
|
||||
class="ri-arrow-right-s-line rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-success-full h-10 w-full
|
||||
justify-center gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="
|
||||
navigationDisabled() ||
|
||||
savingDraft() ||
|
||||
finishing()
|
||||
"
|
||||
(click)="requestFinish()"
|
||||
>
|
||||
@if (finishing()) {
|
||||
<span
|
||||
class="size-4 animate-spin rounded-full border-2
|
||||
border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<span>Provisioning...</span>
|
||||
} @else {
|
||||
<i
|
||||
class="ri-checkbox-circle-line"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
|
||||
<span>Finish & Provision</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</footer>
|
||||
<!-- End Button Group -->
|
||||
</div>
|
||||
<!-- End Stepper Content -->
|
||||
</div>
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
|
||||
export interface OnboardingStep {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'onboarding-stepper',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './onboarding-stepper.html',
|
||||
styleUrl: './onboarding-stepper.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class OnboardingStepper {
|
||||
readonly steps = input.required<readonly OnboardingStep[]>();
|
||||
readonly currentStepIndex = input.required<number>();
|
||||
readonly completedStepIndexes = input<readonly number[]>([]);
|
||||
|
||||
readonly savingDraft = input(false);
|
||||
readonly finishing = input(false);
|
||||
readonly navigationDisabled = input(false);
|
||||
|
||||
readonly stepSelected = output<number>();
|
||||
readonly backClicked = output<void>();
|
||||
readonly nextClicked = output<void>();
|
||||
readonly saveDraftClicked = output<void>();
|
||||
readonly finishClicked = output<void>();
|
||||
readonly cancelClicked = output<void>();
|
||||
|
||||
isFirstStep(): boolean {
|
||||
return this.currentStepIndex() === 0;
|
||||
}
|
||||
|
||||
isLastStep(): boolean {
|
||||
return this.currentStepIndex() === this.steps().length - 1;
|
||||
}
|
||||
|
||||
isCurrentStep(index: number): boolean {
|
||||
return index === this.currentStepIndex();
|
||||
}
|
||||
|
||||
isCompletedStep(index: number): boolean {
|
||||
return this.completedStepIndexes().includes(index);
|
||||
}
|
||||
|
||||
isPendingStep(index: number): boolean {
|
||||
return !this.isCurrentStep(index) && !this.isCompletedStep(index);
|
||||
}
|
||||
|
||||
canOpenStep(index: number): boolean {
|
||||
if (this.navigationDisabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
index < this.currentStepIndex() ||
|
||||
this.isCompletedStep(index)
|
||||
);
|
||||
}
|
||||
|
||||
selectStep(index: number): void {
|
||||
if (!this.canOpenStep(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stepSelected.emit(index);
|
||||
}
|
||||
|
||||
requestBack(): void {
|
||||
if (this.navigationDisabled() || this.isFirstStep()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
requestNext(): void {
|
||||
if (this.navigationDisabled() || this.isLastStep()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextClicked.emit();
|
||||
}
|
||||
|
||||
requestSaveDraft(): void {
|
||||
if (this.navigationDisabled() || this.savingDraft()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveDraftClicked.emit();
|
||||
}
|
||||
|
||||
requestFinish(): void {
|
||||
if (
|
||||
this.navigationDisabled() ||
|
||||
this.finishing() ||
|
||||
!this.isLastStep()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.finishClicked.emit();
|
||||
}
|
||||
|
||||
requestCancel(): void {
|
||||
if (this.navigationDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelClicked.emit();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
OrganizationAdminValue,
|
||||
OrganizationBasicsValue,
|
||||
OrganizationLocalizationValue,
|
||||
OrganizationPlanLimitsValue,
|
||||
} from './organization-onboarding.model';
|
||||
|
||||
export interface OrganizationOnboardingDraft {
|
||||
readonly schemaVersion: 1;
|
||||
readonly draftId: string | null;
|
||||
readonly currentStepIndex: number;
|
||||
readonly completedStepIndexes: readonly number[];
|
||||
readonly basics: Partial<OrganizationBasicsValue> | null;
|
||||
readonly localization: Partial<OrganizationLocalizationValue> | null;
|
||||
readonly planLimits: Partial<OrganizationPlanLimitsValue> | null;
|
||||
readonly admin: Partial<OrganizationAdminValue> | null;
|
||||
readonly savedAt: string;
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
export type OnboardingStepKey =
|
||||
| 'basics'
|
||||
| 'localization'
|
||||
| 'plan-limits'
|
||||
| 'admin-user';
|
||||
|
||||
export interface OnboardingStepDefinition {
|
||||
readonly key: OnboardingStepKey;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface OnboardingLookupValue {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly secondaryLabel?: string | null;
|
||||
}
|
||||
|
||||
export interface CountryLookupValue extends OnboardingLookupValue {
|
||||
readonly iso2?: string | null;
|
||||
}
|
||||
|
||||
export interface OrganizationBasicsValue {
|
||||
readonly organizationCode: string | null;
|
||||
readonly organizationName: string;
|
||||
readonly shortName: string;
|
||||
readonly localLanguageName: string | null;
|
||||
readonly organizationType: OnboardingLookupValue;
|
||||
readonly industry: OnboardingLookupValue;
|
||||
readonly registrationCountry: CountryLookupValue;
|
||||
}
|
||||
|
||||
export type TimeFormatValue = 'TwelveHour' | 'TwentyFourHour';
|
||||
|
||||
export type DateFormatValue =
|
||||
| 'DD/MM/YYYY'
|
||||
| 'MM/DD/YYYY'
|
||||
| 'YYYY-MM-DD';
|
||||
|
||||
export type NumberFormatValue =
|
||||
| 'OneTwoThreeCommaFourFiveSixPointSevenEight'
|
||||
| 'OneTwoThreePointFourFiveSixCommaSevenEight';
|
||||
|
||||
export type FiscalYearConventionValue =
|
||||
| 'CalendarYear'
|
||||
| 'AprilToMarch'
|
||||
| 'JulyToJune';
|
||||
|
||||
export interface OrganizationLocalizationValue {
|
||||
readonly timeZone: OnboardingLookupValue;
|
||||
readonly currency: OnboardingLookupValue;
|
||||
readonly defaultLanguage: OnboardingLookupValue;
|
||||
readonly additionalLanguageIds: readonly string[];
|
||||
readonly additionalLanguageSelections: readonly OnboardingLookupValue[];
|
||||
readonly dateFormat: DateFormatValue;
|
||||
readonly timeFormat: TimeFormatValue;
|
||||
readonly numberFormat: NumberFormatValue;
|
||||
readonly fiscalYearConvention: FiscalYearConventionValue;
|
||||
readonly localizationDefaultsCountryId: string | null;
|
||||
}
|
||||
|
||||
export type OrganizationLicenseType = 'Trial' | 'Paid';
|
||||
|
||||
export interface OrganizationPlanLookupValue extends OnboardingLookupValue {
|
||||
readonly code?: string | null;
|
||||
}
|
||||
|
||||
export interface OrganizationPlanLimitsValue {
|
||||
readonly subscriptionPlan: OrganizationPlanLookupValue;
|
||||
readonly licenseType: OrganizationLicenseType;
|
||||
readonly maximumCompanies: number;
|
||||
readonly maximumUsers: number;
|
||||
readonly maximumStorageGb: number;
|
||||
readonly goLiveDate: string;
|
||||
readonly systemAccessStartDate: string;
|
||||
readonly systemAccessEndDate: string | null;
|
||||
readonly limitsEditable: boolean;
|
||||
}
|
||||
|
||||
export interface OrganizationAdminValue {
|
||||
readonly organizationEmail: string;
|
||||
readonly organizationPhone: string;
|
||||
readonly administratorFullName: string;
|
||||
readonly administratorEmail: string;
|
||||
readonly administratorMobile: string;
|
||||
}
|
||||
|
||||
export interface OrganizationOnboardingData {
|
||||
readonly basics: OrganizationBasicsValue | null;
|
||||
readonly localization: OrganizationLocalizationValue | null;
|
||||
readonly planLimits: OrganizationPlanLimitsValue | null;
|
||||
readonly admin: OrganizationAdminValue | null;
|
||||
}
|
||||
|
||||
export interface CountryLocalizationDefaults {
|
||||
readonly countryId: string;
|
||||
readonly timeZone: OnboardingLookupValue;
|
||||
readonly currency: OnboardingLookupValue;
|
||||
readonly defaultLanguage: OnboardingLookupValue;
|
||||
readonly dateFormat: DateFormatValue;
|
||||
readonly timeFormat: TimeFormatValue;
|
||||
readonly numberFormat: NumberFormatValue;
|
||||
readonly fiscalYearConvention: FiscalYearConventionValue;
|
||||
}
|
||||
|
||||
export interface OrganizationPlanDefaults {
|
||||
readonly plan: OrganizationPlanLookupValue;
|
||||
readonly licenseType: OrganizationLicenseType;
|
||||
readonly maximumCompanies: number;
|
||||
readonly maximumUsers: number;
|
||||
readonly maximumStorageGb: number;
|
||||
readonly limitsEditable: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStepForm<TValue> {
|
||||
validate(): boolean;
|
||||
getValue(): TValue;
|
||||
getDraftValue(): Partial<TValue>;
|
||||
patchValue(value: Partial<TValue>): void;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
OrganizationAdminValue,
|
||||
OrganizationBasicsValue,
|
||||
OrganizationLocalizationValue,
|
||||
OrganizationPlanLimitsValue,
|
||||
} from './organization-onboarding.model';
|
||||
|
||||
export interface OrganizationProvisioningRequest {
|
||||
readonly basics: OrganizationBasicsValue;
|
||||
readonly localization: OrganizationLocalizationValue;
|
||||
readonly planLimits: OrganizationPlanLimitsValue;
|
||||
readonly admin: OrganizationAdminValue;
|
||||
}
|
||||
|
||||
export type OrganizationProvisioningStatus =
|
||||
| 'not-configured'
|
||||
| 'submitted'
|
||||
| 'failed';
|
||||
|
||||
export interface OrganizationProvisioningResult {
|
||||
readonly status: OrganizationProvisioningStatus;
|
||||
readonly message: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<div class="main-content">
|
||||
<div class="container-fluid">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-semibold text-defaulttextcolor">
|
||||
Create Organization
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="rounded-xl border border-defaultborder bg-white
|
||||
p-5 shadow-sm dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
<onboarding-stepper
|
||||
[steps]="steps"
|
||||
[currentStepIndex]="currentStepIndex()"
|
||||
[completedStepIndexes]="completedStepIndexes()"
|
||||
[savingDraft]="savingDraft()"
|
||||
[finishing]="finishing()"
|
||||
[navigationDisabled]="navigationDisabled()"
|
||||
(stepSelected)="onStepSelected($event)"
|
||||
(backClicked)="onBack()"
|
||||
(nextClicked)="onNext()"
|
||||
(saveDraftClicked)="onSaveDraft()"
|
||||
(finishClicked)="onFinish()"
|
||||
(cancelClicked)="onCancel()"
|
||||
>
|
||||
@switch (currentStep().key) {
|
||||
@case ('basics') {
|
||||
<app-organization-basics-step #basicsStep />
|
||||
}
|
||||
|
||||
@case ('localization') {
|
||||
<div
|
||||
class="min-h-72 rounded-xl border border-dashed
|
||||
border-defaultborder bg-gray-50 p-6
|
||||
dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
Localization content
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('plan-limits') {
|
||||
<div
|
||||
class="min-h-72 rounded-xl border border-dashed
|
||||
border-defaultborder bg-gray-50 p-6
|
||||
dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
Plan and Limits content
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('admin-user') {
|
||||
<div
|
||||
class="min-h-72 rounded-xl border border-dashed
|
||||
border-defaultborder bg-gray-50 p-6
|
||||
dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
Admin and User content
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</onboarding-stepper>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError, finalize, of } from 'rxjs';
|
||||
import {
|
||||
OnboardingStep,
|
||||
OnboardingStepper
|
||||
} from './components/onboarding-stepper/onboarding-stepper';
|
||||
import { OrganizationBasicsStepComponent } from './steps/organization-basics/organization-basics';
|
||||
import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service';
|
||||
import { OrganizationOnboardingService } from './services/organization-onboarding.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-onboarding',
|
||||
standalone: true,
|
||||
imports: [OnboardingStepper, OrganizationBasicsStepComponent],
|
||||
templateUrl: './organization-onboarding.html',
|
||||
styleUrl: './organization-onboarding.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [OrganizationOnboardingStateService, OrganizationOnboardingService]
|
||||
})
|
||||
export class OrganizationOnboarding {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly stateService = inject(OrganizationOnboardingStateService);
|
||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||
|
||||
readonly steps: readonly OnboardingStep[] = this.stateService.createStepDefinitions();
|
||||
|
||||
readonly savingDraft = signal(false);
|
||||
readonly finishing = signal(false);
|
||||
|
||||
private basicsStep?: OrganizationBasicsStepComponent;
|
||||
|
||||
readonly currentStepIndex = this.stateService.currentStepIndex;
|
||||
readonly completedStepIndexes = this.stateService.completedStepIndexes;
|
||||
|
||||
readonly currentStep = computed(
|
||||
() => this.steps[this.currentStepIndex()]
|
||||
);
|
||||
|
||||
readonly navigationDisabled = computed(
|
||||
() => this.savingDraft() || this.finishing()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.onboardingService.loadDraft()
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to restore the onboarding draft.', 'Draft restore failed');
|
||||
return of(null);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(draft => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateService.restoreDraft(draft);
|
||||
});
|
||||
}
|
||||
|
||||
registerBasicsStep(component: OrganizationBasicsStepComponent): void {
|
||||
this.basicsStep = component;
|
||||
const basicsDraft = this.stateService.onboardingDraft().basics;
|
||||
|
||||
if (basicsDraft) {
|
||||
component.patchValue(basicsDraft);
|
||||
}
|
||||
}
|
||||
|
||||
onStepSelected(index: number): void {
|
||||
if (!this.stateService.canOpenStep(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateService.setCurrentStepIndex(index);
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
const currentIndex = this.currentStepIndex();
|
||||
|
||||
if (currentIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateService.setCurrentStepIndex(currentIndex - 1);
|
||||
}
|
||||
|
||||
onNext(): void {
|
||||
const currentIndex = this.currentStepIndex();
|
||||
|
||||
if (currentIndex >= this.steps.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentIndex === 0) {
|
||||
if (!this.basicsStep?.validate()) {
|
||||
this.stateService.unmarkStepCompleted(currentIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateService.updateBasics(this.basicsStep.getValue());
|
||||
}
|
||||
|
||||
this.stateService.markStepCompleted(currentIndex);
|
||||
this.stateService.setCurrentStepIndex(currentIndex + 1);
|
||||
}
|
||||
|
||||
onSaveDraft(): void {
|
||||
if (this.savingDraft()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentStepIndex() === 0 && this.basicsStep) {
|
||||
this.stateService.updateBasicsDraft(this.basicsStep.getDraftValue());
|
||||
}
|
||||
|
||||
this.savingDraft.set(true);
|
||||
|
||||
this.onboardingService.saveDraft(this.stateService.buildDraft())
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.savingDraft.set(false);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Onboarding draft saved successfully.', 'Draft saved');
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to save the onboarding draft.', 'Draft save failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFinish(): void {
|
||||
if (this.finishing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.finishing.set(true);
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.finishing.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.stateService.clear();
|
||||
void this.router.navigate(['/configuration/organizations']);
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import { OrganizationOnboardingDraft } from '../models/organization-draft.model';
|
||||
import {
|
||||
OnboardingStepDefinition,
|
||||
OrganizationAdminValue,
|
||||
OrganizationBasicsValue,
|
||||
OrganizationLocalizationValue,
|
||||
OrganizationOnboardingData,
|
||||
OrganizationPlanLimitsValue,
|
||||
} from '../models/organization-onboarding.model';
|
||||
|
||||
const INITIAL_DATA: OrganizationOnboardingData = {
|
||||
basics: null,
|
||||
localization: null,
|
||||
planLimits: null,
|
||||
admin: null,
|
||||
};
|
||||
|
||||
type OnboardingDraftState = {
|
||||
readonly basics: Partial<OrganizationBasicsValue> | null;
|
||||
readonly localization: Partial<OrganizationLocalizationValue> | null;
|
||||
readonly planLimits: Partial<OrganizationPlanLimitsValue> | null;
|
||||
readonly admin: Partial<OrganizationAdminValue> | null;
|
||||
};
|
||||
|
||||
const INITIAL_DRAFT_STATE: OnboardingDraftState = {
|
||||
basics: null,
|
||||
localization: null,
|
||||
planLimits: null,
|
||||
admin: null,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationOnboardingStateService {
|
||||
private readonly onboardingDataState = signal<OrganizationOnboardingData>(INITIAL_DATA);
|
||||
private readonly onboardingDraftState = signal<OnboardingDraftState>(INITIAL_DRAFT_STATE);
|
||||
private readonly currentStepIndexState = signal(0);
|
||||
private readonly completedStepIndexesState = signal<readonly number[]>([]);
|
||||
private readonly savedDraftIdState = signal<string | null>(null);
|
||||
private readonly savedAtState = signal<string | null>(null);
|
||||
|
||||
readonly onboardingData = this.onboardingDataState.asReadonly();
|
||||
readonly onboardingDraft = this.onboardingDraftState.asReadonly();
|
||||
readonly currentStepIndex = this.currentStepIndexState.asReadonly();
|
||||
readonly completedStepIndexes = this.completedStepIndexesState.asReadonly();
|
||||
readonly savedDraftId = this.savedDraftIdState.asReadonly();
|
||||
readonly savedAt = this.savedAtState.asReadonly();
|
||||
|
||||
readonly basics = computed(() => this.onboardingDataState().basics);
|
||||
readonly localization = computed(() => this.onboardingDataState().localization);
|
||||
readonly planLimits = computed(() => this.onboardingDataState().planLimits);
|
||||
readonly admin = computed(() => this.onboardingDataState().admin);
|
||||
readonly hasAnyData = computed(() => {
|
||||
const data = this.onboardingDataState();
|
||||
return !!(data.basics || data.localization || data.planLimits || data.admin);
|
||||
});
|
||||
|
||||
setCurrentStepIndex(index: number): void {
|
||||
this.currentStepIndexState.set(Math.max(0, index));
|
||||
}
|
||||
|
||||
updateBasics(value: OrganizationBasicsValue | null): void {
|
||||
this.onboardingDataState.update(current => ({ ...current, basics: value }));
|
||||
this.onboardingDraftState.update(current => ({ ...current, basics: value }));
|
||||
}
|
||||
|
||||
updateLocalization(value: OrganizationLocalizationValue | null): void {
|
||||
this.onboardingDataState.update(current => ({ ...current, localization: value }));
|
||||
this.onboardingDraftState.update(current => ({ ...current, localization: value }));
|
||||
}
|
||||
|
||||
updatePlanLimits(value: OrganizationPlanLimitsValue | null): void {
|
||||
this.onboardingDataState.update(current => ({ ...current, planLimits: value }));
|
||||
this.onboardingDraftState.update(current => ({ ...current, planLimits: value }));
|
||||
}
|
||||
|
||||
updateAdmin(value: OrganizationAdminValue | null): void {
|
||||
this.onboardingDataState.update(current => ({ ...current, admin: value }));
|
||||
this.onboardingDraftState.update(current => ({ ...current, admin: value }));
|
||||
}
|
||||
|
||||
updateBasicsDraft(value: Partial<OrganizationBasicsValue> | null): void {
|
||||
this.onboardingDraftState.update(current => ({ ...current, basics: value }));
|
||||
}
|
||||
|
||||
updateLocalizationDraft(value: Partial<OrganizationLocalizationValue> | null): void {
|
||||
this.onboardingDraftState.update(current => ({ ...current, localization: value }));
|
||||
}
|
||||
|
||||
updatePlanLimitsDraft(value: Partial<OrganizationPlanLimitsValue> | null): void {
|
||||
this.onboardingDraftState.update(current => ({ ...current, planLimits: value }));
|
||||
}
|
||||
|
||||
updateAdminDraft(value: Partial<OrganizationAdminValue> | null): void {
|
||||
this.onboardingDraftState.update(current => ({ ...current, admin: value }));
|
||||
}
|
||||
|
||||
markStepCompleted(index: number): void {
|
||||
this.completedStepIndexesState.update(current =>
|
||||
current.includes(index)
|
||||
? current
|
||||
: [...current, index].sort((left, right) => left - right)
|
||||
);
|
||||
}
|
||||
|
||||
unmarkStepCompleted(index: number): void {
|
||||
this.completedStepIndexesState.update(current => current.filter(item => item !== index));
|
||||
}
|
||||
|
||||
setCompletedStepIndexes(indexes: readonly number[]): void {
|
||||
const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0))].sort((left, right) => left - right);
|
||||
this.completedStepIndexesState.set(uniqueIndexes);
|
||||
}
|
||||
|
||||
canOpenStep(index: number): boolean {
|
||||
return index <= this.currentStepIndexState() || this.completedStepIndexesState().includes(index);
|
||||
}
|
||||
|
||||
isStepCompleted(index: number): boolean {
|
||||
return this.completedStepIndexesState().includes(index);
|
||||
}
|
||||
|
||||
buildDraft(): OrganizationOnboardingDraft {
|
||||
const draft = this.onboardingDraftState();
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
draftId: this.savedDraftIdState(),
|
||||
currentStepIndex: this.currentStepIndexState(),
|
||||
completedStepIndexes: this.completedStepIndexesState(),
|
||||
basics: draft.basics,
|
||||
localization: draft.localization,
|
||||
planLimits: draft.planLimits,
|
||||
admin: draft.admin,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
restoreDraft(draft: OrganizationOnboardingDraft): void {
|
||||
this.onboardingDraftState.set({
|
||||
basics: draft.basics ? { ...draft.basics } : null,
|
||||
localization: draft.localization ? { ...draft.localization } : null,
|
||||
planLimits: draft.planLimits ? { ...draft.planLimits } : null,
|
||||
admin: draft.admin ? { ...draft.admin } : null,
|
||||
});
|
||||
this.onboardingDataState.set({
|
||||
basics: draft.completedStepIndexes.includes(0) && draft.basics
|
||||
? { ...draft.basics } as OrganizationBasicsValue
|
||||
: null,
|
||||
localization: draft.completedStepIndexes.includes(1) && draft.localization
|
||||
? { ...draft.localization } as OrganizationLocalizationValue
|
||||
: null,
|
||||
planLimits: draft.completedStepIndexes.includes(2) && draft.planLimits
|
||||
? { ...draft.planLimits } as OrganizationPlanLimitsValue
|
||||
: null,
|
||||
admin: draft.completedStepIndexes.includes(3) && draft.admin
|
||||
? { ...draft.admin } as OrganizationAdminValue
|
||||
: null,
|
||||
});
|
||||
this.currentStepIndexState.set(Math.max(0, draft.currentStepIndex));
|
||||
this.setCompletedStepIndexes(draft.completedStepIndexes);
|
||||
this.savedDraftIdState.set(draft.draftId);
|
||||
this.savedAtState.set(draft.savedAt);
|
||||
}
|
||||
|
||||
updateSavedDraftMetadata(draftId: string | null, savedAt: string): void {
|
||||
this.savedDraftIdState.set(draftId);
|
||||
this.savedAtState.set(savedAt);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.onboardingDataState.set(INITIAL_DATA);
|
||||
this.onboardingDraftState.set(INITIAL_DRAFT_STATE);
|
||||
this.currentStepIndexState.set(0);
|
||||
this.completedStepIndexesState.set([]);
|
||||
this.savedDraftIdState.set(null);
|
||||
this.savedAtState.set(null);
|
||||
}
|
||||
|
||||
createStepDefinitions(): readonly OnboardingStepDefinition[] {
|
||||
return [
|
||||
{ key: 'basics', label: 'Basics' },
|
||||
{ key: 'localization', label: 'Localization' },
|
||||
{ key: 'plan-limits', label: 'Plan & Limits' },
|
||||
{ key: 'admin-user', label: 'Admin & User' },
|
||||
];
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
|
||||
import { Observable, catchError, map, of, throwError } from 'rxjs';
|
||||
|
||||
import {
|
||||
CountryLookupDto,
|
||||
CountryService,
|
||||
} from '../../../../global-masters/countries/public-api';
|
||||
import {
|
||||
CurrencyLookupDto,
|
||||
CurrencyService,
|
||||
} from '../../../../global-masters/currencies/public-api';
|
||||
import {
|
||||
LanguageLookupDto,
|
||||
LanguageService,
|
||||
} from '../../../../global-masters/languages/public-api';
|
||||
import {
|
||||
TimezoneLookupDto,
|
||||
TimezoneService,
|
||||
} from '../../../../global-masters/timezones/public-api';
|
||||
import { OrganizationOnboardingDraft } from '../models/organization-draft.model';
|
||||
import {
|
||||
CountryLocalizationDefaults,
|
||||
DateFormatValue,
|
||||
FiscalYearConventionValue,
|
||||
NumberFormatValue,
|
||||
OnboardingLookupValue,
|
||||
OrganizationLicenseType,
|
||||
OrganizationPlanDefaults,
|
||||
OrganizationPlanLookupValue,
|
||||
TimeFormatValue,
|
||||
} from '../models/organization-onboarding.model';
|
||||
import {
|
||||
OrganizationProvisioningRequest,
|
||||
OrganizationProvisioningResult,
|
||||
} from '../models/organization-provisioning.model';
|
||||
|
||||
const ORGANIZATION_ONBOARDING_DRAFT_KEY = 'organization-onboarding-draft-v1';
|
||||
|
||||
const ORGANIZATION_TYPE_OPTIONS: readonly OnboardingLookupValue[] = [
|
||||
{ id: 'enterprise', label: 'Enterprise' },
|
||||
{ id: 'group', label: 'Group' },
|
||||
{ id: 'non-profit', label: 'Non Profit' },
|
||||
{ id: 'public-sector', label: 'Public Sector' },
|
||||
];
|
||||
|
||||
const INDUSTRY_OPTIONS: readonly OnboardingLookupValue[] = [
|
||||
{ id: 'healthcare', label: 'Healthcare' },
|
||||
{ id: 'manufacturing', label: 'Manufacturing' },
|
||||
{ id: 'professional-services', label: 'Professional Services' },
|
||||
{ id: 'retail', label: 'Retail' },
|
||||
{ id: 'technology', label: 'Technology' },
|
||||
];
|
||||
|
||||
const SUBSCRIPTION_PLAN_OPTIONS: readonly OrganizationPlanLookupValue[] = [
|
||||
{ id: 'starter', label: 'Starter', code: 'STARTER' },
|
||||
{ id: 'growth', label: 'Growth', code: 'GROWTH' },
|
||||
{ id: 'enterprise', label: 'Enterprise', code: 'ENTERPRISE' },
|
||||
];
|
||||
|
||||
const PLAN_DEFAULTS: readonly OrganizationPlanDefaults[] = [
|
||||
{
|
||||
plan: { id: 'starter', label: 'Starter', code: 'STARTER' },
|
||||
licenseType: 'Trial',
|
||||
maximumCompanies: 1,
|
||||
maximumUsers: 10,
|
||||
maximumStorageGb: 10,
|
||||
limitsEditable: true,
|
||||
},
|
||||
{
|
||||
plan: { id: 'growth', label: 'Growth', code: 'GROWTH' },
|
||||
licenseType: 'Paid',
|
||||
maximumCompanies: 5,
|
||||
maximumUsers: 100,
|
||||
maximumStorageGb: 100,
|
||||
limitsEditable: true,
|
||||
},
|
||||
{
|
||||
plan: { id: 'enterprise', label: 'Enterprise', code: 'ENTERPRISE' },
|
||||
licenseType: 'Paid',
|
||||
maximumCompanies: 25,
|
||||
maximumUsers: 1000,
|
||||
maximumStorageGb: 500,
|
||||
limitsEditable: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DATE_FORMAT_OPTIONS: readonly FormSelectOption<DateFormatValue>[] = [
|
||||
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
||||
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
||||
];
|
||||
|
||||
const TIME_FORMAT_OPTIONS: readonly FormSelectOption<TimeFormatValue>[] = [
|
||||
{ value: 'TwelveHour', label: '12 hour' },
|
||||
{ value: 'TwentyFourHour', label: '24 hour' },
|
||||
];
|
||||
|
||||
const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption<NumberFormatValue>[] = [
|
||||
{ value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' },
|
||||
{ value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' },
|
||||
];
|
||||
|
||||
const FISCAL_YEAR_OPTIONS: readonly FormSelectOption<FiscalYearConventionValue>[] = [
|
||||
{ value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
|
||||
{ value: 'AprilToMarch', label: 'April - March' },
|
||||
{ value: 'JulyToJune', label: 'July - June' },
|
||||
];
|
||||
|
||||
const LICENSE_TYPE_OPTIONS: readonly FormSelectOption<OrganizationLicenseType>[] = [
|
||||
{ value: 'Trial', label: 'Trial' },
|
||||
{ value: 'Paid', label: 'Paid' },
|
||||
];
|
||||
|
||||
const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
|
||||
{
|
||||
countryId: 'IN',
|
||||
timeZone: { id: 'asia-kolkata', label: 'India Standard Time', secondaryLabel: 'Asia/Kolkata' },
|
||||
currency: { id: 'inr', label: 'Indian Rupee', secondaryLabel: 'INR' },
|
||||
defaultLanguage: { id: 'en-in', label: 'English', secondaryLabel: 'en-IN' },
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timeFormat: 'TwelveHour',
|
||||
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
||||
fiscalYearConvention: 'AprilToMarch',
|
||||
},
|
||||
{
|
||||
countryId: 'US',
|
||||
timeZone: { id: 'america-new-york', label: 'Eastern Time', secondaryLabel: 'America/New_York' },
|
||||
currency: { id: 'usd', label: 'US Dollar', secondaryLabel: 'USD' },
|
||||
defaultLanguage: { id: 'en-us', label: 'English', secondaryLabel: 'en-US' },
|
||||
dateFormat: 'MM/DD/YYYY',
|
||||
timeFormat: 'TwelveHour',
|
||||
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
||||
fiscalYearConvention: 'CalendarYear',
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationOnboardingService {
|
||||
private readonly countryService = inject(CountryService);
|
||||
private readonly currencyService = inject(CurrencyService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly timezoneService = inject(TimezoneService);
|
||||
|
||||
searchOrganizationTypes(term: string | null, limit = 10): Observable<readonly OnboardingLookupValue[]> {
|
||||
return of(this.filterLookupValues(ORGANIZATION_TYPE_OPTIONS, term, limit));
|
||||
}
|
||||
|
||||
resolveOrganizationType(id: string): Observable<OnboardingLookupValue | null> {
|
||||
return of(ORGANIZATION_TYPE_OPTIONS.find(option => option.id === id) ?? null);
|
||||
}
|
||||
|
||||
searchIndustries(term: string | null, limit = 10): Observable<readonly OnboardingLookupValue[]> {
|
||||
return of(this.filterLookupValues(INDUSTRY_OPTIONS, term, limit));
|
||||
}
|
||||
|
||||
resolveIndustry(id: string): Observable<OnboardingLookupValue | null> {
|
||||
return of(INDUSTRY_OPTIONS.find(option => option.id === id) ?? null);
|
||||
}
|
||||
|
||||
searchRegistrationCountries(term: string | null, limit = 10): Observable<readonly CountryLookupDto[]> {
|
||||
return this.countryService.autocomplete(term ?? '', limit);
|
||||
}
|
||||
|
||||
resolveCountry(id: string): Observable<CountryLookupDto | null> {
|
||||
return this.countryService.getCountryById(id).pipe(
|
||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||
);
|
||||
}
|
||||
|
||||
searchTimezones(term: string | null, limit = 10): Observable<readonly TimezoneLookupDto[]> {
|
||||
return this.timezoneService.autocomplete(term, limit);
|
||||
}
|
||||
|
||||
resolveTimezone(id: string): Observable<TimezoneLookupDto | null> {
|
||||
return this.timezoneService.getById(id).pipe(
|
||||
map(timezone => ({ id: timezone.id, ianaId: timezone.ianaId, displayName: timezone.displayName }))
|
||||
);
|
||||
}
|
||||
|
||||
searchCurrencies(term: string | null, limit = 10): Observable<readonly CurrencyLookupDto[]> {
|
||||
return this.currencyService.autocomplete(term, limit);
|
||||
}
|
||||
|
||||
resolveCurrency(id: string): Observable<CurrencyLookupDto | null> {
|
||||
return this.currencyService.getCurrencyById(id).pipe(
|
||||
map(currency => ({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol }))
|
||||
);
|
||||
}
|
||||
|
||||
searchLanguages(term: string | null, limit = 10): Observable<readonly LanguageLookupDto[]> {
|
||||
return this.languageService.autocomplete(term, limit);
|
||||
}
|
||||
|
||||
resolveLanguage(id: string): Observable<LanguageLookupDto | null> {
|
||||
return this.languageService.getById(id).pipe(
|
||||
map(language => ({
|
||||
id: language.id,
|
||||
code: language.code,
|
||||
name: language.name,
|
||||
nativeName: language.nativeName,
|
||||
isRightToLeft: language.isRightToLeft,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
loadLanguageOptions(limit = 50): Observable<readonly FormSelectOption<string>[]> {
|
||||
return this.languageService.autocomplete('', limit).pipe(
|
||||
map(items => items.map(item => ({
|
||||
value: item.id,
|
||||
label: `${item.code} - ${item.name}`,
|
||||
})))
|
||||
);
|
||||
}
|
||||
|
||||
getDateFormatOptions(): readonly FormSelectOption<DateFormatValue>[] {
|
||||
return DATE_FORMAT_OPTIONS;
|
||||
}
|
||||
|
||||
getTimeFormatOptions(): readonly FormSelectOption<TimeFormatValue>[] {
|
||||
return TIME_FORMAT_OPTIONS;
|
||||
}
|
||||
|
||||
getNumberFormatOptions(): readonly FormSelectOption<NumberFormatValue>[] {
|
||||
return NUMBER_FORMAT_OPTIONS;
|
||||
}
|
||||
|
||||
getFiscalYearOptions(): readonly FormSelectOption<FiscalYearConventionValue>[] {
|
||||
return FISCAL_YEAR_OPTIONS;
|
||||
}
|
||||
|
||||
getLicenseTypeOptions(): readonly FormSelectOption<OrganizationLicenseType>[] {
|
||||
return LICENSE_TYPE_OPTIONS;
|
||||
}
|
||||
|
||||
searchSubscriptionPlans(term: string | null, limit = 10): Observable<readonly OrganizationPlanLookupValue[]> {
|
||||
return of(this.filterLookupValues(SUBSCRIPTION_PLAN_OPTIONS, term, limit));
|
||||
}
|
||||
|
||||
resolveSubscriptionPlan(id: string): Observable<OrganizationPlanLookupValue | null> {
|
||||
return of(SUBSCRIPTION_PLAN_OPTIONS.find(option => option.id === id) ?? null);
|
||||
}
|
||||
|
||||
getPlanDefaults(planId: string): Observable<OrganizationPlanDefaults | null> {
|
||||
return of(PLAN_DEFAULTS.find(item => item.plan.id === planId) ?? null);
|
||||
}
|
||||
|
||||
getCountryLocalizationDefaults(countryIso2: string | null): Observable<CountryLocalizationDefaults | null> {
|
||||
if (!countryIso2?.trim()) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return of(LOCALIZATION_DEFAULTS.find(item => item.countryId === countryIso2.trim().toUpperCase()) ?? null);
|
||||
}
|
||||
|
||||
loadDraft(): Observable<OrganizationOnboardingDraft | null> {
|
||||
try {
|
||||
const rawDraft = sessionStorage.getItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
||||
|
||||
if (!rawDraft) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
const parsedDraft = JSON.parse(rawDraft) as Partial<OrganizationOnboardingDraft>;
|
||||
|
||||
if (!this.isValidDraft(parsedDraft)) {
|
||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return of(parsedDraft);
|
||||
} catch (error) {
|
||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
saveDraft(draft: OrganizationOnboardingDraft): Observable<void> {
|
||||
try {
|
||||
sessionStorage.setItem(ORGANIZATION_ONBOARDING_DRAFT_KEY, JSON.stringify(draft));
|
||||
return of(void 0);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
clearDraft(): Observable<void> {
|
||||
try {
|
||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
||||
return of(void 0);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
provisionOrganization(_request: OrganizationProvisioningRequest): Observable<OrganizationProvisioningResult> {
|
||||
return of({
|
||||
status: 'not-configured',
|
||||
message: 'Provisioning is not configured yet for organization onboarding.',
|
||||
});
|
||||
}
|
||||
|
||||
private filterLookupValues<TValue extends OnboardingLookupValue>(
|
||||
source: readonly TValue[],
|
||||
term: string | null,
|
||||
limit: number,
|
||||
): readonly TValue[] {
|
||||
const normalizedTerm = term?.trim().toLowerCase() ?? '';
|
||||
|
||||
return source
|
||||
.filter(option => {
|
||||
if (!normalizedTerm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [option.label, option.secondaryLabel]
|
||||
.filter((value): value is string => !!value)
|
||||
.some(value => value.toLowerCase().includes(normalizedTerm));
|
||||
})
|
||||
.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
private isValidDraft(value: Partial<OrganizationOnboardingDraft>): value is OrganizationOnboardingDraft {
|
||||
return (
|
||||
value.schemaVersion === 1 &&
|
||||
typeof value.currentStepIndex === 'number' &&
|
||||
Array.isArray(value.completedStepIndexes) &&
|
||||
typeof value.savedAt === 'string'
|
||||
);
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<form [formGroup]="form" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
[formControl]="organizationCodeControl"
|
||||
inputId="organization-code"
|
||||
variant="floating"
|
||||
label="Organization Code"
|
||||
[readonly]="true"
|
||||
help="System generated and cannot be changed."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
formControlName="organizationName"
|
||||
inputId="organization-name"
|
||||
variant="floating"
|
||||
label="Organization Name"
|
||||
placeholder="Name"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="150"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Organization Name is required.',
|
||||
maxlength: 'Organization Name cannot exceed 150 characters.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
formControlName="shortName"
|
||||
inputId="organization-short-name"
|
||||
variant="floating"
|
||||
label="Short Name"
|
||||
placeholder="Short name"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="50"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Short Name is required.',
|
||||
maxlength: 'Short Name cannot exceed 50 characters.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
formControlName="localLanguageName"
|
||||
inputId="organization-local-language-name"
|
||||
variant="floating"
|
||||
label="Local Language Name"
|
||||
placeholder="Local language name"
|
||||
autocomplete="off"
|
||||
help="Optional — shown on localized documents."
|
||||
[maxLength]="150"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
maxlength: 'Local Language Name cannot exceed 150 characters.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-autocomplete
|
||||
formControlName="organizationTypeId"
|
||||
inputId="organization-type"
|
||||
variant="floating"
|
||||
label="Organization Type"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchOrganizationTypes"
|
||||
[displayWith]="displayLookup"
|
||||
[valueWith]="lookupValue"
|
||||
[resolveValueFn]="resolveOrganizationType"
|
||||
[selectedItem]="organizationTypeSelection()"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Organization Type is required.'
|
||||
}"
|
||||
(itemSelected)="onOrganizationTypeSelected($event)"
|
||||
(cleared)="onOrganizationTypeCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-autocomplete
|
||||
formControlName="industryId"
|
||||
inputId="organization-industry"
|
||||
variant="floating"
|
||||
label="Industry"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchIndustries"
|
||||
[displayWith]="displayLookup"
|
||||
[valueWith]="lookupValue"
|
||||
[resolveValueFn]="resolveIndustry"
|
||||
[selectedItem]="industrySelection()"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Industry is required.'
|
||||
}"
|
||||
(itemSelected)="onIndustrySelected($event)"
|
||||
(cleared)="onIndustryCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-autocomplete
|
||||
formControlName="registrationCountryId"
|
||||
inputId="organization-registration-country"
|
||||
variant="floating"
|
||||
label="Country of Registration"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[resolveValueFn]="resolveCountry"
|
||||
[selectedItem]="registrationCountrySelection()"
|
||||
help="Determines the localization defaults in Step 2."
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Country of Registration is required.'
|
||||
}"
|
||||
(itemSelected)="onRegistrationCountrySelected($event)"
|
||||
(cleared)="onRegistrationCountryCleared()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError, map, of } from 'rxjs';
|
||||
|
||||
import {
|
||||
CountryLookupDto,
|
||||
} from '../../../../global-masters/countries/public-api';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn,
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import {
|
||||
OnboardingLookupValue,
|
||||
OnboardingStepForm,
|
||||
OrganizationBasicsValue,
|
||||
} from '../../models/organization-onboarding.model';
|
||||
import { OrganizationOnboardingService } from '../../services/organization-onboarding.service';
|
||||
|
||||
interface OrganizationBasicsFormModel {
|
||||
readonly organizationName: string;
|
||||
readonly shortName: string;
|
||||
readonly localLanguageName: string;
|
||||
readonly organizationTypeId: string | null;
|
||||
readonly industryId: string | null;
|
||||
readonly registrationCountryId: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-basics-step',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, FormInput, Autocomplete],
|
||||
templateUrl: './organization-basics.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrganizationBasicsStepComponent implements OnboardingStepForm<OrganizationBasicsValue> {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly organizationCodeControl = this.formBuilder.nonNullable.control('Pending generation');
|
||||
readonly organizationTypeSelection = signal<OnboardingLookupValue | null>(null);
|
||||
readonly industrySelection = signal<OnboardingLookupValue | null>(null);
|
||||
readonly registrationCountrySelection = signal<CountryLookupDto | null>(null);
|
||||
|
||||
readonly form = this.formBuilder.group({
|
||||
organizationName: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]),
|
||||
shortName: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]),
|
||||
localLanguageName: this.formBuilder.nonNullable.control('', [Validators.maxLength(150)]),
|
||||
organizationTypeId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
industryId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
registrationCountryId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
});
|
||||
|
||||
readonly searchOrganizationTypes: AutocompleteSearchFn<OnboardingLookupValue> = (term, limit) =>
|
||||
this.onboardingService.searchOrganizationTypes(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load organization types.');
|
||||
return of<readonly OnboardingLookupValue[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly searchIndustries: AutocompleteSearchFn<OnboardingLookupValue> = (term, limit) =>
|
||||
this.onboardingService.searchIndustries(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load industries.');
|
||||
return of<readonly OnboardingLookupValue[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) =>
|
||||
this.onboardingService.searchRegistrationCountries(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load countries.');
|
||||
return of<readonly CountryLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly displayLookup: AutocompleteDisplayFn<OnboardingLookupValue> = item =>
|
||||
[item.label, item.secondaryLabel].filter(Boolean).join(' - ');
|
||||
|
||||
readonly lookupValue: AutocompleteValueFn<OnboardingLookupValue, string> = item => item.id;
|
||||
|
||||
readonly resolveOrganizationType: AutocompleteResolveValueFn<OnboardingLookupValue, string> = value =>
|
||||
this.onboardingService.resolveOrganizationType(value);
|
||||
|
||||
readonly resolveIndustry: AutocompleteResolveValueFn<OnboardingLookupValue, string> = value =>
|
||||
this.onboardingService.resolveIndustry(value);
|
||||
|
||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
|
||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
|
||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> = value =>
|
||||
this.onboardingService.resolveCountry(value);
|
||||
|
||||
validate(): boolean {
|
||||
this.submitAttempted.set(true);
|
||||
|
||||
if (this.form.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.form.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return false;
|
||||
}
|
||||
|
||||
getValue(): OrganizationBasicsValue {
|
||||
const value = this.form.getRawValue() as OrganizationBasicsFormModel;
|
||||
const organizationType = this.organizationTypeSelection();
|
||||
const industry = this.industrySelection();
|
||||
const registrationCountry = this.registrationCountrySelection();
|
||||
|
||||
if (!organizationType || !industry || !registrationCountry) {
|
||||
throw new Error('Organization basics selections are incomplete.');
|
||||
}
|
||||
|
||||
return {
|
||||
organizationCode: null,
|
||||
organizationName: value.organizationName.trim(),
|
||||
shortName: value.shortName.trim(),
|
||||
localLanguageName: value.localLanguageName.trim() || null,
|
||||
organizationType,
|
||||
industry,
|
||||
registrationCountry: {
|
||||
id: registrationCountry.id,
|
||||
label: registrationCountry.name,
|
||||
iso2: registrationCountry.iso2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getDraftValue(): Partial<OrganizationBasicsValue> {
|
||||
const value = this.form.getRawValue() as OrganizationBasicsFormModel;
|
||||
|
||||
return {
|
||||
organizationCode: null,
|
||||
organizationName: value.organizationName,
|
||||
shortName: value.shortName,
|
||||
localLanguageName: value.localLanguageName || null,
|
||||
organizationType: this.organizationTypeSelection() ?? undefined,
|
||||
industry: this.industrySelection() ?? undefined,
|
||||
registrationCountry: this.registrationCountrySelection()
|
||||
? {
|
||||
id: this.registrationCountrySelection()!.id,
|
||||
label: this.registrationCountrySelection()!.name,
|
||||
iso2: this.registrationCountrySelection()!.iso2,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
patchValue(value: Partial<OrganizationBasicsValue>): void {
|
||||
this.form.patchValue({
|
||||
organizationName: value.organizationName ?? '',
|
||||
shortName: value.shortName ?? '',
|
||||
localLanguageName: value.localLanguageName ?? '',
|
||||
organizationTypeId: value.organizationType?.id ?? null,
|
||||
industryId: value.industry?.id ?? null,
|
||||
registrationCountryId: value.registrationCountry?.id ?? null,
|
||||
}, { emitEvent: false });
|
||||
|
||||
this.organizationTypeSelection.set(value.organizationType ?? null);
|
||||
this.industrySelection.set(value.industry ?? null);
|
||||
this.registrationCountrySelection.set(
|
||||
value.registrationCountry
|
||||
? {
|
||||
id: value.registrationCountry.id,
|
||||
iso2: value.registrationCountry.iso2 ?? '',
|
||||
name: value.registrationCountry.label,
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.submitAttempted.set(false);
|
||||
}
|
||||
|
||||
onOrganizationTypeSelected(value: OnboardingLookupValue): void {
|
||||
this.organizationTypeSelection.set(value);
|
||||
}
|
||||
|
||||
onOrganizationTypeCleared(): void {
|
||||
this.organizationTypeSelection.set(null);
|
||||
}
|
||||
|
||||
onIndustrySelected(value: OnboardingLookupValue): void {
|
||||
this.industrySelection.set(value);
|
||||
}
|
||||
|
||||
onIndustryCleared(): void {
|
||||
this.industrySelection.set(null);
|
||||
}
|
||||
|
||||
onRegistrationCountrySelected(value: CountryLookupDto): void {
|
||||
this.registrationCountrySelection.set(value);
|
||||
}
|
||||
|
||||
onRegistrationCountryCleared(): void {
|
||||
this.registrationCountrySelection.set(null);
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const invalidElement = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'.ng-invalid [data-form-control], [data-form-control].ng-invalid, .ng-invalid .ng-select-container'
|
||||
);
|
||||
|
||||
invalidElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<form [formGroup]="form" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-autocomplete
|
||||
formControlName="timeZoneId"
|
||||
inputId="organization-time-zone"
|
||||
variant="floating"
|
||||
label="Time Zone"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchTimezones"
|
||||
[displayWith]="displayTimezone"
|
||||
[valueWith]="timeZoneValue"
|
||||
[resolveValueFn]="resolveTimezone"
|
||||
[selectedItem]="timeZoneSelection()"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Time Zone is required.' }"
|
||||
(itemSelected)="onTimeZoneSelected($event)"
|
||||
(cleared)="onTimeZoneCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-autocomplete
|
||||
formControlName="currencyId"
|
||||
inputId="organization-currency"
|
||||
variant="floating"
|
||||
label="Currency"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCurrencies"
|
||||
[displayWith]="displayCurrency"
|
||||
[valueWith]="currencyValue"
|
||||
[resolveValueFn]="resolveCurrency"
|
||||
[selectedItem]="currencySelection()"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Currency is required.' }"
|
||||
(itemSelected)="onCurrencySelected($event)"
|
||||
(cleared)="onCurrencyCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-autocomplete
|
||||
formControlName="defaultLanguageId"
|
||||
inputId="organization-default-language"
|
||||
variant="floating"
|
||||
label="Default Language"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchLanguages"
|
||||
[displayWith]="displayLanguage"
|
||||
[valueWith]="languageValue"
|
||||
[resolveValueFn]="resolveLanguage"
|
||||
[selectedItem]="defaultLanguageSelection()"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Default Language is required.' }"
|
||||
(itemSelected)="onDefaultLanguageSelected($event)"
|
||||
(cleared)="onDefaultLanguageCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="additionalLanguageIds"
|
||||
inputId="organization-additional-languages"
|
||||
variant="floating"
|
||||
label="Additional Languages"
|
||||
mode="multiple"
|
||||
[options]="additionalLanguageOptions()"
|
||||
[showCheckboxes]="true"
|
||||
[showSelectAll]="true"
|
||||
[clearable]="true"
|
||||
[searchable]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="dateFormat"
|
||||
inputId="organization-date-format"
|
||||
variant="floating"
|
||||
label="Date Format"
|
||||
[options]="dateFormatOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Date Format is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="timeFormat"
|
||||
inputId="organization-time-format"
|
||||
variant="floating"
|
||||
label="Time Format"
|
||||
[options]="timeFormatOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Time Format is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="numberFormat"
|
||||
inputId="organization-number-format"
|
||||
variant="floating"
|
||||
label="Number Format"
|
||||
[options]="numberFormatOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Number Format is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="fiscalYearConvention"
|
||||
inputId="organization-fiscal-year-convention"
|
||||
variant="floating"
|
||||
label="Fiscal Year Convention"
|
||||
[options]="fiscalYearOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Fiscal Year Convention is required.' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError, of } from 'rxjs';
|
||||
|
||||
import {
|
||||
CurrencyLookupDto,
|
||||
} from '../../../../global-masters/currencies/public-api';
|
||||
import {
|
||||
LanguageLookupDto,
|
||||
} from '../../../../global-masters/languages/public-api';
|
||||
import {
|
||||
TimezoneLookupDto,
|
||||
} from '../../../../global-masters/timezones/public-api';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn,
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
||||
import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
|
||||
import {
|
||||
CountryLocalizationDefaults,
|
||||
DateFormatValue,
|
||||
FiscalYearConventionValue,
|
||||
NumberFormatValue,
|
||||
OnboardingLookupValue,
|
||||
OnboardingStepForm,
|
||||
OrganizationLocalizationValue,
|
||||
TimeFormatValue,
|
||||
} from '../../models/organization-onboarding.model';
|
||||
import { OrganizationOnboardingService } from '../../services/organization-onboarding.service';
|
||||
|
||||
interface OrganizationLocalizationFormModel {
|
||||
readonly timeZoneId: string | null;
|
||||
readonly currencyId: string | null;
|
||||
readonly defaultLanguageId: string | null;
|
||||
readonly additionalLanguageIds: readonly string[];
|
||||
readonly dateFormat: DateFormatValue | null;
|
||||
readonly timeFormat: TimeFormatValue | null;
|
||||
readonly numberFormat: NumberFormatValue | null;
|
||||
readonly fiscalYearConvention: FiscalYearConventionValue | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-localization-step',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, Autocomplete, FormSelect],
|
||||
templateUrl: './organization-localization.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrganizationLocalizationStepComponent implements OnboardingStepForm<OrganizationLocalizationValue> {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly timeZoneSelection = signal<TimezoneLookupDto | null>(null);
|
||||
readonly currencySelection = signal<CurrencyLookupDto | null>(null);
|
||||
readonly defaultLanguageSelection = signal<LanguageLookupDto | null>(null);
|
||||
readonly additionalLanguageOptions = signal<readonly FormSelectOption<string>[]>([]);
|
||||
|
||||
readonly dateFormatOptions = this.onboardingService.getDateFormatOptions();
|
||||
readonly timeFormatOptions = this.onboardingService.getTimeFormatOptions();
|
||||
readonly numberFormatOptions = this.onboardingService.getNumberFormatOptions();
|
||||
readonly fiscalYearOptions = this.onboardingService.getFiscalYearOptions();
|
||||
|
||||
readonly form = this.formBuilder.group({
|
||||
timeZoneId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
currencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
defaultLanguageId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
additionalLanguageIds: this.formBuilder.nonNullable.control<readonly string[]>([]),
|
||||
dateFormat: this.formBuilder.control<DateFormatValue | null>(null, [Validators.required]),
|
||||
timeFormat: this.formBuilder.control<TimeFormatValue | null>(null, [Validators.required]),
|
||||
numberFormat: this.formBuilder.control<NumberFormatValue | null>(null, [Validators.required]),
|
||||
fiscalYearConvention: this.formBuilder.control<FiscalYearConventionValue | null>(null, [Validators.required]),
|
||||
});
|
||||
|
||||
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> = (term, limit) =>
|
||||
this.onboardingService.searchTimezones(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load timezones.');
|
||||
return of<readonly TimezoneLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
|
||||
this.onboardingService.searchCurrencies(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load currencies.');
|
||||
return of<readonly CurrencyLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly searchLanguages: AutocompleteSearchFn<LanguageLookupDto> = (term, limit) =>
|
||||
this.onboardingService.searchLanguages(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load languages.');
|
||||
return of<readonly LanguageLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> = item =>
|
||||
[item.ianaId, item.displayName].filter(Boolean).join(' - ');
|
||||
|
||||
readonly timeZoneValue: AutocompleteValueFn<TimezoneLookupDto, string> = item => item.id;
|
||||
|
||||
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> = value =>
|
||||
this.onboardingService.resolveTimezone(value);
|
||||
|
||||
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = item =>
|
||||
[item.code, item.name, item.symbol ? `(${item.symbol})` : null].filter(Boolean).join(' ');
|
||||
|
||||
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = item => item.id;
|
||||
|
||||
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value =>
|
||||
this.onboardingService.resolveCurrency(value);
|
||||
|
||||
readonly displayLanguage: AutocompleteDisplayFn<LanguageLookupDto> = item =>
|
||||
[item.code, item.name].filter(Boolean).join(' - ');
|
||||
|
||||
readonly languageValue: AutocompleteValueFn<LanguageLookupDto, string> = item => item.id;
|
||||
|
||||
readonly resolveLanguage: AutocompleteResolveValueFn<LanguageLookupDto, string> = value =>
|
||||
this.onboardingService.resolveLanguage(value);
|
||||
|
||||
constructor() {
|
||||
this.onboardingService.loadLanguageOptions(100)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load language options.');
|
||||
return of<readonly FormSelectOption<string>[]>([]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(options => {
|
||||
this.additionalLanguageOptions.update(current => this.mergeLanguageOptions(current, options));
|
||||
});
|
||||
|
||||
this.form.controls.defaultLanguageId.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(defaultLanguageId => {
|
||||
if (!defaultLanguageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredIds = this.form.controls.additionalLanguageIds.value.filter(id => id !== defaultLanguageId);
|
||||
|
||||
if (filteredIds.length !== this.form.controls.additionalLanguageIds.value.length) {
|
||||
this.form.controls.additionalLanguageIds.setValue(filteredIds, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validate(): boolean {
|
||||
this.submitAttempted.set(true);
|
||||
|
||||
if (this.form.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.form.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return false;
|
||||
}
|
||||
|
||||
getValue(): OrganizationLocalizationValue {
|
||||
const value = this.form.getRawValue() as OrganizationLocalizationFormModel;
|
||||
const timeZone = this.timeZoneSelection();
|
||||
const currency = this.currencySelection();
|
||||
const defaultLanguage = this.defaultLanguageSelection();
|
||||
|
||||
if (!timeZone || !currency || !defaultLanguage || !value.dateFormat || !value.timeFormat || !value.numberFormat || !value.fiscalYearConvention) {
|
||||
throw new Error('Organization localization values are incomplete.');
|
||||
}
|
||||
|
||||
const additionalLanguageSelections = this.resolveAdditionalLanguageSelections(value.additionalLanguageIds);
|
||||
|
||||
return {
|
||||
timeZone: { id: timeZone.id, label: timeZone.displayName, secondaryLabel: timeZone.ianaId },
|
||||
currency: { id: currency.id, label: currency.name, secondaryLabel: currency.code },
|
||||
defaultLanguage: { id: defaultLanguage.id, label: defaultLanguage.name, secondaryLabel: defaultLanguage.code },
|
||||
additionalLanguageIds: value.additionalLanguageIds.filter(id => id !== defaultLanguage.id),
|
||||
additionalLanguageSelections,
|
||||
dateFormat: value.dateFormat,
|
||||
timeFormat: value.timeFormat,
|
||||
numberFormat: value.numberFormat,
|
||||
fiscalYearConvention: value.fiscalYearConvention,
|
||||
localizationDefaultsCountryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
getDraftValue(): Partial<OrganizationLocalizationValue> {
|
||||
const value = this.form.getRawValue() as OrganizationLocalizationFormModel;
|
||||
|
||||
return {
|
||||
timeZone: this.timeZoneSelection()
|
||||
? {
|
||||
id: this.timeZoneSelection()!.id,
|
||||
label: this.timeZoneSelection()!.displayName,
|
||||
secondaryLabel: this.timeZoneSelection()!.ianaId,
|
||||
}
|
||||
: undefined,
|
||||
currency: this.currencySelection()
|
||||
? {
|
||||
id: this.currencySelection()!.id,
|
||||
label: this.currencySelection()!.name,
|
||||
secondaryLabel: this.currencySelection()!.code,
|
||||
}
|
||||
: undefined,
|
||||
defaultLanguage: this.defaultLanguageSelection()
|
||||
? {
|
||||
id: this.defaultLanguageSelection()!.id,
|
||||
label: this.defaultLanguageSelection()!.name,
|
||||
secondaryLabel: this.defaultLanguageSelection()!.code,
|
||||
}
|
||||
: undefined,
|
||||
additionalLanguageIds: value.additionalLanguageIds,
|
||||
additionalLanguageSelections: this.resolveAdditionalLanguageSelections(value.additionalLanguageIds),
|
||||
dateFormat: value.dateFormat ?? undefined,
|
||||
timeFormat: value.timeFormat ?? undefined,
|
||||
numberFormat: value.numberFormat ?? undefined,
|
||||
fiscalYearConvention: value.fiscalYearConvention ?? undefined,
|
||||
localizationDefaultsCountryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
patchValue(value: Partial<OrganizationLocalizationValue>): void {
|
||||
this.form.patchValue({
|
||||
timeZoneId: value.timeZone?.id ?? null,
|
||||
currencyId: value.currency?.id ?? null,
|
||||
defaultLanguageId: value.defaultLanguage?.id ?? null,
|
||||
additionalLanguageIds: value.additionalLanguageIds ?? [],
|
||||
dateFormat: value.dateFormat ?? null,
|
||||
timeFormat: value.timeFormat ?? null,
|
||||
numberFormat: value.numberFormat ?? null,
|
||||
fiscalYearConvention: value.fiscalYearConvention ?? null,
|
||||
}, { emitEvent: false });
|
||||
|
||||
this.timeZoneSelection.set(
|
||||
value.timeZone
|
||||
? {
|
||||
id: value.timeZone.id,
|
||||
ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label,
|
||||
displayName: value.timeZone.label,
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.currencySelection.set(
|
||||
value.currency
|
||||
? {
|
||||
id: value.currency.id,
|
||||
code: value.currency.secondaryLabel ?? value.currency.label,
|
||||
name: value.currency.label,
|
||||
symbol: '',
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.defaultLanguageSelection.set(
|
||||
value.defaultLanguage
|
||||
? {
|
||||
id: value.defaultLanguage.id,
|
||||
code: value.defaultLanguage.secondaryLabel ?? value.defaultLanguage.label,
|
||||
name: value.defaultLanguage.label,
|
||||
nativeName: value.defaultLanguage.label,
|
||||
isRightToLeft: false,
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.additionalLanguageOptions.update(current => this.mergeSelectionOptions(current, value.additionalLanguageSelections ?? []));
|
||||
this.submitAttempted.set(false);
|
||||
}
|
||||
|
||||
applyCountryDefaults(defaults: CountryLocalizationDefaults, replaceExisting: boolean): void {
|
||||
const nextValue = {
|
||||
timeZoneId: replaceExisting || !this.form.controls.timeZoneId.value ? defaults.timeZone.id : this.form.controls.timeZoneId.value,
|
||||
currencyId: replaceExisting || !this.form.controls.currencyId.value ? defaults.currency.id : this.form.controls.currencyId.value,
|
||||
defaultLanguageId: replaceExisting || !this.form.controls.defaultLanguageId.value ? defaults.defaultLanguage.id : this.form.controls.defaultLanguageId.value,
|
||||
dateFormat: replaceExisting || !this.form.controls.dateFormat.value ? defaults.dateFormat : this.form.controls.dateFormat.value,
|
||||
timeFormat: replaceExisting || !this.form.controls.timeFormat.value ? defaults.timeFormat : this.form.controls.timeFormat.value,
|
||||
numberFormat: replaceExisting || !this.form.controls.numberFormat.value ? defaults.numberFormat : this.form.controls.numberFormat.value,
|
||||
fiscalYearConvention: replaceExisting || !this.form.controls.fiscalYearConvention.value ? defaults.fiscalYearConvention : this.form.controls.fiscalYearConvention.value,
|
||||
};
|
||||
|
||||
this.form.patchValue(nextValue, { emitEvent: false });
|
||||
this.timeZoneSelection.set(
|
||||
replaceExisting || !this.timeZoneSelection()
|
||||
? {
|
||||
id: defaults.timeZone.id,
|
||||
ianaId: defaults.timeZone.secondaryLabel ?? defaults.timeZone.label,
|
||||
displayName: defaults.timeZone.label,
|
||||
}
|
||||
: this.timeZoneSelection()
|
||||
);
|
||||
this.currencySelection.set(
|
||||
replaceExisting || !this.currencySelection()
|
||||
? {
|
||||
id: defaults.currency.id,
|
||||
code: defaults.currency.secondaryLabel ?? defaults.currency.label,
|
||||
name: defaults.currency.label,
|
||||
symbol: '',
|
||||
}
|
||||
: this.currencySelection()
|
||||
);
|
||||
this.defaultLanguageSelection.set(
|
||||
replaceExisting || !this.defaultLanguageSelection()
|
||||
? {
|
||||
id: defaults.defaultLanguage.id,
|
||||
code: defaults.defaultLanguage.secondaryLabel ?? defaults.defaultLanguage.label,
|
||||
name: defaults.defaultLanguage.label,
|
||||
nativeName: defaults.defaultLanguage.label,
|
||||
isRightToLeft: false,
|
||||
}
|
||||
: this.defaultLanguageSelection()
|
||||
);
|
||||
|
||||
if (replaceExisting) {
|
||||
const filteredIds = this.form.controls.additionalLanguageIds.value.filter(id => id !== defaults.defaultLanguage.id);
|
||||
this.form.controls.additionalLanguageIds.setValue(filteredIds, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
hasSavedOrEnteredValues(): boolean {
|
||||
const value = this.form.getRawValue() as OrganizationLocalizationFormModel;
|
||||
|
||||
return !!(
|
||||
value.timeZoneId ||
|
||||
value.currencyId ||
|
||||
value.defaultLanguageId ||
|
||||
value.additionalLanguageIds.length ||
|
||||
value.dateFormat ||
|
||||
value.timeFormat ||
|
||||
value.numberFormat ||
|
||||
value.fiscalYearConvention
|
||||
);
|
||||
}
|
||||
|
||||
onTimeZoneSelected(item: TimezoneLookupDto): void {
|
||||
this.timeZoneSelection.set(item);
|
||||
}
|
||||
|
||||
onTimeZoneCleared(): void {
|
||||
this.timeZoneSelection.set(null);
|
||||
}
|
||||
|
||||
onCurrencySelected(item: CurrencyLookupDto): void {
|
||||
this.currencySelection.set(item);
|
||||
}
|
||||
|
||||
onCurrencyCleared(): void {
|
||||
this.currencySelection.set(null);
|
||||
}
|
||||
|
||||
onDefaultLanguageSelected(item: LanguageLookupDto): void {
|
||||
this.defaultLanguageSelection.set(item);
|
||||
}
|
||||
|
||||
onDefaultLanguageCleared(): void {
|
||||
this.defaultLanguageSelection.set(null);
|
||||
}
|
||||
|
||||
private resolveAdditionalLanguageSelections(languageIds: readonly string[]): readonly OnboardingLookupValue[] {
|
||||
const uniqueIds = [...new Set(languageIds)];
|
||||
const optionMap = new Map(this.additionalLanguageOptions().map(option => [option.value, option.label]));
|
||||
|
||||
return uniqueIds.map(languageId => ({
|
||||
id: languageId,
|
||||
label: optionMap.get(languageId) ?? languageId,
|
||||
}));
|
||||
}
|
||||
|
||||
private mergeLanguageOptions(
|
||||
current: readonly FormSelectOption<string>[],
|
||||
incoming: readonly FormSelectOption<string>[],
|
||||
): readonly FormSelectOption<string>[] {
|
||||
const merged = new Map<string, FormSelectOption<string>>();
|
||||
|
||||
[...current, ...incoming].forEach(option => {
|
||||
merged.set(option.value, option);
|
||||
});
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
private mergeSelectionOptions(
|
||||
current: readonly FormSelectOption<string>[],
|
||||
selections: readonly OnboardingLookupValue[],
|
||||
): readonly FormSelectOption<string>[] {
|
||||
return this.mergeLanguageOptions(
|
||||
current,
|
||||
selections.map(selection => ({ value: selection.id, label: selection.label }))
|
||||
);
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const invalidElement = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'.ng-invalid [data-form-control], [data-form-control].ng-invalid, .ng-invalid .ng-select-container'
|
||||
);
|
||||
|
||||
invalidElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<form [formGroup]="form" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-autocomplete
|
||||
formControlName="subscriptionPlanId"
|
||||
inputId="organization-subscription-plan"
|
||||
variant="floating"
|
||||
label="Subscription Plan"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchPlans"
|
||||
[displayWith]="displayPlan"
|
||||
[valueWith]="planValue"
|
||||
[resolveValueFn]="resolvePlan"
|
||||
[selectedItem]="planSelection()"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Subscription Plan is required.' }"
|
||||
(itemSelected)="onPlanSelected($event)"
|
||||
(cleared)="onPlanCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="licenseType"
|
||||
inputId="organization-license-type"
|
||||
variant="floating"
|
||||
label="License Type"
|
||||
[options]="licenseTypeOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'License Type is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
formControlName="maximumCompanies"
|
||||
inputId="organization-maximum-companies"
|
||||
variant="floating"
|
||||
label="Maximum Companies"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
[required]="true"
|
||||
[min]="1"
|
||||
[step]="1"
|
||||
[readonly]="!limitsEditable()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Maximum Companies is required.',
|
||||
min: 'Maximum Companies must be at least 1.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
formControlName="maximumUsers"
|
||||
inputId="organization-maximum-users"
|
||||
variant="floating"
|
||||
label="Maximum Users"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
[required]="true"
|
||||
[min]="1"
|
||||
[step]="1"
|
||||
[readonly]="!limitsEditable()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Maximum Users is required.',
|
||||
min: 'Maximum Users must be at least 1.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
formControlName="maximumStorageGb"
|
||||
inputId="organization-maximum-storage"
|
||||
variant="floating"
|
||||
label="Maximum Storage (GB)"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
[required]="true"
|
||||
[min]="1"
|
||||
[step]="1"
|
||||
[readonly]="!limitsEditable()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Maximum Storage is required.',
|
||||
min: 'Maximum Storage must be at least 1 GB.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-date-picker
|
||||
formControlName="goLiveDate"
|
||||
inputId="organization-go-live-date"
|
||||
variant="floating"
|
||||
label="Go-Live Date"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Go-Live Date is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-date-picker
|
||||
formControlName="systemAccessStartDate"
|
||||
inputId="organization-system-access-start-date"
|
||||
variant="floating"
|
||||
label="System Access Start Date"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'System Access Start Date is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-date-picker
|
||||
formControlName="systemAccessEndDate"
|
||||
inputId="organization-system-access-end-date"
|
||||
variant="floating"
|
||||
label="System Access End Date"
|
||||
[required]="requiresAccessEndDate()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'System Access End Date is required for Trial.',
|
||||
trialEndDateRequired: 'System Access End Date is required for Trial.',
|
||||
accessDateRange: 'System Access End Date must be on or after the access start date.',
|
||||
trialEndDateOrder: 'Trial end date must be later than the access start date.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (submitAttempted() && form.errors?.['accessDateRange']) {
|
||||
<div class="col-span-12 text-sm text-danger">
|
||||
System Access Start Date cannot be later than System Access End Date.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (submitAttempted() && form.errors?.['trialEndDateOrder']) {
|
||||
<div class="col-span-12 text-sm text-danger">
|
||||
Trial end date must be later than the access start date.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError, of, switchMap } from 'rxjs';
|
||||
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn,
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormDatePicker } from '../../../../../shared/components/form/form-date-picker/form-date-picker';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
||||
import {
|
||||
OnboardingStepForm,
|
||||
OrganizationLicenseType,
|
||||
OrganizationPlanDefaults,
|
||||
OrganizationPlanLimitsValue,
|
||||
OrganizationPlanLookupValue,
|
||||
} from '../../models/organization-onboarding.model';
|
||||
import { OrganizationOnboardingService } from '../../services/organization-onboarding.service';
|
||||
|
||||
interface OrganizationPlanLimitsFormModel {
|
||||
readonly subscriptionPlanId: string | null;
|
||||
readonly licenseType: OrganizationLicenseType | null;
|
||||
readonly maximumCompanies: number | null;
|
||||
readonly maximumUsers: number | null;
|
||||
readonly maximumStorageGb: number | null;
|
||||
readonly goLiveDate: string | null;
|
||||
readonly systemAccessStartDate: string | null;
|
||||
readonly systemAccessEndDate: string | null;
|
||||
}
|
||||
|
||||
const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
|
||||
const value = control.value as OrganizationPlanLimitsFormModel;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errors: ValidationErrors = {};
|
||||
const { licenseType, systemAccessStartDate, systemAccessEndDate } = value;
|
||||
|
||||
if (licenseType === 'Trial' && !systemAccessEndDate) {
|
||||
errors['trialEndDateRequired'] = true;
|
||||
}
|
||||
|
||||
if (systemAccessStartDate && systemAccessEndDate && systemAccessStartDate > systemAccessEndDate) {
|
||||
errors['accessDateRange'] = true;
|
||||
}
|
||||
|
||||
if (licenseType === 'Trial' && systemAccessStartDate && systemAccessEndDate && systemAccessStartDate >= systemAccessEndDate) {
|
||||
errors['trialEndDateOrder'] = true;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-plan-limits-step',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, Autocomplete, FormSelect, FormInput, FormDatePicker],
|
||||
templateUrl: './organization-plan-limits.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<OrganizationPlanLimitsValue> {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly planSelection = signal<OrganizationPlanLookupValue | null>(null);
|
||||
readonly limitsEditable = signal(true);
|
||||
readonly licenseTypeOptions = this.onboardingService.getLicenseTypeOptions();
|
||||
|
||||
readonly form = this.formBuilder.group({
|
||||
subscriptionPlanId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
licenseType: this.formBuilder.control<OrganizationLicenseType | null>(null, [Validators.required]),
|
||||
maximumCompanies: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
|
||||
maximumUsers: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
|
||||
maximumStorageGb: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
|
||||
goLiveDate: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
systemAccessStartDate: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
systemAccessEndDate: this.formBuilder.control<string | null>(null),
|
||||
}, { validators: accessDateValidator });
|
||||
|
||||
readonly requiresAccessEndDate = computed(() => this.form.controls.licenseType.value === 'Trial');
|
||||
|
||||
readonly searchPlans: AutocompleteSearchFn<OrganizationPlanLookupValue> = (term, limit) =>
|
||||
this.onboardingService.searchSubscriptionPlans(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load subscription plans.');
|
||||
return of<readonly OrganizationPlanLookupValue[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly displayPlan: AutocompleteDisplayFn<OrganizationPlanLookupValue> = item =>
|
||||
[item.label, item.code].filter(Boolean).join(' - ');
|
||||
|
||||
readonly planValue: AutocompleteValueFn<OrganizationPlanLookupValue, string> = item => item.id;
|
||||
|
||||
readonly resolvePlan: AutocompleteResolveValueFn<OrganizationPlanLookupValue, string> = value =>
|
||||
this.onboardingService.resolveSubscriptionPlan(value);
|
||||
|
||||
constructor() {
|
||||
this.form.controls.licenseType.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(licenseType => {
|
||||
if (licenseType === 'Trial') {
|
||||
this.form.controls.systemAccessEndDate.addValidators([Validators.required]);
|
||||
} else {
|
||||
this.form.controls.systemAccessEndDate.removeValidators([Validators.required]);
|
||||
}
|
||||
|
||||
this.form.controls.systemAccessEndDate.updateValueAndValidity({ emitEvent: false });
|
||||
});
|
||||
|
||||
this.form.controls.subscriptionPlanId.valueChanges
|
||||
.pipe(
|
||||
switchMap(planId => {
|
||||
if (!planId) {
|
||||
return of<OrganizationPlanDefaults | null>(null);
|
||||
}
|
||||
|
||||
return this.onboardingService.getPlanDefaults(planId).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load plan defaults.');
|
||||
return of<OrganizationPlanDefaults | null>(null);
|
||||
})
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(defaults => {
|
||||
if (!defaults) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyPlanDefaults(defaults);
|
||||
});
|
||||
}
|
||||
|
||||
validate(): boolean {
|
||||
this.submitAttempted.set(true);
|
||||
|
||||
if (this.form.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.form.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return false;
|
||||
}
|
||||
|
||||
getValue(): OrganizationPlanLimitsValue {
|
||||
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
|
||||
const subscriptionPlan = this.planSelection();
|
||||
|
||||
if (!subscriptionPlan || !value.licenseType || !value.maximumCompanies || !value.maximumUsers || !value.maximumStorageGb || !value.goLiveDate || !value.systemAccessStartDate) {
|
||||
throw new Error('Organization plan and limits values are incomplete.');
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptionPlan,
|
||||
licenseType: value.licenseType,
|
||||
maximumCompanies: value.maximumCompanies,
|
||||
maximumUsers: value.maximumUsers,
|
||||
maximumStorageGb: value.maximumStorageGb,
|
||||
goLiveDate: value.goLiveDate,
|
||||
systemAccessStartDate: value.systemAccessStartDate,
|
||||
systemAccessEndDate: value.systemAccessEndDate,
|
||||
limitsEditable: this.limitsEditable(),
|
||||
};
|
||||
}
|
||||
|
||||
getDraftValue(): Partial<OrganizationPlanLimitsValue> {
|
||||
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
|
||||
|
||||
return {
|
||||
subscriptionPlan: this.planSelection() ?? undefined,
|
||||
licenseType: value.licenseType ?? undefined,
|
||||
maximumCompanies: value.maximumCompanies ?? undefined,
|
||||
maximumUsers: value.maximumUsers ?? undefined,
|
||||
maximumStorageGb: value.maximumStorageGb ?? undefined,
|
||||
goLiveDate: value.goLiveDate ?? undefined,
|
||||
systemAccessStartDate: value.systemAccessStartDate ?? undefined,
|
||||
systemAccessEndDate: value.systemAccessEndDate,
|
||||
limitsEditable: this.limitsEditable(),
|
||||
};
|
||||
}
|
||||
|
||||
patchValue(value: Partial<OrganizationPlanLimitsValue>): void {
|
||||
this.form.patchValue({
|
||||
subscriptionPlanId: value.subscriptionPlan?.id ?? null,
|
||||
licenseType: value.licenseType ?? null,
|
||||
maximumCompanies: value.maximumCompanies ?? null,
|
||||
maximumUsers: value.maximumUsers ?? null,
|
||||
maximumStorageGb: value.maximumStorageGb ?? null,
|
||||
goLiveDate: value.goLiveDate ?? null,
|
||||
systemAccessStartDate: value.systemAccessStartDate ?? null,
|
||||
systemAccessEndDate: value.systemAccessEndDate ?? null,
|
||||
}, { emitEvent: false });
|
||||
|
||||
this.planSelection.set(value.subscriptionPlan ?? null);
|
||||
this.limitsEditable.set(value.limitsEditable ?? true);
|
||||
this.applyLimitsEditableState();
|
||||
this.submitAttempted.set(false);
|
||||
}
|
||||
|
||||
onPlanSelected(item: OrganizationPlanLookupValue): void {
|
||||
this.planSelection.set(item);
|
||||
}
|
||||
|
||||
onPlanCleared(): void {
|
||||
this.planSelection.set(null);
|
||||
}
|
||||
|
||||
private applyPlanDefaults(defaults: OrganizationPlanDefaults): void {
|
||||
this.planSelection.set(defaults.plan);
|
||||
this.limitsEditable.set(defaults.limitsEditable);
|
||||
this.form.patchValue({
|
||||
licenseType: defaults.licenseType,
|
||||
maximumCompanies: defaults.maximumCompanies,
|
||||
maximumUsers: defaults.maximumUsers,
|
||||
maximumStorageGb: defaults.maximumStorageGb,
|
||||
}, { emitEvent: false });
|
||||
this.applyLimitsEditableState();
|
||||
}
|
||||
|
||||
private applyLimitsEditableState(): void {
|
||||
const controls = [
|
||||
this.form.controls.maximumCompanies,
|
||||
this.form.controls.maximumUsers,
|
||||
this.form.controls.maximumStorageGb,
|
||||
];
|
||||
|
||||
if (this.limitsEditable()) {
|
||||
controls.forEach(control => control.enable({ emitEvent: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
controls.forEach(control => control.disable({ emitEvent: false }));
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const invalidElement = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'.ng-invalid [data-form-control], [data-form-control].ng-invalid, .ng-invalid .ng-select-container, .ng-invalid input'
|
||||
);
|
||||
|
||||
invalidElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const organizationsRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./organization-list/organization-list').then((m) => m.OrganizationList),
|
||||
data: { childTitle: 'Organization Management', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path:'onboarding',
|
||||
loadComponent: () => import('./organization-onboarding/organization-onboarding').then((m) => m.OrganizationOnboarding),
|
||||
data: { childTitle: 'Organization Onboarding', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
import { buildApiUrl } from '../../../core/config/api-url.util';
|
||||
|
||||
|
||||
export const TENANT_CURRENCIES_ENDPOINTS = {
|
||||
dataTable: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/tenant-currencies/datatable'
|
||||
),
|
||||
|
||||
create: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/tenant-currencies'
|
||||
),
|
||||
|
||||
getById: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/tenant-currencies/${encodeURIComponent(id)}`
|
||||
),
|
||||
|
||||
autocomplete: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/tenant-currencies/autocomplete'
|
||||
),
|
||||
|
||||
update: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/tenant-currencies/${encodeURIComponent(id)}`
|
||||
)
|
||||
|
||||
} as const;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { TENANT_CURRENCIES_ENDPOINTS } from './tenant-currencies.endpoints';
|
||||
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
|
||||
import {
|
||||
CreateTenantCurrencyRequest,
|
||||
TenantCurrencyDto,
|
||||
TenantCurrencyDto as TenantCurrencyDtoAlias,
|
||||
TenantCurrencyLookupDto,
|
||||
UpdateTenantCurrencyRequest
|
||||
} from '../models/tenant-currencies.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TenantCurrenciesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getTenantDataTable(query: DataTableQuery): Observable<DataTableResult<TenantCurrencyDto>> {
|
||||
return this.http.post<DataTableResult<TenantCurrencyDto>>(TENANT_CURRENCIES_ENDPOINTS.dataTable, query);
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
|
||||
return this.http.post<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.create, request);
|
||||
}
|
||||
|
||||
updateTenant(id: string, request: UpdateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
|
||||
return this.http.put<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
getTenantById(id: string): Observable<TenantCurrencyDto> {
|
||||
return this.http.get<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
autocomplete(term?: string, limit = 10): Observable<readonly TenantCurrencyLookupDto[]> {
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
const normalizedTerm = term?.trim();
|
||||
|
||||
if (normalizedTerm) {
|
||||
params = params.set('term', normalizedTerm);
|
||||
}
|
||||
|
||||
return this.http.get<readonly TenantCurrencyLookupDto[]>(TENANT_CURRENCIES_ENDPOINTS.autocomplete, { params });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { buildApiUrl } from '../../../core/config/api-url.util';
|
||||
|
||||
|
||||
export const TENANT_ENDPOINTS = {
|
||||
dataTable: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/tenants/datatable'
|
||||
),
|
||||
|
||||
create: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/tenants'
|
||||
),
|
||||
|
||||
getById: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/tenants/${encodeURIComponent(id)}`
|
||||
),
|
||||
|
||||
autocomplete: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/tenants/autocomplete'
|
||||
),
|
||||
|
||||
update: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/tenants/${encodeURIComponent(id)}`
|
||||
)
|
||||
|
||||
} as const;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { TENANT_ENDPOINTS } from './tenant.endpoints';
|
||||
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
|
||||
import {
|
||||
CreateTenantRequest,
|
||||
TenantDto,
|
||||
TenantLookupDto,
|
||||
UpdateTenantRequest
|
||||
} from '../models/tenant.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TenantService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getTenantDataTable(query: DataTableQuery): Observable<DataTableResult<TenantDto>> {
|
||||
return this.http.post<DataTableResult<TenantDto>>(TENANT_ENDPOINTS.dataTable, query);
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<TenantDto> {
|
||||
return this.http.post<TenantDto>(TENANT_ENDPOINTS.create, request);
|
||||
}
|
||||
|
||||
updateTenant(id: string, request: UpdateTenantRequest): Observable<TenantDto> {
|
||||
return this.http.put<TenantDto>(TENANT_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
getTenantById(id: string): Observable<TenantDto> {
|
||||
return this.http.get<TenantDto>(TENANT_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
autocomplete(term?: string, limit = 10): Observable<readonly TenantLookupDto[]> {
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
const normalizedTerm = term?.trim();
|
||||
|
||||
if (normalizedTerm) {
|
||||
params = params.set('term', normalizedTerm);
|
||||
}
|
||||
|
||||
return this.http.get<readonly TenantLookupDto[]>(TENANT_ENDPOINTS.autocomplete, { params });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user