From f88f8739b83994f44cca15edf7900685b0d8441b Mon Sep 17 00:00:00 2001 From: Gagan7900 <54118395+Gagan7900@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:45:28 +0530 Subject: [PATCH 1/3] minor firebase file build issue fixed --- src/app/core/services/common/firebase.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/services/common/firebase.service.ts b/src/app/core/services/common/firebase.service.ts index 74b07156..8254331d 100644 --- a/src/app/core/services/common/firebase.service.ts +++ b/src/app/core/services/common/firebase.service.ts @@ -10,7 +10,7 @@ import { environment } from '../../../../environments/environment'; }) export class FirebaseService { constructor() { - AngularFireModule.initializeApp(environment.firebase); + // AngularFireModule.initializeApp(environment.firebase); } getFirestore() { From 95fc5bf17f86b0c66635da5fe8770a26d183e0f5 Mon Sep 17 00:00:00 2001 From: Gagan7900 <54118395+Gagan7900@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:11:19 +0530 Subject: [PATCH 2/3] Organization Onboarding Workflow --- .../core/interceptors/loading-interceptor.ts | 2 +- src/app/core/services/common/menu.data.ts | 84 ++-- src/app/features/billing/billing.routes.ts | 9 - .../pages/billing-list/billing-list.html | 5 - .../pages/billing-list/billing-list.ts | 11 - .../cities/pages/city-list/city-list.html | 14 +- .../cities/pages/city-list/city-list.ts | 3 +- .../states/pages/state-list/state-list.html | 14 +- .../states/pages/state-list/state-list.ts | 3 +- .../localization/localization.routes.ts | 9 - .../localization-list/localization-list.html | 4 - .../localization-list/localization-list.ts | 11 - .../features/monitoring/monitoring.routes.ts | 9 - .../monitoring-dashboard.html | 4 - .../monitoring-dashboard.scss | 0 .../monitoring-dashboard.ts | 11 - .../organizations/dashboard/dashboard.html | 63 +++ .../dashboard/dashboard.scss} | 0 .../organizations/dashboard/dashboard.ts | 263 ++++++++++ .../organization-list/organization-list.html | 45 +- .../organization-list/organization-list.scss | 1 + .../organization-list/organization-list.ts | 246 +++++++++- .../onboarding-stepper.html | 456 +++++++++--------- .../onboarding-stepper.scss | 129 +++++ .../onboarding-stepper/onboarding-stepper.ts | 30 +- .../organization-onboarding.html | 117 +++-- .../organization-onboarding.ts | 243 ++++++++-- .../organization-onboarding-state.service.ts | 33 +- .../organization-onboarding.service.ts | 99 ++-- .../organization-admin.html | 156 ++++++ .../organization-admin/organization-admin.ts | 96 ++++ .../organization-localization.html | 81 +++- .../organization-localization.ts | 163 ++++--- .../organization-plan-limits.html | 23 +- .../organization-plan-limits.ts | 19 +- .../organizations/organizations.routes.ts | 5 + .../pages/platform-list/platform-list.html | 4 - .../pages/platform-list/platform-list.scss | 0 .../pages/platform-list/platform-list.ts | 11 - src/app/features/platform/platform.routes.ts | 9 - .../settings/pages/branding/branding.html | 121 +++++ .../pages/branding/branding.scss} | 0 .../settings/pages/branding/branding.ts | 26 + src/app/features/settings/settings.routes.ts | 9 + src/app/shared/components/button/button.html | 2 + src/app/shared/components/button/button.scss | 92 ++++ src/app/shared/components/button/button.ts | 3 + .../components/data-table/data-table.html | 2 +- .../components/data-table/data-table.ts | 5 + .../form/autocomplete/autocomplete.scss | 9 + .../form/autocomplete/autocomplete.ts | 7 + .../form-date-picker/form-date-picker.html | 42 +- .../form-date-picker/form-date-picker.scss | 110 +++++ .../components/form/form-field/form-field.ts | 4 + .../form/form-input/form-input.scss | 4 +- .../form/form-select/form-select.html | 312 +++++------- .../form/form-select/form-select.scss | 155 +++++- .../form/form-select/form-select.ts | 65 ++- src/app/shared/components/modal/modal.scss | 202 -------- src/app/shell/routes/content.routes.ts | 18 +- src/styles.scss | 23 +- 61 files changed, 2645 insertions(+), 1051 deletions(-) delete mode 100644 src/app/features/billing/billing.routes.ts delete mode 100644 src/app/features/billing/pages/billing-list/billing-list.html delete mode 100644 src/app/features/billing/pages/billing-list/billing-list.ts delete mode 100644 src/app/features/localization/localization.routes.ts delete mode 100644 src/app/features/localization/pages/localization-list/localization-list.html delete mode 100644 src/app/features/localization/pages/localization-list/localization-list.ts delete mode 100644 src/app/features/monitoring/monitoring.routes.ts delete mode 100644 src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.html delete mode 100644 src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.scss delete mode 100644 src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.ts create mode 100644 src/app/features/organizations/dashboard/dashboard.html rename src/app/features/{billing/pages/billing-list/billing-list.scss => organizations/dashboard/dashboard.scss} (100%) create mode 100644 src/app/features/organizations/dashboard/dashboard.ts create mode 100644 src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.html create mode 100644 src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.ts delete mode 100644 src/app/features/platform/pages/platform-list/platform-list.html delete mode 100644 src/app/features/platform/pages/platform-list/platform-list.scss delete mode 100644 src/app/features/platform/pages/platform-list/platform-list.ts delete mode 100644 src/app/features/platform/platform.routes.ts create mode 100644 src/app/features/settings/pages/branding/branding.html rename src/app/features/{localization/pages/localization-list/localization-list.scss => settings/pages/branding/branding.scss} (100%) create mode 100644 src/app/features/settings/pages/branding/branding.ts create mode 100644 src/app/features/settings/settings.routes.ts diff --git a/src/app/core/interceptors/loading-interceptor.ts b/src/app/core/interceptors/loading-interceptor.ts index 260cff1f..227e4af4 100644 --- a/src/app/core/interceptors/loading-interceptor.ts +++ b/src/app/core/interceptors/loading-interceptor.ts @@ -12,7 +12,7 @@ export const loadingInterceptor: HttpInterceptorFn = (req, next) => { : req; if (!shouldSkipLoader) { - loadingService.show(); + // loadingService.show(); } return next(request).pipe( diff --git a/src/app/core/services/common/menu.data.ts b/src/app/core/services/common/menu.data.ts index 137a2564..ca6bda4c 100644 --- a/src/app/core/services/common/menu.data.ts +++ b/src/app/core/services/common/menu.data.ts @@ -24,67 +24,47 @@ export const SAAS_MENU_DATA: MenuContext = { selected: false, dirchange: false, children: [ - { - title: 'Global Master', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false }, - { path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false }, - { path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false }, - { path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false }, - { path: '/global-masters/states', title: 'State', type: 'link', dirchange: false }, - { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, - ], - }, - { - title: 'Tenant Master', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false }, - { path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false } - ], - }, - { path: '/users', title: 'Users', type: 'link', dirchange: false }, - { - title: 'Configuration', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ + { path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false }, + { path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false }, + { path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false }, + { path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false }, + { path: '/global-masters/states', title: 'State', type: 'link', dirchange: false }, + { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, - { 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: 'Tenant Master', + // type: 'sub', + // active: false, + // selected: false, + // dirchange: false, + // children: [ + // { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false }, + // { path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false } + // ], + // } ], }, { - title: 'Operations', - icon: '', + title: 'Organizations', + icon: '', type: 'sub', active: false, selected: false, dirchange: false, children: [ - { path: '/billing', title: 'Billing', type: 'link', dirchange: false }, - { path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false }, + { path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false }, + { path: '/organizations/onboarding', title: 'Organization Onboarding', type: 'link', dirchange: false }, + ], + }, + { + title: 'Settings', + icon: '', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/settings/branding', title: 'Branding', type: 'link', dirchange: false }, ], }, ], diff --git a/src/app/features/billing/billing.routes.ts b/src/app/features/billing/billing.routes.ts deleted file mode 100644 index cdd6637a..00000000 --- a/src/app/features/billing/billing.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const billingRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/billing-list/billing-list').then((m) => m.BillingList), - data: { childTitle: 'Billing', parentTitle: 'Platform', subParentTitle: 'Subscriptions' }, - }, -]; diff --git a/src/app/features/billing/pages/billing-list/billing-list.html b/src/app/features/billing/pages/billing-list/billing-list.html deleted file mode 100644 index 0c7f54a7..00000000 --- a/src/app/features/billing/pages/billing-list/billing-list.html +++ /dev/null @@ -1,5 +0,0 @@ -
-

Billing

-

Plans, subscriptions, and tenant subscription upgrades will - be implemented here.

-
\ No newline at end of file diff --git a/src/app/features/billing/pages/billing-list/billing-list.ts b/src/app/features/billing/pages/billing-list/billing-list.ts deleted file mode 100644 index 95265016..00000000 --- a/src/app/features/billing/pages/billing-list/billing-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-billing-list', - standalone: true, - imports: [CommonModule], - templateUrl: './billing-list.html', - styleUrl: './billing-list.scss', -}) -export class BillingList {} diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.html b/src/app/features/global-masters/cities/pages/city-list/city-list.html index 280df3fd..c61cd3bb 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.html +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.html @@ -50,10 +50,16 @@ />
- +
diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.ts b/src/app/features/global-masters/cities/pages/city-list/city-list.ts index f535d001..3260e29b 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.ts +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.ts @@ -48,6 +48,7 @@ import { } from '../../../../../shared/components/form/autocomplete/autocomplete.types'; import { Modal } from '../../../../../shared/components/modal/modal'; import { FilterCard } from '../../../../../shared/components/filter-card/filter-card'; +import { Button } from '../../../../../shared/components/button/button'; interface CityTableRow extends DataTableRecord { id: string; @@ -66,7 +67,7 @@ interface CityTableRow extends DataTableRecord { @Component({ selector: 'city-list', standalone: true, - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard], + imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard, Button], templateUrl: './city-list.html', styleUrl: './city-list.scss' }) diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.html b/src/app/features/global-masters/states/pages/state-list/state-list.html index 0728e910..bd9ea064 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.html +++ b/src/app/features/global-masters/states/pages/state-list/state-list.html @@ -24,10 +24,16 @@ />
- +
diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.ts b/src/app/features/global-masters/states/pages/state-list/state-list.ts index 1c7ed70a..689f16f1 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.ts +++ b/src/app/features/global-masters/states/pages/state-list/state-list.ts @@ -41,6 +41,7 @@ import { FormInput } from '../../../../../shared/components/form/form-input/form import { Modal } from '../../../../../shared/components/modal/modal'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; import { FilterCard } from '../../../../../shared/components/filter-card/filter-card'; +import { Button as AppButton } from '../../../../../shared/components/button/button'; interface StateTableRow extends DataTableRecord { id: string; @@ -56,7 +57,7 @@ interface StateTableRow extends DataTableRecord { @Component({ selector: 'state-list', standalone: true, - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard], + imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard, AppButton], templateUrl: './state-list.html', styleUrl: './state-list.scss', }) diff --git a/src/app/features/localization/localization.routes.ts b/src/app/features/localization/localization.routes.ts deleted file mode 100644 index 48b08968..00000000 --- a/src/app/features/localization/localization.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const localizationRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/localization-list/localization-list').then((m) => m.LocalizationList), - data: { childTitle: 'Localization', parentTitle: 'Platform', subParentTitle: 'Translations' }, - }, -]; diff --git a/src/app/features/localization/pages/localization-list/localization-list.html b/src/app/features/localization/pages/localization-list/localization-list.html deleted file mode 100644 index 0f245ea0..00000000 --- a/src/app/features/localization/pages/localization-list/localization-list.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Localization

-

Translation browser, editor, import/export, and missing translation reports will be added here.

-
diff --git a/src/app/features/localization/pages/localization-list/localization-list.ts b/src/app/features/localization/pages/localization-list/localization-list.ts deleted file mode 100644 index 15e5cf8d..00000000 --- a/src/app/features/localization/pages/localization-list/localization-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-localization-list', - standalone: true, - imports: [CommonModule], - templateUrl: './localization-list.html', - styleUrl: './localization-list.scss', -}) -export class LocalizationList {} diff --git a/src/app/features/monitoring/monitoring.routes.ts b/src/app/features/monitoring/monitoring.routes.ts deleted file mode 100644 index a5b652a7..00000000 --- a/src/app/features/monitoring/monitoring.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const monitoringRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/monitoring-dashboard/monitoring-dashboard').then((m) => m.MonitoringDashboard), - data: { childTitle: 'Monitoring', parentTitle: 'Platform', subParentTitle: 'Observability' }, - }, -]; diff --git a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.html b/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.html deleted file mode 100644 index e0d9b527..00000000 --- a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Monitoring

-

Tenant counts, subscription status, login audits, and recent failed logins will be added here.

-
diff --git a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.scss b/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.ts b/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.ts deleted file mode 100644 index e3b78496..00000000 --- a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-monitoring-dashboard', - standalone: true, - imports: [CommonModule], - templateUrl: './monitoring-dashboard.html', - styleUrl: './monitoring-dashboard.scss', -}) -export class MonitoringDashboard {} diff --git a/src/app/features/organizations/dashboard/dashboard.html b/src/app/features/organizations/dashboard/dashboard.html new file mode 100644 index 00000000..90954ed7 --- /dev/null +++ b/src/app/features/organizations/dashboard/dashboard.html @@ -0,0 +1,63 @@ +
+
+

Welcome + back, + Json Taylor !

+

Track your sales activity, leads and + deals + here.

+
+
+ + +
+
+ +
+ + @for (card of statCards(); track card.title) { +
+
+
+
+
+

{{ card.title }}

+

+ {{ card.value }} +

+ + {{ card.helper }} + +
+ + + + +
+
+
+
+ } + +
+ + + + + +
+
\ No newline at end of file diff --git a/src/app/features/billing/pages/billing-list/billing-list.scss b/src/app/features/organizations/dashboard/dashboard.scss similarity index 100% rename from src/app/features/billing/pages/billing-list/billing-list.scss rename to src/app/features/organizations/dashboard/dashboard.scss diff --git a/src/app/features/organizations/dashboard/dashboard.ts b/src/app/features/organizations/dashboard/dashboard.ts new file mode 100644 index 00000000..abddc57b --- /dev/null +++ b/src/app/features/organizations/dashboard/dashboard.ts @@ -0,0 +1,263 @@ +import { Component, signal, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +import { DataTable } from '../../../shared/components/data-table/data-table'; +import { DataTableColumn, DataTableRecord } from '../../../shared/components/data-table/data-table.types'; + +type OrganizationPlan = 'Enterprise' | 'Standard' | 'Trial'; +type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; + +interface DashboardStatCard { + title: string; + value: string; + helper: string; + accentClass: string; + iconClass: string; + iconBackgroundClass: string; + helperClass: string; +} + +interface RecentOrganizationRow extends DataTableRecord { + id: string; + code: string; + organizationName: string; + country: string; + plan: OrganizationPlan; + status: OrganizationStatus; + created: string; +} + +@Component({ + selector: 'dashboard', + standalone: true, + imports: [DataTable], + templateUrl: './dashboard.html', + styleUrl: './dashboard.scss', +}) +export class Dashboard { + private readonly router = inject(Router); + + readonly statCards = signal([ + { + title: 'Total Organizations', + value: '24', + helper: 'All registered', + accentClass: 'border-primary', + iconClass: 'ti ti-building-community', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Active', + value: '19', + helper: 'Currently operational', + accentClass: 'border-primary', + iconClass: 'ti ti-circle-check', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Trial', + value: '3', + helper: 'Trial subscriptions', + accentClass: 'border-primary', + iconClass: 'ri-wallet-2-line', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Suspended', + value: '1', + helper: 'Temporarily disabled', + accentClass: 'border-primary', + iconClass: 'ti ti-player-pause', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Expired License', + value: '1', + helper: 'Needs renewal action', + accentClass: 'border-primary', + iconClass: 'ti ti-license', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Active Users', + value: '482', + helper: 'Users with access', + accentClass: 'border-primary', + iconClass: 'ri-profile-line', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + ]); + + readonly recentOrganizations = signal([ + { + id: '1', + code: 'ORG-0024', + organizationName: 'Syscom Group', + country: 'Saudi Arabia', + plan: 'Enterprise', + status: 'Active', + created: '09-07-2026', + }, + { + id: '2', + code: 'ORG-0023', + organizationName: 'Acme Trading', + country: 'India', + plan: 'Standard', + status: 'Active', + created: '01-07-2026', + }, + { + id: '3', + code: 'ORG-0022', + organizationName: 'Falcon Retail LLC', + country: 'UAE', + plan: 'Trial', + status: 'Trial', + created: '28-06-2026', + }, + { + id: '4', + code: 'ORG-0025', + organizationName: 'Syscom Group', + country: 'Saudi Arabia', + plan: 'Enterprise', + status: 'Active', + created: '09-07-2026', + }, + { + id: '5', + code: 'ORG-0026', + organizationName: 'Acme Trading', + country: 'India', + plan: 'Standard', + status: 'Active', + created: '01-07-2026', + }, + { + id: '6', + code: 'ORG-0027', + organizationName: 'Falcon Retail LLC', + country: 'UAE', + plan: 'Trial', + status: 'Trial', + created: '28-06-2026', + }, + { + id: '7', + code: 'ORG-0028', + organizationName: 'Syscom Group', + country: 'Saudi Arabia', + plan: 'Enterprise', + status: 'Active', + created: '09-07-2026', + }, + { + id: '8', + code: 'ORG-0029', + organizationName: 'Acme Trading', + country: 'India', + plan: 'Standard', + status: 'Active', + created: '01-07-2026', + }, + { + id: '9', + code: 'ORG-0030', + organizationName: 'Falcon Retail LLC', + country: 'UAE', + plan: 'Trial', + status: 'Trial', + created: '28-06-2026', + }, + ]); + + readonly columns = signal[]>([ + { key: 'code', label: 'Code', header: 'Code', sortable: false, align: 'left' }, + { + key: 'organizationName', + label: 'Name', + header: 'Name', + sortable: true, + align: 'left', + }, + { key: 'country', label: 'Country', header: 'Country', sortable: false }, + { + key: 'plan', + label: 'Plan', + header: 'Plan', + sortable: true, + badge: true, + badgeClass: value => + value === 'Trial' + ? 'badge bg-warning/10 text-warning' + : 'badge bg-light text-defaulttextcolor', + }, + { + key: 'status', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => + value === 'Active' + ? 'badge bg-success/10 text-success' + : value === 'Trial' + ? 'badge bg-warning/10 text-warning' + : 'badge bg-danger/10 text-danger', + }, + { key: 'created', label: 'Created', header: 'Created', sortable: false }, + ]); + + readonly totalRecords = signal(6); + readonly queryState = { + pageIndex: signal(0) + }; + readonly emptyMessage = signal('No Organizations'); + readonly emptyDescription = signal('Start by adding your first organization'); + + readonly actions = signal([ + { id: 'edit', label: 'Edit', icon: 'ti ti-edit' }, + { id: 'delete', label: 'Delete', icon: 'ti ti-trash' }, + ]); + + onAddOrganization(): void { + void this.router.navigate(['/organizations/onboarding']); + } + + onSearch(searchTerm: string) { + console.log('Search:', searchTerm); + } + + onPageChange(event: any) { + console.log('Page changed:', event); + } + + onSortChange(event: any) { + console.log('Sort changed:', event); + } + + onActionClick(event: any) { + const { actionId, record } = event; + + switch (actionId) { + case 'edit': + console.log('Edit organization:', record); + // Implement edit logic here + break; + case 'delete': + console.log('Delete organization:', record); + // Implement delete logic here + break; + default: + console.log('Unknown action:', actionId); + } + } + +} diff --git a/src/app/features/organizations/organization-list/organization-list.html b/src/app/features/organizations/organization-list/organization-list.html index 4c6f643d..4e56b450 100644 --- a/src/app/features/organizations/organization-list/organization-list.html +++ b/src/app/features/organizations/organization-list/organization-list.html @@ -1 +1,44 @@ -

organization-list works!

+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + diff --git a/src/app/features/organizations/organization-list/organization-list.scss b/src/app/features/organizations/organization-list/organization-list.scss index e69de29b..8b137891 100644 --- a/src/app/features/organizations/organization-list/organization-list.scss +++ b/src/app/features/organizations/organization-list/organization-list.scss @@ -0,0 +1 @@ + diff --git a/src/app/features/organizations/organization-list/organization-list.ts b/src/app/features/organizations/organization-list/organization-list.ts index 0aa9bef6..5becb0d3 100644 --- a/src/app/features/organizations/organization-list/organization-list.ts +++ b/src/app/features/organizations/organization-list/organization-list.ts @@ -1,11 +1,251 @@ -import { Component } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { of, map } from 'rxjs'; + +import { DataTable } from '../../../shared/components/data-table/data-table'; +import { DataTableQueryState } from '../../../shared/components/data-table/data-table-query.state'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTablePageEvent, + DataTableRecord, + DataTableSortEvent, +} from '../../../shared/components/data-table/data-table.types'; +import { Autocomplete } from '../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteResolveValueFn, + AutocompleteSearchFn, + AutocompleteValueFn, +} from '../../../shared/components/form/autocomplete/autocomplete.types'; +import { FilterCard } from '../../../shared/components/filter-card/filter-card'; +import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api'; + +type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; + +interface CountryLookup { + id: string; + name: string; +} + +interface OrganizationListRow extends DataTableRecord { + id: string; + code: string; + organizationName: string; + countryId: string; + country: string; + plan: string; + status: OrganizationStatus; + expiry: string; +} @Component({ selector: 'organization-list', - imports: [], + standalone: true, + imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable], templateUrl: './organization-list.html', styleUrl: './organization-list.scss', }) -export class OrganizationList { + +export class OrganizationList { + private readonly formBuilder = inject(FormBuilder); + private readonly router = inject(Router); + private readonly toastr = inject(ToastrService); + private readonly countryApi = inject(CountryService); + + readonly queryState = new DataTableQueryState(); + + readonly selectedCountryLookup = signal(null); + readonly selectedFormCountry = signal(null); + readonly selectedCountryId = signal(null); + readonly appliedCountryId = signal(null); + + readonly countryFilterForm = this.formBuilder.nonNullable.group({ + countryId: [''], + }); + + readonly allOrganizations = signal([ + { + id: '1', + code: 'ORG-0024', + organizationName: 'Syscom Group', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Enterprise', + status: 'Active', + expiry: '31-12-2026' + }, + { + id: '2', + code: 'ORG-0023', + organizationName: 'Acme Trading', + countryId: 'c2', + country: 'India', + plan: 'Standard', + status: 'Active', + expiry: '31-03-2027' + }, + { + id: '3', + code: 'ORG-0022', + organizationName: 'Falcon Retail LLC', + countryId: 'c3', + country: 'UAE', + plan: 'Trial', + status: 'Trial', + expiry: '28-07-2026' + }, + { + id: '4', + code: 'ORG-0021', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + ]); + + readonly organizations = signal([]); + readonly totalRecords = signal(0); + + readonly searchCountries: AutocompleteSearchFn = (term, limit) => this.countryApi.autocomplete(term, limit); + readonly displayCountry: AutocompleteDisplayFn = country => country.name; + readonly countryValue: AutocompleteValueFn = country => country.id; + readonly resolveCountry: AutocompleteResolveValueFn = + value => this.countryApi.getCountryById(value).pipe( + map(country => ({ id: country.id, iso2: country.iso2, name: country.name })) + ); + + readonly columns = signal[]>([ + { key: 'code', label: 'Code', header: 'Code', sortable: true }, + { + key: 'organizationName', + label: 'Organization Name', + header: 'Organization Name', + sortable: true, + align: 'left', + }, + { key: 'country', label: 'Country', header: 'Country', sortable: true }, + { + key: 'plan', + label: 'Plan', + header: 'Plan', + sortable: true, + badge: true, + badgeClass: value => + value === 'Trial' + ? 'badge bg-warning/10 text-warning' + : 'badge bg-light text-defaulttextcolor', + }, + { + key: 'status', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => { + if (value === 'Active') return 'badge bg-success/10 text-success'; + if (value === 'Trial') return 'badge bg-warning/10 text-warning'; + return 'badge bg-danger/10 text-danger'; + }, + }, + { key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true }, + ]); + + readonly actions = signal[]>([ + { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, + { + type: 'suspend', + label: 'Suspend', + icon: 'ti ti-player-pause', + className: 'text-warning', + visible: row => row.status !== 'Suspended', + }, + ]); + + readonly emptyMessage = signal('No organizations found'); + readonly emptyDescription = signal('There are no organizations available for the current filters.'); + + constructor() { + this.refreshTable(); + } + + onCountryLookupSelected(country: CountryLookupDto): void { + this.selectedCountryLookup.set(country); + } + + applyCountryFilter(): void { + const selectedCountryId = this.countryFilterForm.controls.countryId.value.trim(); + this.appliedCountryId.set(selectedCountryId || null); + this.queryState.pageIndex.set(1); + this.refreshTable(); + } + + onSearch(value: string): void { + this.queryState.setSearch(value); + this.refreshTable(); + } + + onPageChange(event: DataTablePageEvent): void { + this.queryState.setPage(event); + this.refreshTable(); + } + + onSortChange(event: DataTableSortEvent): void { + this.queryState.setSort(event); + this.refreshTable(); + } + + onAddOrganization(): void { + void this.router.navigate(['/organizations/onboarding']); + } + + onActionClick(event: DataTableActionEvent): void { + this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`); + } + + private refreshTable(): void { + const countryId = this.appliedCountryId(); + const search = this.queryState.searchText().trim().toLowerCase(); + const sortBy = this.queryState.sortColumn(); + const sortDir = this.queryState.sortDirection(); + + let rows = this.allOrganizations().filter(row => { + const matchesCountry = !countryId || row.countryId === countryId; + const matchesSearch = !search + || row.code.toLowerCase().includes(search) + || row.organizationName.toLowerCase().includes(search) + || row.country.toLowerCase().includes(search) + || row.plan.toLowerCase().includes(search) + || row.status.toLowerCase().includes(search) + || row.expiry.toLowerCase().includes(search); + + return matchesCountry && matchesSearch; + }); + + if (sortBy) { + rows = [...rows].sort((left, right) => { + const leftValue = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const rightValue = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const compared = leftValue.localeCompare(rightValue); + return sortDir === 'asc' ? compared : -compared; + }); + } + + const total = rows.length; + const pageIndex = this.queryState.pageIndex(); + const pageSize = this.queryState.pageSize(); + const start = (pageIndex - 1) * pageSize; + const end = start + pageSize; + + this.totalRecords.set(total); + this.organizations.set(rows.slice(start, end)); + } } diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html index 9def9eba..92e57aa2 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html @@ -1,239 +1,255 @@
- - - + + +
+ + - -
-
+ +
+
- -
- -
- @if (isFirstStep()) { - - } @else { - - } +
+
+ @if (isFirstStep()) { + + } @else { + + } +
+ +
+ +
+
- -
- +
- @if (!isLastStep()) { - - } @else { - - } + +
+ @if (!isLastStep()) { + + } @else { + + } +
- +
- +
\ No newline at end of file diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss index e69de29b..642f4c5d 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss @@ -0,0 +1,129 @@ +.stepper-header-card { + background-color: transparent; + border: none; + box-shadow: none; + padding: 0.25rem 0 0.75rem; + + @media (min-width: 640px) { + padding: 0.5rem 0 1.25rem; + } +} + +.stepper-nav-scroll-wrapper { + -webkit-overflow-scrolling: touch; +} + +.stepper-progress-line { + background: var(--primary, #7c3deb); + background: linear-gradient(90deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + border-radius: 2px; +} + +.step-node-btn { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + outline: none; + + &:disabled { + cursor: default; + } +} + +.step-circle { + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); + + @media (min-width: 640px) { + width: 4rem; + height: 4rem; + } + + // Pending Step + &.pending { + background-color: #f0f2f5; + border: 2px solid #cbd5e1; + color: #94a3b8; + + :host-context(.dark) & { + background-color: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: #64748b; + } + } + + // Completed Step + &.completed { + background: var(--primary, #7c3deb); + background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + color: #ffffff; + border: none; + box-shadow: 0 4px 12px rgba(124, 61, 235, 0.25); + } + + // Active Step with Glowing Aura in Theme Primary + &.active { + background: var(--primary, #7c3deb); + background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + color: #ffffff; + border: none; + box-shadow: + 0 0 20px 5px rgba(124, 61, 235, 0.45), + 0 0 10px 2px rgba(168, 85, 247, 0.35); + transform: scale(1.05); + + @media (max-width: 639px) { + box-shadow: + 0 0 10px 2px rgba(124, 61, 235, 0.35), + 0 0 5px 1px rgba(168, 85, 247, 0.25); + } + } +} + +.step-label { + margin-top: 0.5rem; + font-size: 0.75rem; + line-height: 1rem; + white-space: nowrap; + transition: all 0.25s ease; + + @media (min-width: 640px) { + margin-top: 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + } + + &.pending { + color: #94a3b8; + font-weight: 400; + + :host-context(.dark) & { + color: #64748b; + } + } + + &.completed { + color: #334155; + font-weight: 500; + + :host-context(.dark) & { + color: #cbd5e1; + } + } + + &.active { + color: var(--primary, #7c3deb); + font-weight: 700; + + :host-context(.dark) & { + color: #ffffff; + } + } +} diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts index c5de3d6f..595ed949 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts @@ -4,6 +4,7 @@ import { input, output } from '@angular/core'; +import { Button } from '../../../../../shared/components/button/button'; export interface OnboardingStep { readonly key: string; @@ -13,7 +14,7 @@ export interface OnboardingStep { @Component({ selector: 'onboarding-stepper', standalone: true, - imports: [], + imports: [Button], templateUrl: './onboarding-stepper.html', styleUrl: './onboarding-stepper.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -34,6 +35,33 @@ export class OnboardingStepper { readonly finishClicked = output(); readonly cancelClicked = output(); + currentStepLabel(): string { + return this.steps()[this.currentStepIndex()]?.label ?? ''; + } + + getProgressPercent(): number { + const total = this.steps().length; + if (total === 0) return 0; + return Math.round(((this.currentStepIndex() + 1) / total) * 100); + } + + getTrackLineLeft(): string { + const total = this.steps().length; + if (total <= 0) return '0%'; + return `${100 / (total * 2)}%`; + } + + getProgressLineWidth(): string { + const total = this.steps().length; + if (total <= 1) return '0%'; + const maxSpan = (100 * (total - 1)) / total; + const progressRatio = Math.min( + 1, + Math.max(0, this.currentStepIndex() / (total - 1)) + ); + return `${maxSpan * progressRatio}%`; + } + isFirstStep(): boolean { return this.currentStepIndex() === 0; } diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.html b/src/app/features/organizations/organization-onboarding/organization-onboarding.html index 4c21af5f..6bc977fa 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.html +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.html @@ -1,66 +1,63 @@ -
-
-
-

- Create Organization -

+
+

+ Create Organization +

+
+
+ +
+
-
- - @switch (currentStep().key) { - @case ('basics') { - - } +
+ +
- @case ('localization') { -
- Localization content -
- } +
+ +
- @case ('plan-limits') { -
- Plan and Limits content -
- } +
+ +
+
+
- @case ('admin-user') { -
- Admin and User content -
- } - } -
-
-
-
\ No newline at end of file + + + + + + + diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts index 5d18da07..3d93e7bf 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts @@ -4,7 +4,8 @@ import { computed, DestroyRef, inject, - signal + signal, + viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; @@ -15,13 +16,26 @@ import { OnboardingStepper } from './components/onboarding-stepper/onboarding-stepper'; import { OrganizationBasicsStepComponent } from './steps/organization-basics/organization-basics'; +import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization'; +import { OrganizationPlanLimitsStepComponent } from './steps/organization-plan-limits/organization-plan-limits'; +import { OrganizationAdminStepComponent } from './steps/organization-admin/organization-admin'; import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service'; import { OrganizationOnboardingService } from './services/organization-onboarding.service'; +import { ConfirmDialog } from '../../../shared/components/confirm-dialog/confirm-dialog'; +import { OnboardingStepForm } from './models/organization-onboarding.model'; +import { OrganizationProvisioningRequest } from './models/organization-provisioning.model'; @Component({ selector: 'app-organization-onboarding', standalone: true, - imports: [OnboardingStepper, OrganizationBasicsStepComponent], + imports: [ + OnboardingStepper, + OrganizationBasicsStepComponent, + OrganizationLocalizationStepComponent, + OrganizationPlanLimitsStepComponent, + OrganizationAdminStepComponent, + ConfirmDialog, + ], templateUrl: './organization-onboarding.html', styleUrl: './organization-onboarding.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -31,7 +45,7 @@ export class OrganizationOnboarding { private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly toastr = inject(ToastrService); - private readonly stateService = inject(OrganizationOnboardingStateService); + readonly stateService = inject(OrganizationOnboardingStateService); private readonly onboardingService = inject(OrganizationOnboardingService); readonly steps: readonly OnboardingStep[] = this.stateService.createStepDefinitions(); @@ -39,7 +53,15 @@ export class OrganizationOnboarding { readonly savingDraft = signal(false); readonly finishing = signal(false); - private basicsStep?: OrganizationBasicsStepComponent; + private readonly basicsStep = viewChild(OrganizationBasicsStepComponent); + private readonly localizationStep = viewChild(OrganizationLocalizationStepComponent); + private readonly planLimitsStep = viewChild(OrganizationPlanLimitsStepComponent); + private readonly adminStep = viewChild(OrganizationAdminStepComponent); + private readonly cancelConfirmDialog = viewChild('cancelConfirmDialog'); + private readonly finishConfirmDialog = viewChild('finishConfirmDialog'); + private savedDraftSnapshot = ''; + + readonly provisioningRequest = signal(null); readonly currentStepIndex = this.stateService.currentStepIndex; readonly completedStepIndexes = this.stateService.completedStepIndexes; @@ -63,28 +85,24 @@ export class OrganizationOnboarding { ) .subscribe(draft => { if (!draft) { + this.savedDraftSnapshot = this.serializeDraftState(); return; } this.stateService.restoreDraft(draft); + this.savedDraftSnapshot = this.serializeDraftState(); + this.hydrateActiveStep(); }); } - 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.captureActiveStepDraft(); this.stateService.setCurrentStepIndex(index); + this.hydrateActiveStep(); } onBack(): void { @@ -94,7 +112,9 @@ export class OrganizationOnboarding { return; } + this.captureActiveStepDraft(); this.stateService.setCurrentStepIndex(currentIndex - 1); + this.hydrateActiveStep(); } onNext(): void { @@ -104,17 +124,16 @@ export class OrganizationOnboarding { return; } - if (currentIndex === 0) { - if (!this.basicsStep?.validate()) { - this.stateService.unmarkStepCompleted(currentIndex); - return; - } - - this.stateService.updateBasics(this.basicsStep.getValue()); + const activeStep = this.getActiveStep(); + if (!activeStep?.validate()) { + this.stateService.unmarkStepCompleted(currentIndex); + return; } + this.storeValidatedActiveStep(activeStep); this.stateService.markStepCompleted(currentIndex); this.stateService.setCurrentStepIndex(currentIndex + 1); + this.hydrateActiveStep(); } onSaveDraft(): void { @@ -122,9 +141,7 @@ export class OrganizationOnboarding { return; } - if (this.currentStepIndex() === 0 && this.basicsStep) { - this.stateService.updateBasicsDraft(this.basicsStep.getDraftValue()); - } + this.captureActiveStepDraft(); this.savingDraft.set(true); @@ -137,6 +154,7 @@ export class OrganizationOnboarding { ) .subscribe({ next: () => { + this.savedDraftSnapshot = this.serializeDraftState(); this.toastr.success('Onboarding draft saved successfully.', 'Draft saved'); }, error: () => { @@ -150,15 +168,182 @@ export class OrganizationOnboarding { return; } - this.finishing.set(true); + const activeStep = this.adminStep(); + if (!activeStep?.validate()) { + this.stateService.unmarkStepCompleted(3); + return; + } - queueMicrotask(() => { - this.finishing.set(false); + this.stateService.updateAdmin(activeStep.getValue()); + this.stateService.markStepCompleted(3); + + const data = this.stateService.onboardingData(); + if (!data.basics || !data.localization || !data.planLimits || !data.admin) { + this.toastr.error('Complete and validate every onboarding step before provisioning.'); + return; + } + + this.provisioningRequest.set({ + basics: data.basics, + localization: data.localization, + planLimits: data.planLimits, + admin: data.admin, }); + void this.finishConfirmDialog()?.open(); } onCancel(): void { - this.stateService.clear(); - void this.router.navigate(['/configuration/organizations']); + this.captureActiveStepDraft(); + if (this.serializeDraftState() !== this.savedDraftSnapshot) { + void this.cancelConfirmDialog()?.open(); + return; + } + + void this.leaveOnboarding(false); } -} \ No newline at end of file + + onDiscardAndLeave(): void { + void this.leaveOnboarding(true); + } + + onProvisioningConfirmed(): void { + this.toastr.info( + 'The provisioning request is ready. Backend provisioning is not connected yet.', + 'Provisioning pending' + ); + } + + private getActiveStep(): OnboardingStepForm | undefined { + switch (this.currentStepIndex()) { + case 0: + return this.basicsStep(); + case 1: + return this.localizationStep(); + case 2: + return this.planLimitsStep(); + case 3: + return this.adminStep(); + default: + return undefined; + } + } + + private storeValidatedActiveStep(step: OnboardingStepForm): void { + switch (this.currentStepIndex()) { + case 0: + this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue()); + break; + case 1: + this.stateService.updateLocalization((step as OrganizationLocalizationStepComponent).getValue()); + break; + case 2: + this.stateService.updatePlanLimits((step as OrganizationPlanLimitsStepComponent).getValue()); + break; + case 3: + this.stateService.updateAdmin((step as OrganizationAdminStepComponent).getValue()); + break; + } + } + + private captureActiveStepDraft(): void { + const currentIndex = this.currentStepIndex(); + const validatedValue = this.getValidatedStepValue(currentIndex); + + switch (currentIndex) { + case 0: + this.stateService.updateBasicsDraft(this.basicsStep()?.getDraftValue() ?? null); + break; + case 1: + this.stateService.updateLocalizationDraft(this.localizationStep()?.getDraftValue() ?? null); + break; + case 2: + this.stateService.updatePlanLimitsDraft(this.planLimitsStep()?.getDraftValue() ?? null); + break; + case 3: + this.stateService.updateAdminDraft(this.adminStep()?.getDraftValue() ?? null); + break; + } + + const draftValue = this.getDraftStepValue(currentIndex); + if ( + this.stateService.isStepCompleted(currentIndex) && + JSON.stringify(draftValue) !== JSON.stringify(validatedValue) + ) { + this.stateService.invalidateStep(currentIndex); + } + } + + private getValidatedStepValue(index: number): unknown { + const data = this.stateService.onboardingData(); + return [data.basics, data.localization, data.planLimits, data.admin][index] ?? null; + } + + private getDraftStepValue(index: number): unknown { + const draft = this.stateService.onboardingDraft(); + return [draft.basics, draft.localization, draft.planLimits, draft.admin][index] ?? null; + } + + private hydrateActiveStep(): void { + queueMicrotask(() => { + const draft = this.stateService.onboardingDraft(); + switch (this.currentStepIndex()) { + case 0: + if (draft.basics) this.basicsStep()?.patchValue(draft.basics); + break; + case 1: + if (draft.localization) { + this.localizationStep()?.patchValue(draft.localization); + } + this.applyCountryDefaults(); + break; + case 2: + if (draft.planLimits) this.planLimitsStep()?.patchValue(draft.planLimits); + break; + case 3: + if (draft.admin) this.adminStep()?.patchValue(draft.admin); + break; + } + }); + } + + private applyCountryDefaults(): void { + const countryIso2 = this.stateService.basics()?.registrationCountry.iso2 ?? null; + this.onboardingService.getCountryLocalizationDefaults(countryIso2) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(defaults => { + if (defaults) { + this.localizationStep()?.applyCountryDefaults(defaults, false); + } + }); + } + + private serializeDraftState(): string { + const draft = this.stateService.buildDraft(); + return JSON.stringify({ + currentStepIndex: draft.currentStepIndex, + completedStepIndexes: draft.completedStepIndexes, + basics: draft.basics, + localization: draft.localization, + planLimits: draft.planLimits, + admin: draft.admin, + }); + } + + private async leaveOnboarding(clearDraft: boolean): Promise { + if (clearDraft) { + try { + await new Promise((resolve, reject) => { + this.onboardingService.clearDraft() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: resolve, error: reject }); + }); + } catch { + this.toastr.error('Unable to clear the saved draft.'); + return; + } + } + + this.stateService.clear(); + await this.router.navigate(['/organizations']); + } +} diff --git a/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts b/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts index 7ae2e8a6..92f47f5e 100644 --- a/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts +++ b/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts @@ -57,7 +57,7 @@ export class OrganizationOnboardingStateService { }); setCurrentStepIndex(index: number): void { - this.currentStepIndexState.set(Math.max(0, index)); + this.currentStepIndexState.set(Math.min(3, Math.max(0, index))); } updateBasics(value: OrganizationBasicsValue | null): void { @@ -108,8 +108,27 @@ export class OrganizationOnboardingStateService { this.completedStepIndexesState.update(current => current.filter(item => item !== index)); } + invalidateStep(index: number): void { + this.unmarkStepCompleted(index); + this.onboardingDataState.update(current => { + switch (index) { + case 0: + return { ...current, basics: null }; + case 1: + return { ...current, localization: null }; + case 2: + return { ...current, planLimits: null }; + case 3: + return { ...current, admin: null }; + default: + return current; + } + }); + } + setCompletedStepIndexes(indexes: readonly number[]): void { - const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0))].sort((left, right) => left - right); + const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0 && index <= 3))] + .sort((left, right) => left - right); this.completedStepIndexesState.set(uniqueIndexes); } @@ -158,7 +177,7 @@ export class OrganizationOnboardingStateService { ? { ...draft.admin } as OrganizationAdminValue : null, }); - this.currentStepIndexState.set(Math.max(0, draft.currentStepIndex)); + this.currentStepIndexState.set(Math.min(3, Math.max(0, draft.currentStepIndex))); this.setCompletedStepIndexes(draft.completedStepIndexes); this.savedDraftIdState.set(draft.draftId); this.savedAtState.set(draft.savedAt); @@ -180,10 +199,10 @@ export class OrganizationOnboardingStateService { createStepDefinitions(): readonly OnboardingStepDefinition[] { return [ - { key: 'basics', label: 'Basics' }, + { key: 'basics', label: 'Basic' }, { key: 'localization', label: 'Localization' }, - { key: 'plan-limits', label: 'Plan & Limits' }, - { key: 'admin-user', label: 'Admin & User' }, + { key: 'plan-limits', label: 'Plans and Limits' }, + { key: 'admin-user', label: 'Admin & Users' }, ]; } -} \ No newline at end of file +} diff --git a/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts b/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts index 2f92c05a..013c802e 100644 --- a/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts +++ b/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; -import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; -import { Observable, catchError, map, of, throwError } from 'rxjs'; +import { Observable, map, of, throwError } from 'rxjs'; +import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models'; import { CountryLookupDto, CountryService, @@ -29,10 +29,6 @@ import { OrganizationPlanLookupValue, TimeFormatValue, } from '../models/organization-onboarding.model'; -import { - OrganizationProvisioningRequest, - OrganizationProvisioningResult, -} from '../models/organization-provisioning.model'; const ORGANIZATION_ONBOARDING_DRAFT_KEY = 'organization-onboarding-draft-v1'; @@ -84,31 +80,31 @@ const PLAN_DEFAULTS: readonly OrganizationPlanDefaults[] = [ }, ]; -const DATE_FORMAT_OPTIONS: readonly FormSelect[] = [ - // { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' }, - // { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' }, - // { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' }, +const DATE_FORMAT_OPTIONS: readonly FormSelectOption[] = [ + { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' }, + { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' }, + { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' }, ]; -const TIME_FORMAT_OPTIONS: readonly FormSelect[] = [ - // { value: 'TwelveHour', label: '12 hour' }, - // { value: 'TwentyFourHour', label: '24 hour' }, +const TIME_FORMAT_OPTIONS: readonly FormSelectOption[] = [ + { value: 'TwelveHour', label: '12 hour' }, + { value: 'TwentyFourHour', label: '24 hour' }, ]; -const NUMBER_FORMAT_OPTIONS: readonly FormSelect[] = [ - // { value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' }, - // { value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' }, +const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption[] = [ + { value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' }, + { value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' }, ]; -const FISCAL_YEAR_OPTIONS: readonly FormSelect[] = [ - // { value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' }, - // { value: 'AprilToMarch', label: 'April - March' }, - // { value: 'JulyToJune', label: 'July - June' }, +const FISCAL_YEAR_OPTIONS: readonly FormSelectOption[] = [ + { value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' }, + { value: 'AprilToMarch', label: 'April - March' }, + { value: 'JulyToJune', label: 'July - June' }, ]; -const LICENSE_TYPE_OPTIONS: readonly FormSelect[] = [ - // { value: 'Trial', label: 'Trial' }, - // { value: 'Paid', label: 'Paid' }, +const LICENSE_TYPE_OPTIONS: readonly FormSelectOption[] = [ + { value: 'Trial', label: 'Trial' }, + { value: 'Paid', label: 'Paid' }, ]; const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [ @@ -202,35 +198,32 @@ export class OrganizationOnboardingService { })) ); } -loadLanguageOptions(limit = 50):readonly FormSelect[] { - return DATE_FORMAT_OPTIONS; + loadLanguageOptions(limit = 50): Observable[]> { + return this.languageService.autocomplete('', limit).pipe( + map(items => items.map(item => ({ + value: item.id, + label: `${item.code} - ${item.name}`, + }))) + ); } - // loadLanguageOptions(limit = 50): Observable[]> { - // // return this.languageService.autocomplete('', limit).pipe( - // // map(items => items.map(item => ({ - // // value: item.id, - // // label: `${item.code} - ${item.name}`, - // // }))) - // // ); - // } - getDateFormatOptions(): readonly FormSelect[] { + getDateFormatOptions(): readonly FormSelectOption[] { return DATE_FORMAT_OPTIONS; } - getTimeFormatOptions(): readonly FormSelect[] { + getTimeFormatOptions(): readonly FormSelectOption[] { return TIME_FORMAT_OPTIONS; } - getNumberFormatOptions(): readonly FormSelect[] { + getNumberFormatOptions(): readonly FormSelectOption[] { return NUMBER_FORMAT_OPTIONS; } - getFiscalYearOptions(): readonly FormSelect[] { + getFiscalYearOptions(): readonly FormSelectOption[] { return FISCAL_YEAR_OPTIONS; } - getLicenseTypeOptions(): readonly FormSelect[] { + getLicenseTypeOptions(): readonly FormSelectOption[] { return LICENSE_TYPE_OPTIONS; } @@ -271,7 +264,11 @@ loadLanguageOptions(limit = 50):readonly FormSelect[] { return of(parsedDraft); } catch (error) { - sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY); + try { + sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY); + } catch { + // The original storage or parsing error is the useful error to report. + } return throwError(() => error); } } @@ -294,13 +291,6 @@ loadLanguageOptions(limit = 50):readonly FormSelect[] { } } - provisionOrganization(_request: OrganizationProvisioningRequest): Observable { - return of({ - status: 'not-configured', - message: 'Provisioning is not configured yet for organization onboarding.', - }); - } - private filterLookupValues( source: readonly TValue[], term: string | null, @@ -325,8 +315,21 @@ loadLanguageOptions(limit = 50):readonly FormSelect[] { return ( value.schemaVersion === 1 && typeof value.currentStepIndex === 'number' && + Number.isInteger(value.currentStepIndex) && + value.currentStepIndex >= 0 && + value.currentStepIndex <= 3 && Array.isArray(value.completedStepIndexes) && - typeof value.savedAt === 'string' + value.completedStepIndexes.every(index => Number.isInteger(index) && index >= 0 && index <= 3) && + typeof value.savedAt === 'string' && + (value.draftId === null || typeof value.draftId === 'string') && + this.isDraftSection(value.basics) && + this.isDraftSection(value.localization) && + this.isDraftSection(value.planLimits) && + this.isDraftSection(value.admin) ); } -} \ No newline at end of file + + private isDraftSection(value: unknown): boolean { + return value === null || (typeof value === 'object' && !Array.isArray(value)); + } +} diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.html b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.html new file mode 100644 index 00000000..9f0ca37b --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.html @@ -0,0 +1,156 @@ +
+
+
+

+ Organization Contact +

+
+ +
+ +
+ +
+ +
+ +
+

+ First Administrator +

+

+ A set-password link will be sent to this administrator. +

+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+

+ Final Review +

+ +
+
+
Organization
+
+ {{ reviewData().basics?.organizationName || 'Not completed' }} +
+
+
+
Country
+
+ {{ reviewData().basics?.registrationCountry?.label || 'Not completed' }} +
+
+
+
Localization
+
+ {{ reviewData().localization?.timeZone?.label || 'Not completed' }} · + {{ reviewData().localization?.currency?.secondaryLabel || reviewData().localization?.currency?.label || '—' }} +
+
+
+
Plan and limits
+
+ {{ reviewData().planLimits?.subscriptionPlan?.label || 'Not completed' }} + @if (reviewData().planLimits) { + · {{ reviewData().planLimits?.maximumCompanies }} companies · + {{ reviewData().planLimits?.maximumUsers }} users · + {{ reviewData().planLimits?.maximumStorageGb }} GB + } +
+
+
+
Access dates
+
+ {{ reviewData().planLimits?.systemAccessStartDate || 'Not completed' }} – + {{ reviewData().planLimits?.systemAccessEndDate || 'No end date' }} +
+
+
+
Administrator
+
+ {{ form.controls.administratorFullName.value || 'Not entered' }} + @if (form.controls.administratorEmail.value) { + · {{ form.controls.administratorEmail.value }} + } +
+
+
+
diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.ts b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.ts new file mode 100644 index 00000000..d9f90e20 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.ts @@ -0,0 +1,96 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + input, + signal, +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { + OnboardingStepForm, + OrganizationAdminValue, + OrganizationOnboardingData, +} from '../../models/organization-onboarding.model'; + +interface OrganizationAdminFormModel { + readonly organizationEmail: string; + readonly organizationPhone: string; + readonly administratorFullName: string; + readonly administratorEmail: string; + readonly administratorMobile: string; +} + +@Component({ + selector: 'app-organization-admin-step', + standalone: true, + imports: [ReactiveFormsModule, FormInput], + templateUrl: './organization-admin.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OrganizationAdminStepComponent implements OnboardingStepForm { + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + + readonly reviewData = input.required(); + readonly submitAttempted = signal(false); + + readonly form = this.formBuilder.nonNullable.group({ + organizationEmail: ['', [Validators.required, Validators.email]], + organizationPhone: ['', [Validators.required]], + administratorFullName: ['', [Validators.required, Validators.maxLength(150)]], + administratorEmail: ['', [Validators.required, Validators.email]], + administratorMobile: ['', [Validators.required]], + }); + + validate(): boolean { + this.submitAttempted.set(true); + if (this.form.valid) { + return true; + } + + this.form.markAllAsTouched(); + this.focusFirstInvalidControl(); + return false; + } + + getValue(): OrganizationAdminValue { + const value = this.form.getRawValue() as OrganizationAdminFormModel; + return this.normalize(value); + } + + getDraftValue(): Partial { + return this.normalize(this.form.getRawValue() as OrganizationAdminFormModel); + } + + patchValue(value: Partial): void { + this.form.patchValue({ + organizationEmail: value.organizationEmail ?? '', + organizationPhone: value.organizationPhone ?? '', + administratorFullName: value.administratorFullName ?? '', + administratorEmail: value.administratorEmail ?? '', + administratorMobile: value.administratorMobile ?? '', + }, { emitEvent: false }); + this.submitAttempted.set(false); + } + + private normalize(value: OrganizationAdminFormModel): OrganizationAdminValue { + return { + organizationEmail: value.organizationEmail.trim(), + organizationPhone: value.organizationPhone.trim(), + administratorFullName: value.administratorFullName.trim(), + administratorEmail: value.administratorEmail.trim(), + administratorMobile: value.administratorMobile.trim(), + }; + } + + private focusFirstInvalidControl(): void { + queueMicrotask(() => { + this.elementRef.nativeElement + .querySelector('[data-form-control].ng-invalid, .ng-invalid [data-form-control]') + ?.focus(); + }); + } +} diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.html b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.html index 80504b6d..8de0ebdb 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.html +++ b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.html @@ -63,20 +63,93 @@
+
+ +
- +
+ +
+ +
+ +
+ +
+ +
- \ No newline at end of file + diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts index cf356394..0933de0e 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts +++ b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; @@ -14,13 +14,13 @@ import { TimezoneLookupDto, } from '../../../../global-masters/timezones/public-api'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { FormSelect } from '../../../../../shared/components/form/form-select/form-select'; 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, @@ -45,6 +45,15 @@ interface OrganizationLocalizationFormModel { readonly fiscalYearConvention: FiscalYearConventionValue | null; } +type ThemeSelectValue = + | string + | number + | readonly string[] + | readonly number[] + | string[] + | number[] + | null; + @Component({ selector: 'app-organization-localization-step', standalone: true, @@ -64,11 +73,18 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm readonly currencySelection = signal(null); readonly defaultLanguageSelection = signal(null); readonly additionalLanguageOptions = signal[]>([]); + readonly additionalLanguageThemeOptions = computed(() => + this.additionalLanguageOptions().map(option => ({ label: option.label, value: option.value })) + ); - readonly dateFormatOptions = this.onboardingService.getDateFormatOptions(); + readonly dateFormatOptions = this.onboardingService.getDateFormatOptions(); readonly timeFormatOptions = this.onboardingService.getTimeFormatOptions(); readonly numberFormatOptions = this.onboardingService.getNumberFormatOptions(); readonly fiscalYearOptions = this.onboardingService.getFiscalYearOptions(); + readonly dateFormatThemeOptions = this.toThemeOptions(this.dateFormatOptions); + readonly timeFormatThemeOptions = this.toThemeOptions(this.timeFormatOptions); + readonly numberFormatThemeOptions = this.toThemeOptions(this.numberFormatOptions); + readonly fiscalYearThemeOptions = this.toThemeOptions(this.fiscalYearOptions); readonly form = this.formBuilder.group({ timeZoneId: this.formBuilder.control(null, [Validators.required]), @@ -130,17 +146,17 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm this.onboardingService.resolveLanguage(value); constructor() { - // this.onboardingService.loadLanguageOptions(100) - // .pipe( - // catchError(() => { - // this.toastr.error('Unable to load language options.'); - // return of[]>([]); - // }), - // takeUntilDestroyed(this.destroyRef) - // ) - // .subscribe(options => { - // this.additionalLanguageOptions.update(current => this.mergeLanguageOptions(current, options)); - // }); + this.onboardingService.loadLanguageOptions(100) + .pipe( + catchError(() => { + this.toastr.error('Unable to load language options.'); + return of[]>([]); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(options => { + this.additionalLanguageOptions.update(current => this.mergeLanguageOptions(current, options)); + }); this.form.controls.defaultLanguageId.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) @@ -201,24 +217,24 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm return { timeZone: this.timeZoneSelection() ? { - id: this.timeZoneSelection()!.id, - label: this.timeZoneSelection()!.displayName, - secondaryLabel: this.timeZoneSelection()!.ianaId, - } + 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, - } + 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, - } + id: this.defaultLanguageSelection()!.id, + label: this.defaultLanguageSelection()!.name, + secondaryLabel: this.defaultLanguageSelection()!.code, + } : undefined, additionalLanguageIds: value.additionalLanguageIds, additionalLanguageSelections: this.resolveAdditionalLanguageSelections(value.additionalLanguageIds), @@ -245,31 +261,31 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm this.timeZoneSelection.set( value.timeZone ? { - id: value.timeZone.id, - ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label, - displayName: value.timeZone.label, - } + 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: '', - } + 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, - } + 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 ?? [])); @@ -291,31 +307,31 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm this.timeZoneSelection.set( replaceExisting || !this.timeZoneSelection() ? { - id: defaults.timeZone.id, - ianaId: defaults.timeZone.secondaryLabel ?? defaults.timeZone.label, - displayName: defaults.timeZone.label, - } + 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: '', - } + 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, - } + id: defaults.defaultLanguage.id, + code: defaults.defaultLanguage.secondaryLabel ?? defaults.defaultLanguage.label, + name: defaults.defaultLanguage.label, + nativeName: defaults.defaultLanguage.label, + isRightToLeft: false, + } : this.defaultLanguageSelection() ); @@ -364,6 +380,35 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm this.defaultLanguageSelection.set(null); } + onAdditionalLanguagesChanged(value: ThemeSelectValue): void { + const selectedIds = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + const defaultLanguageId = this.form.controls.defaultLanguageId.value; + + this.form.controls.additionalLanguageIds.setValue( + defaultLanguageId ? selectedIds.filter(id => id !== defaultLanguageId) : selectedIds + ); + } + + onDateFormatChanged(value: ThemeSelectValue): void { + this.form.controls.dateFormat.setValue(typeof value === 'string' ? value as DateFormatValue : null); + } + + onTimeFormatChanged(value: ThemeSelectValue): void { + this.form.controls.timeFormat.setValue(typeof value === 'string' ? value as TimeFormatValue : null); + } + + onNumberFormatChanged(value: ThemeSelectValue): void { + this.form.controls.numberFormat.setValue(typeof value === 'string' ? value as NumberFormatValue : null); + } + + onFiscalYearConventionChanged(value: ThemeSelectValue): void { + this.form.controls.fiscalYearConvention.setValue( + typeof value === 'string' ? value as FiscalYearConventionValue : 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])); @@ -406,4 +451,8 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm invalidElement?.focus(); }); } -} \ No newline at end of file + + private toThemeOptions(options: readonly FormSelectOption[]) { + return options.map(option => ({ label: option.label, value: option.value })); + } +} diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html index 76205c91..aa590619 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html +++ b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html @@ -19,8 +19,23 @@ (cleared)="onPlanCleared()" /> - - +
+ +
@@ -101,6 +117,7 @@ variant="floating" label="System Access Start Date" [required]="true" + [variant]="'floating'" [submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'System Access Start Date is required.' }" /> @@ -135,4 +152,4 @@
} - \ No newline at end of file + diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts index 0fb4688f..c2977b1e 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts +++ b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts @@ -41,6 +41,15 @@ interface OrganizationPlanLimitsFormModel { readonly systemAccessEndDate: string | null; } +type ThemeSelectValue = + | string + | number + | readonly string[] + | readonly number[] + | string[] + | number[] + | null; + const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { const value = control.value as OrganizationPlanLimitsFormModel; @@ -84,6 +93,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm(null); readonly limitsEditable = signal(true); readonly licenseTypeOptions = this.onboardingService.getLicenseTypeOptions(); + readonly licenseTypeThemeOptions = this.licenseTypeOptions.map(option => ({ + label: option.label, + value: option.value, + })); readonly form = this.formBuilder.group({ subscriptionPlanId: this.formBuilder.control(null, [Validators.required]), @@ -227,6 +240,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm import('./dashboard/dashboard').then((m) => m.Dashboard), + data: { childTitle: 'Dashboard', parentTitle: 'Organizations', subParentTitle: 'Global Organization Management' }, + }, + { + path: 'list', loadComponent: () => import('./organization-list/organization-list').then((m) => m.OrganizationList), data: { childTitle: 'Organization Management', parentTitle: 'Organizations', subParentTitle: 'Configuration' }, }, diff --git a/src/app/features/platform/pages/platform-list/platform-list.html b/src/app/features/platform/pages/platform-list/platform-list.html deleted file mode 100644 index 930f8e71..00000000 --- a/src/app/features/platform/pages/platform-list/platform-list.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Platform

-

Database connections, testing, and tenant assignments will be added here.

-
diff --git a/src/app/features/platform/pages/platform-list/platform-list.scss b/src/app/features/platform/pages/platform-list/platform-list.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/features/platform/pages/platform-list/platform-list.ts b/src/app/features/platform/pages/platform-list/platform-list.ts deleted file mode 100644 index 6c2fb374..00000000 --- a/src/app/features/platform/pages/platform-list/platform-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-platform-list', - standalone: true, - imports: [CommonModule], - templateUrl: './platform-list.html', - styleUrl: './platform-list.scss', -}) -export class PlatformList {} diff --git a/src/app/features/platform/platform.routes.ts b/src/app/features/platform/platform.routes.ts deleted file mode 100644 index 28878dc0..00000000 --- a/src/app/features/platform/platform.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const platformRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/platform-list/platform-list').then((m) => m.PlatformList), - data: { childTitle: 'Platform', parentTitle: 'Platform', subParentTitle: 'Infrastructure' }, - }, -]; diff --git a/src/app/features/settings/pages/branding/branding.html b/src/app/features/settings/pages/branding/branding.html new file mode 100644 index 00000000..ce367989 --- /dev/null +++ b/src/app/features/settings/pages/branding/branding.html @@ -0,0 +1,121 @@ +
+
+
+ Branding +
+
+ +
+
+ +
+ + +
+
+ + + + + Organization Logo + + (click or drag file here) +
+
+ + +
+
+ + + + + White Logo (dark bg) + + (click or drag file here) +
+
+ + +
+
+ + + + + Favicon / Icon + + (click or drag file here) +
+
+ + +
+
+ + + + + Digital Seal & Signature + + (click or drag file here) +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ All settings have working defaults — nothing here blocks onboarding. Security tab holds MFA / password policy + / session / IP restriction with platform defaults. +
+ +
+
+
+ + +
\ No newline at end of file diff --git a/src/app/features/localization/pages/localization-list/localization-list.scss b/src/app/features/settings/pages/branding/branding.scss similarity index 100% rename from src/app/features/localization/pages/localization-list/localization-list.scss rename to src/app/features/settings/pages/branding/branding.scss diff --git a/src/app/features/settings/pages/branding/branding.ts b/src/app/features/settings/pages/branding/branding.ts new file mode 100644 index 00000000..cc1bef79 --- /dev/null +++ b/src/app/features/settings/pages/branding/branding.ts @@ -0,0 +1,26 @@ +import { Component, inject } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { Button } from '../../../../shared/components/button/button'; +import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; +import { FormInput } from '../../../../shared/components/form/form-input/form-input'; +import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models'; + +@Component({ + selector: 'branding', + imports: [ReactiveFormsModule, Button, FormSelect, FormInput], + templateUrl: './branding.html', + styleUrl: './branding.scss', +}) +export class Branding { + private fb = inject(FormBuilder); + + brandingForm: FormGroup = this.fb.group({ + theme: ['default-light'], + brandColor: ['#1F3A93'] + }); + + themeOptions: FormSelectOption[] = [ + { label: 'Default Light', value: 'default-light' }, + { label: 'Dark Mode', value: 'dark' } + ]; +} diff --git a/src/app/features/settings/settings.routes.ts b/src/app/features/settings/settings.routes.ts new file mode 100644 index 00000000..8e80f1b4 --- /dev/null +++ b/src/app/features/settings/settings.routes.ts @@ -0,0 +1,9 @@ +import { Routes } from '@angular/router'; + +export const settingsRoutes: Routes = [ + { + path: 'branding', + loadComponent: () => import('./pages/branding/branding').then((m) => m.Branding), + data: { childTitle: 'Branding Management', parentTitle: 'Settings', subParentTitle: 'Configuration' }, + }, +]; diff --git a/src/app/shared/components/button/button.html b/src/app/shared/components/button/button.html index 2adfdee1..ec65c79f 100644 --- a/src/app/shared/components/button/button.html +++ b/src/app/shared/components/button/button.html @@ -8,6 +8,8 @@ title() || 'Button' " + [attr.aria-expanded]="ariaExpanded()" + [attr.aria-controls]="ariaControls()" [attr.aria-busy]="loading() ? true : null" (click)="onClick($event)" > diff --git a/src/app/shared/components/button/button.scss b/src/app/shared/components/button/button.scss index e69de29b..43f2f672 100644 --- a/src/app/shared/components/button/button.scss +++ b/src/app/shared/components/button/button.scss @@ -0,0 +1,92 @@ +:host-context(.modern-modal-footer) button, +:host-context(.onboarding-action-footer) button { + height: 36px; + padding: 0 16px; + border-radius: 10px; + font-size: 13px; + font-weight: 600; + transition: .3s ease; +} + +:host-context(.modern-modal-footer) button.ti-btn-light, +:host-context(.onboarding-action-footer) button.ti-btn-light { + border: 1px solid #dbe3ef; + background: #ffffff; + color: #475569; +} + +:host-context(.modern-modal-footer) button.ti-btn-light:hover:not(:disabled), +:host-context(.onboarding-action-footer) button.ti-btn-light:hover:not(:disabled) { + background: #f8fafc; + border-color: #cbd5e1; + transform: translateY(-2px); +} + +:host-context(.modern-modal-footer) button.ti-btn-primary-full, +:host-context(.modern-modal-footer) button.ti-btn-success-full, +:host-context(.onboarding-action-footer) button.ti-btn-primary-full, +:host-context(.onboarding-action-footer) button.ti-btn-success-full { + position: relative; + overflow: hidden; + border: none; + color: #ffffff; + background: #7c3deb; + box-shadow: 0 8px 20px rgba(125, 61, 235, 0.468); + transition: .35s ease; +} + +:host-context(.modern-modal-footer) button.ti-btn-primary-full:hover:not(:disabled), +:host-context(.modern-modal-footer) button.ti-btn-success-full:hover:not(:disabled), +:host-context(.onboarding-action-footer) button.ti-btn-primary-full:hover:not(:disabled), +:host-context(.onboarding-action-footer) button.ti-btn-success-full:hover:not(:disabled) { + transform: translateY(-3px); + box-shadow: 0 12px 28px rgba(37, 99, 235, .45); +} + +:host-context(.modern-modal-footer) button.ti-btn-primary-full::before, +:host-context(.modern-modal-footer) button.ti-btn-success-full::before, +:host-context(.onboarding-action-footer) button.ti-btn-primary-full::before, +:host-context(.onboarding-action-footer) button.ti-btn-success-full::before { + content: ""; + position: absolute; + top: 0; + left: -120%; + width: 70%; + height: 100%; + background: linear-gradient(120deg, transparent, rgba(255, 255, 255, .55), transparent); + transform: skewX(-25deg); + animation: saveButtonShine 2s infinite; +} + +:host-context(.modern-modal-footer) button:disabled, +:host-context(.onboarding-action-footer) button:disabled { + opacity: .55; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +:host-context(.onboarding-action-footer) button.onboarding-nav-btn { + height: 34px; + padding: 0 12px; + font-size: 12px; +} + +:host-context(.onboarding-action-footer) button.onboarding-save-btn:hover:not(:disabled) { + transform: translateY(-3px); + box-shadow: 0 12px 28px rgba(37, 99, 235, .45); +} + +@keyframes saveButtonShine { + 0% { + left: -120%; + } + + 40% { + left: 130%; + } + + 100% { + left: 130%; + } +} \ No newline at end of file diff --git a/src/app/shared/components/button/button.ts b/src/app/shared/components/button/button.ts index bdea5aa5..e13598c8 100644 --- a/src/app/shared/components/button/button.ts +++ b/src/app/shared/components/button/button.ts @@ -200,6 +200,7 @@ const BUTTON_ACTION_CONFIG: Record = { selector: 'app-button', standalone: true, templateUrl: './button.html', + styleUrl: './button.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class Button { @@ -231,6 +232,8 @@ export class Button { readonly iconClass = input(''); readonly ariaLabel = input(null); + readonly ariaExpanded = input(null); + readonly ariaControls = input(null); readonly title = input(null); readonly buttonClicked = output(); diff --git a/src/app/shared/components/data-table/data-table.html b/src/app/shared/components/data-table/data-table.html index 1a65ea66..68ab0427 100644 --- a/src/app/shared/components/data-table/data-table.html +++ b/src/app/shared/components/data-table/data-table.html @@ -145,7 +145,7 @@ dark:hover:bg-white/10 " [disabled]="isActionDisabled(action, row)" [class.opacity-50]="isActionDisabled(action, row)" [class.cursor-not-allowed]="isActionDisabled(action, row)" - [class.!text-danger]="action.type === 'delete'" [attr.aria-label]="getActionLabel(action)" + [class.!text-danger]="action.type === 'delete'" (click)="onDropdownActionClick($event, action, row)"> @if (action.icon) { diff --git a/src/app/shared/components/data-table/data-table.ts b/src/app/shared/components/data-table/data-table.ts index dd96df3b..994552f3 100644 --- a/src/app/shared/components/data-table/data-table.ts +++ b/src/app/shared/components/data-table/data-table.ts @@ -517,6 +517,11 @@ export class DataTable { } // 2. Automatic status detection + + if (this.rowColorMode() === 'none') { + return baseClass; + } + const status = row['status'] ?? row['state'] ?? diff --git a/src/app/shared/components/form/autocomplete/autocomplete.scss b/src/app/shared/components/form/autocomplete/autocomplete.scss index 7c8b0606..435a931b 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.scss +++ b/src/app/shared/components/form/autocomplete/autocomplete.scss @@ -77,6 +77,15 @@ padding-block: 0.625rem 0.375rem; } +:host-context(app-filter-card) .autocomplete-control--floating { + min-height: 2rem; +} + +:host-context(app-filter-card) .autocomplete-control--floating .autocomplete-input--floating { + min-height: calc(2rem - 2px); + padding-block: 0.5rem 0.25rem; +} + .autocomplete-dropdown-indicator { position: absolute; inset-inline-end: 0.75rem; diff --git a/src/app/shared/components/form/autocomplete/autocomplete.ts b/src/app/shared/components/form/autocomplete/autocomplete.ts index d5524218..9b53b5ba 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.ts +++ b/src/app/shared/components/form/autocomplete/autocomplete.ts @@ -135,6 +135,8 @@ export class Autocomplete implements ControlValueAccessor { readonly formDisabled = signal(false); readonly panelWidth = signal(0); readonly focused = signal(false); + readonly isForFilter = signal(false); + readonly resolvedInputId = computed(() => this.inputId()?.trim() || this.generatedId); readonly panelId = computed(() => `${this.resolvedInputId()}-listbox`); @@ -170,6 +172,11 @@ export class Autocomplete implements ControlValueAccessor { } return this.options().length ? '' : this.emptyText(); }); + + // readonly resolveHeight = computed(() => { + // return this.he() === 'sm' ? 'h-8' : 'h-10'; + // }); + readonly resolvedInputClass = computed(() => { this.controlStateVersion(); const control = this.control(); diff --git a/src/app/shared/components/form/form-date-picker/form-date-picker.html b/src/app/shared/components/form/form-date-picker/form-date-picker.html index 407ab591..5eee16c0 100644 --- a/src/app/shared/components/form/form-date-picker/form-date-picker.html +++ b/src/app/shared/components/form/form-date-picker/form-date-picker.html @@ -13,27 +13,29 @@ [showValidationWhenDirty]="showValidationWhenDirty()" [submitAttempted]="submitAttempted()" [validationMessages]="validationMessages()" - [wrapperClass]="wrapperClass()" + [wrapperClass]="(isFloating() ? 'date-picker-floating ' : '') + wrapperClass()" [labelClass]="labelClass()" [contentClass]="fieldContentClass()" > - +
+ +
diff --git a/src/app/shared/components/form/form-date-picker/form-date-picker.scss b/src/app/shared/components/form/form-date-picker/form-date-picker.scss index e69de29b..5896a85a 100644 --- a/src/app/shared/components/form/form-date-picker/form-date-picker.scss +++ b/src/app/shared/components/form/form-date-picker/form-date-picker.scss @@ -0,0 +1,110 @@ +:host { + display: block; + width: 100%; + min-width: 0; +} + +:host ::ng-deep .date-picker-floating .floating-label .form-control { + min-height: 2.5rem !important; + height: 2.5rem !important; + border: 1px solid var(--color-inputborder); + border-radius: 0.5rem; + background-color: var(--color-white); + color: transparent !important; + font-size: 0.8125rem; + padding-block: 0.5rem 0.15rem !important; + padding-inline: 0.75rem !important; + box-shadow: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease; +} + +/* Hide native datetime format (dd/mm/yyyy) when un-floated and not focused */ +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-fields-wrapper, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-text, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-month-field, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-day-field, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-year-field { + color: transparent !important; +} + +:host ::ng-deep .date-picker-floating .floating-label > label { + position: absolute !important; + inset-inline-start: 0.75rem !important; + padding-inline: 0.35rem !important; + background-color: var(--color-white) !important; + font-weight: 400 !important; + color: #8c9097 !important; + z-index: 9999 !important; + overflow: visible !important; + white-space: nowrap !important; + pointer-events: none !important; +} + +/* Float label and highlight purple on focus or when date is selected */ +:host ::ng-deep .date-picker-floating .floating-label--float > label, +:host ::ng-deep .date-picker-floating .floating-label:focus-within > label { + top: 0 !important; + transform: translateY(-50%) scale(0.85) !important; + transform-origin: left center !important; + font-weight: 400 !important; + color: var(--color-primary) !important; +} + +:host ::ng-deep .date-picker-floating .floating-label--float .form-control, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control { + color: var(--color-defaulttextcolor) !important; + border-color: var(--color-primary) !important; +} + +/* Show native datetime format / selected date value when floated or focused */ +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-fields-wrapper, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-fields-wrapper, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-text, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-text, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-month-field, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-month-field, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-day-field, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-day-field, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-year-field, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-year-field { + color: var(--color-defaulttextcolor) !important; +} + +:host-context(.dark) ::ng-deep .date-picker-floating .form-control, +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label > label { + background-color: var(--color-bodybg) !important; +} + +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control, +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control { + color: var(--color-white) !important; +} + +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit, +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit { + color: var(--color-white) !important; +} + +/* Calendar icon picker indicator styling */ +:host ::ng-deep .form-control::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s ease, filter 0.2s ease; + + &:hover { + opacity: 1; + } +} + +/* Invert calendar picker icon color to white in dark mode */ +:host-context(.dark) ::ng-deep .form-control::-webkit-calendar-picker-indicator { + filter: invert(1) !important; + opacity: 0.8; + + &:hover { + opacity: 1; + } +} diff --git a/src/app/shared/components/form/form-field/form-field.ts b/src/app/shared/components/form/form-field/form-field.ts index acf1a4c9..b95745c4 100644 --- a/src/app/shared/components/form/form-field/form-field.ts +++ b/src/app/shared/components/form/form-field/form-field.ts @@ -151,6 +151,10 @@ export class FormField { const value = control.value; + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== null && value !== undefined && value !== ''; }); diff --git a/src/app/shared/components/form/form-input/form-input.scss b/src/app/shared/components/form/form-input/form-input.scss index 25f2e9da..0b44641e 100644 --- a/src/app/shared/components/form/form-input/form-input.scss +++ b/src/app/shared/components/form/form-input/form-input.scss @@ -18,14 +18,14 @@ .shared-form-input { display: block; width: 100%; - min-height: 2.5rem; + //min-height: 1.6rem; padding: 0.75rem 0.75rem 0.5rem; border: 1px solid var(--color-inputborder); border-radius: 0.5rem; background-color: var(--color-white); color: var(--color-defaulttextcolor); font-size: 0.8125rem; - line-height: 1.25; + line-height: 10px; outline: none; box-shadow: none; transition: border-color 0.2s ease; diff --git a/src/app/shared/components/form/form-select/form-select.html b/src/app/shared/components/form/form-select/form-select.html index 7323075c..c7bfe274 100644 --- a/src/app/shared/components/form/form-select/form-select.html +++ b/src/app/shared/components/form/form-select/form-select.html @@ -1,225 +1,181 @@ - - - @if (showDropdownHeader()) { + +
+ + @if (showDropdownHeader()) { @if (searchable()) { -
- -
+
+ + +
} - @if (isMultiple() && showSelectAll()) { - - } -
- } - - -
- @if (isMultiple() && showCheckboxes()) { + @if (resolvedIsMultiple() && showSelectAll()) { + + } + + } + + +
+ @if (resolvedIsMultiple() && showCheckboxes()) { + + } + + @if (item.prefixText) { {{ item.prefixText }} - } + } - - - {{ getOptionDisplayLabel(item, item$.label) }} - + + + {{ getOptionDisplayLabel(item, item$.label) }} + - @if (item.description) { + @if (item.description) { {{ item.description }} - } - -
-
+ } + +
+
- - - @if (getSelectionPrefixText(item)) { + + + @if (getSelectionPrefixText(item)) { {{ getSelectionPrefixText(item) }} - } + } - - {{ getSelectionDisplayLabel(item, label) }} - + + {{ getSelectionDisplayLabel(item, label) }} + - @if (isMultiple() && clearable() && !resolvedReadonly()) { - - } - - + } + + - @if (isMultiple()) { + @if (resolvedIsMultiple()) { -
+
@if (items.length > 0) { - - - {{ getMultiLabelText(items) }} - - - @if (clearable() && !resolvedReadonly()) { - - } + + + {{ getFirstOptionLabel(items) }} + + @if (items.length > 1) { + + +{{ items.length - 1 }} + + } + + @if (clearable() && !resolvedReadonly()) { + + } + }
- } + } - @if (isMultiple() && showMultiSelectFooter()) { + @if (resolvedIsMultiple() && showMultiSelectFooter()) { -
+
{{ pendingValue().length }} selected - -
+ } + + + @if (!loading()) { + } - - +
+ \ No newline at end of file diff --git a/src/app/shared/components/form/form-select/form-select.scss b/src/app/shared/components/form/form-select/form-select.scss index e8532903..1ebca17d 100644 --- a/src/app/shared/components/form/form-select/form-select.scss +++ b/src/app/shared/components/form/form-select/form-select.scss @@ -5,31 +5,170 @@ } :host ::ng-deep .form-select-floating .floating-label .ng-select-container { - min-height: 2.5rem; + min-height: 2.5rem !important; + height: 2.5rem !important; border: 1px solid var(--color-inputborder); border-radius: 0.5rem; background-color: var(--color-white); color: var(--color-defaulttextcolor); font-size: 0.8125rem; box-shadow: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + display: flex; + align-items: center; + position: relative; + overflow: visible; } -:host ::ng-deep .form-select-floating .floating-label > label { - padding-inline: 0.25rem; +:host ::ng-deep .form-select-floating .floating-label { + position: relative !important; + overflow: visible !important; +} + +:host ::ng-deep .form-select-floating .floating-label>label { + position: absolute !important; + inset-inline-start: 0.75rem !important; + padding-inline: 0.35rem !important; background-color: var(--color-white) !important; - font-size: 0.75rem !important; + font-weight: 400 !important; + color: #8c9097 !important; + z-index: 9999 !important; + overflow: visible !important; + white-space: nowrap !important; + pointer-events: none !important; +} + +:host ::ng-deep .form-select-floating .floating-label--float>label, +:host ::ng-deep .form-select-floating .floating-label:focus-within>label, +:host ::ng-deep .form-select-floating:has(.ng-select-has-value)>label { + top: 0 !important; + transform: translateY(-50%) scale(0.85) !important; + transform-origin: left center !important; + font-weight: 400 !important; + color: var(--color-primary) !important; +} + +:host ::ng-deep .ng-select .ng-arrow-wrapper { + display: none !important; +} + +.form-select-dropdown-indicator { + position: absolute; + inset-inline-end: 0.75rem; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-textmuted, #8c9097); + font-size: 0.875rem; + line-height: 1; + pointer-events: none; + z-index: 9999 !important; + transition: transform 0.2s ease, color 0.2s ease; + + &.is-expanded { + transform: translateY(-50%) rotate(180deg) !important; + color: var(--color-primary) !important; + } +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select .ng-select-container .ng-value-container { + padding-inline-start: 0.75rem; + padding-inline-end: 2.25rem; + padding-block: 0; + display: flex; + align-items: center; + height: 100%; + max-width: 100%; + overflow: hidden; +} + +:host ::ng-deep .form-select-floating .floating-label--float .ng-select .ng-select-container .ng-value-container, +:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select .ng-select-container .ng-value-container { + padding-top: 0.5rem !important; + padding-bottom: 0.15rem !important; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container { + display: flex; + flex-wrap: nowrap; + gap: 0.375rem; + align-items: center; + max-width: 100%; + overflow: hidden; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-value { + margin: 0; + max-width: 100%; + overflow: hidden; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-input { + margin: 0; + padding: 0; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder { + top: auto; } :host ::ng-deep .form-select-floating .floating-label--float .ng-select-container, -:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select-container { - border-color: var(--color-primary); +:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select-container, +:host ::ng-deep .form-select-floating .ng-select.ng-select-has-value .ng-select-container, +:host ::ng-deep .form-select-floating .ng-select.ng-select-opened .ng-select-container, +:host ::ng-deep .form-select-floating .ng-select.ng-select-focused .ng-select-container { + border-color: var(--color-primary) !important; } :host ::ng-deep .form-select-floating .floating-label--invalid .ng-select-container { - border-color: var(--color-danger); + border-color: var(--color-danger) !important; } :host-context(.dark) ::ng-deep .form-select-floating .ng-select-container, -:host-context(.dark) ::ng-deep .form-select-floating .floating-label > label { +:host-context(.dark) ::ng-deep .form-select-floating .floating-label>label { background-color: var(--color-bodybg) !important; } + +:host ::ng-deep .ng-select .ng-dropdown-panel { + border-radius: 0.5rem !important; + border: 1px solid var(--color-defaultborder, #e9edf6) !important; + background-color: var(--color-white, #ffffff) !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05) !important; + overflow: hidden !important; + margin-top: 4px !important; +} + +:host-context(.dark) ::ng-deep .ng-select .ng-dropdown-panel { + border-color: rgba(255, 255, 255, 0.1) !important; + background-color: var(--color-bodybg, #1a1e25) !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-header { + border-bottom: none !important; + padding: 0 !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-header-tmp button, +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option { + padding: 0.5rem 0.75rem !important; +} + +:host ::ng-deep .ng-select.ng-select-multiple .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected:not(.ng-option-marked) { + background-color: transparent !important; + color: var(--color-defaulttextcolor) !important; +} + +:host-context(.dark) ::ng-deep .ng-select.ng-select-multiple .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected:not(.ng-option-marked) { + color: var(--color-white) !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked { + background-color: var(--color-primary) !important; + color: #ffffff !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked * { + color: #ffffff !important; +} \ No newline at end of file diff --git a/src/app/shared/components/form/form-select/form-select.ts b/src/app/shared/components/form/form-select/form-select.ts index 641af404..365f449f 100644 --- a/src/app/shared/components/form/form-select/form-select.ts +++ b/src/app/shared/components/form/form-select/form-select.ts @@ -3,6 +3,7 @@ import { Component, Injector, computed, + effect, forwardRef, inject, input, @@ -64,6 +65,7 @@ export class FormSelect implements private readonly generatedInputId = `form-select-${FormSelect.nextId++}`; readonly inputId = input(null); + readonly id = input(null); readonly label = input(''); readonly variant = input<'default' | 'floating'>('default'); @@ -71,13 +73,19 @@ export class FormSelect implements readonly name = input(null); readonly options = input[]>([]); + readonly bindLabel = input('label'); + readonly bindValue = input('value'); readonly mode = input('single'); + readonly multiple = input(null); + readonly defaultValue = input | null>(null); readonly searchable = input(true); readonly searchMode = input('client'); + readonly searchPlaceholder = input(null); + readonly editableSearchTerm = input(null); readonly clearable = input(true); @@ -159,6 +167,7 @@ export class FormSelect implements readonly fieldContentClass = input(''); readonly selectClass = input(''); + readonly className = input(''); readonly ariaLabel = input(null); @@ -193,9 +202,9 @@ export class FormSelect implements readonly searchTerm = signal(''); readonly hasFocus = signal(false); - private onChange: (value: FormSelectValue) => void = () => {}; + private onChange: (value: FormSelectValue) => void = () => { }; - private onTouched: () => void = () => {}; + private onTouched: () => void = () => { }; readonly control = computed(() => { return this.injector.get(NgControl, null, { @@ -205,7 +214,7 @@ export class FormSelect implements }); readonly resolvedInputId = computed(() => - this.inputId()?.trim() || this.generatedInputId + this.inputId()?.trim() || this.id()?.trim() || this.generatedInputId ); readonly resolvedName = computed(() => @@ -214,6 +223,10 @@ export class FormSelect implements readonly isMultiple = computed(() => this.mode() === 'multiple'); + readonly resolvedIsMultiple = computed(() => + this.multiple() ?? this.mode() === 'multiple' + ); + readonly isDisabled = computed(() => this.disabled() || this.formDisabled() || @@ -235,7 +248,7 @@ export class FormSelect implements ); readonly resolvedCloseOnSelect = computed(() => - this.closeOnSelect() ?? !this.isMultiple() + this.closeOnSelect() ?? !this.resolvedIsMultiple() ); readonly resolvedEditableSearchTerm = computed(() => @@ -276,7 +289,7 @@ export class FormSelect implements readonly showDropdownHeader = computed(() => this.searchable() || ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showSelectAll() ) ); @@ -291,7 +304,7 @@ export class FormSelect implements readonly modelValue = computed>(() => { if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() && this.dropdownOpen() ) { @@ -353,7 +366,7 @@ export class FormSelect implements ); readonly resolvedSearchPlaceholder = computed(() => - this.placeholder().trim() || + this.searchPlaceholder()?.trim() || ( this.label().trim() ? `Search ${this.label().trim()}` @@ -361,7 +374,7 @@ export class FormSelect implements ) ); - + readonly describedBy = computed(() => { const ids: string[] = []; @@ -437,12 +450,25 @@ export class FormSelect implements 'app-form-select-control', showInvalidState ? 'is-invalid' : '', this.isDisabled() ? 'opacity-60 pointer-events-none' : '', - this.selectClass() + this.selectClass(), + this.className() ] .filter(Boolean) .join(' '); }); + constructor() { + effect(() => { + const defaultValue = this.defaultValue(); + + if (this.control() || defaultValue === null) { + return; + } + + this.value.set(this.normalizeValue(defaultValue)); + }); + } + writeValue(value: FormSelectValue): void { this.value.set(this.normalizeValue(value)); } @@ -463,7 +489,7 @@ export class FormSelect implements const normalizedValue = this.normalizeValue(incomingValue); if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() && this.dropdownOpen() ) { @@ -492,7 +518,7 @@ export class FormSelect implements } toggleSelectAll(): void { - if (!this.isMultiple() || this.isDisabled()) { + if (!this.resolvedIsMultiple() || this.isDisabled()) { return; } @@ -516,7 +542,7 @@ export class FormSelect implements this.searchTerm.set(''); if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() ) { this.pendingValue.set([ @@ -559,7 +585,7 @@ export class FormSelect implements } confirmSelection(select: NgSelectComponent): void { - if (!this.isMultiple()) { + if (!this.resolvedIsMultiple()) { return; } @@ -594,15 +620,18 @@ export class FormSelect implements : firstLabel; } + getFirstOptionLabel(items: readonly FormSelectOption[]): string { + return items[0]?.label ?? ''; + } + trackOption(option: FormSelectOption): TValue { return option.value; } getOptionContainerClass(option: FormSelectOption): string { return [ - 'flex min-w-0 w-full items-start gap-2 text-inherit', - option.disabled ? 'text-textmuted opacity-60' : '', - this.isOptionSelected(option) && !option.disabled ? 'text-white' : '' + 'flex min-w-0 w-full items-center gap-2 text-inherit', + option.disabled ? 'text-textmuted opacity-60' : '' ] .filter(Boolean) .join(' '); @@ -660,7 +689,7 @@ export class FormSelect implements private activeSelectedValues(): readonly TValue[] { if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() && this.dropdownOpen() ) { @@ -679,7 +708,7 @@ export class FormSelect implements } private normalizeValue(value: FormSelectValue): FormSelectValue { - if (this.isMultiple()) { + if (this.resolvedIsMultiple()) { return this.isValueArray(value) ? value.filter(item => !this.isEmptyStringValue(item)) : []; diff --git a/src/app/shared/components/modal/modal.scss b/src/app/shared/components/modal/modal.scss index c251a389..785503d4 100644 --- a/src/app/shared/components/modal/modal.scss +++ b/src/app/shared/components/modal/modal.scss @@ -461,208 +461,6 @@ } -/* ============================ - Cancel Button -============================ */ - - -::ng-deep .modern-modal-footer app-button:first-child button { - - - height: 36px; - - padding: 0 16px; - - border-radius: 10px; - - border: 1px solid #dbe3ef; - - background: #ffffff; - - color: #475569; - - font-weight: 600; - - font-size: 13px; - - transition: .3s ease; - -} - - -::ng-deep .modern-modal-footer app-button:first-child button:hover { - - - background: #f8fafc; - - border-color: #cbd5e1; - - transform: translateY(-2px); - -} - - - -/* ============================ - Save Button -============================ */ - - -::ng-deep .modern-modal-footer app-button:last-child button { - - - position: relative; - - overflow: hidden; - - height: 36px; - - padding: 0 16px; - - border-radius: 10px; - - border: none; - - - color: #ffffff; - - - // background: - - // linear-gradient(135deg, - // #111C43, - // #2563eb); - background: #7c3deb; - - box-shadow: - - 0 8px 20px rgba(125, 61, 235, 0.468); - - - font-weight: 600; - - font-size: 13px; - - - transition: .35s ease; - -} - - - -::ng-deep .modern-modal-footer app-button:last-child button:hover { - - - transform: translateY(-3px); - - - box-shadow: - - 0 12px 28px rgba(37, 99, 235, .45); - -} - - - -/* ============================ - Shine Animation -============================ */ - - -::ng-deep .modern-modal-footer app-button:last-child button::before { - - - content: ""; - - - position: absolute; - - - top: 0; - - left: -120%; - - - width: 70%; - - - height: 100%; - - - background: - - linear-gradient(120deg, - transparent, - rgba(255, 255, 255, .55), - transparent); - - - transform: skewX(-25deg); - - - animation: saveButtonShine 2s infinite; - -} - - - -@keyframes saveButtonShine { - - - 0% { - - left: -120%; - - } - - - 40% { - - left: 130%; - - } - - - 100% { - - left: 130%; - - } - -} - - -/* Disabled */ - -::ng-deep .modern-modal-footer button:disabled { - - - opacity: .55; - - cursor: not-allowed; - - transform: none !important; - - box-shadow: none !important; - -} - -::ng-deep .modern-modal-footer app-button button { - - height: 36px; - - padding: 0 16px; - - border-radius: 10px; - - font-size: 13px; - - font-weight: 600; - - transition: .3s ease; - -} - /* ========================================================== Mobile ========================================================== */ diff --git a/src/app/shell/routes/content.routes.ts b/src/app/shell/routes/content.routes.ts index 41c53d2b..92e23738 100644 --- a/src/app/shell/routes/content.routes.ts +++ b/src/app/shell/routes/content.routes.ts @@ -21,24 +21,12 @@ export const content: Routes = [ path: 'tenants', loadChildren: () => import('../../features/tenants/tenants.routes').then((m) => m.tenantsRoutes), }, - { - path: 'billing', - loadChildren: () => import('../../features/billing/billing.routes').then((m) => m.billingRoutes), - }, - { - path: 'localization', - loadChildren: () => import('../../features/localization/localization.routes').then((m) => m.localizationRoutes), - }, { path: 'theming', loadChildren: () => import('../../features/theming/theming.routes').then((m) => m.themingRoutes), }, { - path: 'platform', - loadChildren: () => import('../../features/platform/platform.routes').then((m) => m.platformRoutes), - }, - { - path: 'monitoring', - loadChildren: () => import('../../features/monitoring/monitoring.routes').then((m) => m.monitoringRoutes), - }, + path: 'settings', + loadChildren: () => import('../../features/settings/settings.routes').then((m) => m.settingsRoutes), + } ]; diff --git a/src/styles.scss b/src/styles.scss index 68b419e8..c8342b64 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -36,27 +36,23 @@ body { } .total-mail-recepients::-webkit-scrollbar { - @apply h-0!; + height: 0 !important; } - .custom-scrollbar-width::-webkit-scrollbar { - @apply w-[5px]!; + width: 5px !important; } -.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected { - background-color: var(--color-primary) !important; - color: var(--color-white) !important; +.ng-dropdown-panel .ng-dropdown-header, +.ng-select.app-form-select-control .ng-dropdown-header { + border-bottom: none !important; + padding: 0 !important; } .ng-select.app-form-select-control .ng-dropdown-panel { z-index: 99 !important; } -.ng-select.app-form-select-control .ng-dropdown-header { - padding: 0; -} - .swal2-styled.app-confirm-dialog-btn { min-width: 110px; min-height: 44px; @@ -69,6 +65,7 @@ body { .ti-btn-primary-full { margin-bottom: 0 !important; } -.ti-form-select{ - border: 1px solid #7c3deb !important; -} + +.ti-form-select { + border: 1px solid #7c3deb !important; +} \ No newline at end of file From 9ce058895c28ee4742ec36d3676529ab1f2289fe Mon Sep 17 00:00:00 2001 From: Gagan7900 <54118395+Gagan7900@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:07:28 +0530 Subject: [PATCH 3/3] orgnanization onboarding stepper form changes, code refactor for custom data-table grid --- src/app/core/auth/auth.service.ts | 133 ---- src/app/core/auth/token-storage.service.ts | 152 ---- src/app/core/guards/auth.guard.ts | 4 +- src/app/core/guards/auth/auth.guard.ts | 2 +- src/app/core/guards/auth/guest.guard.ts | 2 +- src/app/core/guards/auth/super-admin.guard.ts | 4 +- src/app/core/guards/guest.guard.ts | 4 +- src/app/core/interceptors/auth.interceptor.ts | 2 +- src/app/core/models/api-response.model.ts | 12 - src/app/core/models/auth/auth.model.ts | 24 - src/app/core/models/city/city.model.ts | 26 - src/app/core/models/context.model.ts | 33 - src/app/core/models/context/context.model.ts | 2 +- src/app/core/models/country/country.model.ts | 31 - .../core/models/currency/currency.model.ts | 34 - .../core/models/language/language.model.ts | 31 - src/app/core/models/state/state.model.ts | 30 - .../models/tenant/tenant-currencies.model.ts | 53 -- src/app/core/models/tenant/tenant.model.ts | 68 -- .../core/models/timezone/timezone.model.ts | 27 - src/app/core/models/user/user.model.ts | 29 - src/app/core/services/app-context.service.ts | 104 --- .../services/auth/session-timeout.service.ts | 2 +- .../services/auth/token-storage.service.ts | 2 +- src/app/core/services/base-api.service.ts | 34 - .../core/services/common/app-state.service.ts | 245 +++--- src/app/core/services/common/menu.data.ts | 4 +- .../core/services/common/menu.service.spec.ts | 74 -- src/app/core/services/common/nav.service.ts | 38 +- src/app/core/services/loading.service.ts | 24 - src/app/core/services/menu.data.ts | 66 -- src/app/core/services/menu.service.ts | 42 -- src/app/core/services/permission.service.ts | 34 - .../core/services/session-timeout.service.ts | 154 ---- .../core/services/tenant-context.service.ts | 23 - src/app/core/services/user-context.service.ts | 32 - .../data-access}/auth.service.ts | 17 +- .../authentication}/models/auth.model.ts | 0 .../authentication/pages/login/login.ts | 2 +- .../city-form-modal/city-form-modal.html | 93 +++ .../city-form-modal/city-form-modal.ts | 229 ++++++ .../cities/data-access/city.endpoints.ts | 4 + .../cities/data-access/city.service.ts | 11 +- .../cities/models/city.model.ts | 13 +- .../cities/pages/city-list/city-list.html | 267 +++---- .../cities/pages/city-list/city-list.ts | 620 ++++------------ .../country-form-modal.html | 96 +++ .../country-form-modal/country-form-modal.ts | 211 ++++++ .../countries/data-access/country.service.ts | 10 + .../countries/models/country.model.ts | 7 +- .../pages/country-list/country-list.html | 113 +-- .../pages/country-list/country-list.ts | 557 +++----------- .../currency-form-modal.html | 97 +++ .../currency-form-modal.ts | 190 +++++ .../data-access/currency.service.ts | 11 +- .../currencies/models/currency.model.ts | 8 +- .../pages/currency-list/currency-list.html | 87 +-- .../pages/currency-list/currency-list.ts | 536 +++----------- .../language-form-modal.html | 38 + .../language-form-modal.ts | 178 +++++ .../data-access/language.endpoints.ts | 4 + .../languages/data-access/language.service.ts | 11 +- .../languages/models/language.model.ts | 7 +- .../pages/language-list/language-list.html | 100 +-- .../pages/language-list/language-list.ts | 335 ++------- .../state-form-modal/state-form-modal.html | 72 ++ .../state-form-modal/state-form-modal.ts | 199 +++++ .../states/data-access/state.service.ts | 11 +- .../states/models/state.model.ts | 9 +- .../states/pages/state-list/state-list.html | 163 ++-- .../states/pages/state-list/state-list.ts | 575 +++------------ .../timezone-form-modal.html | 100 +++ .../timezone-form-modal.ts | 191 +++++ .../data-access/timezone.endpoints.ts | 4 + .../timezones/data-access/timezone.service.ts | 11 +- .../timezones/models/timezone.model.ts | 5 + .../pages/timezone-list/timezone-list.html | 121 +-- .../pages/timezone-list/timezone-list.ts | 336 +++------ .../data-access/industery.service.ts | 35 + .../data-access/industry.endpoints.ts | 21 + .../industries/models/industry.model.ts | 7 + src/app/features/industries/public-api.ts | 2 + .../organizations/dashboard/dashboard.html | 29 +- .../organizations/dashboard/dashboard.ts | 213 +++--- .../organization-list/organization-list.html | 93 ++- .../organization-list/organization-list.ts | 199 ++--- .../onboarding-stepper.html | 91 ++- .../organization-onboarding.constants.ts | 13 + .../organization-onboarding.endpoints.ts | 28 + .../organization-onboarding-state.service.ts | 43 +- .../organization-onboarding.service.ts | 243 ++++++ .../models/organization-onboarding.model.ts | 235 +++++- .../organization-onboarding.html | 5 - .../organization-onboarding.ts | 215 ++++-- .../organization-onboarding.service.ts | 335 --------- .../organization-admin/organization-admin.ts | 17 +- .../organization-basics.html | 6 + .../organization-basics.ts | 60 +- .../organization-localization.html | 6 + .../organization-localization.ts | 136 +++- .../organization-plan-limits.html | 6 +- .../organization-plan-limits.ts | 53 +- .../settings/pages/branding/branding.html | 165 ++++- .../settings/pages/branding/branding.ts | 123 +++- .../tenant-form-modal/tenant-form-modal.html | 152 ++++ .../tenant-form-modal/tenant-form-modal.ts | 246 +++++++ .../features/tenants/models/tenant.model.ts | 7 +- .../tenant-currencies/tenant-currencies.ts | 13 +- .../pages/tenant-list/tenant-list.html | 198 +---- .../tenants/pages/tenant-list/tenant-list.ts | 695 ++++-------------- .../user-form-modal/user-form-modal.html | 55 ++ .../user-form-modal/user-form-modal.ts | 182 +++++ src/app/features/users/models/user.model.ts | 2 +- .../users/pages/users-list/users-list.html | 44 +- .../users/pages/users-list/users-list.ts | 429 +---------- .../components/app-loader/app-loader.html | 8 - .../components/app-loader/app-loader.scss | 0 .../components/app-loader/app-loader.ts | 13 - src/app/shared/components/button/button.scss | 19 +- .../components/data-table/data-table.store.ts | 134 ++++ src/app/shared/components/footer/footer.html | 15 - src/app/shared/components/footer/footer.scss | 0 src/app/shared/components/footer/footer.ts | 13 - src/app/shared/components/header/header.html | 524 ------------- src/app/shared/components/header/header.scss | 0 src/app/shared/components/header/header.ts | 318 -------- .../shared/components/header/headerdata.ts | 102 --- .../components/page-header/page-header.html | 36 - .../components/page-header/page-header.scss | 0 .../components/page-header/page-header.ts | 34 - .../problem-details-toast.ts | 16 - .../shared/components/sidebar/sidebar.html | 216 ------ .../shared/components/sidebar/sidebar.scss | 0 src/app/shared/components/sidebar/sidebar.ts | 526 ------------- .../shared/components/switcher/switcher.html | 543 -------------- .../shared/components/switcher/switcher.scss | 0 .../shared/components/switcher/switcher.ts | 191 ----- .../components/tab-to-top/tab-to-top.html | 6 - .../components/tab-to-top/tab-to-top.scss | 0 .../components/tab-to-top/tab-to-top.ts | 35 - .../authentication-layout.html | 2 - .../authentication-layout.scss | 0 .../authentication-layout.ts | 14 - .../content-layout/content-layout.html | 46 -- .../content-layout/content-layout.scss | 0 .../layouts/content-layout/content-layout.ts | 143 ---- src/app/shared/services/app-state.service.ts | 321 -------- src/app/shared/services/auth.service.ts | 152 ---- src/app/shared/services/firebase.service.ts | 27 - src/app/shared/services/nav.service.ts | 102 --- .../layouts/content-layout/content-layout.ts | 4 +- src/app/shell/ui/header/header.ts | 16 +- src/app/shell/ui/sidebar/sidebar.ts | 2 +- src/app/shell/ui/switcher/switcher.ts | 8 +- 154 files changed, 5480 insertions(+), 9738 deletions(-) delete mode 100644 src/app/core/auth/auth.service.ts delete mode 100644 src/app/core/auth/token-storage.service.ts delete mode 100644 src/app/core/models/api-response.model.ts delete mode 100644 src/app/core/models/auth/auth.model.ts delete mode 100644 src/app/core/models/city/city.model.ts delete mode 100644 src/app/core/models/context.model.ts delete mode 100644 src/app/core/models/country/country.model.ts delete mode 100644 src/app/core/models/currency/currency.model.ts delete mode 100644 src/app/core/models/language/language.model.ts delete mode 100644 src/app/core/models/state/state.model.ts delete mode 100644 src/app/core/models/tenant/tenant-currencies.model.ts delete mode 100644 src/app/core/models/tenant/tenant.model.ts delete mode 100644 src/app/core/models/timezone/timezone.model.ts delete mode 100644 src/app/core/models/user/user.model.ts delete mode 100644 src/app/core/services/app-context.service.ts delete mode 100644 src/app/core/services/base-api.service.ts delete mode 100644 src/app/core/services/common/menu.service.spec.ts delete mode 100644 src/app/core/services/loading.service.ts delete mode 100644 src/app/core/services/menu.data.ts delete mode 100644 src/app/core/services/menu.service.ts delete mode 100644 src/app/core/services/permission.service.ts delete mode 100644 src/app/core/services/session-timeout.service.ts delete mode 100644 src/app/core/services/tenant-context.service.ts delete mode 100644 src/app/core/services/user-context.service.ts rename src/app/{core/services/auth => features/authentication/data-access}/auth.service.ts (87%) rename src/app/{core => features/authentication}/models/auth.model.ts (100%) create mode 100644 src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.html create mode 100644 src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.ts create mode 100644 src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html create mode 100644 src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts create mode 100644 src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.html create mode 100644 src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.ts create mode 100644 src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.html create mode 100644 src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.ts create mode 100644 src/app/features/global-masters/states/components/state-form-modal/state-form-modal.html create mode 100644 src/app/features/global-masters/states/components/state-form-modal/state-form-modal.ts create mode 100644 src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.html create mode 100644 src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.ts create mode 100644 src/app/features/industries/data-access/industery.service.ts create mode 100644 src/app/features/industries/data-access/industry.endpoints.ts create mode 100644 src/app/features/industries/models/industry.model.ts create mode 100644 src/app/features/industries/public-api.ts create mode 100644 src/app/features/organizations/organization-onboarding/constants/organization-onboarding.constants.ts create mode 100644 src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts rename src/app/features/organizations/organization-onboarding/{ => data-access}/services/organization-onboarding-state.service.ts (80%) create mode 100644 src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts delete mode 100644 src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts create mode 100644 src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.html create mode 100644 src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.ts create mode 100644 src/app/features/users/components/user-form-modal/user-form-modal.html create mode 100644 src/app/features/users/components/user-form-modal/user-form-modal.ts delete mode 100644 src/app/shared/components/app-loader/app-loader.html delete mode 100644 src/app/shared/components/app-loader/app-loader.scss delete mode 100644 src/app/shared/components/app-loader/app-loader.ts create mode 100644 src/app/shared/components/data-table/data-table.store.ts delete mode 100644 src/app/shared/components/footer/footer.html delete mode 100644 src/app/shared/components/footer/footer.scss delete mode 100644 src/app/shared/components/footer/footer.ts delete mode 100644 src/app/shared/components/header/header.html delete mode 100644 src/app/shared/components/header/header.scss delete mode 100644 src/app/shared/components/header/header.ts delete mode 100644 src/app/shared/components/header/headerdata.ts delete mode 100644 src/app/shared/components/page-header/page-header.html delete mode 100644 src/app/shared/components/page-header/page-header.scss delete mode 100644 src/app/shared/components/page-header/page-header.ts delete mode 100644 src/app/shared/components/problem-details-toast/problem-details-toast.ts delete mode 100644 src/app/shared/components/sidebar/sidebar.html delete mode 100644 src/app/shared/components/sidebar/sidebar.scss delete mode 100644 src/app/shared/components/sidebar/sidebar.ts delete mode 100644 src/app/shared/components/switcher/switcher.html delete mode 100644 src/app/shared/components/switcher/switcher.scss delete mode 100644 src/app/shared/components/switcher/switcher.ts delete mode 100644 src/app/shared/components/tab-to-top/tab-to-top.html delete mode 100644 src/app/shared/components/tab-to-top/tab-to-top.scss delete mode 100644 src/app/shared/components/tab-to-top/tab-to-top.ts delete mode 100644 src/app/shared/layouts/authentication-layout/authentication-layout.html delete mode 100644 src/app/shared/layouts/authentication-layout/authentication-layout.scss delete mode 100644 src/app/shared/layouts/authentication-layout/authentication-layout.ts delete mode 100644 src/app/shared/layouts/content-layout/content-layout.html delete mode 100644 src/app/shared/layouts/content-layout/content-layout.scss delete mode 100644 src/app/shared/layouts/content-layout/content-layout.ts delete mode 100644 src/app/shared/services/app-state.service.ts delete mode 100644 src/app/shared/services/auth.service.ts delete mode 100644 src/app/shared/services/firebase.service.ts delete mode 100644 src/app/shared/services/nav.service.ts diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts deleted file mode 100644 index 25a123f3..00000000 --- a/src/app/core/auth/auth.service.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { computed, Injectable, inject, signal } from '@angular/core'; -import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model'; -import { API_CONFIG } from '../config/api.config'; -import { TokenStorageService } from './token-storage.service'; -import { AppContextService } from '../services/app-context.service'; - -@Injectable({ providedIn: 'root' }) -export class AuthService { - private readonly http = inject(HttpClient); - private readonly tokenStorage = inject(TokenStorageService); - private readonly appContextService = inject(AppContextService); - - private readonly userSubject = new BehaviorSubject(null); - readonly user$ = this.userSubject.asObservable(); - readonly currentUserSignal = signal(null); - private readonly accessTokenSignal = signal(null); - readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal()); - - private refreshRequest$: Observable | null = null; - - constructor() { - this.restoreAuthState(); - } - - login(payload: LoginRequest, rememberMe: boolean): Observable { - return this.http.post(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/login`, payload).pipe( - map((response) => { - const user = this.normalizeUserProfile(response); - this.tokenStorage.saveAuth(response, rememberMe); - this.setAuthState(user, this.tokenStorage.getAccessToken()); - return response; - }) - ); - } - - refreshAccessToken(): Observable { - if (this.refreshRequest$) { - return this.refreshRequest$; - } - - const refreshToken = this.tokenStorage.getRefreshToken(); - if (!refreshToken) { - this.logout(); - return throwError(() => new HttpErrorResponse({ status: 401, statusText: 'Refresh token missing' })); - } - - this.refreshRequest$ = this.http.post(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe( - map((response) => { - const user = this.normalizeUserProfile(response, this.currentUserSignal()); - const storageType = this.tokenStorage.getStorageType(); - const rememberMe = storageType === 'local'; - this.tokenStorage.saveAuth(response, rememberMe); - this.setAuthState(user, this.tokenStorage.getAccessToken()); - return response; - }), - catchError((error) => { - this.logout(); - return throwError(() => error); - }), - finalize(() => { - this.refreshRequest$ = null; - }), - shareReplay(1) - ); - - return this.refreshRequest$; - } - - logout(): void { - this.tokenStorage.clearAuth(); - this.appContextService.clearContext(); - this.setAuthState(null, null); - this.refreshRequest$ = null; - } - - get accessToken(): string | null { - return this.tokenStorage.getAccessToken(); - } - - get refreshToken(): string | null { - return this.tokenStorage.getRefreshToken(); - } - - get isAuthenticated(): boolean { - return this.isAuthenticatedSignal(); - } - - get isLoggedIn(): boolean { - return this.isAuthenticatedSignal(); - } - - get currentUser(): UserProfile | null { - return this.currentUserSignal(); - } - - private restoreAuthState(): void { - const storedUser = this.tokenStorage.getUser(); - const storedAccessToken = this.tokenStorage.getAccessToken(); - - if (storedUser && storedAccessToken) { - this.setAuthState(storedUser, storedAccessToken); - return; - } - - this.setAuthState(null, storedAccessToken ?? null); - } - - private setAuthState(user: UserProfile | null, token: string | null): void { - this.userSubject.next(user); - this.currentUserSignal.set(user); - this.accessTokenSignal.set(token); - } - - private normalizeUserProfile(response: LoginResponse, fallbackUser: UserProfile | null = null): UserProfile | null { - if (response.user) { - return response.user; - } - - const userId = response.userId?.trim(); - const email = response.email?.trim(); - if (userId || email) { - return { - id: userId ?? fallbackUser?.id ?? '', - email: email ?? fallbackUser?.email ?? '', - roles: response.roles, - }; - } - - return fallbackUser; - } -} diff --git a/src/app/core/auth/token-storage.service.ts b/src/app/core/auth/token-storage.service.ts deleted file mode 100644 index 1bc3ab7d..00000000 --- a/src/app/core/auth/token-storage.service.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Injectable } from '@angular/core'; -import { LoginResponse, UserProfile } from '../models/auth.model'; - -type StorageType = 'local' | 'session'; - -@Injectable({ providedIn: 'root' }) -export class TokenStorageService { - private readonly accessTokenKey = 'master-admin-access-token'; - private readonly refreshTokenKey = 'master-admin-refresh-token'; - private readonly accessTokenExpiresOnKey = 'master-admin-access-token-expires-on'; - private readonly refreshTokenExpiresOnKey = 'master-admin-refresh-token-expires-on'; - private readonly userKey = 'master-admin-user'; - - saveAuth(response: LoginResponse, rememberMe: boolean): void { - this.clearAuth(); - const selectedStorage = rememberMe ? localStorage : sessionStorage; - - if (response.accessToken) { - selectedStorage.setItem(this.accessTokenKey, response.accessToken); - } - - if (response.refreshToken) { - selectedStorage.setItem(this.refreshTokenKey, response.refreshToken); - } - - if (response.accessTokenExpiresOn) { - selectedStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn); - } - - if (response.refreshTokenExpiresOn) { - selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn); - } - - const user = this.buildUserProfile(response); - if (user) { - selectedStorage.setItem(this.userKey, JSON.stringify(user)); - } - } - - clearAuth(): void { - this.removeFromStorage(localStorage); - this.removeFromStorage(sessionStorage); - } - - getAccessToken(): string | null { - return this.getByPriority(this.accessTokenKey); - } - - getRefreshToken(): string | null { - return this.getByPriority(this.refreshTokenKey); - } - - getAccessTokenExpiresOn(): string | null { - return this.getByPriority(this.accessTokenExpiresOnKey); - } - - getRefreshTokenExpiresOn(): string | null { - return this.getByPriority(this.refreshTokenExpiresOnKey); - } - - getUser(): UserProfile | null { - const raw = this.getByPriority(this.userKey); - - if (!raw) { - return null; - } - - try { - return JSON.parse(raw) as UserProfile; - } catch { - this.clearAuth(); - return null; - } - } - - getStorageType(): 'local' | 'session' | null { - if (this.hasAnyAuthKey(sessionStorage)) { - return 'session'; - } - - if (this.hasAnyAuthKey(localStorage)) { - return 'local'; - } - - return null; - } - - isAccessTokenExpired(): boolean { - return this.isExpired(this.getAccessTokenExpiresOn()); - } - - isRefreshTokenExpired(): boolean { - return this.isExpired(this.getRefreshTokenExpiresOn()); - } - - private getByPriority(key: string): string | null { - const fromSession = sessionStorage.getItem(key); - if (fromSession !== null) { - return fromSession; - } - - return localStorage.getItem(key); - } - - private hasAnyAuthKey(storage: Storage): boolean { - return [ - this.accessTokenKey, - this.refreshTokenKey, - this.accessTokenExpiresOnKey, - this.refreshTokenExpiresOnKey, - this.userKey, - ].some((key) => storage.getItem(key) !== null); - } - - private removeFromStorage(storage: Storage): void { - storage.removeItem(this.accessTokenKey); - storage.removeItem(this.refreshTokenKey); - storage.removeItem(this.accessTokenExpiresOnKey); - storage.removeItem(this.refreshTokenExpiresOnKey); - storage.removeItem(this.userKey); - } - - private isExpired(expiresOn: string | null): boolean { - debugger; - if (!expiresOn) { - return true; - } - - const parsedExpiry = Date.parse(expiresOn); - - if (Number.isNaN(parsedExpiry)) { - return true; - } - - return parsedExpiry <= Date.now(); - } - private buildUserProfile(response: LoginResponse): UserProfile | null { - if (response.user) { - return response.user; - } - - if (response.userId || response.email) { - return { - id: response.userId ?? '', - email: response.email ?? '', - roles: response.roles, - }; - } - - return null; - } -} diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts index cb0fab9b..cbb7a8b7 100644 --- a/src/app/core/guards/auth.guard.ts +++ b/src/app/core/guards/auth.guard.ts @@ -1,8 +1,8 @@ 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'; +import { AuthService } from '../../features/authentication/data-access/auth.service'; +import { TokenStorageService } from '../services/auth/token-storage.service'; export const authGuard: CanActivateChildFn = (_childRoute, state) => { const authService = inject(AuthService); diff --git a/src/app/core/guards/auth/auth.guard.ts b/src/app/core/guards/auth/auth.guard.ts index 764b2048..b30e5221 100644 --- a/src/app/core/guards/auth/auth.guard.ts +++ b/src/app/core/guards/auth/auth.guard.ts @@ -1,7 +1,7 @@ import { inject } from '@angular/core'; import { CanActivateChildFn, Router } from '@angular/router'; import { catchError, map, of } from 'rxjs'; -import { AuthService } from '../../services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; import { TokenStorageService } from '../../services/auth/token-storage.service'; export const authGuard: CanActivateChildFn = (_childRoute, state) => { diff --git a/src/app/core/guards/auth/guest.guard.ts b/src/app/core/guards/auth/guest.guard.ts index e62d18af..d39f66e8 100644 --- a/src/app/core/guards/auth/guest.guard.ts +++ b/src/app/core/guards/auth/guest.guard.ts @@ -1,7 +1,7 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { catchError, map, of } from 'rxjs'; -import { AuthService } from '../../services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; import { TokenStorageService } from '../../services/auth/token-storage.service'; const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm'; diff --git a/src/app/core/guards/auth/super-admin.guard.ts b/src/app/core/guards/auth/super-admin.guard.ts index d109777b..1541a35f 100644 --- a/src/app/core/guards/auth/super-admin.guard.ts +++ b/src/app/core/guards/auth/super-admin.guard.ts @@ -1,11 +1,11 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; -import { AuthService } from '../../services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; export const superAdminGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); - return (auth.currentUser?.roles ?? []).includes('super_admin') + return (auth.currentUser()?.roles ?? []).includes('super_admin') ? true : router.createUrlTree(['/dashboards/crm']); }; diff --git a/src/app/core/guards/guest.guard.ts b/src/app/core/guards/guest.guard.ts index 42629512..f580219c 100644 --- a/src/app/core/guards/guest.guard.ts +++ b/src/app/core/guards/guest.guard.ts @@ -1,8 +1,8 @@ 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'; +import { AuthService } from '../../features/authentication/data-access/auth.service'; +import { TokenStorageService } from '../services/auth/token-storage.service'; const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm'; diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 8418add5..68dc20b9 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { catchError, switchMap, throwError } from 'rxjs'; -import { AuthService } from '../services/auth/auth.service'; +import { AuthService } from '../../features/authentication/data-access/auth.service'; import { API_CONFIG } from '../config/api.config'; import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints'; diff --git a/src/app/core/models/api-response.model.ts b/src/app/core/models/api-response.model.ts deleted file mode 100644 index 0ec47b3b..00000000 --- a/src/app/core/models/api-response.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface ApiResponse { - data: T; - message?: string; - success: boolean; -} - -export interface ProblemDetails { - title?: string; - status?: number; - detail?: string; - errors?: Record; -} diff --git a/src/app/core/models/auth/auth.model.ts b/src/app/core/models/auth/auth.model.ts deleted file mode 100644 index 0c747c92..00000000 --- a/src/app/core/models/auth/auth.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface LoginRequest { - email: string; - password: string; -} - -export interface LoginResponse { - accessToken: string; - refreshToken?: string; - accessTokenExpiresOn?: string; - refreshTokenExpiresOn?: string; - expiresIn?: number; - user?: UserProfile; - userId?: string; - email?: string; - roles?: string[]; -} - -export interface UserProfile { - id: string; - email: string; - displayName?: string; - roles?: string[]; - tenantId?: string; -} diff --git a/src/app/core/models/city/city.model.ts b/src/app/core/models/city/city.model.ts deleted file mode 100644 index 69dfe3b2..00000000 --- a/src/app/core/models/city/city.model.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface CityDto { - id: string; - stateId: string; - name: string; - code: string | null; - timezoneId: string | null; - isActive: boolean; - createdOn?: string; - modifiedOn?: string | null; -} - -export interface CreateCityRequest { - stateId: string; - name: string; - code: string; - timezoneId: string | null; -} - -export interface UpdateCityRequest { - name: string; - code: string; - timezoneId: string | null; - isActive: boolean; -} - -export type CityModalMode = 'create' | 'edit' ; diff --git a/src/app/core/models/context.model.ts b/src/app/core/models/context.model.ts deleted file mode 100644 index aab308a8..00000000 --- a/src/app/core/models/context.model.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { UserProfile } from './auth.model'; -import { Menu } from '../../shared/services/nav.service'; - -export interface CurrentUserContext extends UserProfile { - fullName?: string; - defaultLandingPage?: string; -} - -export interface TenantContext { - tenantId?: string; - companyId?: string; - companyName?: string; - tenantName?: string; - defaultLandingPage?: string; -} - -export interface PermissionContext { - roles: string[]; - permissions: string[]; - defaultLandingPage?: string; -} - -export interface MenuContext { - items: Menu[]; - defaultLandingPage?: string; -} - -export interface AppContextState { - user: CurrentUserContext; - tenant: TenantContext; - permissions: PermissionContext; - menu: MenuContext; -} \ No newline at end of file diff --git a/src/app/core/models/context/context.model.ts b/src/app/core/models/context/context.model.ts index 57efe873..90d3ab3f 100644 --- a/src/app/core/models/context/context.model.ts +++ b/src/app/core/models/context/context.model.ts @@ -1,4 +1,4 @@ -import { UserProfile } from '../auth/auth.model'; +import { UserProfile } from '../../../features/authentication/models/auth.model'; import { Menu } from '../../services/common/nav.service'; export interface CurrentUserContext extends UserProfile { diff --git a/src/app/core/models/country/country.model.ts b/src/app/core/models/country/country.model.ts deleted file mode 100644 index 9c4b220f..00000000 --- a/src/app/core/models/country/country.model.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface CountryDto { - id: string; - iso2: string; - iso3: string; - name: string; - phoneCode: string | null; - defaultCurrencyId: string | null; - isActive: boolean; - createdOn?: string; - modifiedOn?: string | null; -} - -export interface CountryLookupDto { - id: string; - iso2: string; - name: string; -} - -export interface CreateCountryRequest { - iso2: string; - iso3: string; - name: string; - phoneCode: string | null; - defaultCurrencyId: string | null; -} - -export interface UpdateCountryRequest extends CreateCountryRequest { - isActive: boolean; -} - -export type CountryModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/currency/currency.model.ts b/src/app/core/models/currency/currency.model.ts deleted file mode 100644 index 012e2a19..00000000 --- a/src/app/core/models/currency/currency.model.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface CurrencyDto { - id: string; - code: string; - iso2: string; - name: string; - symbol: string; - numericCode: number; - decimalDigits: number; - isActive: boolean; - createdOn?: string; - modifiedOn?: string | null; -} - - -export interface CurrencyLookupDto { - readonly id: string; - readonly code: string; - readonly name: string; - readonly symbol: string; -} - -export interface CreateCurrencyRequest { - code: string; - name: string; - symbol: string; - numericCode: number; - decimalDigits: number; -} - -export interface UpdateCurrencyRequest extends CreateCurrencyRequest { - isActive: boolean; -} - -export type CurrencyModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/language/language.model.ts b/src/app/core/models/language/language.model.ts deleted file mode 100644 index fc1f7539..00000000 --- a/src/app/core/models/language/language.model.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface LanguageDto { - id: string; - code: string; - name: string; - nativeName: string; - isRightToLeft: boolean; - isActive: boolean; - createdOn: string; - modifiedOn: string | null; -} - -export interface LanguageLookupDto { - readonly id: string; - readonly code: string; - readonly name: string; - readonly nativeName: string; - readonly isRightToLeft: boolean; -} - -export interface CreateLanguageRequest { - code: string; - name: string; - nativeName: string; - isRightToLeft: boolean; -} - -export interface UpdateLanguageRequest extends CreateLanguageRequest { - isActive: boolean; -} - -export type LanguageModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/state/state.model.ts b/src/app/core/models/state/state.model.ts deleted file mode 100644 index 384358d9..00000000 --- a/src/app/core/models/state/state.model.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface StateDto { - id: string; - countryId: string; - name: string; - code: string | null; - isActive: boolean; - createdOn?: string; - modifiedOn?: string | null; -} - -export interface StateLookupDto { - id: string; - name: string; - code: string; -} - -export interface CreateStateRequest { - countryId: string; - name: string; - code: string; -} - -export interface UpdateStateRequest { - countryId: null; - name: string; - code: string; - isActive: boolean; -} - -export type StateModalMode = 'create' | 'edit'; diff --git a/src/app/core/models/tenant/tenant-currencies.model.ts b/src/app/core/models/tenant/tenant-currencies.model.ts deleted file mode 100644 index 8ba9c947..00000000 --- a/src/app/core/models/tenant/tenant-currencies.model.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { DataTableRecord } from '../../../shared/components/data-table/data-table.types'; - - -export interface TenantCurrencyDto { - id: string; - tenantId: string; - tenantName: string; - currencyId: string; - currencyName: string; - isBaseCurrency: boolean; - isReporting: boolean; - isActive: boolean; - createdOn?: string; - modifiedOn?: string | null; -} - -export interface TenantCurrencyLookupDto { - id: string; - tenantId: string; - currencyId: string; - isBaseCurrency: boolean; - isReporting: boolean; -} - -export interface CreateTenantCurrencyRequest { - tenantId: string; - currencyId: string; - isBaseCurrency: boolean; - isReporting: boolean; -} - -export interface UpdateTenantCurrencyRequest { - isBaseCurrency: boolean; - isReporting: boolean; - isActive: boolean; -} - - -export interface TenantCurrencyTableRow extends DataTableRecord { - readonly id: string; - readonly tenantId: string; - readonly tenantName: string; - readonly currencyId: string; - readonly currencyName: string; - readonly isBaseCurrency: boolean; - readonly isReporting: boolean; - readonly isActive: boolean; - readonly serialNumber: number; - readonly createdOn?: string; - readonly modifiedOn?: string | null; -} - -export type TenantCurrencyModalMode = 'create' | 'edit'; \ No newline at end of file diff --git a/src/app/core/models/tenant/tenant.model.ts b/src/app/core/models/tenant/tenant.model.ts deleted file mode 100644 index 5aa875b4..00000000 --- a/src/app/core/models/tenant/tenant.model.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { DataTableRecord } from '../../../shared/components/data-table/data-table.types'; - -export enum TenantStatus { Trial = 0, Active = 1, Suspended = 2, Cancelled = 3 } - -export interface TenantDto { - id: string; - code: string; - name: string; - status: TenantStatus; - defaultLanguageId: string; - defaultLanguageName: string | null; - defaultDbConnectionId: string | null; - defaultDbConnectionName: string | null; - defaultCurrencyId: string; - defaultCurrencyName: string | null; - defaultTimezoneId: string; - defaultTimezoneName: string | null; - dataRegion: string; - isActive: boolean; - createdOn?: string; - modifiedOn?: string | null; -} - -export interface TenantLookupDto { - id: string; - name: string; - code: string; -} - -export interface CreateTenantRequest { - code: string; - name: string; - status: TenantStatus; - defaultLanguageId: string; - defaultCurrencyId: string; - defaultTimezoneId: string; - dataRegion: string; -} - -export interface UpdateTenantRequest { - code: string; - name: string; - status: TenantStatus; - defaultLanguageId: string; - defaultCurrencyId: string; - defaultTimezoneId: string; - defaultDbConnectionId: string | null; - dataRegion: string; - isActive: boolean; -} - - -export interface TenantTableRow extends DataTableRecord { - readonly id: string; - readonly code: string; - readonly name: string; - readonly status: TenantStatus; - readonly dataRegion: string; - readonly isActive: boolean; - readonly serialNumber: number; - readonly createdOn?: string; - readonly modifiedOn?: string | null; - readonly defaultLanguageName: string | null; - readonly defaultCurrencyName: string | null; - readonly defaultTimezoneName: string | null; -} - -export type TenantModalMode = 'create' | 'edit'; \ No newline at end of file diff --git a/src/app/core/models/timezone/timezone.model.ts b/src/app/core/models/timezone/timezone.model.ts deleted file mode 100644 index 31a75ee6..00000000 --- a/src/app/core/models/timezone/timezone.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface TimezoneDto { - readonly id: string; - readonly ianaId: string; - readonly displayName: string; - readonly utcOffsetMinutes: number; - readonly isActive: boolean; - readonly createdOn: string; - readonly modifiedOn: string | null; -} - -export interface TimezoneLookupDto { - readonly id: string; - readonly ianaId: string; - readonly displayName: string; -} - -export interface CreateTimezoneRequest { - readonly ianaId: string; - readonly displayName: string; - readonly utcOffsetMinutes: number; -} - -export interface UpdateTimezoneRequest extends CreateTimezoneRequest { - readonly isActive: boolean; -} - -export type TimezoneModalMode = 'create' | 'edit' | 'view'; diff --git a/src/app/core/models/user/user.model.ts b/src/app/core/models/user/user.model.ts deleted file mode 100644 index b7cd6b9c..00000000 --- a/src/app/core/models/user/user.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -export enum UserStatus { Pending = 0, Active = 1, Suspended = 2, Locked = 3, Disabled = 4 } - -export interface CreateUserRequest { - email: string; - password: string; - roleCodes: string[]; -} - -export interface UserDto { - id: string; - email: string; - status: UserStatus; - roles: string[]; - isActive: boolean; - createdOn: string; - updatedOn?: string | null; - lastLoginOn: string | null; -} - -export interface UserLookupDto { - readonly id: string; - readonly ianaId: string; - readonly displayName: string; -} -export interface UpdateUserRequest extends CreateUserRequest { - readonly isActive: boolean; -} - -export type UserModalMode = 'create' | 'edit'; \ No newline at end of file diff --git a/src/app/core/services/app-context.service.ts b/src/app/core/services/app-context.service.ts deleted file mode 100644 index eaa14305..00000000 --- a/src/app/core/services/app-context.service.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { finalize, forkJoin, Observable, of, shareReplay, tap } from 'rxjs'; -import { AppContextState, MenuContext } from '../models/context.model'; -import { MenuService } from './menu.service'; -import { PermissionService } from './permission.service'; -import { TenantContextService } from './tenant-context.service'; -import { UserContextService } from './user-context.service'; -import { NavService } from '../../shared/services/nav.service'; - -@Injectable({ providedIn: 'root' }) -export class AppContextService { - private readonly userContextService = inject(UserContextService); - private readonly tenantContextService = inject(TenantContextService); - private readonly permissionService = inject(PermissionService); - private readonly menuService = inject(MenuService); - private readonly navService = inject(NavService); - - private loadRequest$: Observable | null = null; - private menuLoadRequest$: Observable | null = null; - private loaded = false; - - ensureMenuInitialized(forceReload = false): Observable { - const currentMenu = this.menuService.menuContext(); - if (currentMenu && !forceReload) { - this.navService.setMenuItems(this.menuService.getNavigationMenu()); - return of(currentMenu); - } - - if (this.menuLoadRequest$ && !forceReload) { - return this.menuLoadRequest$; - } - - this.menuLoadRequest$ = this.menuService.loadMenu().pipe( - tap(() => { - this.navService.setMenuItems(this.menuService.getNavigationMenu()); - }), - finalize(() => { - this.menuLoadRequest$ = null; - }), - shareReplay(1) - ); - - return this.menuLoadRequest$; - } - - loadAppContext(forceReload = false): Observable { - if (this.loaded && !forceReload) { - return of(this.getCurrentState()); - } - - if (this.loadRequest$ && !forceReload) { - return this.loadRequest$; - } - - this.loadRequest$ = forkJoin({ - user: this.userContextService.loadCurrentUserProfile(), - tenant: this.tenantContextService.loadTenantContext(), - permissions: this.permissionService.loadPermissions(), - menu: this.menuService.loadMenu(), - }).pipe( - tap((context) => { - this.navService.setMenuItems(this.menuService.getNavigationMenu()); - this.loaded = true; - }), - finalize(() => { - this.loadRequest$ = null; - }), - shareReplay(1) - ); - - return this.loadRequest$; - } - - ensureContextLoaded(): Observable { - return this.loadAppContext(); - } - - clearContext(): void { - this.userContextService.clear(); - this.tenantContextService.clear(); - this.permissionService.clear(); - this.menuService.clear(); - this.navService.clearMenuItems(); - this.loaded = false; - this.loadRequest$ = null; - } - - getDefaultLandingPage(): string { - return this.userContextService.currentUserContext()?.defaultLandingPage - ?? this.permissionService.permissionContext()?.defaultLandingPage - ?? this.tenantContextService.tenantContext()?.defaultLandingPage - ?? this.menuService.getDefaultLandingPage() - ?? '/dashboards/crm'; - } - - private getCurrentState(): AppContextState { - return { - user: this.userContextService.currentUserContext() ?? { id: '', email: '', roles: [] }, - tenant: this.tenantContextService.tenantContext() ?? {}, - permissions: this.permissionService.permissionContext() ?? { roles: [], permissions: [] }, - menu: this.menuService.menuContext() ?? { items: [] }, - }; - } -} \ No newline at end of file diff --git a/src/app/core/services/auth/session-timeout.service.ts b/src/app/core/services/auth/session-timeout.service.ts index 1404257c..138cbe6b 100644 --- a/src/app/core/services/auth/session-timeout.service.ts +++ b/src/app/core/services/auth/session-timeout.service.ts @@ -5,7 +5,7 @@ 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'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; @Injectable() export class SessionTimeoutService implements OnDestroy { diff --git a/src/app/core/services/auth/token-storage.service.ts b/src/app/core/services/auth/token-storage.service.ts index b62a6bec..4bdcb281 100644 --- a/src/app/core/services/auth/token-storage.service.ts +++ b/src/app/core/services/auth/token-storage.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { LoginResponse, UserProfile } from '../../models/auth/auth.model'; +import { LoginResponse, UserProfile } from '../../../features/authentication/models/auth.model'; type StorageType = 'local' | 'session'; diff --git a/src/app/core/services/base-api.service.ts b/src/app/core/services/base-api.service.ts deleted file mode 100644 index f432d80b..00000000 --- a/src/app/core/services/base-api.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; -import { API_CONFIG } from '../config/api.config'; -import { ApiResponse } from '../models/api-response.model'; - -@Injectable({ providedIn: 'root' }) -export class BaseApiService { - private readonly http = inject(HttpClient); - - protected get(resource: string, params?: Record): Observable { - let httpParams = new HttpParams(); - - if (params) { - Object.entries(params).forEach(([key, value]) => { - httpParams = httpParams.set(key, String(value)); - }); - } - - return this.http.get(`${API_CONFIG.baseUrl}${resource}`, { params: httpParams }); - } - - protected post(resource: string, body: T): Observable { - return this.http.post(`${API_CONFIG.baseUrl}${resource}`, body); - } - - protected put(resource: string, body: T): Observable { - return this.http.put(`${API_CONFIG.baseUrl}${resource}`, body); - } - - protected delete(resource: string): Observable { - return this.http.delete(`${API_CONFIG.baseUrl}${resource}`); - } -} diff --git a/src/app/core/services/common/app-state.service.ts b/src/app/core/services/common/app-state.service.ts index 76d41601..a9adbaf5 100644 --- a/src/app/core/services/common/app-state.service.ts +++ b/src/app/core/services/common/app-state.service.ts @@ -1,55 +1,56 @@ -import { DOCUMENT, ElementRef, inject, Injectable, Renderer2 } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; +import { DOCUMENT, inject, Injectable } from '@angular/core'; +import { signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; -interface StateType { +export interface StateType { direction: string; theme: string; - navigationStyles: string, // vertical, horizontal - menuStyles: string, // menu-click, menu-hover, icon-click, icon-hover - layoutStyles: string, // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu - pageStyles: string, // regular, classic, modern - widthStyles: string, // fullwidth, boxed - menuPosition: string, // fixed, scrollable - headerPosition: string, // fixed, scrollable - menuColor: string, // light, dark, color, gradient, transparent - headerColor: string, // light, dark, color, gradient, transparent - themePrimary: string, // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90' - themeBackground: string, - backgroundImage: string, -}; + navigationStyles: string; + menuStyles: string; + layoutStyles: string; + pageStyles: string; + widthStyles: string; + menuPosition: string; + headerPosition: string; + menuColor: string; + headerColor: string; + themePrimary: string; + themeBackground: any; + backgroundImage: string; +} + @Injectable({ providedIn: 'root' }) export class AppStateService { - private readonly localStorageKey = 'Ynex-ng'; // Customize this key - private initialState: StateType = { - theme: 'light', // light, dark - direction: 'ltr', // ltr, rtl - navigationStyles: 'vertical', // vertical, horizontal - menuStyles: '', // menu-click, menu-hover, icon-click, icon-hover - layoutStyles: 'default', // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu - pageStyles: 'regular', // regular, classic, modern - widthStyles: 'fullwidth', // fullwidth, boxed - menuPosition: 'fixed', // fixed, scrollable - headerPosition: 'fixed', // fixed, scrollable - menuColor: 'dark', // light, dark, color, gradient, transparent - headerColor: 'light', // light, dark, color, gradient, transparent - themePrimary: '', // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90' + private readonly localStorageKey = 'Ynex-ng'; + private readonly initialState: StateType = { + theme: 'light', + direction: 'ltr', + navigationStyles: 'vertical', + menuStyles: '', + layoutStyles: 'default', + pageStyles: 'regular', + widthStyles: 'fullwidth', + menuPosition: 'fixed', + headerPosition: 'fixed', + menuColor: 'dark', + headerColor: 'light', + themePrimary: '', themeBackground: '', - backgroundImage: '', // bgimg1, bgimg2, bgimg3, bgimg4, bgimg5 - } // Store initial state - private stateSubject = new BehaviorSubject(this.initialState); // Use any for initial null value - state$ = this.stateSubject.asObservable(); + backgroundImage: '', + }; + + private readonly stateSignal = signal(this.getInitialStateFromLocalStorage()); + readonly state = this.stateSignal.asReadonly(); + readonly state$ = toObservable(this.stateSignal); + private document = inject(DOCUMENT); navigationStyles: any; private html = this.document.documentElement; constructor() { - - const initialState: StateType = this.getInitialStateFromLocalStorage(); - // this.initializeState(); - this.stateSubject.next(initialState); - + this.updateStateAndEmit(this.stateSignal()); } private getInitialStateFromLocalStorage(): StateType { @@ -64,40 +65,33 @@ export class AppStateService { return this.initialState; } - - - - getupdateState() { - const currentState = this.stateSubject.getValue(); - return currentState + getupdateState(): StateType { + return this.stateSignal(); } - updateState(newState?: Partial) { // Use any for partial updates - const currentState = this.stateSubject.getValue(); // Get current state + updateState(newState?: Partial) { + const currentState = this.stateSignal(); if (!currentState) { - - // Handle initial update case (no state emitted yet) - this.updateStateAndEmit(newState); + if (newState) { + this.updateStateAndEmit({ ...this.initialState, ...newState }); + } return; } if (newState) { - - const updatedState = { ...currentState, ...newState }; // Merge updates - this.updateStateAndEmit(updatedState); // Update and emit combined state + const updatedState = { ...currentState, ...newState }; + this.updateStateAndEmit(updatedState); } else { this.updateStateAndEmit(currentState); - - return; } } - private state: { [key: string]: any } = {}; + private stateStore: { [key: string]: any } = {}; getState(menuStyles: string): any { - return this.state[menuStyles]; + return this.stateStore[menuStyles]; } - private applyThemeBackgroundSpecificChanges(background: any) { + private applyThemeBackgroundSpecificChanges(background: any) { this.html?.style.setProperty('--color-bodybg', background.main); this.html?.style.setProperty('--color-bodybg2', background.secondary); this.html?.style.setProperty('--color-light', background.accent); @@ -107,48 +101,30 @@ export class AppStateService { this.applythemeSpecificChanges(background.theme); } - private applyDirectionSpecificChanges(direction: string) { - - this.html?.setAttribute('dir', direction); - } + private applythemeSpecificChanges(theme: string) { - - - this.html?.setAttribute('class', theme); //setting theme style - this.html?.setAttribute('data-header-styles', theme); //setting header style - - - - - + this.html?.setAttribute('class', theme); + this.html?.setAttribute('data-header-styles', theme); } - - private applyNavigationStylesSpecificChanges(navigationStyles: string) { - - - this.html?.setAttribute('data-nav-layout', navigationStyles); - if (navigationStyles == 'horizontal') { + if (navigationStyles === 'horizontal') { this.html?.setAttribute('data-nav-style', 'menu-click'); this.html?.removeAttribute('data-vertical-style'); - } } + private applyMenuStylesSpecificChanges(menuStyles: string) { - - - this.html?.setAttribute('data-nav-style', menuStyles); this.html?.setAttribute('data-toggled', menuStyles + '-closed'); this.html?.removeAttribute('data-vertical-style'); } - private applyLayoutStylesSpecificChanges(layoutStyles: string) { + private applyLayoutStylesSpecificChanges(layoutStyles: string) { this.html?.setAttribute('data-vertical-style', layoutStyles); this.html?.removeAttribute('data-nav-style'); switch (layoutStyles) { @@ -175,64 +151,57 @@ export class AppStateService { if (layoutStyles === 'icon-text') { this.html?.setAttribute('icon-text', 'open'); } else { - // If not 'icon-text', remove the icon-text attribute this.html?.removeAttribute('icon-text'); } } - private applypageStylesSpecificChanges(pageStyles: string) { + private applypageStylesSpecificChanges(pageStyles: string) { this.html?.setAttribute('data-page-style', pageStyles); const slideRight = document.querySelector('.slide-right') as HTMLElement | null; if (slideRight) { - // If the element exists, toggle the 'd-none' class if (slideRight.classList.contains('d-none')) { slideRight.classList.remove('d-none'); } else { slideRight.classList.add('d-none'); } } else { - // If the element does not exist (is null), create a safe fallback by adding 'd-none' const dummySlideRight = document.createElement('div'); - dummySlideRight.classList.add('slide-right', 'd-none'); // Add classes to the new element - document.body.appendChild(dummySlideRight); // Append it to the DOM as a fallback + dummySlideRight.classList.add('slide-right', 'd-none'); + document.body.appendChild(dummySlideRight); } } - private applywidthStylesSpecificChanges(widthStyles: string) { + private applywidthStylesSpecificChanges(widthStyles: string) { this.html?.setAttribute('data-width', widthStyles); } - private applymenuPositionSpecificChanges(menuPosition: string) { + private applymenuPositionSpecificChanges(menuPosition: string) { this.html?.setAttribute('data-menu-position', menuPosition); } - private applyheaderPositionSpecificChanges(headerPosition: string) { + private applyheaderPositionSpecificChanges(headerPosition: string) { this.html?.setAttribute('data-header-position', headerPosition); } - private applyheaderColorSpecificChanges(headerColor: string) { + private applyheaderColorSpecificChanges(headerColor: string) { this.html?.setAttribute('data-header-styles', headerColor); } - private applymenuColorSpecificChanges(menuColor: string) { + private applymenuColorSpecificChanges(menuColor: string) { this.html?.setAttribute('data-menu-styles', menuColor); } - private applyPrimarySpecificChanges(primary: string) { + private applyPrimarySpecificChanges(primary: string) { this.html?.style.setProperty('--color-primaryrgb', primary); this.html?.style.setProperty('--color-primary', primary); } - private applybackgroundImageSpecificChanges(backgroundImage: string) { + private applybackgroundImageSpecificChanges(backgroundImage: string) { this.html?.setAttribute('bg-img', backgroundImage); } - - - public applyReset() { - if (this.html) { this.html?.style.removeProperty('--color-bodybg'); this.html?.style.removeProperty('--color-gray3'); @@ -242,76 +211,70 @@ export class AppStateService { this.html?.style.removeProperty('--color-inputborder'); this.html?.style.removeProperty('--color-primary'); this.html?.style.removeProperty('--color-primaryrgb'); - } this.html?.removeAttribute('bg-img'); this.html?.setAttribute('data-vertical-style', 'overlay'); - this.stateSubject.next(this.initialState); this.updateStateAndEmit(this.initialState); - localStorage.clear(); + try { + localStorage.removeItem(this.localStorageKey); + } catch (error) { + console.error('Error resetting local storage:', error); + } if (window.innerWidth <= 992) { this.html?.setAttribute('data-toggled', 'close'); } } - private updateStateAndEmit(state: any) { - // Conditional logic based on direction changes - - const currentState = this.stateSubject.getValue(); // Get current state - // Conditional logic based on theme changes - if (state['theme']) { - - this.applythemeSpecificChanges(state['theme']); + private updateStateAndEmit(state: StateType) { + if (state.theme) { + this.applythemeSpecificChanges(state.theme); } - if (state['direction']) { - - this.applyDirectionSpecificChanges(state['direction']); + if (state.direction) { + this.applyDirectionSpecificChanges(state.direction); } - // Conditional logic based on theme changes - if (state['navigationStyles']) { - this.applyNavigationStylesSpecificChanges(state['navigationStyles']); + if (state.navigationStyles) { + this.applyNavigationStylesSpecificChanges(state.navigationStyles); } - // Conditional logic based on theme changes - if (state['menuStyles'] && !state['layoutStyles']) { - this.applyMenuStylesSpecificChanges(state['menuStyles']); + if (state.menuStyles && !state.layoutStyles) { + this.applyMenuStylesSpecificChanges(state.menuStyles); } - if (state['layoutStyles'] && !state['menuStyles']) { - this.applyLayoutStylesSpecificChanges(state['layoutStyles']); + if (state.layoutStyles && !state.menuStyles) { + this.applyLayoutStylesSpecificChanges(state.layoutStyles); } - if (state['pageStyles']) { - this.applypageStylesSpecificChanges(state['pageStyles']); + if (state.pageStyles) { + this.applypageStylesSpecificChanges(state.pageStyles); } - if (state['widthStyles']) { - this.applywidthStylesSpecificChanges(state['widthStyles']); + if (state.widthStyles) { + this.applywidthStylesSpecificChanges(state.widthStyles); } - if (state['menuPosition']) { - this.applymenuPositionSpecificChanges(state['menuPosition']); + if (state.menuPosition) { + this.applymenuPositionSpecificChanges(state.menuPosition); } - if (state['headerPosition']) { - this.applyheaderPositionSpecificChanges(state['headerPosition']); + if (state.headerPosition) { + this.applyheaderPositionSpecificChanges(state.headerPosition); } - if (state['themePrimary']) { - this.applyPrimarySpecificChanges(state['themePrimary']); + if (state.themePrimary) { + this.applyPrimarySpecificChanges(state.themePrimary); } - if (state['themeBackground']) { - this.applyThemeBackgroundSpecificChanges(state['themeBackground']); + if (state.themeBackground) { + this.applyThemeBackgroundSpecificChanges(state.themeBackground); } - if (state['headerColor']) { - this.applyheaderColorSpecificChanges(state['headerColor']); + if (state.headerColor) { + this.applyheaderColorSpecificChanges(state.headerColor); } - if (state['menuColor']) { - this.applymenuColorSpecificChanges(state['menuColor']); + if (state.menuColor) { + this.applymenuColorSpecificChanges(state.menuColor); } - if (state['backgroundImage']) { - this.applybackgroundImageSpecificChanges(state['backgroundImage']); + if (state.backgroundImage) { + this.applybackgroundImageSpecificChanges(state.backgroundImage); } - this.stateSubject.next(state); + this.stateSignal.set(state); this.updateLocalStorage(state); } - private updateLocalStorage(state: any) { + private updateLocalStorage(state: StateType) { try { localStorage.setItem(this.localStorageKey, JSON.stringify(state)); } catch (error) { diff --git a/src/app/core/services/common/menu.data.ts b/src/app/core/services/common/menu.data.ts index ca6bda4c..2aa1b2e4 100644 --- a/src/app/core/services/common/menu.data.ts +++ b/src/app/core/services/common/menu.data.ts @@ -46,14 +46,14 @@ export const SAAS_MENU_DATA: MenuContext = { }, { title: 'Organizations', - icon: '', + icon: '', type: 'sub', active: false, selected: false, dirchange: false, children: [ + { path: '/organizations', title: 'Dashboard', type: 'link', dirchange: false }, { path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false }, - { path: '/organizations/onboarding', title: 'Organization Onboarding', type: 'link', dirchange: false }, ], }, { diff --git a/src/app/core/services/common/menu.service.spec.ts b/src/app/core/services/common/menu.service.spec.ts deleted file mode 100644 index ec059a31..00000000 --- a/src/app/core/services/common/menu.service.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; -import { AuthService } from '../auth/auth.service'; -import { TokenStorageService } from '../auth/token-storage.service'; -import { MenuService } from './menu.service'; - -describe('MenuService dependency and authorization', () => { - let menuService: MenuService; - let tokenStorage: TokenStorageService; - - beforeEach(() => { - localStorage.clear(); - sessionStorage.clear(); - TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideRouter([])], - }); - menuService = TestBed.inject(MenuService); - tokenStorage = TestBed.inject(TokenStorageService); - }); - - afterEach(() => { - localStorage.clear(); - sessionStorage.clear(); - }); - - it('constructs MenuService and AuthService without a circular dependency', () => { - expect(menuService).toBeTruthy(); - expect(TestBed.inject(AuthService)).toBeTruthy(); - }); - - it('hides the Users menu when the stored user is not a super administrator', async () => { - tokenStorage.saveAuth( - { accessToken: 'token', user: { id: '1', email: 'user@example.com', roles: ['admin'] } }, - false, - ); - - const context = await firstValueFrom(menuService.loadMenu()); - - expect(hasPath(context.items, '/users')).toBe(false); - }); - - it('shows the Users menu when the stored user is a super administrator', async () => { - tokenStorage.saveAuth( - { - accessToken: 'token', - user: { id: '1', email: 'super@example.com', roles: ['super_admin'] }, - }, - false, - ); - - const context = await firstValueFrom(menuService.loadMenu()); - - expect(hasPath(context.items, '/users')).toBe(true); - }); - - it('clears menu state during context cleanup', async () => { - await firstValueFrom(menuService.loadMenu()); - - menuService.clear(); - - expect(menuService.menuContext()).toBeNull(); - }); -}); - -function hasPath(items: ReturnType, path: string): boolean { - return items.some( - (item) => - item.path === path || - (item.children ? hasPath(item.children, path) : false) || - (item.children2 ? hasPath(item.children2, path) : false), - ); -} diff --git a/src/app/core/services/common/nav.service.ts b/src/app/core/services/common/nav.service.ts index 450e2863..00c81291 100644 --- a/src/app/core/services/common/nav.service.ts +++ b/src/app/core/services/common/nav.service.ts @@ -1,8 +1,9 @@ -import { Injectable, OnDestroy } from '@angular/core'; -import { Subject, BehaviorSubject, fromEvent } from 'rxjs'; +import { Injectable, OnDestroy, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { Subject, fromEvent } from 'rxjs'; import { takeUntil, debounceTime } from 'rxjs/operators'; import { Router } from '@angular/router'; -// Menu + export interface Menu { headTitle?: string; headTitle2?: string; @@ -20,10 +21,9 @@ export interface Menu { children2?: Menu[]; Menusub?: boolean; target?: boolean; - menutype?: string, - dirchange?: boolean, - nochild?: any - + menutype?: string; + dirchange?: boolean; + nochild?: any; } @Injectable({ @@ -31,9 +31,14 @@ export interface Menu { }) export class NavService implements OnDestroy { private unsubscriber: Subject = new Subject(); - public screenWidth: BehaviorSubject = new BehaviorSubject( - window.innerWidth - ); + + private readonly screenWidthSignal = signal(window.innerWidth); + readonly screenWidth = this.screenWidthSignal.asReadonly(); + readonly screenWidth$ = toObservable(this.screenWidthSignal); + + private readonly itemsSignal = signal([]); + readonly items = this.itemsSignal.asReadonly(); + readonly items$ = toObservable(this.itemsSignal); // Search Box public search = false; @@ -72,8 +77,7 @@ export class NavService implements OnDestroy { } }); if (window.innerWidth < 991) { - // Detect Route change sidebar close - this.router.events.subscribe((event) => { + this.router.events.subscribe(() => { this.collapseSidebar = true; this.megaMenu = false; this.levelMenu = false; @@ -82,21 +86,19 @@ export class NavService implements OnDestroy { } ngOnDestroy() { - this.unsubscriber.next; + this.unsubscriber.next(true); this.unsubscriber.complete(); } private setScreenWidth(width: number): void { - this.screenWidth.next(width); + this.screenWidthSignal.set(width); } - items = new BehaviorSubject([]); - setMenuItems(menuItems: Menu[]): void { - this.items.next(menuItems); + this.itemsSignal.set(menuItems); } clearMenuItems(): void { - this.items.next([]); + this.itemsSignal.set([]); } } diff --git a/src/app/core/services/loading.service.ts b/src/app/core/services/loading.service.ts deleted file mode 100644 index fa48c82e..00000000 --- a/src/app/core/services/loading.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable, signal} from '@angular/core'; - -@Injectable({ - providedIn: 'root', -}) -export class LoadingService { - - private requestCount = 0; - readonly isLoading = signal(false); - - show(): void { - this.requestCount++; - this.isLoading.set(true); - } - - hide(): void { - this.requestCount--; - - if (this.requestCount <= 0) { - this.requestCount = 0; - this.isLoading.set(false); - } - } -} diff --git a/src/app/core/services/menu.data.ts b/src/app/core/services/menu.data.ts deleted file mode 100644 index 60b32306..00000000 --- a/src/app/core/services/menu.data.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { MenuContext } from '../models/context.model'; - -export const SAAS_MENU_DATA: MenuContext = { - defaultLandingPage: '/dashboards/crm', - items: [ - { headTitle: 'MAIN' }, - { - title: 'Dashboards', - icon: '', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false }, - ], - }, - { headTitle: 'SAAS ADMIN' }, - { - title: 'Management', - icon: '', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false }, - { path: '/users', title: 'Users', type: 'link', dirchange: false }, - { - title: 'Configuration', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/global-masters', title: 'Global Masters', type: 'link', dirchange: false }, - { path: '/localization', title: 'Localization', type: 'link', dirchange: false }, - { - title: 'Branding', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/theming', title: 'Theming', type: 'link', dirchange: false }, - { path: '/platform', title: 'Platform', type: 'link', dirchange: false }, - ], - }, - ], - }, - ], - }, - { - title: 'Operations', - icon: '', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/billing', title: 'Billing', type: 'link', dirchange: false }, - { path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false }, - ], - }, - ], -}; \ No newline at end of file diff --git a/src/app/core/services/menu.service.ts b/src/app/core/services/menu.service.ts deleted file mode 100644 index 51fc72e7..00000000 --- a/src/app/core/services/menu.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable, signal } from '@angular/core'; -import { Observable, of, tap } from 'rxjs'; -import { MenuContext } from '../models/context.model'; -import { SAAS_MENU_DATA } from './menu.data'; -import { Menu } from '../../shared/services/nav.service'; - -@Injectable({ providedIn: 'root' }) -export class MenuService { - readonly menuContext = signal(null); - - loadMenu(): Observable { - const context = this.cloneMenuContext(SAAS_MENU_DATA); - return of(context).pipe( - tap((menuContext) => { - this.menuContext.set(menuContext); - }) - ); - } - - getNavigationMenu(): Menu[] { - return this.cloneMenuItems(this.menuContext()?.items ?? []); - } - - getDefaultLandingPage(): string | null { - return this.menuContext()?.defaultLandingPage ?? null; - } - - clear(): void { - this.menuContext.set(null); - } - - private cloneMenuContext(context: MenuContext): MenuContext { - return { - defaultLandingPage: context.defaultLandingPage, - items: this.cloneMenuItems(context.items), - }; - } - - private cloneMenuItems(items: Menu[]): Menu[] { - return JSON.parse(JSON.stringify(items)) as Menu[]; - } -} \ No newline at end of file diff --git a/src/app/core/services/permission.service.ts b/src/app/core/services/permission.service.ts deleted file mode 100644 index 773ee393..00000000 --- a/src/app/core/services/permission.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject, signal } from '@angular/core'; -import { map, Observable, tap } from 'rxjs'; -import { API_CONFIG } from '../config/api.config'; -import { PermissionContext } from '../models/context.model'; - -interface PermissionResponse { - roles?: string[]; - permissions?: string[]; - defaultLandingPage?: string; -} - -@Injectable({ providedIn: 'root' }) -export class PermissionService { - private readonly http = inject(HttpClient); - readonly permissionContext = signal(null); - - loadPermissions(): Observable { - return this.http.get(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}`).pipe( - map((response) => ({ - roles: response.roles ?? [], - permissions: response.permissions ?? [], - defaultLandingPage: response.defaultLandingPage?.trim() || undefined, - })), - tap((context) => { - this.permissionContext.set(context); - }) - ); - } - - clear(): void { - this.permissionContext.set(null); - } -} \ No newline at end of file diff --git a/src/app/core/services/session-timeout.service.ts b/src/app/core/services/session-timeout.service.ts deleted file mode 100644 index 7a20fd76..00000000 --- a/src/app/core/services/session-timeout.service.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { DOCUMENT } from '@angular/common'; -import { DestroyRef, effect, inject, Injectable, OnDestroy, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Router } from '@angular/router'; -import { merge, fromEvent, Subscription } from 'rxjs'; -import { throttleTime } from 'rxjs/operators'; -import { environment } from '../../../environments/environment'; -import { AuthService } from '../auth/auth.service'; - -@Injectable() -export class SessionTimeoutService implements OnDestroy { - private readonly document = inject(DOCUMENT); - private readonly router = inject(Router); - private readonly authService = inject(AuthService); - private readonly destroyRef = inject(DestroyRef); - - readonly showWarning = signal(false); - readonly remainingSeconds = signal(0); - - private readonly warningAfterMs = environment.sessionTimeout?.warningAfterMs ?? 25 * 60 * 1000; - private readonly logoutAfterMs = environment.sessionTimeout?.logoutAfterMs ?? 30 * 60 * 1000; - - private activitySubscription: Subscription | null = null; - private warningTimer: ReturnType | null = null; - private logoutTimer: ReturnType | null = null; - private countdownTimer: ReturnType | null = null; - private logoutDeadline = 0; - private trackingEnabled = false; - - constructor() { - effect(() => { - if (this.authService.currentUserSignal()) { - this.start(); - return; - } - - this.stop(); - }); - } - - start(): void { - if (this.trackingEnabled) { - this.resetTimers(); - return; - } - - this.trackingEnabled = true; - this.bindActivityTracking(); - this.resetTimers(); - } - - stop(): void { - this.trackingEnabled = false; - this.showWarning.set(false); - this.remainingSeconds.set(0); - this.clearTimers(); - this.activitySubscription?.unsubscribe(); - this.activitySubscription = null; - } - - staySignedIn(): void { - if (!this.trackingEnabled) { - return; - } - - this.resetTimers(); - } - - logoutNow(): void { - this.handleTimeoutLogout(); - } - - ngOnDestroy(): void { - this.stop(); - } - - private bindActivityTracking(): void { - if (this.activitySubscription) { - return; - } - - this.activitySubscription = merge( - fromEvent(this.document, 'mousemove'), - fromEvent(this.document, 'keydown'), - fromEvent(this.document, 'click'), - fromEvent(window, 'scroll') - ) - .pipe(throttleTime(1000), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - if (!this.trackingEnabled || !this.authService.currentUserSignal()) { - return; - } - - this.resetTimers(); - }); - } - - private resetTimers(): void { - if (!this.trackingEnabled) { - return; - } - - this.clearTimers(); - this.showWarning.set(false); - this.remainingSeconds.set(0); - - const safeWarningDelay = Math.max(Math.min(this.warningAfterMs, this.logoutAfterMs), 0); - const safeLogoutDelay = Math.max(this.logoutAfterMs, 0); - - this.warningTimer = setTimeout(() => { - this.showWarning.set(true); - this.logoutDeadline = Date.now() + Math.max(safeLogoutDelay - safeWarningDelay, 0); - this.updateRemainingSeconds(); - this.countdownTimer = setInterval(() => { - this.updateRemainingSeconds(); - }, 1000); - }, safeWarningDelay); - - this.logoutTimer = setTimeout(() => { - this.handleTimeoutLogout(); - }, safeLogoutDelay); - } - - private updateRemainingSeconds(): void { - const remainingMs = Math.max(this.logoutDeadline - Date.now(), 0); - this.remainingSeconds.set(Math.ceil(remainingMs / 1000)); - } - - private handleTimeoutLogout(): void { - const returnUrl = this.router.url.startsWith('/auth') ? '/' : this.router.url; - this.stop(); - this.authService.logout(); - void this.router.navigate(['/auth/login'], { - queryParams: returnUrl && returnUrl !== '/' ? { returnUrl } : undefined, - }); - } - - private clearTimers(): void { - if (this.warningTimer) { - clearTimeout(this.warningTimer); - this.warningTimer = null; - } - - if (this.logoutTimer) { - clearTimeout(this.logoutTimer); - this.logoutTimer = null; - } - - if (this.countdownTimer) { - clearInterval(this.countdownTimer); - this.countdownTimer = null; - } - } -} diff --git a/src/app/core/services/tenant-context.service.ts b/src/app/core/services/tenant-context.service.ts deleted file mode 100644 index aa9e6f62..00000000 --- a/src/app/core/services/tenant-context.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject, signal } from '@angular/core'; -import { Observable, tap } from 'rxjs'; -import { API_CONFIG } from '../config/api.config'; -import { TenantContext } from '../models/context.model'; - -@Injectable({ providedIn: 'root' }) -export class TenantContextService { - private readonly http = inject(HttpClient); - readonly tenantContext = signal(null); - - loadTenantContext(): Observable { - return this.http.get(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`).pipe( - tap((context) => { - this.tenantContext.set(context); - }) - ); - } - - clear(): void { - this.tenantContext.set(null); - } -} \ No newline at end of file diff --git a/src/app/core/services/user-context.service.ts b/src/app/core/services/user-context.service.ts deleted file mode 100644 index 632f1511..00000000 --- a/src/app/core/services/user-context.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject, signal } from '@angular/core'; -import { map, Observable, tap } from 'rxjs'; -import { API_CONFIG } from '../config/api.config'; -import { CurrentUserContext } from '../models/context.model'; - -@Injectable({ providedIn: 'root' }) -export class UserContextService { - private readonly http = inject(HttpClient); - readonly currentUserContext = signal(null); - - loadCurrentUserProfile(): Observable { - return this.http.get>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}`).pipe( - map((response) => ({ - id: response.id?.trim() ?? '', - email: response.email?.trim() ?? '', - displayName: response.displayName?.trim() || undefined, - fullName: response.fullName?.trim() || undefined, - tenantId: response.tenantId?.trim() || undefined, - roles: response.roles ?? [], - defaultLandingPage: response.defaultLandingPage?.trim() || undefined, - })), - tap((profile) => { - this.currentUserContext.set(profile); - }) - ); - } - - clear(): void { - this.currentUserContext.set(null); - } -} \ No newline at end of file diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/features/authentication/data-access/auth.service.ts similarity index 87% rename from src/app/core/services/auth/auth.service.ts rename to src/app/features/authentication/data-access/auth.service.ts index 1b6c4338..2804b925 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/features/authentication/data-access/auth.service.ts @@ -1,9 +1,10 @@ import { computed, Injectable, inject, signal } from '@angular/core'; -import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { LoginRequest, LoginResponse, UserProfile } from '../../models/auth/auth.model'; -import { TokenStorageService } from './token-storage.service'; -import { AppContextService } from '../../services/context/app-context.service'; +import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model'; +import { TokenStorageService } from '../../../core/services/auth/token-storage.service'; +import { AppContextService } from '../../../core/services/context/app-context.service'; import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints'; @Injectable({ providedIn: 'root' }) @@ -12,9 +13,10 @@ export class AuthService { private readonly tokenStorage = inject(TokenStorageService); private readonly appContextService = inject(AppContextService); - private readonly userSubject = new BehaviorSubject(null); - readonly user$ = this.userSubject.asObservable(); readonly currentUserSignal = signal(null); + readonly currentUser = this.currentUserSignal.asReadonly(); + readonly user$ = toObservable(this.currentUserSignal); + private readonly accessTokenSignal = signal(null); readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal()); @@ -91,7 +93,7 @@ export class AuthService { return this.isAuthenticatedSignal(); } - get currentUser(): UserProfile | null { + get currentUserValue(): UserProfile | null { return this.currentUserSignal(); } @@ -108,7 +110,6 @@ export class AuthService { } private setAuthState(user: UserProfile | null, token: string | null): void { - this.userSubject.next(user); this.currentUserSignal.set(user); this.accessTokenSignal.set(token); } diff --git a/src/app/core/models/auth.model.ts b/src/app/features/authentication/models/auth.model.ts similarity index 100% rename from src/app/core/models/auth.model.ts rename to src/app/features/authentication/models/auth.model.ts diff --git a/src/app/features/authentication/pages/login/login.ts b/src/app/features/authentication/pages/login/login.ts index 74b11327..b2c3eb8b 100644 --- a/src/app/features/authentication/pages/login/login.ts +++ b/src/app/features/authentication/pages/login/login.ts @@ -1,7 +1,7 @@ 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 '../../data-access/auth.service'; import { ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; import { catchError, finalize, of, switchMap, tap } from 'rxjs'; diff --git a/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.html b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.html new file mode 100644 index 00000000..d5af2add --- /dev/null +++ b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.html @@ -0,0 +1,93 @@ + + @if (modalLoading()) { +
+ + Loading city... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.ts b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.ts new file mode 100644 index 00000000..0c255aab --- /dev/null +++ b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.ts @@ -0,0 +1,229 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToastrService } from 'ngx-toastr'; +import { of } from 'rxjs'; +import { catchError, finalize, map, switchMap } from 'rxjs/operators'; + +import { CityDto, CityModalMode, CreateCityRequest, UpdateCityRequest } from '../../models/city.model'; +import { CountryLookupDto } from '../../../countries/models/country.model'; +import { StateLookupDto } from '../../../states/models/state.model'; +import { TimezoneLookupDto } from '../../../timezones/models/timezone.model'; +import { CityService } from '../../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 { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-city-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete], + templateUrl: './city-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CityFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly cityApi = inject(CityService); + private readonly countryApi = inject(CountryService); + private readonly stateApi = inject(StateService); + private readonly timezoneApi = inject(TimezoneService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + readonly cityId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedCity = signal(null); + readonly selectedFormCountry = signal(null); + readonly selectedFormState = signal(null); + + readonly cityForm = this.formBuilder.nonNullable.group({ + countryId: ['', Validators.required], + stateId: ['', Validators.required], + name: ['', [Validators.required, Validators.maxLength(150)]], + code: ['', [Validators.required, Validators.maxLength(16), Validators.pattern(/^[A-Za-z0-9_-]+$/)]], + timezoneId: this.formBuilder.control(null) + }); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add City'; + case 'edit': return 'Edit City'; + case 'view': return 'View City'; + } + }); + + readonly countrySearchFn: AutocompleteSearchFn = (term, page) => + this.countryApi.autocomplete(term, page); + readonly countryValueFn: AutocompleteValueFn = country => country.id; + readonly countryDisplayFn: AutocompleteDisplayFn = country => country.name; + + readonly stateSearchFn: AutocompleteSearchFn = (term, page) => { + const countryId = this.cityForm.controls.countryId.value || this.selectedFormCountry()?.id || ''; + if (!countryId) return of([]); + return this.stateApi.autocomplete(countryId, term || '', page); + }; + readonly stateValueFn: AutocompleteValueFn = state => state.id; + readonly stateDisplayFn: AutocompleteDisplayFn = state => state.name; + + readonly timezoneSearchFn: AutocompleteSearchFn = (term, page) => + this.timezoneApi.autocomplete(term, page); + readonly timezoneValueFn: AutocompleteValueFn = tz => tz.id; + readonly timezoneDisplayFn: AutocompleteDisplayFn = tz => tz.displayName; + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.cityId()); + } + }); + } + + onFormCountryChanged(country: CountryLookupDto | null): void { + this.selectedFormCountry.set(country); + this.cityForm.controls.countryId.setValue(country ? country.id : ''); + this.cityForm.controls.stateId.setValue(''); + this.selectedFormState.set(null); + } + + prepareModal(id: string | null): void { + this.submitAttempted.set(false); + this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }); + this.selectedFormCountry.set(null); + this.selectedFormState.set(null); + + if (!id || this.mode() === 'create') { + this.selectedCity.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.cityApi.getCityById(id).pipe( + switchMap(city => { + this.selectedCity.set(city); + return this.stateApi.getStateById(city.stateId).pipe( + switchMap(state => this.countryApi.getCountryById(state.countryId).pipe( + map(country => ({ city, state, country })) + )), + catchError(() => of({ city, state: null, country: null })) + ); + }), + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: ({ city, state, country }) => { + if (country) this.selectedFormCountry.set({ id: country.id, name: country.name, iso2: country.iso2 }); + if (state) this.selectedFormState.set({ id: state.id, name: state.name, code: state.code ?? '' }); + + this.cityForm.patchValue({ + countryId: country?.id ?? '', + stateId: city.stateId, + name: city.name, + code: city.code ?? '', + timezoneId: city.timezoneId + }); + }, + error: () => { + this.toastr.error('Unable to load city details.'); + this.closeModal(); + } + }); + } + + saveCity(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.submitAttempted.set(true); + if (this.cityForm.invalid || this.saving()) return; + + this.saving.set(true); + if (this.mode() === 'create') { + const request: CreateCityRequest = { + stateId: this.cityForm.controls.stateId.value, + name: this.cityForm.controls.name.value.trim(), + code: this.cityForm.controls.code.value.trim().toUpperCase(), + timezoneId: this.cityForm.controls.timezoneId.value || null + }; + + this.cityApi.createCity(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('City created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.cityId(); + if (!id) return; + + const request: UpdateCityRequest = { + name: this.cityForm.controls.name.value.trim(), + code: this.cityForm.controls.code.value.trim().toUpperCase(), + timezoneId: this.cityForm.controls.timezoneId.value || null, + isActive: this.selectedCity()?.isActive ?? true + }; + + this.cityApi.updateCity(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('City updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.toastr.error('A city with this code already exists in this state.'); + return; + } + this.toastr.error(`Unable to ${action} city. Please try again.`); + } +} diff --git a/src/app/features/global-masters/cities/data-access/city.endpoints.ts b/src/app/features/global-masters/cities/data-access/city.endpoints.ts index 0c668c39..39048906 100644 --- a/src/app/features/global-masters/cities/data-access/city.endpoints.ts +++ b/src/app/features/global-masters/cities/data-access/city.endpoints.ts @@ -7,5 +7,9 @@ export const CITY_ENDPOINTS = { buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), update: (id: string) => buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}/status`), autocomplete: buildApiUrl('masterAdmin', '/v1/cities/autocomplete') } as const; diff --git a/src/app/features/global-masters/cities/data-access/city.service.ts b/src/app/features/global-masters/cities/data-access/city.service.ts index dd8a49a1..fb6caba2 100644 --- a/src/app/features/global-masters/cities/data-access/city.service.ts +++ b/src/app/features/global-masters/cities/data-access/city.service.ts @@ -6,7 +6,8 @@ import { CITY_ENDPOINTS } from './city.endpoints'; import { CityDto, CreateCityRequest, - UpdateCityRequest + UpdateCityRequest, + UpdateCityStatusRequest } from '../models/city.model'; import { DataTableQuery, @@ -40,6 +41,14 @@ export class CityService { return this.http.put(CITY_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateCityStatusRequest): Observable { + return this.http.patch(CITY_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(CITY_ENDPOINTS.delete(id)); + } + getCityById(id: string): Observable { return this.http.get(CITY_ENDPOINTS.getById(id)); } diff --git a/src/app/features/global-masters/cities/models/city.model.ts b/src/app/features/global-masters/cities/models/city.model.ts index 19a7dbf2..97a044e9 100644 --- a/src/app/features/global-masters/cities/models/city.model.ts +++ b/src/app/features/global-masters/cities/models/city.model.ts @@ -2,8 +2,10 @@ export interface CityDto { id: string; stateId: string; name: string; - state:string; - country:string; + state?: string | { name?: string }; + stateName?: string; + country?: string | { name?: string }; + countryName?: string; code: string | null; timezoneId: string | null; isActive: boolean; @@ -25,4 +27,9 @@ export interface UpdateCityRequest { isActive: boolean; } -export type CityModalMode = 'create' | 'edit'; +export interface UpdateCityStatusRequest { + isActive: boolean; +} + +export type CityModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.html b/src/app/features/global-masters/cities/pages/city-list/city-list.html index c61cd3bb..ea08ca13 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.html +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.html @@ -1,176 +1,97 @@ - -
-
- -
-
- -
+ + +
+ +
-
- -
-
- -
- -
-
-
- - - - - -
-
-
- -
- -
- -
- -
- -
- -
- -
- -
- -
+
+ +
+
+
- + + + + + + + diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.ts b/src/app/features/global-masters/cities/pages/city-list/city-list.ts index 3260e29b..c15fe4d8 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.ts +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.ts @@ -1,54 +1,34 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { - Subject, - catchError, - debounceTime, - distinctUntilChanged, - finalize, - map, - of, - switchMap, - take -} from 'rxjs'; +import { of } from 'rxjs'; +import { catchError, finalize } from 'rxjs/operators'; -import { - CityDto, - CityModalMode, - CreateCityRequest, - UpdateCityRequest -} from '../../models/city.model'; +import { CityDto, UpdateCityRequest } from '../../models/city.model'; import { CountryLookupDto } from '../../../countries/models/country.model'; import { StateLookupDto } from '../../../states/models/state.model'; -import { TimezoneDto, TimezoneLookupDto } from '../../../timezones/models/timezone.model'; -import { CityService } from '../../../cities/data-access/city.service'; +import { CityService } from '../../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 { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { AutocompleteDisplayFn, - AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../../shared/components/form/autocomplete/autocomplete.types'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { FilterCard } from '../../../../../shared/components/filter-card/filter-card'; import { Button } from '../../../../../shared/components/button/button'; +import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal'; interface CityTableRow extends DataTableRecord { id: string; @@ -60,6 +40,8 @@ interface CityTableRow extends DataTableRecord { serialNumber: number; state: string; country: string; + stateName: string; + countryName: string; createdOn?: string; modifiedOn?: string | null; } @@ -67,495 +49,213 @@ interface CityTableRow extends DataTableRecord { @Component({ selector: 'city-list', standalone: true, - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard, Button], + imports: [ + DataTable, + ReactiveFormsModule, + Autocomplete, + FilterCard, + Button, + ConfirmDialog, + CityFormModalComponent + ], + providers: [DataTableStore], templateUrl: './city-list.html', styleUrl: './city-list.scss' }) -export class CityList { +export class CityList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly cityApi = inject(CityService); private readonly countryApi = inject(CountryService); private readonly stateApi = inject(StateService); - private readonly timezoneApi = inject(TimezoneService); private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly cityQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - readonly cities = signal([]); - readonly selectedCountryId = signal(null); - readonly selectedStateId = signal(null); - readonly appliedCountryId = signal(null); - readonly appliedStateId = signal(null); readonly selectedCountry = signal(null); readonly selectedFilterState = signal(null); - readonly selectedFormCountry = signal(null); - readonly selectedFormState = signal(null); - readonly totalRecords = signal(0); - readonly saving = signal(false); - readonly showCityModal = signal(false); - readonly modalMode = signal('create'); - readonly selectedCity = signal(null); - readonly submitAttempted = signal(false); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteCity = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly filterForm = this.formBuilder.nonNullable.group({ countryId: [''], stateId: [{ value: '', disabled: true }] }); - readonly cityForm = this.formBuilder.nonNullable.group({ - countryId: ['', Validators.required], - stateId: ['', Validators.required], - name: ['', [Validators.required, Validators.maxLength(150)]], - code: [ - '', - [ - Validators.required, - Validators.maxLength(16), - Validators.pattern(/^[A-Za-z0-9_-]+$/) - ] - ], - timezoneId: this.formBuilder.control(null) - }); - - readonly searchTimezones: AutocompleteSearchFn = - (term, limit) => this.timezoneApi.autocomplete(term, limit); - readonly searchCountries: AutocompleteSearchFn = - (term, limit) => this.countryApi.autocomplete(term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load countries.'); - return of([]); - }) - ); - readonly searchFilterStates: AutocompleteSearchFn = - (term, limit) => { - const countryId = this.selectedCountryId(); - if (!countryId) return of([]); - return this.stateApi.autocomplete(countryId, term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load states.'); - return of([]); - }) - ); - }; - readonly searchFormStates: AutocompleteSearchFn = - (term, limit) => { - const countryId = this.cityForm.controls.countryId.value; - if (!countryId) return of([]); - return this.stateApi.autocomplete(countryId, term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load states.'); - return of([]); - }) - ); - }; - readonly displayCountry: AutocompleteDisplayFn = country => country.name; - readonly countryValue: AutocompleteValueFn = country => country.id; - readonly resolveCountry: AutocompleteResolveValueFn = - value => this.countryApi.getCountryById(value).pipe( - map(country => ({ id: country.id, iso2: country.iso2, name: country.name })) - ); - readonly displayState: AutocompleteDisplayFn = state => state.name; - readonly stateValue: AutocompleteValueFn = state => state.id; - readonly resolveState: AutocompleteResolveValueFn = - value => this.stateApi.getStateById(value).pipe( - map(state => ({ id: state.id, name: state.name, code: state.code ?? '' })) - ); - readonly displayTimezone: AutocompleteDisplayFn = - timezone => `${timezone.ianaId} — ${timezone.displayName}`; - readonly timezoneValue: AutocompleteValueFn = - timezone => timezone.id; - readonly resolveTimezone: AutocompleteResolveValueFn = - value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone))); - readonly filterStatePlaceholder = computed(() => - this.selectedCountryId() ? 'Search' : 'Select a country first' - ); - readonly formStatePlaceholder = computed(() => - this.cityForm.controls.countryId.value ? 'Search' : 'Search' - ); - readonly emptyMessage = computed(() => - this.appliedCountryId() && this.appliedStateId() - ? 'No cities found' - : 'Select a country and state' - ); - - readonly emptyDescription = computed(() => - this.appliedCountryId() && this.appliedStateId() - ? 'There are no cities available for the selected state.' - : 'Choose a country and state to view available cities.' - ); - readonly modalTitle = computed(() => { - const mode = this.modalMode(); - return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City'; - }); - readonly submitLabel = computed(() => - this.modalMode() === 'create' ? 'Save' : 'Update' - ); - readonly loadingLabel = computed(() => - this.modalMode() === 'create' ? 'Saving...' : 'Updating...' - ); - readonly columns = signal[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '70px' }, - { key: 'name', label: 'City Name', header: 'City Name', sortable: true, align: 'left' }, - { key: 'code', label: 'Code', header: 'Code', sortable: true }, - { key: 'stateName', label: 'State', header: 'State', sortable: false }, - { key: 'countryName', label: 'Country', header: 'Country', sortable: false }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'name', label: 'City Name', header: 'City Name', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' }, + { key: 'stateName', label: 'State', header: 'State', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'countryName', label: 'Country', header: 'Country', sortable: true, headerAlign: 'center', align: 'center' }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - width: '100px', - formatter: value => value ? 'Active' : 'Inactive', - badgeClass: value => value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger' + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', + formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ - // { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'deactivate', - label: 'Deactivate', - icon: 'ti ti-ban', - className: 'text-danger', - visible: row => row.isActive + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id + }, + { + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.configureCityQueries(); - this.configureFilterChanges(); - this.configureFormChanges(); - } + readonly filterCountrySearchFn: AutocompleteSearchFn = (term, page) => + this.countryApi.autocomplete(term, page); + readonly filterCountryValueFn: AutocompleteValueFn = c => c.id; + readonly filterCountryDisplayFn: AutocompleteDisplayFn = c => c.name; + + readonly filterStateSearchFn: AutocompleteSearchFn = (term, page) => { + const countryId = this.filterForm.controls.countryId.value || this.selectedCountry()?.id || ''; + if (!countryId) return of([]); + return this.stateApi.autocomplete(countryId, term || '', page); + }; + readonly filterStateValueFn: AutocompleteValueFn = s => s.id; + readonly filterStateDisplayFn: AutocompleteDisplayFn = s => s.name; ngOnInit(): void { - this.loadCities(this.queryState.getQuery()); + this.tableStore.initialize({ + fetcher: query => { + const countryId = this.filterForm.controls.countryId.value || null; + const stateId = this.filterForm.controls.stateId.value || null; + return this.cityApi.getCityDataTable(query, countryId, stateId); + }, + mapRow: (city, serialNumber) => { + const resolvedStateName = city.stateName + || (typeof city.state === 'string' ? city.state : city.state?.name) + || '—'; + const resolvedCountryName = city.countryName + || (typeof city.country === 'string' ? city.country : city.country?.name) + || '—'; + + return { + id: city.id, + stateId: city.stateId, + name: city.name, + code: city.code, + timezoneId: city.timezoneId, + isActive: city.isActive, + serialNumber, + state: resolvedStateName, + country: resolvedCountryName, + stateName: resolvedStateName, + countryName: resolvedCountryName, + createdOn: city.createdOn, + modifiedOn: city.modifiedOn + }; + } + }); } - onFilterCountrySelected(country: CountryLookupDto): void { + onFilterCountryChanged(country: CountryLookupDto | null): void { this.selectedCountry.set(country); + this.filterForm.controls.countryId.setValue(country ? country.id : ''); + this.filterForm.controls.stateId.setValue(''); + this.selectedFilterState.set(null); + if (country) { + this.filterForm.controls.stateId.enable(); + } else { + this.filterForm.controls.stateId.disable(); + } } - onFilterCountryCleared(): void { - this.selectedCountry.set(null); - } - - onFilterStateSelected(state: StateLookupDto): void { + onFilterStateChanged(state: StateLookupDto | null): void { this.selectedFilterState.set(state); } - onFilterStateCleared(): void { - this.selectedFilterState.set(null); - } - - applyCityFilters(): void { - const countryId = this.filterForm.controls.countryId.value || null; - const stateId = this.filterForm.controls.stateId.value || null; - - this.appliedCountryId.set(countryId); - this.appliedStateId.set(stateId); - - this.loadCities(this.queryState.setPage({ - pageIndex: 1, - pageSize: this.queryState.pageSize() - })); - } - - onFormCountrySelected(country: CountryLookupDto): void { - this.selectedFormCountry.set(country); - } - - onFormCountryCleared(): void { - this.selectedFormCountry.set(null); - } - - onFormStateSelected(state: StateLookupDto): void { - this.selectedFormState.set(state); - } - - onFormStateCleared(): void { - this.selectedFormState.set(null); - } - - loadCities(query: DataTableQuery): void { - this.cityQueryRequests$.next(query); - } - - onSearch(value: string): void { - this.loadCities(this.queryState.setSearch(value.trim())); - } - - onPageChange(event: DataTablePageEvent): void { - this.loadCities(this.queryState.setPage(event)); - } - - onSortChange(event: DataTableSortEvent): void { - this.loadCities(this.queryState.setSort(event)); - } - - onActionClick(event: DataTableActionEvent): void { - const city = this.toCityDto(event.row); - switch (event.action.type) { - case 'edit': - this.openExistingCity(city, 'edit'); - break; - case 'activate': - this.updateCityStatus(city, true); - break; - case 'deactivate': - this.updateCityStatus(city, false); - break; + onApplyFilter(event?: Event): void { + event?.preventDefault(); + event?.stopPropagation(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); } + this.tableStore.refresh(); + } + + onResetFilter(): void { + this.filterForm.reset({ countryId: '', stateId: '' }); + this.filterForm.controls.stateId.disable(); + this.selectedCountry.set(null); + this.selectedFilterState.set(null); + this.tableStore.reset(); } onAddCity(): void { - this.modalMode.set('create'); - this.selectedCity.set(null); - this.submitAttempted.set(false); - this.selectedFormCountry.set(null); - this.selectedFormState.set(null); - this.cityForm.enable({ emitEvent: false }); - this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false }); - this.resetFormState(); - this.showCityModal.set(true); + this.tableStore.openCreateModal(); } - closeCityModal(): void { - if (this.saving()) return; - this.showCityModal.set(false); - this.selectedCity.set(null); - this.selectedFormCountry.set(null); - this.selectedFormState.set(null); - this.submitAttempted.set(false); + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteCity(event.row); + if (event.action.type === 'activate') this.changeCityStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeCityStatus(event.row, false); } - saveCity(): void { - if (this.saving()) return; - if (this.cityForm.invalid) { - this.submitAttempted.set(true); - this.cityForm.markAllAsTouched(); - this.focusFirstInvalidControl(); - return; - } + onDeleteConfirmed(): void { + const city = this.pendingDeleteCity(); + if (!city) return; + this.pendingDeleteCity.set(null); + this.deletingId.set(city.id); - this.saving.set(true); - const city = this.selectedCity(); - const request$ = this.modalMode() === 'create' - ? this.cityApi.createCity(this.buildCreateRequest()) - : city - ? this.cityApi.updateCity(city.id, this.buildUpdateRequest(city.isActive)) - : null; - - if (!request$) { - this.saving.set(false); - return; - } - - request$.pipe(finalize(() => this.saving.set(false))).subscribe({ + this.cityApi.delete(city.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ next: () => { - this.toastr.success( - this.modalMode() === 'create' - ? 'City saved successfully.' - : 'City updated successfully.' - ); - this.showCityModal.set(false); - this.selectedCity.set(null); - this.loadCities(this.queryState.getQuery()); + this.toastr.success('City deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete city.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete city because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'City not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); } }); } - private configureCityQueries(): void { - this.cityQueryRequests$.pipe( - switchMap(query => { - const stateId = this.appliedStateId(); - const countryId = this.appliedCountryId(); - return this.cityApi.getCityDataTable(query, countryId, stateId).pipe( - catchError(() => { - this.toastr.error('Unable to load cities.'); - this.clearGrid(); - return of(null); - }) - ); - }), - takeUntilDestroyed(this.destroyRef) - ).subscribe(response => { - if (!response) return; - const query = this.queryState.getQuery(); - if (response.draw !== query.draw) return; - - this.cities.set(response.rows.map((city, index) => ({ - ...city, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - }))); - this.totalRecords.set(response.filtered); - }); + onDeleteCancelled(): void { + this.pendingDeleteCity.set(null); } - private configureFilterChanges(): void { - this.filterForm.controls.countryId.valueChanges.pipe( - distinctUntilChanged(), + private requestDeleteCity(city: CityTableRow): void { + this.pendingDeleteCity.set(city); + this.deleteConfirmDialog()?.open(); + } + + private changeCityStatus(city: CityTableRow, activate: boolean): void { + this.statusChangingId.set(city.id); + + this.cityApi.updateStatus(city.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), takeUntilDestroyed(this.destroyRef) - ).subscribe(countryId => { - if (!countryId || this.selectedCountry()?.id !== countryId) { - this.selectedCountry.set(null); + ).subscribe({ + next: () => { + this.toastr.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} city.`; + this.toastr.error(msg); } - - this.selectedCountryId.set(countryId || null); - this.selectedStateId.set(null); - this.selectedFilterState.set(null); - - this.filterForm.controls.stateId.reset('', { emitEvent: false }); - countryId - ? this.filterForm.controls.stateId.enable({ emitEvent: false }) - : this.filterForm.controls.stateId.disable({ emitEvent: false }); - }); - - this.filterForm.controls.stateId.valueChanges.pipe( - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ).subscribe(stateId => { - if (!stateId || this.selectedFilterState()?.id !== stateId) { - this.selectedFilterState.set(null); - } - - this.selectedStateId.set(stateId || null); - }); - } - - private configureFormChanges(): void { - this.cityForm.controls.countryId.valueChanges.pipe( - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ).subscribe(countryId => { - if (!this.showCityModal() || this.modalMode() !== 'create') return; - - if (!countryId || this.selectedFormCountry()?.id !== countryId) { - this.selectedFormCountry.set(null); - } - - this.selectedFormState.set(null); - this.cityForm.controls.stateId.reset('', { emitEvent: false }); - this.cityForm.controls.stateId.enable({ emitEvent: false }); - }); - } - - private openExistingCity(city: CityDto, mode: 'edit'): void { - this.cityApi.getCityById(city.id).pipe( - switchMap(details => this.stateApi.getStateById(details.stateId).pipe( - map(stateDetails => ({ details, countryId: stateDetails.countryId })) - )), - take(1) - ).subscribe(({ details, countryId }) => { - this.selectedCity.set(details); - this.modalMode.set(mode); - this.submitAttempted.set(false); - this.selectedFormCountry.set(null); - this.selectedFormState.set(null); - this.cityForm.enable({ emitEvent: false }); - this.cityForm.reset({ - countryId, - stateId: details.stateId, - name: details.name ?? '', - code: details.code ?? '', - timezoneId: details.timezoneId - }, { emitEvent: false }); - // this.cityForm.controls.countryId.disable({ emitEvent: false }); - // this.cityForm.controls.stateId.disable({ emitEvent: false }); - this.resetFormState(); - this.showCityModal.set(true); - }); - } - - private updateCityStatus(city: CityDto, isActive: boolean): void { - this.cityApi.updateCity(city.id, { - name: city.name.trim(), - code: city.code?.trim().toUpperCase() ?? '', - timezoneId: city.timezoneId, - isActive - }).subscribe(() => { - this.toastr.success( - isActive ? 'City activated successfully.' : 'City deactivated successfully.' - ); - this.loadCities(this.queryState.getQuery()); - }); - } - - private buildCreateRequest(): CreateCityRequest { - const value = this.cityForm.getRawValue(); - return { - stateId: value.stateId, - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - timezoneId: value.timezoneId - }; - } - - private buildUpdateRequest(isActive: boolean): UpdateCityRequest { - const value = this.cityForm.getRawValue(); - return { - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - timezoneId: value.timezoneId, - isActive - }; - } - - private clearGrid(): void { - this.cities.set([]); - this.totalRecords.set(0); - } - - private resetFormState(): void { - this.cityForm.markAsPristine(); - this.cityForm.markAsUntouched(); - this.cityForm.updateValueAndValidity(); - } - - private focusFirstInvalidControl(): void { - queueMicrotask(() => { - const control = this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - control?.focus(); - control?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - } - - private toCityDto(row: CityTableRow): CityDto { - return { - id: row.id, - stateId: row.stateId, - name: row.name, - state: row.state, - country: row.country, - code: row.code, - timezoneId: row.timezoneId, - isActive: row.isActive, - createdOn: row.createdOn, - modifiedOn: row.modifiedOn - }; - } - - private toTimezoneLookup(timezone: TimezoneDto): TimezoneLookupDto { - return { - id: timezone.id, - ianaId: timezone.ianaId, - displayName: timezone.displayName - }; } } diff --git a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html new file mode 100644 index 00000000..f19802ab --- /dev/null +++ b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html @@ -0,0 +1,96 @@ + + @if (modalLoading()) { +
+ + Loading country... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
\ No newline at end of file diff --git a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts new file mode 100644 index 00000000..f5eccd98 --- /dev/null +++ b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts @@ -0,0 +1,211 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToastrService } from 'ngx-toastr'; +import { of } from 'rxjs'; +import { catchError, finalize, map, switchMap } from 'rxjs/operators'; + +import { + CountryDto, + CountryModalMode, + CreateCountryRequest, + UpdateCountryRequest +} from '../../models/country.model'; +import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api'; +import { CountryService } from '../../data-access/country.service'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-country-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete], + templateUrl: './country-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CountryFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly countryApi = inject(CountryService); + private readonly currencyApi = inject(CurrencyService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + readonly countryId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly countrySubmitAttempted = signal(false); + readonly selectedCountry = signal(null); + readonly selectedCurrency = signal(null); + + readonly countryForm = this.formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.maxLength(150)]], + iso2: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{2}$/)]], + iso3: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{3}$/)]], + phoneCode: [ + '', + [Validators.maxLength(16), Validators.pattern(/^\+?[0-9]{1,15}$/)] + ], + defaultCurrencyId: this.formBuilder.control(null) + }); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add Country'; + case 'edit': return 'Edit Country'; + case 'view': return 'View Country'; + } + }); + + readonly currencySearchFn: AutocompleteSearchFn = (term, page) => + this.currencyApi.autocomplete(term, page); + readonly currencyValueFn: AutocompleteValueFn = currency => currency.id; + readonly currencyDisplayFn: AutocompleteDisplayFn = currency => + `${currency.code} - ${currency.name}`; + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.countryId()); + } + }); + } + + prepareModal(id: string | null): void { + this.countrySubmitAttempted.set(false); + this.countryForm.reset({ name: '', iso2: '', iso3: '', phoneCode: '', defaultCurrencyId: null }); + this.selectedCurrency.set(null); + + if (!id || this.mode() === 'create') { + this.selectedCountry.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.countryApi.getCountryById(id).pipe( + switchMap(country => { + this.selectedCountry.set(country); + if (!country.defaultCurrencyId) return of({ country, currency: null }); + return this.currencyApi.getCurrencyById(country.defaultCurrencyId).pipe( + map(currency => ({ country, currency })), + catchError(() => of({ country, currency: null })) + ); + }), + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: ({ country, currency }) => { + if (currency) { + this.selectedCurrency.set({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol }); + } + this.countryForm.patchValue({ + name: country.name, + iso2: country.iso2, + iso3: country.iso3, + phoneCode: country.phoneCode ?? '', + defaultCurrencyId: country.defaultCurrencyId + }); + }, + error: () => { + this.toastr.error('Unable to load country details.'); + this.closeModal(); + } + }); + } + + saveCountry(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.countrySubmitAttempted.set(true); + if (this.countryForm.invalid || this.saving()) return; + + this.saving.set(true); + if (this.mode() === 'create') { + const request: CreateCountryRequest = { + name: this.countryForm.controls.name.value.trim(), + iso2: this.countryForm.controls.iso2.value.trim().toUpperCase(), + iso3: this.countryForm.controls.iso3.value.trim().toUpperCase(), + phoneCode: this.countryForm.controls.phoneCode.value.trim() || null, + defaultCurrencyId: this.countryForm.controls.defaultCurrencyId.value || null + }; + + this.countryApi.createCountry(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Country created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.countryId(); + if (!id) return; + + const request: UpdateCountryRequest = { + name: this.countryForm.controls.name.value.trim(), + iso2: this.countryForm.controls.iso2.value.trim().toUpperCase(), + iso3: this.countryForm.controls.iso3.value.trim().toUpperCase(), + phoneCode: this.countryForm.controls.phoneCode.value.trim() || null, + defaultCurrencyId: this.countryForm.controls.defaultCurrencyId.value || null, + isActive: this.selectedCountry()?.isActive ?? true + }; + + this.countryApi.updateCountry(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Country updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.toastr.error('A country with this ISO code already exists.'); + return; + } + this.toastr.error(`Unable to ${action} country. Please try again.`); + } +} diff --git a/src/app/features/global-masters/countries/data-access/country.service.ts b/src/app/features/global-masters/countries/data-access/country.service.ts index 10d406b0..3865932a 100644 --- a/src/app/features/global-masters/countries/data-access/country.service.ts +++ b/src/app/features/global-masters/countries/data-access/country.service.ts @@ -11,6 +11,7 @@ import { CountryLookupDto, CreateCountryRequest, UpdateCountryRequest, + UpdateCountryStatusRequest, } from '../models/country.model'; import { COUNTRY_ENDPOINTS } from './country.endpoints'; @@ -32,6 +33,14 @@ export class CountryService { return this.http.put(COUNTRY_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateCountryStatusRequest): Observable { + return this.http.patch(COUNTRY_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(COUNTRY_ENDPOINTS.delete(id)); + } + getCountryById(id: string): Observable { return this.http.get(COUNTRY_ENDPOINTS.getById(id)); } @@ -42,3 +51,4 @@ export class CountryService { }); } } + diff --git a/src/app/features/global-masters/countries/models/country.model.ts b/src/app/features/global-masters/countries/models/country.model.ts index 9c4b220f..9020295f 100644 --- a/src/app/features/global-masters/countries/models/country.model.ts +++ b/src/app/features/global-masters/countries/models/country.model.ts @@ -28,4 +28,9 @@ export interface UpdateCountryRequest extends CreateCountryRequest { isActive: boolean; } -export type CountryModalMode = 'create' | 'edit'; +export interface UpdateCountryStatusRequest { + isActive: boolean; +} + +export type CountryModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.html b/src/app/features/global-masters/countries/pages/country-list/country-list.html index 43a71e14..1bfbf840 100644 --- a/src/app/features/global-masters/countries/pages/country-list/country-list.html +++ b/src/app/features/global-masters/countries/pages/country-list/country-list.html @@ -1,19 +1,34 @@ - +
@if (getFlagUrl(row.iso2); as flagUrl) { - + } - - - {{ value }} - + {{ value }}
@@ -27,70 +42,10 @@ (cancelled)="onDeleteCancelled()" /> - -
-
-
- -
-
- -
- -
- -
- -
- -
- -
- -
-
-
-
+ diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.ts b/src/app/features/global-masters/countries/pages/country-list/country-list.ts index a46ddcf1..24103be6 100644 --- a/src/app/features/global-masters/countries/pages/country-list/country-list.ts +++ b/src/app/features/global-masters/countries/pages/country-list/country-list.ts @@ -1,42 +1,21 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - FormBuilder, - ReactiveFormsModule, - Validators -} from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs'; +import { finalize } from 'rxjs/operators'; -import { - CountryDto, - CountryModalMode, - CreateCountryRequest, - UpdateCountryRequest -} from '../../models/country.model'; -import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api'; +import { CountryDto, UpdateCountryRequest } from '../../models/country.model'; import { CountryService } from '../../data-access/country.service'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; -import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; -import { - AutocompleteDisplayFn, - AutocompleteSearchFn, - AutocompleteValueFn -} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { CountryFormModalComponent } from '../../components/country-form-modal/country-form-modal'; interface CountryTableRow extends DataTableRecord { id: string; @@ -54,380 +33,110 @@ interface CountryTableRow extends DataTableRecord { @Component({ selector: 'country-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog], + imports: [DataTable, DataTableCellDirective, ConfirmDialog, CountryFormModalComponent], + providers: [DataTableStore], templateUrl: './country-list.html', styleUrl: './country-list.scss', }) -export class CountryList { +export class CountryList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly countryApi = inject(CountryService); - private readonly currencyApi = inject(CurrencyService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly countryQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - - readonly countries = signal([]); - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - readonly saving = signal(false); - - readonly showCountryModal = signal(false); - readonly countryModalMode = signal('create'); - readonly selectedCountryId = signal(null); - readonly selectedCountry = signal(null); - readonly selectedCurrency = signal(null); - readonly countrySubmitAttempted = signal(false); - readonly pendingDeleteCountry = signal(null); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteCountry = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly countryForm = this.formBuilder.nonNullable.group({ - name: [ - '', - [ - Validators.required, - Validators.maxLength(150) - ] - ], - iso2: [ - '', - [ - Validators.required, - Validators.pattern(/^[A-Za-z]{2}$/) - ] - ], - iso3: [ - '', - [ - Validators.required, - Validators.pattern(/^[A-Za-z]{3}$/) - ] - ], - phoneCode: [ - '', - [ - Validators.maxLength(16), - Validators.pattern(/^\+?[0-9\- ]{1,15}$/) - ] - ], - defaultCurrencyId: this.formBuilder.control(null) - }); - - readonly searchCurrencies: AutocompleteSearchFn = - (term, limit) => this.currencyApi.autocomplete(term, limit); - readonly displayCurrency: AutocompleteDisplayFn = currency => { - const baseLabel = [currency.code, currency.name].filter(Boolean).join(' - '); - - return currency.symbol?.trim() - ? `${baseLabel} (${currency.symbol})` - : baseLabel; - }; - readonly currencyValue: AutocompleteValueFn = currency => currency.id; - - readonly countryModalTitle = computed(() => - this.countryModalMode() === 'create' - ? 'Add Country' - : 'Edit Country' - ); - - readonly countrySubmitLabel = computed(() => - this.countryModalMode() === 'create' - ? 'Save' - : 'Update' - ); - - readonly countryLoadingLabel = computed(() => - this.countryModalMode() === 'create' - ? 'Saving...' - : 'Updating...' - ); - - readonly countrySubmitAction = computed<'save' | 'update'>(() => - this.countryModalMode() === 'create' - ? 'save' - : 'update' - ); - readonly columns = signal[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, - { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, - { key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true }, - { key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true }, - { key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true }, - { key: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' + }, + { key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' + }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - badgeClass: value => - value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', + key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center', + formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' + }, + { + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.countryQueryRequests$ - .pipe( - switchMap(query => - this.countryApi.getCountryDataTable(query).pipe( - catchError(() => { - this.toastr.error('Unable to load countries.'); - this.clearCountryGrid(); - return of(null); - }) - ) - ), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (!response) { - return; - } - - const query = this.queryState.getQuery(); - - if (response.draw !== query.draw) { - return; - } - - const countriesWithSerialNumbers: CountryTableRow[] = response.rows.map((country, index) => ({ - ...country, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.countries.set(countriesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); - } - ngOnInit(): void { - this.loadCountries(this.queryState.getQuery()); - } - - loadCountries(query: DataTableQuery): void { - this.countryQueryRequests$.next(query); - } - - onSearch(value: string): void { - const query = this.queryState.setSearch(value.trim()); - this.loadCountries(query); - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.queryState.setPage(event); - this.loadCountries(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.queryState.setSort(event); - this.loadCountries(query); - } - - onRefresh(): void { - const currentQuery = this.queryState.getQuery(); - - this.loadCountries({ - ...currentQuery, - draw: currentQuery.draw + 1 + this.tableStore.initialize({ + fetcher: query => this.countryApi.getCountryDataTable(query) }); } - onReset(): void { - const query = this.queryState.reset(); - this.loadCountries(query); + onAddCountry(): void { + this.tableStore.openCreateModal(); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteCountry(event.row); + if (event.action.type === 'activate') this.changeCountryStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeCountryStatus(event.row, false); } onDeleteConfirmed(): void { const country = this.pendingDeleteCountry(); - - if (!country) { - return; - } - + if (!country) return; this.pendingDeleteCountry.set(null); - this.deleteCountry(country); + this.deletingId.set(country.id); + + this.countryApi.delete(country.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success('Country deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete country.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete country because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Country not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } + }); } onDeleteCancelled(): void { this.pendingDeleteCountry.set(null); } - onActionClick(event: DataTableActionEvent): void { - const country = this.toCountryDto(event.row); - - switch (event.action.type) { - case 'view': - this.viewCountry(country); - break; - case 'edit': - this.openEditCountry(country); - break; - case 'delete': - this.requestDeleteCountry(country); - break; - case 'activate': - this.activateCountry(country); - break; - } - } - - onAddCountry(): void { - this.countryModalMode.set('create'); - this.selectedCountryId.set(null); - this.selectedCountry.set(null); - this.selectedCurrency.set(null); - this.countrySubmitAttempted.set(false); - - this.countryForm.reset({ - name: '', - iso2: '', - iso3: '', - phoneCode: '', - defaultCurrencyId: null - }); - this.resetCountryFormState(); - - this.showCountryModal.set(true); - } - - closeCountryModal(): void { - if (this.saving()) { - return; - } - - this.showCountryModal.set(false); - this.selectedCountryId.set(null); - this.selectedCountry.set(null); - this.selectedCurrency.set(null); - this.countrySubmitAttempted.set(false); - } - - saveCountry(): void { - if (this.countryForm.invalid) { - this.countrySubmitAttempted.set(true); - this.countryForm.markAllAsTouched(); - this.focusFirstInvalidCountryControl(); - return; - } - - if (this.saving()) { - return; - } - - this.saving.set(true); - - if (this.countryModalMode() === 'create') { - this.countryApi - .createCountry(this.buildCreateCountryRequest()) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Country saved successfully.'); - this.finishCountrySave(); - } - }); - - return; - } - - const countryId = this.selectedCountryId(); - - if (!countryId) { - this.saving.set(false); - return; - } - - this.countryApi - .updateCountry( - countryId, - this.buildUpdateCountryRequest(this.selectedCountry()?.isActive ?? true) - ) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Country updated successfully.'); - this.finishCountrySave(); - } - }); - } - - openEditCountry(country: CountryDto): void { - this.countryModalMode.set('edit'); - this.selectedCountryId.set(country.id); - this.selectedCountry.set(null); - this.selectedCurrency.set(null); - this.countrySubmitAttempted.set(false); - - this.countryApi - .getCountryById(country.id) - .pipe( - switchMap(countryDetails => { - const currencyId = countryDetails.defaultCurrencyId; - - if (!currencyId) { - return of({ countryDetails, currency: null }); - } - - return this.currencyApi.getCurrencyById(currencyId).pipe( - map(currency => ({ countryDetails, currency })), - catchError(() => { - this.toastr.error('Unable to load the selected currency.'); - return of({ countryDetails, currency: null }); - }) - ); - }) - ) - .subscribe({ - next: ({ countryDetails, currency }) => { - this.selectedCountry.set(countryDetails); - this.selectedCurrency.set(currency); - this.countryForm.reset({ - name: countryDetails.name ?? '', - iso2: countryDetails.iso2 ?? '', - iso3: countryDetails.iso3 ?? '', - phoneCode: countryDetails.phoneCode ?? '', - defaultCurrencyId: countryDetails.defaultCurrencyId ?? null - }); - this.resetCountryFormState(); - - this.showCountryModal.set(true); - } - }); - } - getFlagUrl(iso2: string | null | undefined): string { const code = iso2?.trim().toLowerCase(); - return code && /^[a-z]{2}$/.test(code) ? `https://flagcdn.com/24x18/${code}.png` : ''; @@ -438,122 +147,26 @@ export class CountryList { image.style.display = 'none'; } - private viewCountry(country: CountryDto): void { - this.openEditCountry(country); - } - - private requestDeleteCountry(country: CountryDto): void { + private requestDeleteCountry(country: CountryTableRow): void { this.pendingDeleteCountry.set(country); this.deleteConfirmDialog()?.open(); } - private deleteCountry(country: CountryDto): void { - this.updateCountryStatus(country, false); - } + private changeCountryStatus(country: CountryTableRow, activate: boolean): void { + this.statusChangingId.set(country.id); - private activateCountry(country: CountryDto): void { - this.updateCountryStatus(country, true); - } - - private buildCreateCountryRequest(): CreateCountryRequest { - const value = this.countryForm.getRawValue(); - - return { - name: value.name.trim(), - iso2: value.iso2.trim().toUpperCase(), - iso3: value.iso3.trim().toUpperCase(), - phoneCode: this.nullWhenBlank(value.phoneCode), - defaultCurrencyId: this.nullWhenBlank(value.defaultCurrencyId) - }; - } - - private buildUpdateCountryRequest(isActive: boolean): UpdateCountryRequest { - return { - ...this.buildCreateCountryRequest(), - isActive - }; - } - - private countryToUpdateRequest(country: CountryDto, isActive: boolean): UpdateCountryRequest { - return { - name: country.name?.trim() ?? '', - iso2: country.iso2?.trim().toUpperCase() ?? '', - iso3: country.iso3?.trim().toUpperCase() ?? '', - phoneCode: this.nullWhenBlank(country.phoneCode), - defaultCurrencyId: this.nullWhenBlank(country.defaultCurrencyId), - isActive - }; - } - - private updateCountryStatus(country: CountryDto, isActive: boolean): void { - this.countryApi - .updateCountry(country.id, this.countryToUpdateRequest(country, isActive)) - .subscribe({ - next: () => { - this.toastr.success( - isActive - ? 'Country activated successfully.' - : 'Country deactivated successfully.' - ); - this.loadCountries(this.queryState.getQuery()); - } - }); - } - - private resetCountryFormState(): void { - this.countryForm.markAsPristine(); - this.countryForm.markAsUntouched(); - this.countryForm.updateValueAndValidity(); - } - - private finishCountrySave(): void { - this.showCountryModal.set(false); - this.selectedCountryId.set(null); - this.selectedCountry.set(null); - this.countrySubmitAttempted.set(false); - this.loadCountries(this.queryState.getQuery()); - } - - private clearCountryGrid(): void { - this.countries.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - - private focusFirstInvalidCountryControl(): void { - queueMicrotask(() => { - const firstInvalidControl = - this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - - firstInvalidControl?.focus(); - firstInvalidControl?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); + this.countryApi.updateStatus(country.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} country.`; + this.toastr.error(msg); + } }); } - - private nullWhenBlank(value: string | null | undefined): string | null { - const normalized = value?.trim(); - - return normalized - ? normalized - : null; - } - - private toCountryDto(row: CountryTableRow): CountryDto { - return { - id: row.id, - iso2: row.iso2, - iso3: row.iso3, - name: row.name, - phoneCode: row.phoneCode, - defaultCurrencyId: row.defaultCurrencyId, - isActive: row.isActive, - createdOn: row.createdOn, - modifiedOn: row.modifiedOn - }; - } } diff --git a/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.html b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.html new file mode 100644 index 00000000..22a5c2da --- /dev/null +++ b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.html @@ -0,0 +1,97 @@ + + @if (modalLoading()) { +
+ + Loading currency... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.ts b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.ts new file mode 100644 index 00000000..a2dea5df --- /dev/null +++ b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.ts @@ -0,0 +1,190 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToastrService } from 'ngx-toastr'; +import { finalize } from 'rxjs/operators'; + +import { + CreateCurrencyRequest, + CurrencyDto, + CurrencyIso2Value, + CurrencyModalMode, + UpdateCurrencyRequest +} from '../../models/currency.model'; +import { CurrencyService } from '../../data-access/currency.service'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-currency-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput], + templateUrl: './currency-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CurrencyFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly currencyApi = inject(CurrencyService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + readonly currencyId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly currencySubmitAttempted = signal(false); + readonly selectedCurrency = signal(null); + + readonly currencyForm = this.formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.maxLength(100)]], + code: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{3}$/)]], + symbol: ['', [Validators.required, Validators.maxLength(10)]], + numericCode: [ + 0, + [Validators.required, Validators.min(1), Validators.max(999)] + ], + decimalDigits: [ + 2, + [Validators.required, Validators.min(0), Validators.max(8)] + ] + }); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add Currency'; + case 'edit': return 'Edit Currency'; + case 'view': return 'View Currency'; + } + }); + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.currencyId()); + } + }); + } + + prepareModal(id: string | null): void { + this.currencySubmitAttempted.set(false); + this.currencyForm.reset({ + name: '', code: '', symbol: '', numericCode: 0, decimalDigits: 2 + }); + + if (!id || this.mode() === 'create') { + this.selectedCurrency.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.currencyApi.getCurrencyById(id).pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: currency => { + this.selectedCurrency.set(currency); + this.currencyForm.patchValue({ + name: currency.name, + code: currency.code, + symbol: currency.symbol, + numericCode: currency.numericCode, + decimalDigits: currency.decimalDigits + }); + }, + error: () => { + this.toastr.error('Unable to load currency details.'); + this.closeModal(); + } + }); + } + + saveCurrency(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.currencySubmitAttempted.set(true); + if (this.currencyForm.invalid || this.saving()) return; + + this.saving.set(true); + if (this.mode() === 'create') { + const request: CreateCurrencyRequest = { + code: this.currencyForm.controls.code.value.trim().toUpperCase(), + name: this.currencyForm.controls.name.value.trim(), + symbol: this.currencyForm.controls.symbol.value.trim(), + numericCode: this.currencyForm.controls.numericCode.value, + decimalDigits: this.currencyForm.controls.decimalDigits.value + }; + + this.currencyApi.createCurrency(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Currency created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.currencyId(); + if (!id) return; + + const request: UpdateCurrencyRequest = { + code: this.currencyForm.controls.code.value.trim().toUpperCase(), + name: this.currencyForm.controls.name.value.trim(), + symbol: this.currencyForm.controls.symbol.value.trim(), + numericCode: this.currencyForm.controls.numericCode.value, + decimalDigits: this.currencyForm.controls.decimalDigits.value, + isActive: this.selectedCurrency()?.isActive ?? true + }; + + this.currencyApi.updateCurrency(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Currency updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.toastr.error('A currency with this ISO code already exists.'); + return; + } + this.toastr.error(`Unable to ${action} currency. Please try again.`); + } +} diff --git a/src/app/features/global-masters/currencies/data-access/currency.service.ts b/src/app/features/global-masters/currencies/data-access/currency.service.ts index cebb5a1c..a7e348c4 100644 --- a/src/app/features/global-masters/currencies/data-access/currency.service.ts +++ b/src/app/features/global-masters/currencies/data-access/currency.service.ts @@ -7,7 +7,8 @@ import { CreateCurrencyRequest, CurrencyDto, CurrencyLookupDto, - UpdateCurrencyRequest + UpdateCurrencyRequest, + UpdateCurrencyStatusRequest } from '../models/currency.model'; import { CURRENCY_ENDPOINTS } from './currency.endpoints'; @@ -29,6 +30,14 @@ export class CurrencyService { return this.http.put(CURRENCY_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateCurrencyStatusRequest): Observable { + return this.http.patch(CURRENCY_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(CURRENCY_ENDPOINTS.delete(id)); + } + getCurrencyById(id: string): Observable { return this.http.get(CURRENCY_ENDPOINTS.getById(id)); } diff --git a/src/app/features/global-masters/currencies/models/currency.model.ts b/src/app/features/global-masters/currencies/models/currency.model.ts index 8ea67172..5c844ea7 100644 --- a/src/app/features/global-masters/currencies/models/currency.model.ts +++ b/src/app/features/global-masters/currencies/models/currency.model.ts @@ -20,7 +20,6 @@ export interface CurrencyDto { modifiedOn?: string | null; } - export interface CurrencyLookupDto { readonly id: string; readonly code: string; @@ -40,4 +39,9 @@ export interface UpdateCurrencyRequest extends CreateCurrencyRequest { isActive: boolean; } -export type CurrencyModalMode = 'create' | 'edit'; +export interface UpdateCurrencyStatusRequest { + isActive: boolean; +} + +export type CurrencyModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html index 27f32995..f32b068e 100644 --- a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html +++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html @@ -1,10 +1,24 @@ - - + + @if (visibleCountries(row); as countries) { @if (countries.length > 0) {
@@ -100,47 +114,22 @@ {{ value }} - + + - + - -
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
+ diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts index cfeeb807..b1ce5aa0 100644 --- a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts +++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts @@ -1,45 +1,35 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ToastrService } from 'ngx-toastr'; +import { finalize } from 'rxjs/operators'; import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedOverlayPositionChange, ConnectedPosition } from '@angular/cdk/overlay'; -import { - FormBuilder, - ReactiveFormsModule, - Validators -} from '@angular/forms'; -import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; import { - CreateCurrencyRequest, CurrencyCountryFlag, CurrencyDto, CurrencyIso2Value, - CurrencyModalMode, UpdateCurrencyRequest } from '../../models/currency.model'; import { CurrencyService } from '../../data-access/currency.service'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { CurrencyFormModalComponent } from '../../components/currency-form-modal/currency-form-modal'; type Iso2TooltipPlacement = 'above' | 'below' | 'left' | 'right'; + interface CurrencyTableRow extends DataTableRecord { id: string; code: string; @@ -57,478 +47,145 @@ interface CurrencyTableRow extends DataTableRecord { @Component({ selector: 'currency-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog, CdkOverlayOrigin, CdkConnectedOverlay], + imports: [ + DataTable, + DataTableCellDirective, + ConfirmDialog, + CdkOverlayOrigin, + CdkConnectedOverlay, + CurrencyFormModalComponent + ], + providers: [DataTableStore], templateUrl: './currency-list.html', styleUrl: './currency-list.scss', }) -export class CurrencyList { +export class CurrencyList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly currencyApi = inject(CurrencyService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly currencyQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - - readonly currencies = signal([]); - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - readonly saving = signal(false); - - readonly showCurrencyModal = signal(false); - readonly currencyModalMode = signal('create'); - readonly selectedCurrencyId = signal(null); - readonly selectedCurrency = signal(null); - readonly currencySubmitAttempted = signal(false); - readonly pendingDeleteCurrency = signal(null); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteCurrency = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly openIso2TooltipCurrencyId = signal(null); + + readonly openIso2TooltipCurrencyId = signal(null); readonly iso2TooltipPlacement = signal('right'); readonly iso2TooltipPositions: ConnectedPosition[] = [ { - originX: 'end', - originY: 'center', - overlayX: 'start', - overlayY: 'center', - offsetX: 12 - } + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 12 + } ]; private iso2TooltipCloseTimer: ReturnType | null = null; - readonly currencyForm = this.formBuilder.nonNullable.group({ - name: [ - '', - [ - Validators.required, - Validators.maxLength(100) - ] - ], - code: [ - '', - [ - Validators.required, - Validators.minLength(3), - Validators.maxLength(3), - Validators.pattern(/^[A-Za-z]{3}$/) - ] - ], - symbol: [ - '', - [ - Validators.required, - Validators.maxLength(8) - ] - ], - numericCode: [ - 0, - [ - Validators.required, - Validators.min(1), - Validators.max(999) - ] - ], - decimalDigits: [ - 2, - [ - Validators.required, - Validators.min(0), - Validators.max(4) - ] - ] - }); - - readonly currencyModalTitle = computed(() => - this.currencyModalMode() === 'create' - ? 'Add Currency' - : 'Edit Currency' - ); - - readonly currencySubmitLabel = computed(() => - this.currencyModalMode() === 'create' - ? 'Save' - : 'Update' - ); - - readonly currencyLoadingLabel = computed(() => - this.currencyModalMode() === 'create' - ? 'Saving...' - : 'Updating...' - ); - - readonly currencySubmitAction = computed<'save' | 'update'>(() => - this.currencyModalMode() === 'create' - ? 'save' - : 'update' - ); - readonly columns = signal[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, - { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, - { key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true , align: 'left'}, - { key: 'code', label: 'Code', header: 'Code', sortable: true }, - { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true }, - { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true }, - { key: 'decimalDigits', label: 'Decimal Digits', header: 'Decimal Digits', sortable: true }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '90px' }, + { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'iso2', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'left' }, + { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: false, headerAlign: 'center', align: 'center', width: '90px' }, + { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true, headerAlign: 'center', align: 'center', width: '130px' }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - badgeClass: value => - value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.currencyQueryRequests$ - .pipe( - switchMap(query => - this.currencyApi.getCurrencyDataTable(query).pipe( - catchError(() => { - this.toastr.error('Unable to load currencies.'); - this.clearCurrencyGrid(); - return of(null); - }) - ) - ), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (!response) { - return; - } - - const query = this.queryState.getQuery(); - - if (response.draw !== query.draw) { - return; - } - - const currenciesWithSerialNumbers: CurrencyTableRow[] = response.rows.map((currency, index) => ({ - ...currency, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.currencies.set(currenciesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); - } - ngOnInit(): void { - this.loadCurrencies(this.queryState.getQuery()); - } - - loadCurrencies(query: DataTableQuery): void { - this.currencyQueryRequests$.next(query); - } - - onSearch(value: string): void { - const query = this.queryState.setSearch(value.trim()); - this.loadCurrencies(query); - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.queryState.setPage(event); - this.loadCurrencies(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.queryState.setSort(event); - this.loadCurrencies(query); - } - - onRefresh(): void { - const currentQuery = this.queryState.getQuery(); - - this.loadCurrencies({ - ...currentQuery, - draw: currentQuery.draw + 1 + this.tableStore.initialize({ + fetcher: query => this.currencyApi.getCurrencyDataTable(query) }); } - onReset(): void { - const query = this.queryState.reset(); - this.loadCurrencies(query); + onAddCurrency(): void { + this.tableStore.openCreateModal(); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteCurrency(event.row); + if (event.action.type === 'activate') this.changeCurrencyStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeCurrencyStatus(event.row, false); } onDeleteConfirmed(): void { const currency = this.pendingDeleteCurrency(); - - if (!currency) { - return; - } - + if (!currency) return; this.pendingDeleteCurrency.set(null); - this.deleteCurrency(currency); + this.deletingId.set(currency.id); + + this.currencyApi.delete(currency.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success('Currency deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete currency.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete currency because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Currency not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } + }); } onDeleteCancelled(): void { this.pendingDeleteCurrency.set(null); } - onActionClick(event: DataTableActionEvent): void { - const currency = this.toCurrencyDto(event.row); - - switch (event.action.type) { - case 'view': - this.viewCurrency(currency); - break; - case 'edit': - this.openEditCurrency(currency); - break; - case 'delete': - this.requestDeleteCurrency(currency); - break; - case 'activate': - this.activateCurrency(currency); - break; - } - } - - onAddCurrency(): void { - this.currencyModalMode.set('create'); - this.selectedCurrencyId.set(null); - this.selectedCurrency.set(null); - this.currencySubmitAttempted.set(false); - - this.currencyForm.reset({ - name: '', - code: '', - symbol: '', - numericCode: 0, - decimalDigits: 2 - }); - this.resetCurrencyFormState(); - - this.showCurrencyModal.set(true); - } - - closeCurrencyModal(): void { - if (this.saving()) { - return; - } - - this.showCurrencyModal.set(false); - this.selectedCurrencyId.set(null); - this.selectedCurrency.set(null); - this.currencySubmitAttempted.set(false); - } - - saveCurrency(): void { - if (this.currencyForm.invalid) { - this.currencySubmitAttempted.set(true); - this.currencyForm.markAllAsTouched(); - this.focusFirstInvalidCurrencyControl(); - return; - } - - if (this.saving()) { - return; - } - - this.saving.set(true); - - if (this.currencyModalMode() === 'create') { - this.currencyApi - .createCurrency(this.buildCreateCurrencyRequest()) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Currency saved successfully.'); - this.finishCurrencySave(); - } - }); - - return; - } - - const currencyId = this.selectedCurrencyId(); - - if (!currencyId) { - this.saving.set(false); - return; - } - - this.currencyApi - .updateCurrency( - currencyId, - this.buildUpdateCurrencyRequest(this.selectedCurrency()?.isActive ?? true) - ) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Currency updated successfully.'); - this.finishCurrencySave(); - } - }); - } - - private viewCurrency(currency: CurrencyDto): void { - this.openEditCurrency(currency); - } - - private openEditCurrency(currency: CurrencyDto): void { - this.currencyModalMode.set('edit'); - this.selectedCurrencyId.set(currency.id); - this.selectedCurrency.set(currency); - this.currencySubmitAttempted.set(false); - - this.currencyApi - .getCurrencyById(currency.id) - .subscribe({ - next: currencyDetails => { - this.selectedCurrency.set(currencyDetails); - this.currencyForm.reset({ - name: currencyDetails.name ?? '', - code: currencyDetails.code ?? '', - symbol: currencyDetails.symbol ?? '', - numericCode: currencyDetails.numericCode ?? '0', - decimalDigits: currencyDetails.decimalDigits ?? 2 - }); - this.resetCurrencyFormState(); - - this.showCurrencyModal.set(true); - } - }); - } - - private requestDeleteCurrency(currency: CurrencyDto): void { + private requestDeleteCurrency(currency: CurrencyTableRow): void { this.pendingDeleteCurrency.set(currency); this.deleteConfirmDialog()?.open(); } - private deleteCurrency(currency: CurrencyDto): void { - this.updateCurrencyStatus(currency, false); - } + private changeCurrencyStatus(currency: CurrencyTableRow, activate: boolean): void { + this.statusChangingId.set(currency.id); - private activateCurrency(currency: CurrencyDto): void { - this.updateCurrencyStatus(currency, true); - } - - private buildCreateCurrencyRequest(): CreateCurrencyRequest { - const value = this.currencyForm.getRawValue(); - - return { - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - symbol: value.symbol.trim(), - numericCode: value.numericCode, - decimalDigits: value.decimalDigits - }; - } - - private buildUpdateCurrencyRequest(isActive: boolean): UpdateCurrencyRequest { - return { - ...this.buildCreateCurrencyRequest(), - isActive - }; - } - - private currencyToUpdateRequest(currency: CurrencyDto, isActive: boolean): UpdateCurrencyRequest { - return { - name: currency.name?.trim() ?? '', - code: currency.code?.trim().toUpperCase() ?? '', - symbol: currency.symbol?.trim() ?? '', - numericCode: currency.numericCode, - decimalDigits: currency.decimalDigits, - isActive - }; - } - - private updateCurrencyStatus(currency: CurrencyDto, isActive: boolean): void { - this.currencyApi - .updateCurrency(currency.id, this.currencyToUpdateRequest(currency, isActive)) - .subscribe({ - next: () => { - this.toastr.success( - isActive - ? 'Currency activated successfully.' - : 'Currency deactivated successfully.' - ); - this.loadCurrencies(this.queryState.getQuery()); - } - }); - } - - private resetCurrencyFormState(): void { - this.currencyForm.markAsPristine(); - this.currencyForm.markAsUntouched(); - this.currencyForm.updateValueAndValidity(); - } - - private finishCurrencySave(): void { - this.showCurrencyModal.set(false); - this.selectedCurrencyId.set(null); - this.selectedCurrency.set(null); - this.currencySubmitAttempted.set(false); - this.loadCurrencies(this.queryState.getQuery()); - } - - private clearCurrencyGrid(): void { - this.currencies.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - - private focusFirstInvalidCurrencyControl(): void { - queueMicrotask(() => { - const firstInvalidControl = - this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - - firstInvalidControl?.focus(); - firstInvalidControl?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); + this.currencyApi.updateStatus(currency.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} currency.`; + this.toastr.error(msg); + } }); } - private toCurrencyDto(row: CurrencyTableRow): CurrencyDto { - return { - id: row.id, - code: row.code, - iso2: row.iso2, - name: row.name, - symbol: row.symbol, - numericCode: row.numericCode, - decimalDigits: row.decimalDigits, - isActive: row.isActive, - createdOn: row.createdOn, - modifiedOn: row.modifiedOn - }; - } - visibleCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] { return this.normalizeIso2Codes(row.iso2).slice(0, 1); } @@ -589,7 +246,7 @@ export class CurrencyList { const rawCode = typeof item === 'string' ? item : item && typeof item === 'object' && 'iso2' in item - ? String(item.iso2) + ? String((item as { iso2?: unknown }).iso2) : ''; const iso2 = rawCode.trim().toUpperCase(); @@ -615,6 +272,7 @@ export class CurrencyList { ? `https://flagcdn.com/24x18/${code}.png` : ''; } + onFlagError(event: Event): void { const image = event.target as HTMLImageElement; image.classList.add('hidden'); diff --git a/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.html b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.html new file mode 100644 index 00000000..b1c290fb --- /dev/null +++ b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.html @@ -0,0 +1,38 @@ + + @if (modalLoading()) { +
+ + Loading language... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
\ No newline at end of file diff --git a/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.ts b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.ts new file mode 100644 index 00000000..1c5af17f --- /dev/null +++ b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.ts @@ -0,0 +1,178 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToastrService } from 'ngx-toastr'; +import { finalize } from 'rxjs/operators'; + +import { + CreateLanguageRequest, + LanguageDto, + LanguageModalMode, + UpdateLanguageRequest +} from '../../models/language.model'; +import { LanguageService } from '../../data-access/language.service'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { FormCheckbox } from '../../../../../shared/components/form/form-checkbox/form-checkbox'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-language-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput], + templateUrl: './language-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class LanguageFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly languageApi = inject(LanguageService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + readonly languageId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedLanguage = signal(null); + + readonly languageForm = this.formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], + code: ['', [Validators.required, Validators.maxLength(35), Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/)]], + nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], + isRightToLeft: [false] + }); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add Language'; + case 'edit': return 'Edit Language'; + case 'view': return 'View Language'; + } + }); + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.languageId()); + } + }); + } + + prepareModal(id: string | null): void { + this.submitAttempted.set(false); + this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false }); + + if (!id || this.mode() === 'create') { + this.selectedLanguage.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.languageApi.getById(id).pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: language => { + this.selectedLanguage.set(language); + this.languageForm.patchValue({ + name: language.name, + code: language.code, + nativeName: language.nativeName, + isRightToLeft: language.isRightToLeft + }); + }, + error: () => { + this.toastr.error('Unable to load language details.'); + this.closeModal(); + } + }); + } + + saveLanguage(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.submitAttempted.set(true); + if (this.languageForm.invalid || this.saving()) return; + + this.saving.set(true); + if (this.mode() === 'create') { + const request: CreateLanguageRequest = { + code: this.languageForm.controls.code.value.trim(), + name: this.languageForm.controls.name.value.trim(), + nativeName: this.languageForm.controls.nativeName.value.trim(), + isRightToLeft: this.languageForm.controls.isRightToLeft.value + }; + + this.languageApi.create(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Language created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.languageId(); + if (!id) return; + + const request: UpdateLanguageRequest = { + code: this.languageForm.controls.code.value.trim(), + name: this.languageForm.controls.name.value.trim(), + nativeName: this.languageForm.controls.nativeName.value.trim(), + isRightToLeft: this.languageForm.controls.isRightToLeft.value, + isActive: this.selectedLanguage()?.isActive ?? true + }; + + this.languageApi.update(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Language updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.toastr.error('A language with this code already exists.'); + return; + } + this.toastr.error(`Unable to ${action} language. Please try again.`); + } +} diff --git a/src/app/features/global-masters/languages/data-access/language.endpoints.ts b/src/app/features/global-masters/languages/data-access/language.endpoints.ts index 123ad1fa..374b2517 100644 --- a/src/app/features/global-masters/languages/data-access/language.endpoints.ts +++ b/src/app/features/global-masters/languages/data-access/language.endpoints.ts @@ -7,5 +7,9 @@ export const LANGUAGE_ENDPOINTS = { buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), update: (id: string) => buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}/status`), autocomplete: buildApiUrl('masterAdmin', '/v1/languages/autocomplete') } as const; diff --git a/src/app/features/global-masters/languages/data-access/language.service.ts b/src/app/features/global-masters/languages/data-access/language.service.ts index d491b95f..c2082875 100644 --- a/src/app/features/global-masters/languages/data-access/language.service.ts +++ b/src/app/features/global-masters/languages/data-access/language.service.ts @@ -7,7 +7,8 @@ import { CreateLanguageRequest, LanguageDto, LanguageLookupDto, - UpdateLanguageRequest + UpdateLanguageRequest, + UpdateLanguageStatusRequest } from '../models/language.model'; import { DataTableQuery, @@ -34,6 +35,14 @@ export class LanguageService { return this.http.put(LANGUAGE_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateLanguageStatusRequest): Observable { + return this.http.patch(LANGUAGE_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(LANGUAGE_ENDPOINTS.delete(id)); + } + autocomplete( term: string | null, limit = 10 diff --git a/src/app/features/global-masters/languages/models/language.model.ts b/src/app/features/global-masters/languages/models/language.model.ts index fc1f7539..224222e8 100644 --- a/src/app/features/global-masters/languages/models/language.model.ts +++ b/src/app/features/global-masters/languages/models/language.model.ts @@ -28,4 +28,9 @@ export interface UpdateLanguageRequest extends CreateLanguageRequest { isActive: boolean; } -export type LanguageModalMode = 'create' | 'edit'; +export interface UpdateLanguageStatusRequest { + isActive: boolean; +} + +export type LanguageModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.html b/src/app/features/global-masters/languages/pages/language-list/language-list.html index 6aaa3e77..edc48aab 100644 --- a/src/app/features/global-masters/languages/pages/language-list/language-list.html +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.html @@ -1,67 +1,37 @@ - - - {{ value }} - - - {{ value }} - - + - + - - @if (modalLoading()) { -
- - Loading language... -
- } @else { -
-
-
- -
-
- -
-
- -
- - -
- -
-
-
- } -
\ No newline at end of file + \ No newline at end of file diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.ts b/src/app/features/global-masters/languages/pages/language-list/language-list.ts index 56e47501..8eb61544 100644 --- a/src/app/features/global-masters/languages/pages/language-list/language-list.ts +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.ts @@ -1,32 +1,20 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; +import { finalize } from 'rxjs/operators'; -import { - CreateLanguageRequest, - LanguageDto, - LanguageModalMode, - UpdateLanguageRequest -} from '../../models/language.model'; +import { LanguageDto, UpdateLanguageRequest } from '../../models/language.model'; import { LanguageService } from '../../data-access/language.service'; import { DataTable } from '../../../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; -import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { LanguageFormModalComponent } from '../../components/language-form-modal/language-form-modal'; interface LanguageTableRow extends DataTableRecord { id: string; @@ -43,314 +31,125 @@ interface LanguageTableRow extends DataTableRecord { @Component({ selector: 'language-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog], + imports: [DataTable, ConfirmDialog, LanguageFormModalComponent], + providers: [DataTableStore], templateUrl: './language-list.html', styleUrl: './language-list.scss' }) -export class LanguageList { +export class LanguageList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly languageApi = inject(LanguageService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly queryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - readonly languages = signal([]); - readonly totalRecords = signal(0); - readonly modalLoading = signal(false); - readonly saving = signal(false); readonly statusChangingId = signal(null); - readonly showModal = signal(false); - readonly modalMode = signal('create'); - readonly selectedLanguageId = signal(null); - readonly selectedLanguage = signal(null); - readonly submitAttempted = signal(false); - readonly pendingDeleteLanguageId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteLanguage = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly languageForm = this.formBuilder.nonNullable.group({ - name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], - code: ['', [ - Validators.required, - Validators.maxLength(35), - Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/) - ]], - nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], - isRightToLeft: [false] - }); - - readonly modalTitle = computed(() => - this.modalMode() === 'create' ? 'Add Language' : 'Edit Language' - ); - readonly submitLabel = computed(() => - this.modalMode() === 'create' ? 'Save' : 'Update' - ); - readonly loadingLabel = computed(() => - this.modalMode() === 'create' ? 'Saving...' : 'Updating...' - ); - readonly submitAction = computed<'save' | 'update'>(() => - this.modalMode() === 'create' ? 'save' : 'update' - ); - readonly columns = signal[]>([ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, { key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' }, { key: 'code', label: 'Language Code', header: 'Language Code', sortable: true }, { key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true }, { - key: 'isRightToLeft', label: 'Direction', header: 'Direction', sortable: true, - formatter: value => value ? 'RTL' : 'LTR' + key: 'isRightToLeft', label: 'RTL', header: 'RTL', sortable: false, badge: true, + badgeClass: value => value === true ? 'badge bg-info/10 text-info' : 'badge bg-light text-defaulttextcolor', + formatter: value => value ? 'Yes' : 'No' }, { key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, - badgeClass: value => value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive, - disabled: row => this.statusChangingId() === row.id + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive, - disabled: row => this.statusChangingId() === row.id + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.queryRequests$.pipe( - switchMap(query => this.languageApi.getDataTable(query).pipe( - catchError(() => { - this.toastr.error('Unable to load languages.'); - this.languages.set([]); - this.totalRecords.set(0); - return of(null); - }) - )), - takeUntilDestroyed(this.destroyRef) - ).subscribe(response => { - if (!response || response.draw !== this.queryState.getQuery().draw) { - return; - } - const query = this.queryState.getQuery(); - this.languages.set(response.rows.map((language, index) => ({ - ...language, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - }))); - this.totalRecords.set(response.total); - }); - } - ngOnInit(): void { - this.loadLanguages(this.queryState.getQuery()); - } - - loadLanguages(query: DataTableQuery): void { this.queryRequests$.next(query); } - onSearch(value: string): void { this.loadLanguages(this.queryState.setSearch(value.trim())); } - onPageChange(event: DataTablePageEvent): void { this.loadLanguages(this.queryState.setPage(event)); } - onSortChange(event: DataTableSortEvent): void { this.loadLanguages(this.queryState.setSort(event)); } - - onDeleteConfirmed(): void { - const languageId = this.pendingDeleteLanguageId(); - - if (!languageId) { - return; - } - - this.pendingDeleteLanguageId.set(null); - this.changeLanguageStatus(languageId, false); - } - - onDeleteCancelled(): void { - this.pendingDeleteLanguageId.set(null); - } - - onActionClick(event: DataTableActionEvent): void { - if (event.action.type === 'edit') { - this.openEditLanguage(event.row.id); - } else if (event.action.type === 'delete') { - this.requestDeleteLanguage(event.row.id); - } else if (event.action.type === 'activate') { - this.changeLanguageStatus(event.row.id, true); - } - } - - private requestDeleteLanguage(id: string): void { - this.pendingDeleteLanguageId.set(id); - this.deleteConfirmDialog()?.open(); + this.tableStore.initialize({ + fetcher: query => this.languageApi.getDataTable(query) + }); } onAddLanguage(): void { - this.modalMode.set('create'); - this.selectedLanguageId.set(null); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false }); - this.resetFormState(); - this.showModal.set(true); + this.tableStore.openCreateModal(); } - openEditLanguage(id: string): void { - this.modalMode.set('edit'); - this.selectedLanguageId.set(id); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false }); - this.resetFormState(); - this.modalLoading.set(true); - this.showModal.set(true); - - this.languageApi.getById(id).pipe( - finalize(() => this.modalLoading.set(false)), - takeUntilDestroyed(this.destroyRef) - ).subscribe({ - next: language => { - if (this.selectedLanguageId() !== language.id || !this.showModal()) { - return; - } - this.selectedLanguage.set(language); - this.languageForm.reset({ - name: language.name, - code: language.code, - nativeName: language.nativeName, - isRightToLeft: language.isRightToLeft - }); - this.resetFormState(); - }, - error: () => this.showModal.set(false) - }); + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteLanguage(event.row); + if (event.action.type === 'activate') this.changeLanguageStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeLanguageStatus(event.row, false); } - closeModal(): void { - if (this.saving()) return; - this.showModal.set(false); - this.selectedLanguageId.set(null); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - } + onDeleteConfirmed(): void { + const lang = this.pendingDeleteLanguage(); + if (!lang) return; + this.pendingDeleteLanguage.set(null); + this.deletingId.set(lang.id); - saveLanguage(): void { - if (this.languageForm.invalid) { - this.submitAttempted.set(true); - this.languageForm.markAllAsTouched(); - this.focusFirstInvalidControl(); - return; - } - if (this.saving() || this.modalLoading()) return; - - this.saving.set(true); - const request = this.buildCreateRequest(); - const operation = this.modalMode() === 'create' - ? this.languageApi.create(request) - : this.languageApi.update( - this.selectedLanguageId() ?? '', - { ...request, isActive: this.selectedLanguage()?.isActive ?? true } - ); - - operation.pipe( - finalize(() => this.saving.set(false)), + this.languageApi.delete(lang.id).pipe( + finalize(() => this.deletingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ next: () => { - this.toastr.success( - this.modalMode() === 'create' - ? 'Language saved successfully.' - : 'Language updated successfully.' - ); - this.finishSave(); + this.toastr.success('Language deleted successfully.'); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => this.handleSaveError(error) + error: (err) => { + let errorMsg = 'Unable to delete language.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete language because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Language not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } }); } - private buildCreateRequest(): CreateLanguageRequest { - const value = this.languageForm.getRawValue(); - return { - code: this.normalizeCode(value.code), - name: value.name.trim(), - nativeName: value.nativeName.trim(), - isRightToLeft: value.isRightToLeft - }; + onDeleteCancelled(): void { + this.pendingDeleteLanguage.set(null); } - private normalizeCode(code: string): string { - return code.trim().split('-').map((part, index) => - index === 0 ? part.toLowerCase() : part.toUpperCase() - ).join('-'); + private requestDeleteLanguage(language: LanguageTableRow): void { + this.pendingDeleteLanguage.set(language); + this.deleteConfirmDialog()?.open(); } - private changeLanguageStatus(id: string, isActive: boolean): void { - if (this.statusChangingId()) return; - this.statusChangingId.set(id); - this.languageApi.getById(id).pipe( - switchMap(language => this.languageApi.update(id, { - code: language.code, - name: language.name, - nativeName: language.nativeName, - isRightToLeft: language.isRightToLeft, - isActive - })), + private changeLanguageStatus(language: LanguageTableRow, activate: boolean): void { + this.statusChangingId.set(language.id); + + this.languageApi.updateStatus(language.id, { isActive: activate }).pipe( finalize(() => this.statusChangingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ next: () => { - this.toastr.success(isActive - ? 'Language activated successfully.' - : 'Language deleted successfully.'); - this.loadLanguages(this.queryState.getQuery()); + this.toastr.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => { - if (error.status === 404) this.toastr.error('The language is no longer available.'); + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} language.`; + this.toastr.error(msg); } }); } - - private handleSaveError(error: HttpErrorResponse): void { - if (error.status === 409) { - this.toastr.error('A language with this code already exists.', 'Duplicate language code'); - } - } - - private finishSave(): void { - this.showModal.set(false); - this.selectedLanguageId.set(null); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - this.loadLanguages(this.queryState.getQuery()); - } - - private resetFormState(): void { - this.languageForm.markAsPristine(); - this.languageForm.markAsUntouched(); - this.languageForm.updateValueAndValidity(); - } - - private focusFirstInvalidControl(): void { - queueMicrotask(() => { - const control = this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - control?.focus(); - control?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - } } diff --git a/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.html b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.html new file mode 100644 index 00000000..bfe8d384 --- /dev/null +++ b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.html @@ -0,0 +1,72 @@ + + @if (modalLoading()) { +
+ + Loading state... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.ts b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.ts new file mode 100644 index 00000000..1d069ce8 --- /dev/null +++ b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.ts @@ -0,0 +1,199 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToastrService } from 'ngx-toastr'; +import { of } from 'rxjs'; +import { catchError, finalize, map, switchMap } from 'rxjs/operators'; + +import { CountryLookupDto, CountryService } from '../../../countries/public-api'; +import { + CreateStateRequest, + StateDto, + StateModalMode, + UpdateStateRequest +} from '../../models/state.model'; +import { StateService } from '../../data-access/state.service'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-state-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete], + templateUrl: './state-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class StateFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly stateApi = inject(StateService); + private readonly countryApi = inject(CountryService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + readonly stateId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedState = signal(null); + readonly selectedFormCountry = signal(null); + + readonly stateForm = this.formBuilder.nonNullable.group({ + countryId: ['', Validators.required], + name: ['', [Validators.required, Validators.maxLength(150)]], + code: ['', [Validators.required, Validators.maxLength(16), Validators.pattern(/^[A-Za-z0-9_-]+$/)]] + }); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add State'; + case 'edit': return 'Edit State'; + case 'view': return 'View State'; + } + }); + + readonly countrySearchFn: AutocompleteSearchFn = (term, page) => + this.countryApi.autocomplete(term, page).pipe(catchError(() => of([]))); + readonly countryValueFn: AutocompleteValueFn = country => country.id; + readonly countryDisplayFn: AutocompleteDisplayFn = country => country.name; + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.stateId()); + } + }); + } + + prepareModal(id: string | null): void { + this.submitAttempted.set(false); + this.stateForm.reset({ countryId: '', name: '', code: '' }); + this.selectedFormCountry.set(null); + + if (!id || this.mode() === 'create') { + this.selectedState.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.stateApi.getStateById(id).pipe( + switchMap(state => { + this.selectedState.set(state); + if (!state.countryId) return of({ state, country: null }); + return this.countryApi.getCountryById(state.countryId).pipe( + map(country => ({ state, country })), + catchError(() => of({ state, country: null })) + ); + }), + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: ({ state, country }) => { + if (country) { + this.selectedFormCountry.set({ id: country.id, name: country.name, iso2: country.iso2 }); + } + this.stateForm.patchValue({ + countryId: state.countryId, + name: state.name, + code: state.code ?? '' + }); + }, + error: () => { + this.toastr.error('Unable to load state details.'); + this.closeModal(); + } + }); + } + + saveState(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.submitAttempted.set(true); + if (this.stateForm.invalid || this.saving()) return; + + this.saving.set(true); + if (this.mode() === 'create') { + const request: CreateStateRequest = { + countryId: this.stateForm.controls.countryId.value, + name: this.stateForm.controls.name.value.trim(), + code: this.stateForm.controls.code.value.trim().toUpperCase() + }; + + this.stateApi.createState(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('State created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.stateId(); + if (!id) return; + + const request: UpdateStateRequest = { + countryId: this.stateForm.controls.countryId.value || null, + name: this.stateForm.controls.name.value.trim(), + code: this.stateForm.controls.code.value.trim().toUpperCase(), + isActive: this.selectedState()?.isActive ?? true + }; + + this.stateApi.updateState(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('State updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.toastr.error('A state with this code already exists in this country.'); + return; + } + this.toastr.error(`Unable to ${action} state. Please try again.`); + } +} diff --git a/src/app/features/global-masters/states/data-access/state.service.ts b/src/app/features/global-masters/states/data-access/state.service.ts index ae925b34..9bee3f5c 100644 --- a/src/app/features/global-masters/states/data-access/state.service.ts +++ b/src/app/features/global-masters/states/data-access/state.service.ts @@ -7,7 +7,8 @@ import { CreateStateRequest, StateDto, StateLookupDto, - UpdateStateRequest + UpdateStateRequest, + UpdateStateStatusRequest } from "../models/state.model"; @Injectable({ @@ -30,6 +31,14 @@ export class StateService { return this.http.put(STATE_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateStateStatusRequest): Observable { + return this.http.patch(STATE_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(STATE_ENDPOINTS.delete(id)); + } + getStateById(id: string): Observable { return this.http.get(STATE_ENDPOINTS.getById(id)); } diff --git a/src/app/features/global-masters/states/models/state.model.ts b/src/app/features/global-masters/states/models/state.model.ts index 384358d9..ec82b129 100644 --- a/src/app/features/global-masters/states/models/state.model.ts +++ b/src/app/features/global-masters/states/models/state.model.ts @@ -21,10 +21,15 @@ export interface CreateStateRequest { } export interface UpdateStateRequest { - countryId: null; + countryId: string | null; name: string; code: string; isActive: boolean; } -export type StateModalMode = 'create' | 'edit'; +export interface UpdateStateStatusRequest { + isActive: boolean; +} + +export type StateModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.html b/src/app/features/global-masters/states/pages/state-list/state-list.html index bd9ea064..4dbbdd43 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.html +++ b/src/app/features/global-masters/states/pages/state-list/state-list.html @@ -1,52 +1,61 @@ -
-
- -
-
- -
-
- -
-
-
-
-
+ +
+
+ +
+
+ +
+
+
- + - -
-
-
- -
- -
- -
- -
- -
-
-
-
+ diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.ts b/src/app/features/global-masters/states/pages/state-list/state-list.ts index 689f16f1..075a46aa 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.ts +++ b/src/app/features/global-masters/states/pages/state-list/state-list.ts @@ -1,35 +1,21 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - FormBuilder, - ReactiveFormsModule, - Validators -} from '@angular/forms'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, distinctUntilChanged, finalize, map, of, switchMap } from 'rxjs'; +import { of } from 'rxjs'; +import { catchError, finalize, map } from 'rxjs/operators'; -import { - CountryLookupDto -} from '../../../countries/public-api'; -import { - CreateStateRequest, - StateDto, - StateModalMode, - UpdateStateRequest -} from '../../models/state.model'; -import { CountryService } from '../../../countries/public-api'; +import { CountryLookupDto, CountryService } from '../../../countries/public-api'; +import { StateDto, UpdateStateRequest } from '../../models/state.model'; import { StateService } from '../../data-access/state.service'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { AutocompleteDisplayFn, @@ -37,11 +23,10 @@ import { AutocompleteSearchFn, AutocompleteValueFn } from '../../../../../shared/components/form/autocomplete/autocomplete.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; import { FilterCard } from '../../../../../shared/components/filter-card/filter-card'; import { Button as AppButton } from '../../../../../shared/components/button/button'; +import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal'; interface StateTableRow extends DataTableRecord { id: string; @@ -57,515 +42,165 @@ interface StateTableRow extends DataTableRecord { @Component({ selector: 'state-list', standalone: true, - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard, AppButton], + imports: [ + DataTable, + ReactiveFormsModule, + Autocomplete, + ConfirmDialog, + FilterCard, + AppButton, + StateFormModalComponent + ], + providers: [DataTableStore], templateUrl: './state-list.html', styleUrl: './state-list.scss', }) -export class StateList { - +export class StateList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly stateApi = inject(StateService); private readonly countryApi = inject(CountryService); private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly stateQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - - readonly states = signal([]); readonly selectedCountryLookup = signal(null); - readonly selectedFormCountry = signal(null); - readonly selectedCountryId = signal(null); - readonly appliedCountryId = signal(null); - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - readonly saving = signal(false); - - readonly showStateModal = signal(false); - readonly stateModalMode = signal('create'); - readonly selectedStateId = signal(null); - readonly selectedState = signal(null); - readonly stateSubmitAttempted = signal(false); - readonly pendingDeleteState = signal(null); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteState = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly countryFilterForm = this.formBuilder.nonNullable.group({ countryId: [''] }); - readonly searchCountries: AutocompleteSearchFn = - (term, limit) => this.countryApi.autocomplete(term, limit); + readonly searchCountries: AutocompleteSearchFn = (term, limit) => + this.countryApi.autocomplete(term, limit); readonly displayCountry: AutocompleteDisplayFn = country => country.name; readonly countryValue: AutocompleteValueFn = country => country.id; - readonly resolveCountry: AutocompleteResolveValueFn = - value => this.countryApi.getCountryById(value).pipe( + readonly resolveCountry: AutocompleteResolveValueFn = value => + this.countryApi.getCountryById(value).pipe( map(country => ({ id: country.id, iso2: country.iso2, name: country.name })) ); - readonly stateForm = this.formBuilder.nonNullable.group({ - countryId: [ - '', - [ - Validators.required - ] - ], - name: [ - '', - [ - Validators.required, - Validators.maxLength(150) - ] - ], - code: [ - '', - [ - Validators.required, - Validators.maxLength(16), - Validators.pattern(/^[A-Za-z0-9_-]+$/) - ] - ] - }); - - readonly emptyMessage = computed(() => - this.appliedCountryId() - ? 'No states found' - : 'No records found' - ); - - readonly emptyDescription = computed(() => - this.appliedCountryId() - ? 'There are no states available for the selected country.' - : 'There is currently no data to display.' - ); - - readonly stateModalTitle = computed(() => - this.stateModalMode() === 'create' - ? 'Add State' - : 'Edit State' - ); - - readonly stateSubmitLabel = computed(() => - this.stateModalMode() === 'create' - ? 'Save' - : 'Update' - ); - - readonly stateLoadingLabel = computed(() => - this.stateModalMode() === 'create' - ? 'Saving...' - : 'Updating...' - ); - - readonly stateSubmitAction = computed<'save' | 'update'>(() => - this.stateModalMode() === 'create' - ? 'save' - : 'update' - ); - readonly columns = signal[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' }, - { key: 'name', header: 'Name', label: 'Name', sortable: true, align: 'left' }, - { key: 'code', header: 'Code', label: 'Code', sortable: true }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'name', label: 'State Name', header: 'State Name', sortable: true, headerAlign: 'center', align: 'left' }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - badgeClass: value => - value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', - width: '100px', + key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary', + formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' + }, + { + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.countryFilterForm.controls.countryId.valueChanges - .pipe( - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(countryId => { - this.onCountrySelected(countryId || null); - }); - - this.stateQueryRequests$ - .pipe( - switchMap(query => { - const countryId = this.appliedCountryId(); - - return this.stateApi.getStateDataTable(query, countryId).pipe( - catchError(() => { - this.toastr.error('Unable to load states.'); - this.clearStateGrid(); - return of(null); - }) - ); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (!response) { - return; - } - - const query = this.queryState.getQuery(); - - if (response.draw !== query.draw) { - return; - } - - const statesWithSerialNumbers: StateTableRow[] = response.rows.map((state, index) => ({ - ...state, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.states.set(statesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); - } - ngOnInit(): void { - this.loadStates(this.queryState.getQuery()); + this.tableStore.initialize({ + fetcher: query => { + const countryId = this.countryFilterForm.controls.countryId.value || null; + return this.stateApi.getStateDataTable(query, countryId); + } + }); } - loadStates(query: DataTableQuery): void { - this.stateQueryRequests$.next(query); - } - - onCountryLookupSelected(country: CountryLookupDto): void { + onFilterCountrySelected(country: CountryLookupDto | null): void { this.selectedCountryLookup.set(country); + this.countryFilterForm.controls.countryId.setValue(country ? country.id : ''); } - 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() - })); + onApplyFilter(): void { + this.tableStore.refresh(); } - onFormCountrySelected(country: CountryLookupDto): void { - this.selectedFormCountry.set(country); + onResetFilter(): void { + this.countryFilterForm.reset({ countryId: '' }); + this.selectedCountryLookup.set(null); + this.tableStore.reset(); } - onFormCountryCleared(): void { - this.selectedFormCountry.set(null); + onAddState(): void { + this.tableStore.openCreateModal(); } - onSearch(value: string): void { - const query = this.queryState.setSearch(value.trim()); - this.loadStates(query); - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.queryState.setPage(event); - this.loadStates(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.queryState.setSort(event); - this.loadStates(query); - } - - onRefresh(): void { - const currentQuery = this.queryState.getQuery(); - - const query: DataTableQuery = { - ...currentQuery, - draw: currentQuery.draw + 1 - }; - - this.loadStates(query); - } - - onReset(): void { - const query = this.queryState.reset(); - this.loadStates(query); + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteState(event.row); + if (event.action.type === 'activate') this.changeStateStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeStateStatus(event.row, false); } onDeleteConfirmed(): void { const state = this.pendingDeleteState(); - - if (!state) { - return; - } - + if (!state) return; this.pendingDeleteState.set(null); - this.deleteState(state); + this.deletingId.set(state.id); + + this.stateApi.delete(state.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success('State deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete state.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete state because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'State not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } + }); } onDeleteCancelled(): void { this.pendingDeleteState.set(null); } - onActionClick(event: DataTableActionEvent): void { - const action = event.action.type; - const state = this.toStateDto(event.row); - - switch (action) { - case 'view': - this.viewState(state); - break; - case 'edit': - this.openEditState(state); - break; - case 'delete': - this.requestDeleteState(state); - break; - case 'activate': - this.activateState(state); - break; - } - } - - onAddState(): void { - this.stateModalMode.set('create'); - this.selectedStateId.set(null); - this.selectedState.set(null); - this.stateSubmitAttempted.set(false); - - this.selectedFormCountry.set(null); - this.stateForm.reset({ - countryId: '', - name: '', - code: '' - }); - this.resetStateFormState(); - - this.showStateModal.set(true); - } - - closeStateModal(): void { - if (this.saving()) { - return; - } - - this.showStateModal.set(false); - this.selectedStateId.set(null); - this.selectedState.set(null); - this.selectedFormCountry.set(null); - this.stateSubmitAttempted.set(false); - } - - saveState(): void { - if (this.stateForm.invalid) { - this.stateSubmitAttempted.set(true); - this.stateForm.markAllAsTouched(); - this.focusFirstInvalidStateControl(); - return; - } - - if (this.saving()) { - return; - } - - this.saving.set(true); - - if (this.stateModalMode() === 'create') { - this.stateApi - .createState(this.buildCreateStateRequest()) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('State saved successfully.'); - this.finishStateSave(); - } - }); - - return; - } - - const stateId = this.selectedStateId(); - - if (!stateId) { - this.saving.set(false); - return; - } - - this.stateApi - .updateState( - stateId, - this.buildUpdateStateRequest(this.selectedState()?.isActive ?? true) - ) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('State updated successfully.'); - this.finishStateSave(); - } - }); - } - - private onCountrySelected(countryId: string | null): void { - this.selectedCountryId.set(countryId); - - if (!countryId || this.selectedCountryLookup()?.id !== countryId) { - this.selectedCountryLookup.set(null); - } - - } - - private clearStateGrid(): void { - this.states.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - - private viewState(state: StateDto): void { - this.openEditState(state); - } - - private openEditState(state: StateDto): void { - this.stateModalMode.set('edit'); - this.selectedStateId.set(state.id); - this.selectedState.set(state); - this.stateSubmitAttempted.set(false); - - this.stateApi - .getStateById(state.id) - .subscribe({ - next: stateDetails => { - this.selectedState.set(stateDetails); - this.selectedFormCountry.set(null); - this.stateForm.reset({ - countryId: stateDetails.countryId ?? '', - name: stateDetails.name ?? '', - code: stateDetails.code ?? '' - }); - this.resetStateFormState(); - - this.showStateModal.set(true); - } - }); - } - - private requestDeleteState(state: StateDto): void { + private requestDeleteState(state: StateTableRow): void { this.pendingDeleteState.set(state); this.deleteConfirmDialog()?.open(); } - private deleteState(state: StateDto): void { - this.updateStateStatus(state, false); - } + private changeStateStatus(state: StateTableRow, activate: boolean): void { + this.statusChangingId.set(state.id); - private activateState(state: StateDto): void { - this.updateStateStatus(state, true); - } - - private buildCreateStateRequest(): CreateStateRequest { - const value = this.stateForm.getRawValue(); - - return { - countryId: value.countryId, - name: value.name.trim(), - code: value.code.trim().toUpperCase() - }; - } - - private buildUpdateStateRequest(isActive: boolean): UpdateStateRequest { - const value = this.stateForm.getRawValue(); - - return { - countryId: null, - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - isActive - }; - } - - private stateToUpdateRequest(state: StateDto, isActive: boolean): UpdateStateRequest { - return { - countryId: null, - name: state.name?.trim() ?? '', - code: state.code?.trim().toUpperCase() ?? '', - isActive - }; - } - - private updateStateStatus(state: StateDto, isActive: boolean): void { - this.stateApi - .updateState(state.id, this.stateToUpdateRequest(state, isActive)) - .subscribe({ - next: () => { - this.toastr.success( - isActive - ? 'State activated successfully.' - : 'State deactivated successfully.' - ); - this.loadStates(this.queryState.getQuery()); - } - }); - } - - private resetStateFormState(): void { - this.stateForm.markAsPristine(); - this.stateForm.markAsUntouched(); - this.stateForm.updateValueAndValidity(); - } - - private finishStateSave(): void { - this.showStateModal.set(false); - this.selectedStateId.set(null); - this.selectedState.set(null); - this.selectedFormCountry.set(null); - this.stateSubmitAttempted.set(false); - this.loadStates(this.queryState.getQuery()); - } - - private focusFirstInvalidStateControl(): void { - queueMicrotask(() => { - const firstInvalidControl = - this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - - firstInvalidControl?.focus(); - firstInvalidControl?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); + this.stateApi.updateStatus(state.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} state.`; + this.toastr.error(msg); + } }); } - - private toStateDto(row: StateTableRow): StateDto { - return { - id: row.id, - countryId: row.countryId, - name: row.name, - code: row.code, - isActive: row.isActive, - createdOn: row.createdOn, - modifiedOn: row.modifiedOn - }; - } } diff --git a/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.html b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.html new file mode 100644 index 00000000..20d7762f --- /dev/null +++ b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.html @@ -0,0 +1,100 @@ + + @if (modalLoading()) { +
+ + Loading timezone... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+ @if (isViewMode() && selectedTimezone(); as timezone) { +
+ Formatted UTC Offset + {{ formatUtcOffset(timezone.utcOffsetMinutes) }} +
+
+ Status + + {{ timezone.isActive ? 'Active' : 'Inactive' }} + +
+ } +
+
+ } +
diff --git a/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.ts b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.ts new file mode 100644 index 00000000..8f0b5581 --- /dev/null +++ b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.ts @@ -0,0 +1,191 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToastrService } from 'ngx-toastr'; +import { finalize } from 'rxjs/operators'; + +import { + CreateTimezoneRequest, + TimezoneDto, + TimezoneModalMode, + UpdateTimezoneRequest +} from '../../models/timezone.model'; +import { TimezoneService } from '../../data-access/timezone.service'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-timezone-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput], + templateUrl: './timezone-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TimezoneFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly timezoneApi = inject(TimezoneService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + readonly timezoneId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedTimezone = signal(null); + + readonly timezoneForm = this.formBuilder.nonNullable.group({ + ianaId: ['', [ + Validators.required, + Validators.maxLength(64), + Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/) + ]], + displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]], + utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]] + }); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add Timezone'; + case 'edit': return 'Edit Timezone'; + case 'view': return 'View Timezone'; + } + }); + readonly submitLabel = computed(() => this.mode() === 'create' ? 'Save' : 'Update'); + readonly loadingLabel = computed(() => this.mode() === 'create' ? 'Saving...' : 'Updating...'); + readonly submitAction = computed<'save' | 'update'>(() => this.mode() === 'create' ? 'save' : 'update'); + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.timezoneId()); + } + }); + } + + prepareModal(id: string | null): void { + this.submitAttempted.set(false); + this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 }); + + if (!id || this.mode() === 'create') { + this.selectedTimezone.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.timezoneApi.getById(id).pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: timezone => { + this.selectedTimezone.set(timezone); + this.timezoneForm.patchValue({ + ianaId: timezone.ianaId, + displayName: timezone.displayName, + utcOffsetMinutes: timezone.utcOffsetMinutes + }); + }, + error: () => { + this.toastr.error('Unable to load timezone details.'); + this.closeModal(); + } + }); + } + + saveTimezone(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.submitAttempted.set(true); + if (this.timezoneForm.invalid || this.saving()) return; + + this.saving.set(true); + if (this.mode() === 'create') { + const request: CreateTimezoneRequest = { + ianaId: this.timezoneForm.controls.ianaId.value.trim(), + displayName: this.timezoneForm.controls.displayName.value.trim(), + utcOffsetMinutes: this.timezoneForm.controls.utcOffsetMinutes.value + }; + + this.timezoneApi.create(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Timezone created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: (error: HttpErrorResponse) => this.handleSaveError(error, 'create') + }); + } else { + const id = this.timezoneId(); + if (!id) return; + + const request: UpdateTimezoneRequest = { + ianaId: this.timezoneForm.controls.ianaId.value.trim(), + displayName: this.timezoneForm.controls.displayName.value.trim(), + utcOffsetMinutes: this.timezoneForm.controls.utcOffsetMinutes.value, + isActive: this.selectedTimezone()?.isActive ?? true + }; + + this.timezoneApi.update(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Timezone updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: (error: HttpErrorResponse) => this.handleSaveError(error, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + formatUtcOffset(totalMinutes: number): string { + const isNegative = totalMinutes < 0; + const absMinutes = Math.abs(totalMinutes); + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + const prefix = isNegative ? '-' : '+'; + return `UTC${prefix}${formattedHours}:${formattedMinutes}`; + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.toastr.error('A timezone with this IANA ID already exists.'); + return; + } + this.toastr.error(`Unable to ${action} timezone. Please try again.`); + } +} diff --git a/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts b/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts index 6a5d5516..6374a957 100644 --- a/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts +++ b/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts @@ -7,5 +7,9 @@ export const TIMEZONE_ENDPOINTS = { buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), update: (id: string) => buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}/status`), autocomplete: buildApiUrl('masterAdmin', '/v1/timezones/autocomplete') } as const; diff --git a/src/app/features/global-masters/timezones/data-access/timezone.service.ts b/src/app/features/global-masters/timezones/data-access/timezone.service.ts index 559982a6..73063b4f 100644 --- a/src/app/features/global-masters/timezones/data-access/timezone.service.ts +++ b/src/app/features/global-masters/timezones/data-access/timezone.service.ts @@ -7,7 +7,8 @@ import { CreateTimezoneRequest, TimezoneDto, TimezoneLookupDto, - UpdateTimezoneRequest + UpdateTimezoneRequest, + UpdateTimezoneStatusRequest } from '../models/timezone.model'; import { DataTableQuery, @@ -34,6 +35,14 @@ export class TimezoneService { return this.http.put(TIMEZONE_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateTimezoneStatusRequest): Observable { + return this.http.patch(TIMEZONE_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(TIMEZONE_ENDPOINTS.delete(id)); + } + autocomplete(term: string | null, limit = 10): Observable { const normalizedTerm = term?.trim() || null; let params = new HttpParams().set('limit', limit); diff --git a/src/app/features/global-masters/timezones/models/timezone.model.ts b/src/app/features/global-masters/timezones/models/timezone.model.ts index 31a75ee6..66447724 100644 --- a/src/app/features/global-masters/timezones/models/timezone.model.ts +++ b/src/app/features/global-masters/timezones/models/timezone.model.ts @@ -24,4 +24,9 @@ export interface UpdateTimezoneRequest extends CreateTimezoneRequest { readonly isActive: boolean; } +export interface UpdateTimezoneStatusRequest { + readonly isActive: boolean; +} + export type TimezoneModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html index bd22a860..8a085fe2 100644 --- a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html +++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html @@ -1,10 +1,10 @@ @@ -35,103 +35,10 @@ (cancelled)="onDeleteCancelled()" /> - - @if (modalLoading()) { -
- - Loading timezone... -
- } @else { -
-
-
- -
-
- -
-
- -
- @if (isViewMode() && selectedTimezone(); as timezone) { -
- Formatted UTC Offset - {{ formatUtcOffset(timezone.utcOffsetMinutes) }} -
-
- Status - - {{ timezone.isActive ? 'Active' : 'Inactive' }} - -
- } -
-
- } -
+ diff --git a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts index a1ffe667..5821f660 100644 --- a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts +++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts @@ -1,32 +1,28 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, DestroyRef, ElementRef, OnInit, computed, inject, signal, viewChild } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; - import { - CreateTimezoneRequest, - TimezoneDto, - TimezoneModalMode, - UpdateTimezoneRequest -} from '../../models/timezone.model'; + Component, + DestroyRef, + OnInit, + inject, + signal, + viewChild +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ToastrService } from 'ngx-toastr'; +import { finalize } from 'rxjs/operators'; + +import { TimezoneDto, UpdateTimezoneRequest } from '../../models/timezone.model'; import { TimezoneService } from '../../data-access/timezone.service'; import { DataTable } from '../../../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { TimezoneFormModalComponent } from '../../components/timezone-form-modal/timezone-form-modal'; interface TimezoneTableRow extends DataTableRecord { readonly id: string; @@ -42,54 +38,27 @@ interface TimezoneTableRow extends DataTableRecord { @Component({ selector: 'timezone-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog], + imports: [ + DataTable, + DataTableCellDirective, + ConfirmDialog, + TimezoneFormModalComponent + ], + providers: [DataTableStore], templateUrl: './timezone-list.html', styleUrl: './timezone-list.scss' }) export class TimezoneList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly timezoneApi = inject(TimezoneService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly queryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - readonly timezones = signal([]); - readonly totalRecords = signal(0); - readonly modalLoading = signal(false); - readonly saving = signal(false); readonly statusChangingId = signal(null); - readonly showModal = signal(false); - readonly modalMode = signal('create'); - readonly selectedTimezoneId = signal(null); - readonly selectedTimezone = signal(null); - readonly submitAttempted = signal(false); - readonly pendingDeleteTimezoneId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteTimezone = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly timezoneForm = this.formBuilder.nonNullable.group({ - ianaId: ['', [ - Validators.required, - Validators.maxLength(64), - Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/) - ]], - displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]], - utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]] - }); - - readonly isViewMode = computed(() => this.modalMode() === 'view'); - readonly modalTitle = computed(() => { - switch (this.modalMode()) { - case 'create': return 'Add Timezone'; - case 'edit': return 'Edit Timezone'; - case 'view': return 'View Timezone'; - } - }); - readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save' : 'Update'); - readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...'); - readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update'); - readonly columns = signal[]>([ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'ianaId', label: 'IANA ID', header: 'IANA ID', sortable: true, headerAlign: 'center', align: 'left' }, @@ -115,234 +84,111 @@ export class TimezoneList implements OnInit { className: 'text-primary' }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', + type: 'deactivate', + label: 'Deactivate', + icon: 'ti ti-toggle-right', + className: 'text-warning', visible: row => row.isActive, - disabled: row => this.statusChangingId() === row.id + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { type: 'activate', label: 'Activate', - icon: 'ti ti-check', + icon: 'ti ti-toggle-left', className: 'text-success', visible: row => !row.isActive, - disabled: row => this.statusChangingId() === row.id + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.queryRequests$.pipe( - switchMap(query => this.timezoneApi.getDataTable(query).pipe( - catchError(() => { - this.timezones.set([]); - this.totalRecords.set(0); - return of(null); - }) - )), - takeUntilDestroyed(this.destroyRef) - ).subscribe(response => { - if (!response || response.draw !== this.queryState.getQuery().draw) return; - const query = this.queryState.getQuery(); - this.timezones.set(response.rows.map((timezone, index) => ({ - ...timezone, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - }))); - this.totalRecords.set(response.total); + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => this.timezoneApi.getDataTable(query) }); } - ngOnInit(): void { this.loadTimezones(this.queryState.getQuery()); } - loadTimezones(query: DataTableQuery): void { this.queryRequests$.next(query); } - onSearch(value: string): void { this.loadTimezones(this.queryState.setSearch(value.trim())); } - onPageChange(event: DataTablePageEvent): void { this.loadTimezones(this.queryState.setPage(event)); } - onSortChange(event: DataTableSortEvent): void { this.loadTimezones(this.queryState.setSort(event)); } - - onDeleteConfirmed(): void { - const timezoneId = this.pendingDeleteTimezoneId(); - - if (!timezoneId) { - return; - } - - this.pendingDeleteTimezoneId.set(null); - this.changeTimezoneStatus(timezoneId, false); - } - - onDeleteCancelled(): void { - this.pendingDeleteTimezoneId.set(null); + onAddTimezone(): void { + this.tableStore.openCreateModal(); } onActionClick(event: DataTableActionEvent): void { - if (event.action.type === 'view') this.openTimezone(event.row.id, 'view'); - if (event.action.type === 'edit') this.openTimezone(event.row.id, 'edit'); - if (event.action.type === 'delete') this.requestDeleteTimezone(event.row.id); - if (event.action.type === 'activate') this.changeTimezoneStatus(event.row.id, true); + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteTimezone(event.row); + if (event.action.type === 'activate') this.changeTimezoneStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeTimezoneStatus(event.row, false); } - private requestDeleteTimezone(id: string): void { - this.pendingDeleteTimezoneId.set(id); - this.deleteConfirmDialog()?.open(); - } + onDeleteConfirmed(): void { + const timezone = this.pendingDeleteTimezone(); + if (!timezone) return; + this.pendingDeleteTimezone.set(null); + this.deletingId.set(timezone.id); - onAddTimezone(): void { - this.modalMode.set('create'); - this.prepareModal(null); - this.showModal.set(true); - } - - openTimezone(id: string, mode: 'edit' | 'view'): void { - this.modalMode.set(mode); - this.prepareModal(id); - this.modalLoading.set(true); - this.showModal.set(true); - this.timezoneApi.getById(id).pipe( - finalize(() => this.modalLoading.set(false)), + this.timezoneApi.delete(timezone.id).pipe( + finalize(() => this.deletingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ - next: timezone => { - if (this.selectedTimezoneId() !== timezone.id || !this.showModal()) return; - this.selectedTimezone.set(timezone); - this.timezoneForm.reset({ - ianaId: timezone.ianaId, - displayName: timezone.displayName, - utcOffsetMinutes: timezone.utcOffsetMinutes - }); - if (mode === 'view') this.timezoneForm.disable(); - this.resetFormState(); + next: () => { + this.toastr.success('Timezone deleted successfully.'); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => { - this.showModal.set(false); - if (error.status === 404) this.toastr.error('The timezone is no longer available.'); + error: (err) => { + let errorMsg = 'Unable to delete timezone.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete timezone because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Timezone not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); } }); } - closeModal(): void { - if (this.saving()) return; - this.showModal.set(false); - this.selectedTimezoneId.set(null); - this.selectedTimezone.set(null); - this.submitAttempted.set(false); - this.timezoneForm.enable(); + onDeleteCancelled(): void { + this.pendingDeleteTimezone.set(null); } - saveTimezone(): void { - if (this.isViewMode() || this.saving() || this.modalLoading()) return; - if (this.timezoneForm.invalid) { - this.submitAttempted.set(true); - this.timezoneForm.markAllAsTouched(); - this.focusFirstInvalidControl(); - return; - } - const selected = this.selectedTimezone(); - const id = this.selectedTimezoneId(); - if (this.modalMode() === 'edit' && (!selected || !id)) return; - - this.saving.set(true); - const createRequest = this.buildCreateRequest(); - const operation = this.modalMode() === 'create' - ? this.timezoneApi.create(createRequest) - : this.timezoneApi.update(id!, { ...createRequest, isActive: selected!.isActive }); - operation.pipe( - finalize(() => this.saving.set(false)), - takeUntilDestroyed(this.destroyRef) - ).subscribe({ - next: () => { - this.toastr.success(this.modalMode() === 'create' - ? 'Timezone saved successfully.' - : 'Timezone updated successfully.'); - this.finishSave(); - }, - error: (error: HttpErrorResponse) => this.handleSaveError(error) - }); + private requestDeleteTimezone(timezone: TimezoneTableRow): void { + this.pendingDeleteTimezone.set(timezone); + this.deleteConfirmDialog()?.open(); } - formatUtcOffset(minutes: number): string { - const sign = minutes >= 0 ? '+' : '-'; - const absolute = Math.abs(minutes); - return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`; - } + private changeTimezoneStatus(timezone: TimezoneTableRow, activate: boolean): void { + this.statusChangingId.set(timezone.id); - private prepareModal(id: string | null): void { - this.selectedTimezoneId.set(id); - this.selectedTimezone.set(null); - this.submitAttempted.set(false); - this.timezoneForm.enable(); - this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 }); - this.resetFormState(); - } - - private buildCreateRequest(): CreateTimezoneRequest { - const value = this.timezoneForm.getRawValue(); - return { - ianaId: value.ianaId.trim(), - displayName: value.displayName.trim(), - utcOffsetMinutes: value.utcOffsetMinutes - }; - } - - private changeTimezoneStatus(id: string, isActive: boolean): void { - if (this.statusChangingId()) return; - this.statusChangingId.set(id); - this.timezoneApi.getById(id).pipe( - switchMap(timezone => this.timezoneApi.update(id, { - ianaId: timezone.ianaId, - displayName: timezone.displayName, - utcOffsetMinutes: timezone.utcOffsetMinutes, - isActive - })), + this.timezoneApi.updateStatus(timezone.id, { isActive: activate }).pipe( finalize(() => this.statusChangingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ next: () => { - this.toastr.success(isActive - ? 'Timezone activated successfully.' - : 'Timezone deleted successfully.'); - this.loadTimezones(this.queryState.getQuery()); + this.toastr.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => { - if (error.status === 404) this.toastr.error('The timezone is no longer available.'); + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} timezone.`; + this.toastr.error(msg); } }); } - private handleSaveError(error: HttpErrorResponse): void { - if (error.status === 409) { - const message = this.apiErrorMessage(error) ?? 'A timezone with this IANA ID already exists.'; - this.toastr.error(message, 'Duplicate IANA timezone ID'); - } else if (error.status === 404) { - this.toastr.error('The timezone is no longer available.'); - this.closeModal(); - } - } - - private apiErrorMessage(error: HttpErrorResponse): string | null { - const body: unknown = error.error; - if (!body || typeof body !== 'object') return null; - if ('detail' in body && typeof body.detail === 'string') return body.detail; - if ('message' in body && typeof body.message === 'string') return body.message; - return null; - } - - private finishSave(): void { - this.showModal.set(false); - this.selectedTimezoneId.set(null); - this.selectedTimezone.set(null); - this.submitAttempted.set(false); - this.loadTimezones(this.queryState.getQuery()); - } - - private resetFormState(): void { - this.timezoneForm.markAsPristine(); - this.timezoneForm.markAsUntouched(); - this.timezoneForm.updateValueAndValidity(); - } - - private focusFirstInvalidControl(): void { - queueMicrotask(() => this.elementRef.nativeElement - .querySelector('modal [data-form-control][aria-invalid="true"]') - ?.focus()); + formatUtcOffset(totalMinutes: number): string { + const isNegative = totalMinutes < 0; + const absMinutes = Math.abs(totalMinutes); + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + const prefix = isNegative ? '-' : '+'; + return `UTC${prefix}${formattedHours}:${formattedMinutes}`; } } diff --git a/src/app/features/industries/data-access/industery.service.ts b/src/app/features/industries/data-access/industery.service.ts new file mode 100644 index 00000000..2cf78517 --- /dev/null +++ b/src/app/features/industries/data-access/industery.service.ts @@ -0,0 +1,35 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { OnboardingLookupValue } from '../../organizations/organization-onboarding/constants/organization-onboarding.constants'; +import { IndustryLookupDto } from '../models/industry.model'; +import { INDUSTRY_ENDPOINTS } from './industry.endpoints'; +import { DataTableQuery } from '../../../shared/components/data-table/data-table-query.types'; +import { DataTableResult } from '../../../shared/components/data-table/data-table.types'; + + +@Injectable({ + providedIn: 'root' +}) +export class IndustryApiService { + private readonly http = inject(HttpClient); + + getIndustryById(id: string): Observable { + return this.http.get(INDUSTRY_ENDPOINTS.getById(id)); + } + + delete(id: string): Observable { + return this.http.delete(INDUSTRY_ENDPOINTS.delete(id)); + } + + autocomplete(term = '', limit = 50): Observable { + let params = new HttpParams().set('limit', limit); + const normalizedTerm = term?.trim(); + + if (normalizedTerm) { + params = params.set('term', normalizedTerm); + } + + return this.http.get(INDUSTRY_ENDPOINTS.autocomplete, { params }); + } +} \ No newline at end of file diff --git a/src/app/features/industries/data-access/industry.endpoints.ts b/src/app/features/industries/data-access/industry.endpoints.ts new file mode 100644 index 00000000..b7bfaced --- /dev/null +++ b/src/app/features/industries/data-access/industry.endpoints.ts @@ -0,0 +1,21 @@ +import { buildApiUrl } from '../../../core/config/api-url.util'; + +export const INDUSTRY_ENDPOINTS = { + dataTable: buildApiUrl('masterAdmin', '/v1/industries/datatable'), + + create: buildApiUrl('masterAdmin', '/v1/industries'), + + getById: (id: string) => + buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}`), + + autocomplete: buildApiUrl('masterAdmin', '/v1/industries/autocomplete'), + + update: (id: string) => + buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}`), + + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}`), + + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}/status`), +} as const; diff --git a/src/app/features/industries/models/industry.model.ts b/src/app/features/industries/models/industry.model.ts new file mode 100644 index 00000000..f52c68f6 --- /dev/null +++ b/src/app/features/industries/models/industry.model.ts @@ -0,0 +1,7 @@ +export interface IndustryLookupDto { + id: string; + tenantId?: string | null; + industryCode?: string; + industryName: string; + parentIndustryId?: string | null; +} \ No newline at end of file diff --git a/src/app/features/industries/public-api.ts b/src/app/features/industries/public-api.ts new file mode 100644 index 00000000..d8a92bbc --- /dev/null +++ b/src/app/features/industries/public-api.ts @@ -0,0 +1,2 @@ +export { IndustryApiService } from './data-access/industery.service'; +export type { IndustryLookupDto } from './models/industry.model'; diff --git a/src/app/features/organizations/dashboard/dashboard.html b/src/app/features/organizations/dashboard/dashboard.html index 90954ed7..692de853 100644 --- a/src/app/features/organizations/dashboard/dashboard.html +++ b/src/app/features/organizations/dashboard/dashboard.html @@ -50,13 +50,28 @@
- +
diff --git a/src/app/features/organizations/dashboard/dashboard.ts b/src/app/features/organizations/dashboard/dashboard.ts index abddc57b..2e40a8a0 100644 --- a/src/app/features/organizations/dashboard/dashboard.ts +++ b/src/app/features/organizations/dashboard/dashboard.ts @@ -1,8 +1,17 @@ -import { Component, signal, inject } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { of } from 'rxjs'; import { DataTable } from '../../../shared/components/data-table/data-table'; -import { DataTableColumn, DataTableRecord } from '../../../shared/components/data-table/data-table.types'; +import { DataTableStore } from '../../../shared/components/data-table/data-table.store'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTableRecord, + DataTableResult, +} from '../../../shared/components/data-table/data-table.types'; type OrganizationPlan = 'Enterprise' | 'Standard' | 'Trial'; type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; @@ -16,26 +25,30 @@ interface DashboardStatCard { iconBackgroundClass: string; helperClass: string; } - -interface RecentOrganizationRow extends DataTableRecord { + +interface OrganizationListRow extends DataTableRecord { id: string; code: string; organizationName: string; + countryId: string; country: string; - plan: OrganizationPlan; + plan: string; status: OrganizationStatus; - created: string; + expiry: string; } @Component({ selector: 'dashboard', standalone: true, imports: [DataTable], + providers: [DataTableStore], templateUrl: './dashboard.html', styleUrl: './dashboard.scss', }) -export class Dashboard { - private readonly router = inject(Router); +export class Dashboard implements OnInit { + private readonly router = inject(Router); + private readonly toastr = inject(ToastrService); + readonly tableStore = inject(DataTableStore); readonly statCards = signal([ { @@ -79,7 +92,7 @@ export class Dashboard { value: '1', helper: 'Needs renewal action', accentClass: 'border-primary', - iconClass: 'ti ti-license', + iconClass: 'ri ri-pass-expired-line', iconBackgroundClass: 'bg-primary', helperClass: 'bg-primary/10 text-primary', }, @@ -88,98 +101,107 @@ export class Dashboard { value: '482', helper: 'Users with access', accentClass: 'border-primary', - iconClass: 'ri-profile-line', + iconClass: 'ri ri-group-line', iconBackgroundClass: 'bg-primary', helperClass: 'bg-primary/10 text-primary', }, ]); - readonly recentOrganizations = signal([ + readonly recentOrganizations = signal([ { id: '1', code: 'ORG-0024', organizationName: 'Syscom Group', + countryId: 'c1', country: 'Saudi Arabia', plan: 'Enterprise', status: 'Active', - created: '09-07-2026', + expiry: '31-12-2026' }, { id: '2', code: 'ORG-0023', organizationName: 'Acme Trading', + countryId: 'c2', country: 'India', plan: 'Standard', status: 'Active', - created: '01-07-2026', + expiry: '31-03-2027' }, { id: '3', code: 'ORG-0022', organizationName: 'Falcon Retail LLC', + countryId: 'c3', country: 'UAE', plan: 'Trial', status: 'Trial', - created: '28-06-2026', + expiry: '28-07-2026' }, { id: '4', - code: 'ORG-0025', - organizationName: 'Syscom Group', + code: 'ORG-0021', + organizationName: 'Oasis Foods', + countryId: 'c1', country: 'Saudi Arabia', - plan: 'Enterprise', - status: 'Active', - created: '09-07-2026', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' }, { id: '5', - code: 'ORG-0026', - organizationName: 'Acme Trading', - country: 'India', + code: 'ORG-0022', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', plan: 'Standard', - status: 'Active', - created: '01-07-2026', + status: 'Suspended', + expiry: '15-06-2026' }, { id: '6', - code: 'ORG-0027', - organizationName: 'Falcon Retail LLC', - country: 'UAE', - plan: 'Trial', - status: 'Trial', - created: '28-06-2026', + code: 'ORG-0023', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' }, { id: '7', - code: 'ORG-0028', - organizationName: 'Syscom Group', + code: 'ORG-0024', + organizationName: 'Oasis Foods', + countryId: 'c1', country: 'Saudi Arabia', - plan: 'Enterprise', - status: 'Active', - created: '09-07-2026', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' }, { id: '8', - code: 'ORG-0029', - organizationName: 'Acme Trading', - country: 'India', + code: 'ORG-0025', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', plan: 'Standard', - status: 'Active', - created: '01-07-2026', + status: 'Suspended', + expiry: '15-06-2026' }, { id: '9', - code: 'ORG-0030', - organizationName: 'Falcon Retail LLC', - country: 'UAE', - plan: 'Trial', - status: 'Trial', - created: '28-06-2026', + code: 'ORG-0026', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' }, ]); - readonly columns = signal[]>([ - { key: 'code', label: 'Code', header: 'Code', sortable: false, align: 'left' }, + readonly columns = signal[]>([ + { key: 'code', label: 'Code', header: 'Code', sortable: true }, { key: 'organizationName', label: 'Name', @@ -187,7 +209,7 @@ export class Dashboard { sortable: true, align: 'left', }, - { key: 'country', label: 'Country', header: 'Country', sortable: false }, + { key: 'country', label: 'Country', header: 'Country', sortable: true }, { key: 'plan', label: 'Plan', @@ -212,52 +234,75 @@ export class Dashboard { ? 'badge bg-warning/10 text-warning' : 'badge bg-danger/10 text-danger', }, - { key: 'created', label: 'Created', header: 'Created', sortable: false }, + { key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true }, ]); - readonly totalRecords = signal(6); - readonly queryState = { - pageIndex: signal(0) - }; readonly emptyMessage = signal('No Organizations'); readonly emptyDescription = signal('Start by adding your first organization'); - readonly actions = signal([ - { id: 'edit', label: 'Edit', icon: 'ti ti-edit' }, - { id: 'delete', label: 'Delete', icon: 'ti ti-trash' }, + readonly actions = signal[]>([ + { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, + { + type: 'suspend', + label: 'Suspend', + icon: 'ti ti-player-pause', + className: 'text-warning', + visible: row => row.status !== 'Suspended', + }, ]); + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => { + const search = (query.search || '').trim().toLowerCase(); + const sortBy = query.sortBy; + const sortDir = query.sortDir; + + let rows = this.recentOrganizations().filter(row => { + const matchesSearch = !search + || row.code.toLowerCase().includes(search) + || row.organizationName.toLowerCase().includes(search) + || row.country.toLowerCase().includes(search) + || row.plan.toLowerCase().includes(search) + || row.status.toLowerCase().includes(search) + || (row.expiry && row.expiry.toLowerCase().includes(search)); + return matchesSearch; + }); + + if (sortBy) { + rows = [...rows].sort((left, right) => { + const leftVal = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const rightVal = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const compared = leftVal.localeCompare(rightVal); + return sortDir === 'asc' ? compared : -compared; + }); + } + + const total = rows.length; + const start = (query.page - 1) * query.pageSize; + const end = start + query.pageSize; + const result: DataTableResult = { + draw: query.draw, + total, + filtered: total, + rows: rows.slice(start, end) + }; + return of(result); + } + }); + } + onAddOrganization(): void { void this.router.navigate(['/organizations/onboarding']); } - onSearch(searchTerm: string) { - console.log('Search:', searchTerm); - } - - onPageChange(event: any) { - console.log('Page changed:', event); - } - - onSortChange(event: any) { - console.log('Sort changed:', event); - } - - onActionClick(event: any) { - const { actionId, record } = event; - - switch (actionId) { - case 'edit': - console.log('Edit organization:', record); - // Implement edit logic here - break; - case 'delete': - console.log('Delete organization:', record); - // Implement delete logic here - break; - default: - console.log('Unknown action:', actionId); + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') { + void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } }); + return; } + this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`); } - } + diff --git a/src/app/features/organizations/organization-list/organization-list.html b/src/app/features/organizations/organization-list/organization-list.html index 4e56b450..e32a2627 100644 --- a/src/app/features/organizations/organization-list/organization-list.html +++ b/src/app/features/organizations/organization-list/organization-list.html @@ -1,44 +1,63 @@
-
-
- -
-
- -
-
+
+
+ +
+
+ +
+
- - + diff --git a/src/app/features/organizations/organization-list/organization-list.ts b/src/app/features/organizations/organization-list/organization-list.ts index 5becb0d3..6c685454 100644 --- a/src/app/features/organizations/organization-list/organization-list.ts +++ b/src/app/features/organizations/organization-list/organization-list.ts @@ -1,18 +1,17 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { of, map } from 'rxjs'; +import { of } from 'rxjs'; import { DataTable } from '../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../shared/components/data-table/data-table-query.state'; +import { DataTableStore } from '../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, DataTableRecord, - DataTableSortEvent, + DataTableResult } from '../../../shared/components/data-table/data-table.types'; import { Autocomplete } from '../../../shared/components/form/autocomplete/autocomplete'; import { @@ -22,15 +21,11 @@ import { AutocompleteValueFn, } from '../../../shared/components/form/autocomplete/autocomplete.types'; import { FilterCard } from '../../../shared/components/filter-card/filter-card'; +import { Button } from '../../../shared/components/button/button'; import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api'; type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; -interface CountryLookup { - id: string; - name: string; -} - interface OrganizationListRow extends DataTableRecord { id: string; code: string; @@ -45,23 +40,19 @@ interface OrganizationListRow extends DataTableRecord { @Component({ selector: 'organization-list', standalone: true, - imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable], + imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable, Button], + providers: [DataTableStore], templateUrl: './organization-list.html', styleUrl: './organization-list.scss', }) - - -export class OrganizationList { +export class OrganizationList implements OnInit { private readonly formBuilder = inject(FormBuilder); private readonly router = inject(Router); private readonly toastr = inject(ToastrService); private readonly countryApi = inject(CountryService); - - readonly queryState = new DataTableQueryState(); + readonly tableStore = inject(DataTableStore); readonly selectedCountryLookup = signal(null); - readonly selectedFormCountry = signal(null); - readonly selectedCountryId = signal(null); readonly appliedCountryId = signal(null); readonly countryFilterForm = this.formBuilder.nonNullable.group({ @@ -109,18 +100,61 @@ export class OrganizationList { status: 'Suspended', expiry: '15-06-2026' }, + { + id: '5', + code: 'ORG-0022', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '6', + code: 'ORG-0023', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '7', + code: 'ORG-0024', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '8', + code: 'ORG-0025', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '9', + code: 'ORG-0026', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, ]); - readonly organizations = signal([]); - readonly totalRecords = signal(0); - readonly searchCountries: AutocompleteSearchFn = (term, limit) => this.countryApi.autocomplete(term, limit); readonly displayCountry: AutocompleteDisplayFn = country => country.name; readonly countryValue: AutocompleteValueFn = country => country.id; - readonly resolveCountry: AutocompleteResolveValueFn = - value => this.countryApi.getCountryById(value).pipe( - map(country => ({ id: country.id, iso2: country.iso2, name: country.name })) - ); readonly columns = signal[]>([ { key: 'code', label: 'Code', header: 'Code', sortable: true }, @@ -170,37 +204,64 @@ export class OrganizationList { }, ]); - readonly emptyMessage = signal('No organizations found'); - readonly emptyDescription = signal('There are no organizations available for the current filters.'); + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => { + const countryId = this.appliedCountryId(); + const selectedCountryName = this.selectedCountryLookup()?.name?.toLowerCase(); + const search = (query.search || '').trim().toLowerCase(); + const sortBy = query.sortBy; + const sortDir = query.sortDir; - constructor() { - this.refreshTable(); + let rows = this.allOrganizations().filter(row => { + const matchesCountry = !countryId + || row.countryId === countryId + || (!!selectedCountryName && row.country.toLowerCase() === selectedCountryName); + const matchesSearch = !search + || row.code.toLowerCase().includes(search) + || row.organizationName.toLowerCase().includes(search) + || row.country.toLowerCase().includes(search) + || row.plan.toLowerCase().includes(search) + || row.status.toLowerCase().includes(search) + || row.expiry.toLowerCase().includes(search); + return matchesCountry && matchesSearch; + }); + + if (sortBy) { + rows = [...rows].sort((left, right) => { + const leftVal = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const rightVal = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const compared = leftVal.localeCompare(rightVal); + return sortDir === 'asc' ? compared : -compared; + }); + } + + const total = rows.length; + const start = (query.page - 1) * query.pageSize; + const end = start + query.pageSize; + const result: DataTableResult = { + draw: query.draw, + total, + filtered: total, + rows: rows.slice(start, end) + }; + return of(result); + } + }); } - onCountryLookupSelected(country: CountryLookupDto): void { + onCountryLookupSelected(country: CountryLookupDto | null): void { this.selectedCountryLookup.set(country); } applyCountryFilter(): void { - const selectedCountryId = this.countryFilterForm.controls.countryId.value.trim(); + const rawValue = this.countryFilterForm.controls.countryId.value; + const selectedCountryId = (rawValue || '').trim(); + if (!selectedCountryId) { + this.selectedCountryLookup.set(null); + } this.appliedCountryId.set(selectedCountryId || null); - this.queryState.pageIndex.set(1); - this.refreshTable(); - } - - onSearch(value: string): void { - this.queryState.setSearch(value); - this.refreshTable(); - } - - onPageChange(event: DataTablePageEvent): void { - this.queryState.setPage(event); - this.refreshTable(); - } - - onSortChange(event: DataTableSortEvent): void { - this.queryState.setSort(event); - this.refreshTable(); + this.tableStore.refresh(); } onAddOrganization(): void { @@ -208,44 +269,10 @@ export class OrganizationList { } onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') { + void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } }); + return; + } this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`); } - - private refreshTable(): void { - const countryId = this.appliedCountryId(); - const search = this.queryState.searchText().trim().toLowerCase(); - const sortBy = this.queryState.sortColumn(); - const sortDir = this.queryState.sortDirection(); - - let rows = this.allOrganizations().filter(row => { - const matchesCountry = !countryId || row.countryId === countryId; - const matchesSearch = !search - || row.code.toLowerCase().includes(search) - || row.organizationName.toLowerCase().includes(search) - || row.country.toLowerCase().includes(search) - || row.plan.toLowerCase().includes(search) - || row.status.toLowerCase().includes(search) - || row.expiry.toLowerCase().includes(search); - - return matchesCountry && matchesSearch; - }); - - if (sortBy) { - rows = [...rows].sort((left, right) => { - const leftValue = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); - const rightValue = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); - const compared = leftValue.localeCompare(rightValue); - return sortDir === 'asc' ? compared : -compared; - }); - } - - const total = rows.length; - const pageIndex = this.queryState.pageIndex(); - const pageSize = this.queryState.pageSize(); - const start = (pageIndex - 1) * pageSize; - const end = start + pageSize; - - this.totalRecords.set(total); - this.organizations.set(rows.slice(start, end)); - } } diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html index 92e57aa2..4d5ef8e4 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html @@ -111,61 +111,36 @@
-