diff --git a/src/app/app.config.ts b/src/app/app.config.ts index b26bebc6..45e88166 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,13 +1,8 @@ import { ApplicationConfig, importProvidersFrom } from '@angular/core'; -import { RouterOutlet, provideRouter, withRouterConfig } from '@angular/router'; +import { provideRouter, withRouterConfig } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { App_Route } from './app.routes'; -import { AngularFireModule } from '@angular/fire/compat'; -import { environment } from '../environments/environment'; -import { AngularFireAuthModule } from '@angular/fire/compat/auth'; -import { AngularFireDatabaseModule } from '@angular/fire/compat/database'; -import { AngularFirestoreModule } from '@angular/fire/compat/firestore'; import { ToastrModule } from 'ngx-toastr'; import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { authInterceptor } from './core/interceptors/auth.interceptor'; @@ -18,15 +13,9 @@ export const appConfig: ApplicationConfig = { providers: [ provideRouter(App_Route, withRouterConfig({ onSameUrlNavigation: 'reload' })), provideHttpClient(withInterceptors([authInterceptor, errorInterceptor,loadingInterceptor])), - RouterOutlet, provideAnimations(), - AngularFireAuthModule, - AngularFirestoreModule, - AngularFireDatabaseModule, - AngularFireModule, provideCharts(withDefaultRegisterables()), importProvidersFrom( - // AngularFireModule.initializeApp(environment.firebase), ToastrModule.forRoot({ timeOut: 1500, closeButton: true, diff --git a/src/app/components/dashboards/crm/crm.html b/src/app/components/dashboards/crm/crm.html deleted file mode 100644 index 55adc727..00000000 --- a/src/app/components/dashboards/crm/crm.html +++ /dev/null @@ -1,529 +0,0 @@ -
-
-

Welcome back, - Json Taylor !

-

Track your sales activity, leads and deals - here.

-
-
- - -
-
- -
-
-
-
-
-
-
-
-
-
Your target is incomplete
- You have - completed - 48% of the given - target, you can also check your status. - Click - here -
-
- - -
-
-
-
-
-
-
-
-
- Top Deals -
-
- - -
-
-
-
    - @for (deal of topDeals; track $index) { -
  • -
    - -
    - - @if (deal.avatarImg) { - - } @else { - - {{ deal.initials }} - - } - -
    - -
    -

    - {{ deal.name }} -

    -

    - {{ deal.email }} -

    -
    - -
    - {{ deal.amount }} -
    - -
    -
  • - } - -
-
-
-
-
-
-
-
Profit Earned
- -
-
- - -
-
-
-
-
-
- @for (item of crmCards; track $index) { -
-
-
-
-
- - - -
-
-
-
-

{{item.title}}

-

{{item.number}}

-
- -
-
-
- View All -
-
-

{{item.percentage}} -

-

this month

-
-
-
-
-
-
-
- } - -
-
-
-
- Revenue Analytics -
- -
-
- - -
-
-
-
-
-
-
-
-
- Deals Statistics -
-
-
- -
- -
-
-
-
-
- - - @for (row of SalesRepData.rows; track row.id) { - - - - - - - -
- - img - - {{ row.name }} -
- - - {{ row.category }} - - {{ row.mail }} - - - - {{ row.location }} - - - - {{ row.date }} - - - - - - - } -
-
-
-
- -
-
-
-
-
-
-
-
-
-
- Leads By Source -
-
- - -
-
-
- -
- - - -
- Total - 4,145 -
-
-
-
- - @for (item of deviceLeads; track item.label) { -
-
- - - {{ item.label }} - - -
- {{ item.value }} -
- -
-
- } - -
-
-
-
-
-
-
- Deals Status -
- -
-
-
-

4,289

-
- 1.02 - compared to last week -
-
- -
-
-
-
-
-
-
-
-
-
-
    - - @for (deal of dealStats; track deal.title) { -
  • -
    -
    {{ deal.title }}
    - -
    - {{ deal.count }} -
    -
    -
  • - } - -
-
-
-
-
-
-
-
- Recent Activity -
- -
-
-
-
    - @for (item of recentActivity; track $index) { -
  • -
    - -
    - - - -
    - -
    - {{ item.mainText }} - - @if (item.linkText) { - {{ item.linkText }} - } - - @if (item.boldText) { - {{ item.boldText }} - } - - @if (item.highlightText) { - {{ item.highlightText }} - } - - @if (item.tagText) { - {{ item.tagText }} - } - - @if (item.afterText) { - {{ item.afterText }} - } - - @if (item.showAddIcon) { - - - - } - - @if (item.showCheckIcon) { - - } - - @if (item.subText) { - - {{ item.subText }} - - } -
    - -
    - - {{ item.time }} - -
    - -
    -
  • - } -
-
-
-
-
-
-
-
diff --git a/src/app/components/dashboards/crm/crm.scss b/src/app/components/dashboards/crm/crm.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/components/dashboards/crm/crm.ts b/src/app/components/dashboards/crm/crm.ts deleted file mode 100644 index 12d5cc75..00000000 --- a/src/app/components/dashboards/crm/crm.ts +++ /dev/null @@ -1,618 +0,0 @@ -import { Chart, ChartConfiguration, Plugin } from 'chart.js'; -import { Component } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { SpkApexcharts } from "../../../@spk/charts/charts/spk-apexcharts/spk-apexcharts"; -import { ApexOptions } from 'ng-apexcharts'; -import { SpkReusableTables } from "../../../@spk/tables/spk-reusable-tables/spk-reusable-tables"; -import { SpkChartjs } from "../../../@spk/charts/charts/spk-chartjs/spk-chartjs"; -@Component({ - selector: 'app-crm', - standalone: true, - imports: [RouterModule, SpkApexcharts, SpkReusableTables, SpkChartjs], - templateUrl: './crm.html', - styleUrl: './crm.scss' -}) -export class Crm { - YourtargetisincompleteChart: ApexOptions = { - chart: { - height: 127, - width: 100, - type: 'radialBar', - }, - - series: [48], - // colors: ['#fff'], - plotOptions: { - radialBar: { - hollow: { - margin: 0, - size: '55%', - background: '#fff', - }, - dataLabels: { - name: { - offsetY: -10, - color: '#4b9bfa', - fontSize: '.625rem', - show: false, - }, - value: { - offsetY: 5, - color: '#4b9bfa', - fontSize: '.875rem', - show: true, - fontWeight: 600, - }, - }, - }, - }, - stroke: { - lineCap: 'round', - }, - labels: ['Status'], - colors: ['#fff'] - } - - topDeals = [ - { - name: 'Michael Jordan', - email: 'michael.jordan@example.com', - amount: '$2,893', - avatarImg: './assets/images/faces/10.jpg' - }, - { - name: 'Emigo Kiaren', - email: 'emigo.kiaren@gmail.com', - amount: '$4,289', - initials: 'EK', - color: 'warning', // Used for text-warning and bg-warning/10 - }, - { - name: 'Randy Origoan', - email: 'randy.origoan@gmail.com', - amount: '$6,347', - avatarImg: './assets/images/faces/12.jpg' - }, - { - name: 'George Pieterson', - email: 'george.pieterson@gmail.com', - amount: '$3,894', - initials: 'GP', - color: 'success', - }, - { - name: 'Kiara Advain', - email: 'kiaraadvain214@gmail.com', - amount: '$2,679', - initials: 'KA', - color: 'primary', - } - ]; - - profitEarnedChart: ApexOptions = { - series: [ - { - name: 'Profit Earned', - data: [44, 42, 57, 86, 58, 55, 70], - }, - { - name: 'Total Sales', - data: [34, 22, 37, 56, 21, 35, 60], - }, - ], - chart: { - type: 'bar', - height: 180, - toolbar: { - show: false, - }, - }, - grid: { - borderColor: '#f1f1f1', - strokeDashArray: 3, - }, - colors: ['rgb(132, 90, 223)', '#e4e7ed'], - plotOptions: { - bar: { - colors: { - ranges: [ - { - from: -100, - to: -46, - color: '#ebeff5', - }, - { - from: -45, - to: 0, - color: '#ebeff5', - }, - ], - }, - columnWidth: '60%', - borderRadius: 5, - }, - }, - dataLabels: { - enabled: false, - }, - stroke: { - show: true, - width: 2, - colors: undefined, - }, - legend: { - show: false, - position: 'top', - }, - yaxis: { - title: { - style: { - color: '#adb5be', - fontSize: '13px', - fontFamily: 'poppins, sans-serif', - fontWeight: 600, - cssClass: 'apexcharts-yaxis-label', - }, - }, - labels: { - formatter: function (y: number) { - return y.toFixed(0) + ''; - }, - }, - }, - xaxis: { - type: 'category', - categories: ['S', 'M', 'T', 'W', 'T', 'F', 'S'], - axisBorder: { - show: true, - color: 'rgba(119, 119, 142, 0.05)', - offsetX: 0, - offsetY: 0, - }, - axisTicks: { - show: true, - borderType: 'solid', - color: 'rgba(119, 119, 142, 0.05)', - - offsetX: 0, - offsetY: 0, - }, - labels: { - rotate: -90, - }, - }, - } - - - - crmCardsChartOptions({ series, colors }: { colors: string[], series: { data: number[], name: string }[] }): ApexOptions { - return { - chart: { - type: 'line', - height: 40, - width: 100, - sparkline: { - enabled: true - } - }, - dataLabels: { - enabled: false - }, - - stroke: { - show: true, - curve: 'smooth', - lineCap: 'butt', - colors: undefined, - width: 1.5, - dashArray: 0, - }, - fill: { - type: 'gradient', - gradient: { - opacityFrom: 0.9, - opacityTo: 0.9, - stops: [0, 98], - } - }, - series: series, - yaxis: { - min: 0, - show: false, - axisBorder: { - show: false - }, - }, - xaxis: { - labels: { - show: false, - }, - axisBorder: { - show: false - }, - }, - tooltip: { - enabled: true, - }, - colors: colors, - } - } - - crmCards = [ - { - color: 'primary', - icon: 'ti ti-users', - title: 'Total Customers', - number: '1,02,890', - chartId: 'crm-total-customers', - chartoptions: this.crmCardsChartOptions({ - series: [{ - name: 'Value', - data: [20, 14, 19, 10, 23, 20, 22, 9, 12] - }], colors: ["rgb(132, 90, 223)"], - }), - viewallTextColor: 'text-primary', - percentage: '+40%', - percentageColor: 'text-success', - }, - { - color: 'secondary', - icon: 'ti ti-wallet', - title: 'Total Revenue', - number: '$56,562', - chartId: 'crm-total-revenue', - chartoptions: this.crmCardsChartOptions({ - series: [ - { - name: 'Value', - data: [20, 14, 20, 22, 9, 12, 19, 10, 25], - }, - ], colors: ['rgb(35, 183, 229)'], - }), - viewallTextColor: 'text-secondary', - percentage: '+25%', - percentageColor: 'text-success', - }, - { - color: 'success', - icon: 'ti ti-wave-square', - title: 'Conversion Ratio', - number: '12.08%', - chartId: 'crm-conversion-ratio', - chartoptions: this.crmCardsChartOptions({ - series: [ - { - name: 'Value', - data: [20, 20, 22, 9, 14, 19, 10, 25, 12], - }, - ], colors: ['rgb(38, 191, 148)'], - }), - viewallTextColor: 'text-success', - percentage: '-12%', - percentageColor: 'text-danger', - }, - { - color: 'warning', - icon: 'ti ti-briefcase', - title: 'Total Deals', - number: '2,543', - chartId: 'crm-total-deals', - chartoptions: this.crmCardsChartOptions({ - series: [ - { - name: 'Value', - data: [20, 20, 22, 9, 12, 14, 19, 10, 25], - }, - ], colors: ['rgb(245, 184, 73)'], - }), - viewallTextColor: 'text-warning', - percentage: '+19%', - percentageColor: 'text-success', - } - ]; - - - - RevenueAnalytics: ApexOptions = { - series: [ - { - type: 'line', - name: 'Profit', - data: [ - { x: 'Jan', y: 100 }, - { x: 'Feb', y: 210 }, - { x: 'Mar', y: 180 }, - { x: 'Apr', y: 454 }, - { x: 'May', y: 230 }, - { x: 'Jun', y: 320 }, - { x: 'Jul', y: 656 }, - { x: 'Aug', y: 830 }, - { x: 'Sep', y: 350 }, - { x: 'Oct', y: 350 }, - { x: 'Nov', y: 210 }, - { x: 'Dec', y: 410 }, - ], - }, - { - type: 'line', - name: 'Revenue', - data: [ - { x: 'Jan', y: 180 }, - { x: 'Feb', y: 620 }, - { x: 'Mar', y: 476 }, - { x: 'Apr', y: 220 }, - { x: 'May', y: 520 }, - { x: 'Jun', y: 780 }, - { x: 'Jul', y: 435 }, - { x: 'Aug', y: 515 }, - { x: 'Sep', y: 738 }, - { x: 'Oct', y: 454 }, - { x: 'Nov', y: 525 }, - { x: 'Dec', y: 230 }, - ], - }, - { - type: 'area', - name: 'Sales', - data: [ - { x: 'Jan', y: 200 }, - { x: 'Feb', y: 530 }, - { x: 'Mar', y: 110 }, - { x: 'Apr', y: 130 }, - { x: 'May', y: 480 }, - { x: 'Jun', y: 520 }, - { x: 'Jul', y: 780 }, - { x: 'Aug', y: 435 }, - { x: 'Sep', y: 475 }, - { x: 'Oct', y: 738 }, - { x: 'Nov', y: 454 }, - { x: 'Dec', y: 480 }, - ], - }, - ], - chart: { - type: 'line', - height: 350, - animations: { - speed: 500, - }, - toolbar: { - show: true - }, - zoom: { - enabled: true, - }, - dropShadow: { - enabled: false, - enabledOnSeries: undefined, - top: 8, - left: 0, - blur: 3, - color: '#000', - opacity: 0.1, - }, - }, - colors: ["rgb(132, 90, 223)", "rgba(35, 183, 229, 0.85)", "rgba(119, 119, 142, 0.05)"], - dataLabels: { - enabled: false, - }, - grid: { - padding:{ - left:0, - right:0, - top:0, - bottom:0 - }, - borderColor: '#f1f1f1', - strokeDashArray: 3, - yaxis: { - lines: { - show: false - } - }, - }, - stroke: { - curve: 'smooth', - width: [2, 2, 0], - dashArray: [0, 5, 0], - }, - xaxis: { - axisTicks: { - show: false, - }, - }, - yaxis: { - - labels: { - formatter: (value: number) => `$${value}`, - }, - - }, - tooltip: { - y: [ - { - formatter: (value: number) => `$${value}`, - }, - { - formatter: (value: number) => `$${value.toFixed(0)}`, - }, - { - formatter: (value: number) => `$${value.toFixed(0)}`, - }, - ], - }, - legend: { - show: true, - offsetY: 15, - customLegendItems: ['Profit', 'Revenue', 'Sales'], - inverseOrder: true, - markers: { - size: 5 - } - }, - title: { - text: 'Revenue Analytics with sales & profit (USD)', - align: 'left', - style: { - fontSize: '.8125rem', - fontWeight: 'semibold', - color: '#8c9097', - }, - }, - markers: { - hover: { - sizeOffset: 5, - }, - }, - } - - SalesRepData = { - columns: [ - { header: 'Sales Rep', tableHeadColumn: '!text-start !text-[0.85rem] min-w-[200px]' }, - { header: 'Category', tableHeadColumn: '!text-start !text-[0.85rem]' }, - { header: 'Mail', tableHeadColumn: '!text-start !text-[0.85rem]' }, - { header: 'Location', tableHeadColumn: '!text-start !text-[0.85rem]' }, - { header: 'Date', tableHeadColumn: '!text-start !text-[0.85rem]' }, - { header: 'Action', tableHeadColumn: '!text-start !text-[0.85rem]' } - ], - rows: [ - { id: 1, name: 'Mayor Kelly', img: './assets/images/faces/4.jpg', category: 'Manufacture', mail: 'mayorkelly@gmail.com', location: 'Germany', locationClass: 'info', date: 'Sep 15 - Oct 12, 2023', checked: false }, - { id: 2, name: 'Andrew Garfield', img: './assets/images/faces/15.jpg', category: 'Development', mail: 'andrewgarfield@gmail.com', location: 'Canada', locationClass: 'primary', date: 'Apr 10 - Dec 12, 2023', checked: true }, - { id: 3, name: 'Simon Cowel', img: './assets/images/faces/11.jpg', category: 'Service', mail: 'simoncowel234@gmail.com', location: 'Europe', locationClass: 'danger', date: 'Sep 15 - Oct 12, 2023', checked: false }, - { id: 4, name: 'Mirinda Hers', img: './assets/images/faces/8.jpg', category: 'Marketing', mail: 'mirindahers@gmail.com', location: 'USA', locationClass: 'warning', date: 'Apr 14 - Dec 14, 2023', checked: true }, - { id: 5, name: 'Jacob Smith', img: './assets/images/faces/9.jpg', category: 'Social Plataform', mail: 'jacobsmith@gmail.com', location: 'Singapore', locationClass: 'success', date: 'Feb 25 - Nov 25, 2023', checked: true } - ] - }; - - - - - - - LeadsBySourceChart: ChartConfiguration<'doughnut'> = { - type: 'doughnut', - data: { - datasets: [{ - data: [32, 27, 25, 16], - backgroundColor: [ - 'rgb(132, 90, 223)', // Purple - 'rgb(35, 183, 229)', // Blue - 'rgb(245, 184, 73)', // Orange - 'rgb(38, 191, 148)', // Green - ], - // 1. INCREASE SPACING: This creates the clear gap between segments - spacing: -20, - // 2. ROUNDED BORDER: 10-20 is usually the sweet spot for this thinness - borderRadius: 20, - borderWidth: 0, - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - // 3. THINNESS: Ensure this is high enough (80% to 90%) - cutout: '86%', - plugins: { - legend: { display: false }, - tooltip: { enabled: true } - } - } - }; - recentActivity = [ - { - statusClass: 'text-primary bg-primary/10', - time: '4:45PM', - mainText: 'Update of calendar events & ', - boldMain: true, - linkText: 'Added new events in next week.', - linkClass: 'text-primary font-semibold' - }, - { - statusClass: 'text-secondary bg-secondary/10', - time: '3 hrs', - mainText: 'New theme for ', - boldText: 'Spruko Website', - afterText: ' completed', - subText: 'Lorem ipsum, dolor sit amet.' - }, - { - statusClass: 'text-success bg-success/10', - time: '22 hrs', - mainText: 'Created a ', - highlightText: 'New Task', - highlightClass: 'text-success font-semibold', - afterText: ' today', - showAddIcon: true - }, - { - statusClass: 'text-pink bg-pink/10', - time: 'Today', - mainText: 'New member ', - tagText: '@andreas gurrero', - tagClass: 'py-[0.2rem] px-[0.45rem] font-semibold rounded-sm text-pink text-[0.75em] bg-pink/10', - afterText: ' added today to AI Summit.' - }, - { - statusClass: 'text-warning bg-warning/10', - time: '22 hrs', - mainText: '32 New people joined summit.' - }, - { - statusClass: 'text-info bg-info/10', - time: '12 hrs', - mainText: 'Neon Tarly added ', - highlightText: 'Robert Bright', - highlightClass: 'text-info font-semibold', - afterText: ' to AI summit project.' - }, - { - statusClass: 'text-[#232323] dark:text-white bg-[#232323]/10 dark:bg-white/20', - time: '4 hrs', - mainText: 'Replied to new support request ', - showCheckIcon: true - }, - { - statusClass: 'text-purple bg-purple/10', - time: '4 hrs', - mainText: 'Completed documentation of ', - linkText: 'AI Summit.', - linkClass: 'text-purple underline font-semibold' - } - ]; - - deviceLeads = [ - { - label: 'Mobile', - value: '1,624', - legendClass: 'mobile', - containerClass: '!ps-4 p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10' - }, - { - label: 'Desktop', - value: '1,267', - legendClass: 'desktop', - containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10' - }, - { - label: 'Laptop', - value: '1,153', - legendClass: 'laptop', - containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10' - }, - { - label: 'Tablet', - value: '679', - legendClass: 'tablet', - containerClass: '!pe-4 p-[0.95rem] text-center' - } - ]; - - dealStats = [ - { title: 'Successful Deals', count: '987 deals', liClass: 'primary' }, - { title: 'Pending Deals', count: '1,073 deals', liClass: 'info' }, - { title: 'Rejected Deals', count: '1,674 deals', liClass: 'warning' }, - { title: 'Upcoming Deals', count: '921 deals', liClass: 'success' } - ]; -} - - diff --git a/src/app/components/dashboards/dashboard.routes.ts b/src/app/components/dashboards/dashboard.routes.ts deleted file mode 100644 index 7f34349f..00000000 --- a/src/app/components/dashboards/dashboard.routes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Routes } from '@angular/router'; - -export const dashboardRoutingModule: Routes = [ - - { - path: 'crm', - loadComponent: () => import('./crm/crm').then((m) => m.Crm), - title: 'YNEX - Crm', - }, - - -]; diff --git a/src/app/components/error/error.routes.ts b/src/app/components/error/error.routes.ts deleted file mode 100644 index 04e3aeb7..00000000 --- a/src/app/components/error/error.routes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Routes } from '@angular/router'; - -export const errorRoutingModule: Routes = [ - { - path: 'error', children: [ - { - path: 'error404', - loadComponent: () => import('./error404/error404').then( (m) => m.Error404 ), - title: 'YNEX - Error 404' - }, - - ] - } -]; - diff --git a/src/app/components/error/error404/error404.html b/src/app/components/error/error404/error404.html deleted file mode 100644 index cf0f11a3..00000000 --- a/src/app/components/error/error404/error404.html +++ /dev/null @@ -1,27 +0,0 @@ -
- - - -
-
-
-
-
-
-

404

-

Oops 😭,The page you are looking for is not available.

-
-
-

We are sorry for the inconvenience,The page you are trying to access has - been removed or never been existed.

-
-
- BACK TO HOME -
-
-
-
-
-
-
diff --git a/src/app/components/error/error404/error404.scss b/src/app/components/error/error404/error404.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/components/error/error404/error404.ts b/src/app/components/error/error404/error404.ts deleted file mode 100644 index 5f5998ac..00000000 --- a/src/app/components/error/error404/error404.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Component } from '@angular/core'; -import {RouterModule} from'@angular/router'; -import { SpkParticles } from "../../../@spk/plugins&reusable/spk-particles/spk-particles"; -import {particlesOptions} from "../particleoptions" -@Component({ - selector: 'app-error404', - standalone: true, - imports: [RouterModule, SpkParticles], - templateUrl: './error404.html', - styleUrls: ['./error404.scss'] -}) -export class Error404 { -particlesOptions=particlesOptions -} - - diff --git a/src/app/components/error/particleoptions.ts b/src/app/components/error/particleoptions.ts deleted file mode 100644 index 6e7c01ec..00000000 --- a/src/app/components/error/particleoptions.ts +++ /dev/null @@ -1,36 +0,0 @@ - export const particlesOptions = { - fpsLimit: 60, // 200 is excessive; 60 is smooth and efficient - interactivity: { - events: { - onClick: { enable: true }, - onHover: { enable: true }, - resize: { enable: true } - }, - modes: { - push: { quantity: 4 }, - repulse: { distance: 200, duration: 0.4 } - } - }, - particles: { - number: { - value: 80, - density: { enable: true, value_area: 800 } - }, - color: { value: "#845adf" }, - shape: { type: "circle" }, - opacity: { value: 0.5 }, - size: { value: 2, random: true }, - line_linked: { - enable: true, - distance: 150, - color: "#d1d9e0", - opacity: 0.4, - width: 1 - }, - move: { - enable: true, - speed: 2, - out_mode: "out" - } - } - }; diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts deleted file mode 100644 index cbb7a8b7..00000000 --- a/src/app/core/guards/auth.guard.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateChildFn, Router } from '@angular/router'; -import { catchError, map, of } from 'rxjs'; -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); - const tokenStorage = inject(TokenStorageService); - const router = inject(Router); - - const loginUrlTree = router.createUrlTree(['/auth/login'], { - queryParams: { returnUrl: state.url }, - }); - - if (!authService.accessToken || !authService.currentUser) { - authService.logout(); - return loginUrlTree; - } - - if (!tokenStorage.isAccessTokenExpired()) { - return true; - } - - if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) { - authService.logout(); - return loginUrlTree; - } - - return authService.refreshAccessToken().pipe( - map(() => true), - catchError(() => { - authService.logout(); - return of(loginUrlTree); - }) - ); -}; diff --git a/src/app/core/guards/guest.guard.ts b/src/app/core/guards/guest.guard.ts deleted file mode 100644 index f580219c..00000000 --- a/src/app/core/guards/guest.guard.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { catchError, map, of } from 'rxjs'; -import { AuthService } from '../../features/authentication/data-access/auth.service'; -import { TokenStorageService } from '../services/auth/token-storage.service'; - -const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm'; - -function resolveSafeReturnUrl(returnUrl: string | null): string { - const candidate = returnUrl?.trim(); - if (!candidate) { - return DEFAULT_AUTHENTICATED_REDIRECT; - } - - const lowerCandidate = candidate.toLowerCase(); - const isSafeInternal = - candidate.startsWith('/') && - !candidate.startsWith('//') && - !lowerCandidate.includes('http://') && - !lowerCandidate.includes('https://'); - - return isSafeInternal ? candidate : DEFAULT_AUTHENTICATED_REDIRECT; -} - -export const guestGuard: CanActivateFn = (route) => { - const authService = inject(AuthService); - const tokenStorage = inject(TokenStorageService); - const router = inject(Router); - - const targetUrl = resolveSafeReturnUrl(route.queryParamMap.get('returnUrl')); - const targetUrlTree = router.createUrlTree([targetUrl]); - - if (!authService.accessToken || !authService.currentUser) { - return true; - } - - if (!tokenStorage.isAccessTokenExpired()) { - return targetUrlTree; - } - - if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) { - authService.logout(); - return true; - } - - return authService.refreshAccessToken().pipe( - map(() => targetUrlTree), - catchError(() => { - authService.logout(); - return of(true); - }) - ); -}; \ No newline at end of file diff --git a/src/app/core/services/common/firebase.service.ts b/src/app/core/services/common/firebase.service.ts deleted file mode 100644 index 8254331d..00000000 --- a/src/app/core/services/common/firebase.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@angular/core'; -import { AngularFireModule } from '@angular/fire/compat'; -import { AngularFirestoreModule } from '@angular/fire/compat/firestore'; -import { AngularFireDatabaseModule } from '@angular/fire/compat/database'; -import { AngularFireAuthModule } from '@angular/fire/compat/auth'; -import { environment } from '../../../../environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class FirebaseService { - constructor() { - // AngularFireModule.initializeApp(environment.firebase); - } - - getFirestore() { - return AngularFirestoreModule; - } - - getDatabase() { - return AngularFireDatabaseModule; - } - - getAuth() { - return AngularFireAuthModule; - } -} diff --git a/src/app/core/services/common/permission.service.ts b/src/app/core/services/common/permission.service.ts index 938c2da0..357de4bd 100644 --- a/src/app/core/services/common/permission.service.ts +++ b/src/app/core/services/common/permission.service.ts @@ -1,6 +1,7 @@ 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/context.model'; interface PermissionResponse { @@ -15,7 +16,9 @@ export class PermissionService { readonly permissionContext = signal(null); loadPermissions(): Observable { - return this.http.get(``).pipe( + return this.http.get( + `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}` + ).pipe( map((response) => ({ roles: response.roles ?? [], permissions: response.permissions ?? [], diff --git a/src/app/core/services/context/tenant-context.service.ts b/src/app/core/services/context/tenant-context.service.ts index da6ba416..fb922757 100644 --- a/src/app/core/services/context/tenant-context.service.ts +++ b/src/app/core/services/context/tenant-context.service.ts @@ -1,6 +1,7 @@ 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/context.model'; @Injectable({ providedIn: 'root' }) @@ -9,7 +10,9 @@ export class TenantContextService { readonly tenantContext = signal(null); loadTenantContext(): Observable { - return this.http.get(``).pipe( + return this.http.get( + `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}` + ).pipe( tap((context) => { this.tenantContext.set(context); }) diff --git a/src/app/core/services/context/user-context.service.ts b/src/app/core/services/context/user-context.service.ts index 9c202794..6ea08a25 100644 --- a/src/app/core/services/context/user-context.service.ts +++ b/src/app/core/services/context/user-context.service.ts @@ -1,6 +1,7 @@ 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/context.model'; @Injectable({ providedIn: 'root' }) @@ -9,7 +10,9 @@ export class UserContextService { readonly currentUserContext = signal(null); loadCurrentUserProfile(): Observable { - return this.http.get>(``).pipe( + return this.http.get>( + `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}` + ).pipe( map((response) => ({ id: response.id?.trim() ?? '', email: response.email?.trim() ?? '', diff --git a/src/app/features/authentication/pages/login/login.html b/src/app/features/authentication/pages/login/login.html index cb60c4c7..3392bf50 100644 --- a/src/app/features/authentication/pages/login/login.html +++ b/src/app/features/authentication/pages/login/login.html @@ -60,11 +60,11 @@ - Forgot password? + Forgot Password?
-
diff --git a/src/app/features/authentication/pages/login/login.ts b/src/app/features/authentication/pages/login/login.ts index c35d7b02..3c829195 100644 --- a/src/app/features/authentication/pages/login/login.ts +++ b/src/app/features/authentication/pages/login/login.ts @@ -1,4 +1,5 @@ -import { ChangeDetectorRef, Component, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AuthService } from '../../data-access/auth.service'; @@ -18,10 +19,11 @@ import { AppContextService } from '../../../../core/services/context/app-context export class Login { private readonly formBuilder = inject(FormBuilder); private readonly cdr = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); private readonly fallbackRoute = '/dashboards/crm'; public readonly adminLoginForm = this.formBuilder.nonNullable.group({ - username: ['superadmin@syscom.dev', [Validators.required]], + username: ['', [Validators.required]], password: ['', Validators.required], rememberMe: [false] }); @@ -71,8 +73,9 @@ export class Login { ), finalize(() => { this.isSubmitting = false; - this.cdr.detectChanges(); - }) + this.cdr.markForCheck(); + }), + takeUntilDestroyed(this.destroyRef) ) .subscribe({ next: () => { diff --git a/src/app/features/organizations/dashboard/dashboard.html b/src/app/features/organizations/dashboard/dashboard.html index 692de853..ef0a35c6 100644 --- a/src/app/features/organizations/dashboard/dashboard.html +++ b/src/app/features/organizations/dashboard/dashboard.html @@ -1,4 +1,4 @@ -
+ -
+
@for (card of statCards(); track card.title) {
-
+
@@ -54,7 +54,7 @@ [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()" - [totalRecords]="tableStore.totalRecords()" + [totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()" [pageSize]="tableStore.queryState.pageSize()" tableTitle="Recently Created Organizations" diff --git a/src/app/features/organizations/dashboard/dashboard.ts b/src/app/features/organizations/dashboard/dashboard.ts index 2e40a8a0..0eca0dbe 100644 --- a/src/app/features/organizations/dashboard/dashboard.ts +++ b/src/app/features/organizations/dashboard/dashboard.ts @@ -1,7 +1,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { of } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; import { DataTable } from '../../../shared/components/data-table/data-table'; import { DataTableStore } from '../../../shared/components/data-table/data-table.store'; @@ -10,11 +10,31 @@ import { DataTableActionEvent, DataTableColumn, DataTableRecord, - DataTableResult, } from '../../../shared/components/data-table/data-table.types'; +import { + OrganizationStatusCountsDto, + RecentOrganizationDto, +} from '../organization-list/models/organization.model'; +import { OrganizationService } from '../organization-list/data-access/organization.service'; -type OrganizationPlan = 'Enterprise' | 'Standard' | 'Trial'; -type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; +export interface RecentOrganizationTableRow extends DataTableRecord { + readonly id: string; + readonly code: string; + readonly name: string; + readonly organizationName: string; + readonly country: string; + readonly countryName: string; + readonly plan: string; + readonly planName: string; + readonly status: string; + readonly rawStatus: number | string; + readonly subscriptionStatus: string; + readonly expiry: string; + readonly planExpiresOn: string; + readonly isActive: boolean; + readonly createdOn: string; + readonly serialNumber: number; +} interface DashboardStatCard { title: string; @@ -25,16 +45,48 @@ interface DashboardStatCard { iconBackgroundClass: string; helperClass: string; } - -interface OrganizationListRow extends DataTableRecord { - id: string; - code: string; - organizationName: string; - countryId: string; - country: string; - plan: string; - status: OrganizationStatus; - expiry: string; + +function formatTenantStatus(status: number | string | undefined | null): string { + if (status === null || status === undefined) return 'Draft'; + if (typeof status === 'number') { + switch (status) { + case 0: return 'Draft'; + case 1: return 'Active'; + case 2: return 'Suspended'; + case 3: return 'Cancelled'; + case 4: return 'Trial'; + case 5: return 'Awaiting Database'; + case 6: return 'Awaiting Activation'; + default: return 'Draft'; + } + } + return String(status).replace(/([a-z])([A-Z])/g, '$1 $2'); +} + +function formatSubscriptionStatus(subStatus: number | string | undefined | null): string { + if (subStatus === null || subStatus === undefined) return '—'; + if (typeof subStatus === 'number') { + switch (subStatus) { + case 0: return 'Trialing'; + case 1: return 'Active'; + case 2: return 'Past Due'; + case 3: return 'Cancelled'; + case 4: return 'Expired'; + default: return 'Active'; + } + } + return String(subStatus).replace(/([a-z])([A-Z])/g, '$1 $2'); +} + +function formatDateDisplay(dateStr: string | undefined | null): string { + if (!dateStr) return '—'; + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }); + } catch { + return dateStr; + } } @Component({ @@ -48,12 +100,16 @@ interface OrganizationListRow extends DataTableRecord { export class Dashboard implements OnInit { private readonly router = inject(Router); private readonly toastr = inject(ToastrService); - readonly tableStore = inject(DataTableStore); + private readonly organizationApi = inject(OrganizationService); + readonly tableStore = inject(DataTableStore); + + readonly selectedMonths = signal(undefined); + readonly selectedLimit = signal(undefined); readonly statCards = signal([ { title: 'Total Organizations', - value: '24', + value: '0', helper: 'All registered', accentClass: 'border-primary', iconClass: 'ti ti-building-community', @@ -62,7 +118,7 @@ export class Dashboard implements OnInit { }, { title: 'Active', - value: '19', + value: '0', helper: 'Currently operational', accentClass: 'border-primary', iconClass: 'ti ti-circle-check', @@ -71,16 +127,25 @@ export class Dashboard implements OnInit { }, { title: 'Trial', - value: '3', + value: '0', helper: 'Trial subscriptions', accentClass: 'border-primary', iconClass: 'ri-wallet-2-line', iconBackgroundClass: 'bg-primary', helperClass: 'bg-primary/10 text-primary', }, + { + title: 'Draft', + value: '0', + helper: 'Incomplete onboarding', + accentClass: 'border-primary', + iconClass: 'ti ti-file-text', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, { title: 'Suspended', - value: '1', + value: '0', helper: 'Temporarily disabled', accentClass: 'border-primary', iconClass: 'ti ti-player-pause', @@ -88,119 +153,36 @@ export class Dashboard implements OnInit { helperClass: 'bg-primary/10 text-primary', }, { - title: 'Expired License', - value: '1', - helper: 'Needs renewal action', + title: 'Cancelled', + value: '0', + helper: 'Cancelled subscriptions', accentClass: 'border-primary', - iconClass: 'ri ri-pass-expired-line', - iconBackgroundClass: 'bg-primary', - helperClass: 'bg-primary/10 text-primary', - }, - { - title: 'Active Users', - value: '482', - helper: 'Users with access', - accentClass: 'border-primary', - iconClass: 'ri ri-group-line', + iconClass: 'ti ti-square-x', iconBackgroundClass: 'bg-primary', helperClass: 'bg-primary/10 text-primary', }, + // { + // title: 'Awaiting Database', + // value: '0', + // helper: 'DB provisioning pending', + // accentClass: 'border-primary', + // iconClass: 'ti ti-database', + // iconBackgroundClass: 'bg-primary', + // helperClass: 'bg-primary/10 text-primary', + // }, + // { + // title: 'Awaiting Activation', + // value: '0', + // helper: 'Activation pending', + // accentClass: 'border-primary', + // iconClass: 'ti ti-clock-check', + // iconBackgroundClass: 'bg-primary', + // helperClass: 'bg-primary/10 text-primary', + // }, ]); - readonly recentOrganizations = 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' - }, - { - 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 columns = signal[]>([ + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'code', label: 'Code', header: 'Code', sortable: true }, { key: 'organizationName', @@ -217,7 +199,7 @@ export class Dashboard implements OnInit { sortable: true, badge: true, badgeClass: value => - value === 'Trial' + value === 'Trial' || value === 'Basic' ? 'badge bg-warning/10 text-warning' : 'badge bg-light text-defaulttextcolor', }, @@ -231,6 +213,21 @@ export class Dashboard implements OnInit { value === 'Active' ? 'badge bg-success/10 text-success' : value === 'Trial' + ? 'badge bg-warning/10 text-warning' + : value === 'Draft' + ? 'badge bg-info/10 text-info' + : 'badge bg-danger/10 text-danger', + }, + { + key: 'subscriptionStatus', + label: 'Subscription', + header: 'Subscription', + sortable: true, + badge: true, + badgeClass: value => + value === 'Active' + ? 'badge bg-success/10 text-success' + : value === 'Trialing' ? 'badge bg-warning/10 text-warning' : 'badge bg-danger/10 text-danger', }, @@ -240,7 +237,7 @@ export class Dashboard implements OnInit { readonly emptyMessage = signal('No Organizations'); readonly emptyDescription = signal('Start by adding your first organization'); - readonly actions = signal[]>([ + 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' }, { @@ -254,50 +251,170 @@ export class Dashboard implements OnInit { ngOnInit(): void { this.tableStore.initialize({ - fetcher: query => { - const search = (query.search || '').trim().toLowerCase(); - const sortBy = query.sortBy; - const sortDir = query.sortDir; + fetcher: query => + this.organizationApi.getRecentOrganizationsDashboard(this.selectedMonths(), this.selectedLimit()).pipe( + tap(res => { + if (res?.counts) { + this.updateStatCards(res.counts); + } + }), + map(res => { + let orgs = [...(res.organizations || [])]; + const total = orgs.length; - 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 (query.search) { + const term = query.search.toLowerCase().trim(); + orgs = orgs.filter(o => + (o.name && o.name.toLowerCase().includes(term)) || + (o.code && o.code.toLowerCase().includes(term)) || + (o.countryName && o.countryName.toLowerCase().includes(term)) || + (o.planName && o.planName.toLowerCase().includes(term)) + ); + } - 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 filtered = orgs.length; - 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) + if (query.sortBy) { + const sortKey = query.sortBy as keyof RecentOrganizationDto; + const dir = query.sortDir === 'desc' ? -1 : 1; + orgs.sort((a: any, b: any) => { + const valA = a[sortKey] ?? ''; + const valB = b[sortKey] ?? ''; + if (typeof valA === 'string' && typeof valB === 'string') { + return valA.localeCompare(valB) * dir; + } + return (valA > valB ? 1 : valA < valB ? -1 : 0) * dir; + }); + } + + const start = (query.page - 1) * query.pageSize; + const pagedOrgs = orgs.slice(start, start + query.pageSize); + + return { + draw: query.draw, + rows: pagedOrgs as RecentOrganizationDto[], + total, + filtered, + }; + }) + ), + mapRow: (item, serialNumber) => { + const statusText = formatTenantStatus(item.status); + const subStatusText = formatSubscriptionStatus(item.subscriptionStatus); + const resolvedName = item.name || '—'; + const resolvedCountry = item.countryName || '—'; + const resolvedPlan = item.planName || '—'; + const resolvedExpiry = item.planExpiresOn ? String(item.planExpiresOn) : '—'; + + return { + ...item, + id: item.id, + code: item.code || '—', + name: resolvedName, + organizationName: resolvedName, + country: resolvedCountry, + countryName: resolvedCountry, + plan: resolvedPlan, + planName: resolvedPlan, + status: statusText, + rawStatus: item.status, + subscriptionStatus: subStatusText, + expiry: resolvedExpiry, + planExpiresOn: resolvedExpiry, + isActive: item.isActive ?? true, + createdOn: item.createdOn ? formatDateDisplay(item.createdOn) : '—', + serialNumber, }; - return of(result); - } + }, + onError: (err: any) => { + const msg = err?.error?.message || err?.error?.title || 'Failed to load dashboard organizations.'; + this.toastr.error(msg); + }, }); } + private updateStatCards(counts: OrganizationStatusCountsDto): void { + this.statCards.set([ + { + title: 'Total Organizations', + value: String(counts.totalOrganizations ?? 0), + helper: 'All registered', + accentClass: 'border-primary', + iconClass: 'ti ti-building-community', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Active', + value: String(counts.active ?? 0), + helper: 'Currently operational', + accentClass: 'border-primary', + iconClass: 'ti ti-circle-check', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Trial', + value: String(counts.trial ?? 0), + helper: 'Trial subscriptions', + accentClass: 'border-primary', + iconClass: 'ri-wallet-2-line', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Draft', + value: String(counts.draft ?? 0), + helper: 'Incomplete onboarding', + accentClass: 'border-primary', + iconClass: 'ti ti-file-text', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Suspended', + value: String(counts.suspended ?? 0), + helper: 'Temporarily disabled', + accentClass: 'border-primary', + iconClass: 'ti ti-player-pause', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Cancelled', + value: String(counts.cancelled ?? 0), + helper: 'Cancelled subscriptions', + accentClass: 'border-primary', + iconClass: 'ti ti-square-x', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + // { + // title: 'Awaiting Database', + // value: String(counts.awaitingDatabase ?? 0), + // helper: 'DB provisioning pending', + // accentClass: 'border-primary', + // iconClass: 'ti ti-database', + // iconBackgroundClass: 'bg-primary', + // helperClass: 'bg-primary/10 text-primary', + // }, + // { + // title: 'Awaiting Activation', + // value: String(counts.awaitingActivation ?? 0), + // helper: 'Activation pending', + // accentClass: 'border-primary', + // iconClass: 'ti ti-clock-check', + // iconBackgroundClass: 'bg-primary', + // helperClass: 'bg-primary/10 text-primary', + // }, + ]); + } + onAddOrganization(): void { void this.router.navigate(['/organizations/onboarding']); } - onActionClick(event: DataTableActionEvent): void { + 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; @@ -305,4 +422,3 @@ export class Dashboard implements OnInit { this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`); } } - diff --git a/src/app/features/organizations/organization-list/data-access/organization.endpoints.ts b/src/app/features/organizations/organization-list/data-access/organization.endpoints.ts new file mode 100644 index 00000000..4f38fddc --- /dev/null +++ b/src/app/features/organizations/organization-list/data-access/organization.endpoints.ts @@ -0,0 +1,25 @@ +import { buildApiUrl } from '../../../../core/config/api-url.util'; + +export const ORGANIZATION_ENDPOINTS = { + dataTable: buildApiUrl( + 'masterAdmin', + '/v1/tenants/datatable' + ), + + getById: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/tenants/${encodeURIComponent(id)}` + ), + + autocomplete: buildApiUrl( + 'masterAdmin', + '/v1/tenants/autocomplete' + ), + + dashboard: buildApiUrl( + 'masterAdmin', + '/v1/organizations/dashboard' + ), +} as const; + diff --git a/src/app/features/organizations/organization-list/data-access/organization.service.ts b/src/app/features/organizations/organization-list/data-access/organization.service.ts new file mode 100644 index 00000000..e3120a72 --- /dev/null +++ b/src/app/features/organizations/organization-list/data-access/organization.service.ts @@ -0,0 +1,53 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { ORGANIZATION_ENDPOINTS } from './organization.endpoints'; +import { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types'; +import { + OrganizationDto, + OrganizationLookupDto, + OrganizationTableRow, + RecentOrganizationsDashboardDto +} from '../models/organization.model'; + +@Injectable({ + providedIn: 'root' +}) +export class OrganizationService { + private readonly http = inject(HttpClient); + + getRecentOrganizationsDashboard(months?: number, limit?: number): Observable { + let params = new HttpParams(); + if (months !== undefined && months !== null) { + params = params.set('months', months.toString()); + } + if (limit !== undefined && limit !== null) { + params = params.set('limit', limit.toString()); + } + return this.http.get(ORGANIZATION_ENDPOINTS.dashboard, { params }); + } + + getOrganizationDataTable(query: DataTableQuery, countryId?: string | null): Observable> { + const payload = { + ...query, + ...(countryId ? { countryId } : {}) + }; + return this.http.post>(ORGANIZATION_ENDPOINTS.dataTable, payload); + } + + getOrganizationById(id: string): Observable { + return this.http.get(ORGANIZATION_ENDPOINTS.getById(id)); + } + + autocomplete(term?: string, limit = 10): Observable { + let params = new HttpParams().set('limit', limit); + const normalizedTerm = term?.trim(); + + if (normalizedTerm) { + params = params.set('term', normalizedTerm); + } + + return this.http.get(ORGANIZATION_ENDPOINTS.autocomplete, { params }); + } +} diff --git a/src/app/features/organizations/organization-list/models/organization.model.ts b/src/app/features/organizations/organization-list/models/organization.model.ts new file mode 100644 index 00000000..9162b601 --- /dev/null +++ b/src/app/features/organizations/organization-list/models/organization.model.ts @@ -0,0 +1,104 @@ +import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types'; + +export enum OrganizationStatus { Trial = 0, Active = 1, Suspended = 2, Cancelled = 3 } + +export interface OrganizationDto { + id: string; + code: string; + name: string; + organizationName?: string | null; + status: OrganizationStatus | string; + countryId?: string | null; + countryName?: string | null; + country?: string | null; + planId?: string | null; + planName?: string | null; + plan?: string | null; + expiryDate?: string | null; + expiry?: string | null; + defaultLanguageId?: string | null; + defaultLanguageName?: string | null; + defaultDbConnectionId?: string | null; + defaultDbConnectionName?: string | null; + defaultCurrencyId?: string | null; + defaultCurrencyName?: string | null; + defaultTimezoneId?: string | null; + defaultTimezoneName?: string | null; + dataRegion?: string | null; + isActive?: boolean; + createdOn?: string; + modifiedOn?: string | null; +} + +export interface OrganizationLookupDto { + id: string; + name: string; + code: string; +} + +export interface OrganizationTableRow extends DataTableRecord { + readonly id: string; + readonly code: string; + readonly name: string; + readonly organizationName: string; + readonly countryId: string | null; + readonly country: string; + readonly countryName: string | null; + readonly plan: string; + readonly planName: string | null; + readonly status: string | OrganizationStatus; + readonly expiry: string; + readonly expiryDate: string | null; + readonly dataRegion: string | null; + readonly isActive: boolean; + readonly serialNumber: number; + readonly defaultLanguageId?: string | null; + readonly defaultCurrencyId?: string | null; + readonly defaultTimezoneId?: string | null; + readonly defaultDbConnectionId?: string | null; + readonly createdOn?: string; + readonly modifiedOn?: string | null; + readonly defaultLanguageName?: string | null; + readonly defaultCurrencyName?: string | null; + readonly defaultTimezoneName?: string | null; +} + +export type OrganizationModalMode = 'create' | 'edit' | 'view'; + +export interface OrganizationStatusCountsDto { + totalOrganizations: number; + trial: number; + active: number; + suspended: number; + cancelled: number; + draft: number; + awaitingDatabase: number; + awaitingActivation: number; +} + +export interface RecentOrganizationDto { + id: string; + code: string; + name: string; + status: number | string; + countryId?: string | null; + countryName?: string | null; + planId?: string | null; + planCode?: string | null; + planName?: string | null; + subscriptionStatus?: number | string | null; + planExpiresOn?: string | null; + isActive: boolean; + createdOn: string; +} + +export interface RecentOrganizationsDashboardDto { + months: number; + limit: number; + from: string; + generatedOn: string; + counts: OrganizationStatusCountsDto; + organizations: RecentOrganizationDto[]; +} + + diff --git a/src/app/features/organizations/organization-list/organization-list.html b/src/app/features/organizations/organization-list/organization-list.html index 2fe0c7fa..ead650b7 100644 --- a/src/app/features/organizations/organization-list/organization-list.html +++ b/src/app/features/organizations/organization-list/organization-list.html @@ -2,7 +2,7 @@ [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()" - [totalRecords]="tableStore.totalRecords()" + [totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()" [pageSize]="tableStore.queryState.pageSize()" tableTitle="Organizations" diff --git a/src/app/features/organizations/organization-list/organization-list.ts b/src/app/features/organizations/organization-list/organization-list.ts index 7be4918a..4622ff9a 100644 --- a/src/app/features/organizations/organization-list/organization-list.ts +++ b/src/app/features/organizations/organization-list/organization-list.ts @@ -2,7 +2,6 @@ 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 } from 'rxjs'; import { DataTable, DataTableToolbarDirective } from '../../../shared/components/data-table/data-table'; import { DataTableStore } from '../../../shared/components/data-table/data-table.store'; @@ -10,31 +9,17 @@ import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTableRecord, - DataTableResult } 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 { Button } from '../../../shared/components/button/button'; import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api'; - -type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; - -interface OrganizationListRow extends DataTableRecord { - id: string; - code: string; - organizationName: string; - countryId: string; - country: string; - plan: string; - status: OrganizationStatus; - expiry: string; -} +import { OrganizationDto, OrganizationStatus, OrganizationTableRow } from './models/organization.model'; +import { OrganizationService } from './data-access/organization.service'; @Component({ selector: 'organization-list', @@ -49,7 +34,8 @@ export class OrganizationList implements OnInit { private readonly router = inject(Router); private readonly toastr = inject(ToastrService); private readonly countryApi = inject(CountryService); - readonly tableStore = inject(DataTableStore); + private readonly organizationApi = inject(OrganizationService); + readonly tableStore = inject(DataTableStore); readonly selectedCountryLookup = signal(null); readonly appliedCountryId = signal(null); @@ -59,104 +45,11 @@ export class OrganizationList implements OnInit { 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' - }, - { - 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 searchCountries: AutocompleteSearchFn = (term, limit) => this.countryApi.autocomplete(term, limit); readonly displayCountry: AutocompleteDisplayFn = country => country.name; readonly countryValue: AutocompleteValueFn = country => country.id; - readonly columns = signal[]>([ + readonly columns = signal[]>([ { key: 'code', label: 'Code', header: 'Code', sortable: true }, { key: 'organizationName', @@ -173,7 +66,7 @@ export class OrganizationList implements OnInit { sortable: true, badge: true, badgeClass: value => - value === 'Trial' + value === 'Trial' || value === OrganizationStatus.Trial ? 'badge bg-warning/10 text-warning' : 'badge bg-light text-defaulttextcolor', }, @@ -184,15 +77,15 @@ export class OrganizationList implements OnInit { 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'; + if (value === 'Active' || value === OrganizationStatus.Active) return 'badge bg-success/10 text-success'; + if (value === 'Trial' || value === OrganizationStatus.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[]>([ + 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' }, { @@ -200,52 +93,45 @@ export class OrganizationList implements OnInit { label: 'Suspend', icon: 'ti ti-player-pause', className: 'text-warning', - visible: row => row.status !== 'Suspended', + visible: row => row.status !== 'Suspended' && row.status !== OrganizationStatus.Suspended, }, ]); 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; + fetcher: query => this.organizationApi.getOrganizationDataTable(query, this.appliedCountryId()), + mapRow: (item, serialNumber) => { + const statusText = typeof item.status === 'number' + ? (OrganizationStatus[item.status] ?? 'Active') + : (item.status || 'Active'); - 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; - }); + const resolvedName = item.name || item.organizationName || '—'; + const resolvedCountry = item.countryName || item.country || '—'; + const resolvedPlan = item.planName || item.plan || '—'; + const resolvedExpiry = item.expiryDate || item.expiry || '—'; - 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 { + ...item, + id: item.id, + code: item.code || '—', + name: resolvedName, + organizationName: resolvedName, + countryId: item.countryId || null, + country: resolvedCountry, + countryName: resolvedCountry, + plan: resolvedPlan, + planName: resolvedPlan, + status: statusText, + expiry: resolvedExpiry, + expiryDate: resolvedExpiry, + dataRegion: item.dataRegion || null, + isActive: item.isActive ?? true, + serialNumber, }; - return of(result); + }, + onError: (err: any) => { + const msg = err?.error?.message || err?.error?.title || 'Failed to load organization list.'; + this.toastr.error(msg); } }); } @@ -279,7 +165,7 @@ export class OrganizationList implements OnInit { this.showFilters.update(value => !value); } - onActionClick(event: DataTableActionEvent): void { + 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; @@ -288,3 +174,4 @@ export class OrganizationList implements OnInit { } } + 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 30391530..cd0bce1b 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 @@ -110,6 +110,25 @@
+ @if (isEditMode()) { +
+ +
+ +
+ + +
+ +
+
+ } @else {
@@ -153,6 +172,7 @@ }
+ }
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 6f2cd35b..ae3da4b3 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 @@ -24,6 +24,8 @@ export class OnboardingStepper { readonly currentStepIndex = input.required(); readonly completedStepIndexes = input([]); + readonly isEditMode = input(false); + readonly savingDraft = input(false); readonly finishing = input(false); readonly navigationDisabled = input(false); @@ -32,6 +34,7 @@ export class OnboardingStepper { readonly backClicked = output(); readonly nextClicked = output(); readonly saveDraftClicked = output(); + readonly saveChangesClicked = output(); readonly finishClicked = output(); readonly cancelClicked = output(); @@ -122,6 +125,14 @@ export class OnboardingStepper { this.saveDraftClicked.emit(); } + requestSaveChanges(): void { + if (this.navigationDisabled() || this.finishing() || this.savingDraft()) { + return; + } + + this.saveChangesClicked.emit(); + } + requestFinish(): void { if ( this.navigationDisabled() || diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.html b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.html new file mode 100644 index 00000000..5d144fb9 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.html @@ -0,0 +1,126 @@ +
+ +
+ +
+ + + {{ currentStepIndex() + 1 }} + + Step {{ currentStepIndex() + 1 }} of {{ steps().length }}: {{ currentStepLabel() }} + + + {{ getProgressPercent() }}% + +
+ + + +
+ + + +
+
+ +
+ + +
+ @if (isEditMode()) { +
+ +
+ +
+ + +
+ +
+
+ } @else { +
+ +
+ +
+ @if (isFirstStep()) { + + } @else { + + + + } +
+ + +
+ +
+
+ + +
+ @if (!isLastStep()) { + + } @else { + + } +
+
+ } +
+ +
+ +
diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.scss b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.scss new file mode 100644 index 00000000..efce4921 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.scss @@ -0,0 +1,159 @@ +.tabs-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; + } +} + +.tabs-mobile-banner { + background-color: rgba(124, 61, 235, 0.08); + color: var(--primary, #7c3deb); + + :host-context(.dark) &, + :host-context([data-theme-mode="dark"]) & { + background-color: rgba(168, 85, 247, 0.15) !important; + color: #c084fc !important; + } +} + +.tabs-bar-nav { + position: relative; +} + +.tabs-bar-list { + position: relative; + z-index: 10; + display: flex; + width: 100%; + list-style: none; + margin: 0; + padding: 0; + gap: 0.5rem; + overflow-x: auto; + border-bottom: 1px solid #e2e8f0; + + :host-context(.dark) &, + :host-context([data-theme-mode="dark"]) & { + border-bottom-color: rgba(255, 255, 255, 0.12); + } +} + +.tabs-bar-item { + flex: 1 1 0; + min-width: 0; +} + +.tab-btn { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + padding: 0.625rem 0.5rem; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + transition: all 0.25s ease; + outline: none; + + @media (min-width: 640px) { + font-size: 0.875rem; + padding: 0.75rem 1rem; + } + + &:disabled { + cursor: default; + } + + // Pending Tab + &.pending { + color: #64748b; + + :host-context(.dark) &, + :host-context([data-theme-mode="dark"]) & { + color: #94a3b8 !important; + } + } + + // Completed Tab + &.completed { + color: #334155; + + :host-context(.dark) &, + :host-context([data-theme-mode="dark"]) & { + color: #cbd5e1 !important; + } + } + + // Active Tab + &.active { + color: var(--primary, #7c3deb); + border-bottom-color: var(--primary, #7c3deb); + font-weight: 700; + + :host-context(.dark) &, + :host-context([data-theme-mode="dark"]) & { + color: #c084fc !important; + border-bottom-color: #c084fc !important; + } + } +} + +.tab-index-badge { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 700; + background-color: #f1f5f9; + color: inherit; + + :host-context(.dark) &, + :host-context([data-theme-mode="dark"]) & { + background-color: rgba(255, 255, 255, 0.08) !important; + } + + .tab-btn.active & { + background: var(--primary, #7c3deb); + background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + color: #ffffff; + } + + .tab-btn.completed & { + background: var(--primary, #7c3deb); + background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + color: #ffffff; + } +} + +.tab-label { + overflow: hidden; + text-overflow: ellipsis; + + @media (max-width: 639px) { + display: none; + } +} + +.tabs-content-box { + background-color: #ffffff; + border: 1px dashed #e2e8f0; + + :host-context(.dark) &, + :host-context([data-theme-mode="dark"]) & { + background-color: var(--bg-body, #1a1c1e) !important; + border-color: rgba(255, 255, 255, 0.12) !important; + color: #e2e8f0; + } +} diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.ts b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.ts new file mode 100644 index 00000000..b83873b5 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.ts @@ -0,0 +1,155 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output +} from '@angular/core'; +import { Button } from '../../../../../shared/components/button/button'; + +export interface OnboardingStep { + readonly key: string; + readonly label: string; +} + +@Component({ + selector: 'onboarding-tabs', + standalone: true, + imports: [Button], + templateUrl: './onboarding-tabs.html', + styleUrl: './onboarding-tabs.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class OnboardingTabs { + readonly steps = input.required(); + readonly currentStepIndex = input.required(); + readonly completedStepIndexes = input([]); + + readonly isEditMode = input(false); + + readonly savingDraft = input(false); + readonly finishing = input(false); + readonly navigationDisabled = input(false); + + readonly stepSelected = output(); + readonly backClicked = output(); + readonly nextClicked = output(); + readonly saveDraftClicked = output(); + readonly saveChangesClicked = output(); + 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; + } + + isLastStep(): boolean { + return this.currentStepIndex() === this.steps().length - 1; + } + + isCurrentStep(index: number): boolean { + return index === this.currentStepIndex(); + } + + isCompletedStep(index: number): boolean { + return this.completedStepIndexes().includes(index); + } + + isPendingStep(index: number): boolean { + return !this.isCurrentStep(index) && !this.isCompletedStep(index); + } + + canOpenStep(index: number): boolean { + if (this.navigationDisabled()) { + return false; + } + + return index >= 0 && index < this.steps().length; + } + + selectStep(index: number): void { + if (!this.canOpenStep(index)) { + return; + } + + this.stepSelected.emit(index); + } + + requestBack(): void { + if (this.navigationDisabled() || this.isFirstStep()) { + return; + } + + this.backClicked.emit(); + } + + requestNext(): void { + if (this.navigationDisabled() || this.isLastStep()) { + return; + } + + this.nextClicked.emit(); + } + + requestSaveDraft(): void { + if (this.navigationDisabled() || this.savingDraft()) { + return; + } + + this.saveDraftClicked.emit(); + } + + requestSaveChanges(): void { + if (this.navigationDisabled() || this.finishing() || this.savingDraft()) { + return; + } + + this.saveChangesClicked.emit(); + } + + requestFinish(): void { + if ( + this.navigationDisabled() || + this.finishing() || + !this.isLastStep() + ) { + return; + } + + this.finishClicked.emit(); + } + + requestCancel(): void { + if (this.navigationDisabled()) { + return; + } + + this.cancelClicked.emit(); + } +} diff --git a/src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts b/src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts index aeaaa533..9fb845d7 100644 --- a/src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts +++ b/src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts @@ -3,6 +3,8 @@ import { buildApiUrl } from '../../../../core/config/api-url.util'; export const ORGANIZATION_ONBOARDING_ENDPOINTS = { createDraft: buildApiUrl('masterAdmin', '/v1/organizations'), + getNextCode: buildApiUrl('masterAdmin', '/v1/organizations/next-code'), + getById: (id: string) => buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}`), diff --git a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts index dea1558a..21126894 100644 --- a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts +++ b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts @@ -33,8 +33,19 @@ const INITIAL_DRAFT_STATE: OnboardingDraftState = { admin: null, }; +type RawDraftRecord = Record; + +function firstDefined(...values: readonly unknown[]): unknown { + return values.find(value => value !== null && value !== undefined); +} + +function firstTruthy(...values: readonly unknown[]): unknown { + return values.find(value => !!value); +} + @Injectable() export class OrganizationOnboardingStateService { + private readonly isEditModeState = signal(false); private readonly organizationIdState = signal(null); private readonly onboardingDataState = signal(INITIAL_DATA); private readonly onboardingDraftState = signal(INITIAL_DRAFT_STATE); @@ -43,6 +54,7 @@ export class OrganizationOnboardingStateService { private readonly savedDraftIdState = signal(null); private readonly savedAtState = signal(null); + readonly isEditMode = this.isEditModeState.asReadonly(); readonly organizationId = this.organizationIdState.asReadonly(); readonly onboardingData = this.onboardingDataState.asReadonly(); readonly onboardingDraft = this.onboardingDraftState.asReadonly(); @@ -60,6 +72,10 @@ export class OrganizationOnboardingStateService { return !!(data.basics || data.localization || data.planLimits || data.admin); }); + setIsEditMode(isEdit: boolean): void { + this.isEditModeState.set(isEdit); + } + setOrganizationId(id: string | null): void { this.organizationIdState.set(id); } @@ -164,33 +180,96 @@ export class OrganizationOnboardingStateService { }; } - restoreServerDraft(serverDraft: OrganizationServerDraftResponse): void { + restoreServerDraft(serverDraft: OrganizationServerDraftResponse, isEditMode = false): void { this.organizationIdState.set(serverDraft.id); + this.isEditModeState.set(isEditMode); + + const rawRecord = serverDraft as unknown as RawDraftRecord; + const orgCode = firstDefined( + serverDraft.code, rawRecord['Code'], rawRecord['code'], rawRecord['organizationCode'], rawRecord['OrganizationCode'] + ) ?? null; + + let basicsData = (firstDefined(serverDraft.basics, rawRecord['Basics'], rawRecord['basics']) ?? ( + firstTruthy(rawRecord['name'], rawRecord['Name'], rawRecord['organizationName'], rawRecord['OrganizationName'], orgCode) !== undefined + ? rawRecord + : null + )) as RawDraftRecord | null; + + if (basicsData) { + basicsData = { + ...basicsData, + code: firstDefined(basicsData['code'], basicsData['Code'], basicsData['organizationCode'], basicsData['OrganizationCode'], orgCode), + organizationCode: firstDefined(basicsData['organizationCode'], basicsData['OrganizationCode'], basicsData['code'], basicsData['Code'], orgCode), + }; + } + + const localizationData = (firstDefined(serverDraft.localization, rawRecord['Localization'], rawRecord['localization']) ?? ( + firstTruthy(rawRecord['defaultTimezoneId'], rawRecord['DefaultTimezoneId'], rawRecord['defaultCurrencyId'], rawRecord['DefaultCurrencyId'], rawRecord['dateFormat'], rawRecord['DateFormat']) !== undefined + ? rawRecord + : null + )) as RawDraftRecord | null; + + const planLimitsData = (firstDefined(serverDraft.planLimits, rawRecord['PlanLimits'], rawRecord['planLimits'], rawRecord['plan'], rawRecord['Plan']) ?? ( + firstTruthy(rawRecord['planId'], rawRecord['PlanId'], rawRecord['licenseType'], rawRecord['LicenseType'], rawRecord['maxCompanies'], rawRecord['MaxCompanies']) !== undefined + ? rawRecord + : null + )) as RawDraftRecord | null; + + const adminData = (firstDefined(serverDraft.admin, rawRecord['Admin'], rawRecord['admin'], rawRecord['adminContact'], rawRecord['AdminContact']) ?? ( + firstTruthy(rawRecord['adminEmail'], rawRecord['AdminEmail'], rawRecord['orgEmail'], rawRecord['OrgEmail'], rawRecord['administratorEmail'], rawRecord['AdministratorEmail']) !== undefined + ? rawRecord + : null + )) as RawDraftRecord | null; + this.onboardingDraftState.set({ - basics: serverDraft.basics ? { ...serverDraft.basics } : null, - localization: serverDraft.localization ? { ...serverDraft.localization } : null, - planLimits: serverDraft.planLimits ? { ...serverDraft.planLimits } : null, - admin: serverDraft.admin ? { ...serverDraft.admin } : null, + basics: basicsData ? { ...basicsData } : null, + localization: localizationData ? { ...localizationData } : null, + planLimits: planLimitsData ? { ...planLimitsData } : null, + admin: adminData ? { ...adminData } : null, }); - const completedIndexes = serverDraft.completedStepIndexes ?? []; + const statusStr = String(serverDraft.status || rawRecord['status'] || rawRecord['Status'] || '').toLowerCase(); + const isNonDraftActive = isEditMode && statusStr !== '' && statusStr !== 'draft' && statusStr !== '0'; + + const explicitIndexes = serverDraft.completedStepIndexes ?? []; + + const isStep0Done = isNonDraftActive || evaluateSectionCompletion(basicsData, 'basics', rawRecord, explicitIndexes, 0); + const isStep1Done = isNonDraftActive || evaluateSectionCompletion(localizationData, 'localization', rawRecord, explicitIndexes, 1); + const isStep2Done = isNonDraftActive || evaluateSectionCompletion(planLimitsData, 'plan', rawRecord, explicitIndexes, 2) || evaluateSectionCompletion(planLimitsData, 'planLimits', rawRecord, explicitIndexes, 2); + const isStep3Done = isNonDraftActive || evaluateSectionCompletion(adminData, 'admin', rawRecord, explicitIndexes, 3) || evaluateSectionCompletion(adminData, 'adminContact', rawRecord, explicitIndexes, 3); + + const completedIndexes: number[] = []; + if (isStep0Done) completedIndexes.push(0); + if (isStep1Done) completedIndexes.push(1); + if (isStep2Done) completedIndexes.push(2); + if (isStep3Done) completedIndexes.push(3); + this.onboardingDataState.set({ - basics: completedIndexes.includes(0) && serverDraft.basics - ? { ...serverDraft.basics } as OrganizationBasicsValue + // Trust boundary: the server draft payload is only validated at runtime by the API, + // so this cast is an intentional, explicit assumption rather than a type-safety gap. + basics: (isEditMode || completedIndexes.includes(0)) && basicsData + ? { ...basicsData } as unknown as OrganizationBasicsValue : null, - localization: completedIndexes.includes(1) && serverDraft.localization - ? { ...serverDraft.localization } as OrganizationLocalizationValue + localization: (isEditMode || completedIndexes.includes(1)) && localizationData + ? { ...localizationData } as unknown as OrganizationLocalizationValue : null, - planLimits: completedIndexes.includes(2) && serverDraft.planLimits - ? { ...serverDraft.planLimits } as OrganizationPlanLimitsValue + planLimits: (isEditMode || completedIndexes.includes(2)) && planLimitsData + ? { ...planLimitsData } as unknown as OrganizationPlanLimitsValue : null, - admin: completedIndexes.includes(3) && serverDraft.admin - ? { ...serverDraft.admin } as OrganizationAdminValue + admin: (isEditMode || completedIndexes.includes(3)) && adminData + ? { ...adminData } as unknown as OrganizationAdminValue : null, }); - const stepIndex = typeof serverDraft.currentStepIndex === 'number' ? serverDraft.currentStepIndex : 0; - this.currentStepIndexState.set(Math.min(3, Math.max(0, stepIndex))); + let initialStepIndex = 0; + if (typeof serverDraft.currentStepIndex === 'number' && serverDraft.currentStepIndex > 0 && serverDraft.currentStepIndex <= 3) { + initialStepIndex = serverDraft.currentStepIndex; + } else { + const firstUncompletedIndex = [0, 1, 2, 3].find(idx => !completedIndexes.includes(idx)); + initialStepIndex = firstUncompletedIndex !== undefined ? firstUncompletedIndex : 0; + } + + this.currentStepIndexState.set(Math.min(3, Math.max(0, initialStepIndex))); this.setCompletedStepIndexes(completedIndexes); } @@ -227,6 +306,7 @@ export class OrganizationOnboardingStateService { } clear(): void { + this.isEditModeState.set(false); this.organizationIdState.set(null); this.onboardingDataState.set(INITIAL_DATA); this.onboardingDraftState.set(INITIAL_DRAFT_STATE); @@ -245,3 +325,57 @@ export class OrganizationOnboardingStateService { ]; } } + +function isValidDateOrTrue(val: unknown): boolean { + if (val === true || val === 1 || val === '1' || val === 'true' || val === 'True') { + return true; + } + if (typeof val === 'string' && val.trim().length > 0) { + const d = Date.parse(val); + return !isNaN(d); + } + return false; +} + +function evaluateSectionCompletion( + sectionData: RawDraftRecord | null, + sectionKey: string, + rawRecord: RawDraftRecord, + explicitCompletedIndexes?: readonly number[], + stepIndex?: number +): boolean { + if (explicitCompletedIndexes && typeof stepIndex === 'number' && explicitCompletedIndexes.includes(stepIndex)) { + return true; + } + + if (sectionData) { + const sectionCompletionVal = firstDefined( + sectionData['completedAt'], sectionData['CompletedAt'], + sectionData['completedOn'], sectionData['CompletedOn'], + sectionData['markComplete'], sectionData['MarkComplete'], + sectionData['isCompleted'], sectionData['IsCompleted'] + ); + + if (isValidDateOrTrue(sectionCompletionVal)) { + return true; + } + } + + const topKeyCap = capitalize(sectionKey); + const topLevelVal = firstDefined( + rawRecord[`${sectionKey}CompletedAt`], rawRecord[`${topKeyCap}CompletedAt`], + rawRecord[`${sectionKey}CompletedOn`], rawRecord[`${topKeyCap}CompletedOn`], + rawRecord[`is${topKeyCap}Completed`], rawRecord[`Is${topKeyCap}Completed`], + rawRecord[`${sectionKey}MarkedComplete`], rawRecord[`${topKeyCap}MarkedComplete`] + ); + + if (isValidDateOrTrue(topLevelVal)) { + return true; + } + + return false; +} + +function capitalize(str: string): string { + return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; +} diff --git a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts index 55a3ce2a..17fa22d8 100644 --- a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts +++ b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts @@ -10,6 +10,7 @@ import { FiscalYearConventionValue, NumberFormatValue, OnboardingLookupValue, + OrganizationCodePreviewDto, OrganizationLicenseType, OrganizationPlanDefaults, OrganizationPlanLookupValue, @@ -164,6 +165,24 @@ export class OrganizationOnboardingService { return this.http.post(ORGANIZATION_ONBOARDING_ENDPOINTS.finish(id), {}); } + getNextOrganizationCode(): Observable { + return this.http.get(ORGANIZATION_ONBOARDING_ENDPOINTS.getNextCode).pipe( + map(res => { + if (typeof res === 'string') { + return res; + } + if (res && typeof res === 'object') { + return res.code ?? res.nextCode ?? res.organizationCode ?? res.value ?? null; + } + return null; + }), + catchError(err => { + console.error('Failed to fetch next organization code from server', err); + return of(null); + }) + ); + } + // --- Lookups and Defaults --- searchOrganizationTypes(term: string | null, limit = 10): Observable { diff --git a/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts index 9fb16e4e..c4575f78 100644 --- a/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts +++ b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts @@ -205,6 +205,13 @@ export interface OrganizationPlanDefaults { readonly limitsEditable: boolean; } +export interface OrganizationCodePreviewDto { + code?: string | null; + nextCode?: string | null; + organizationCode?: string | null; + value?: string | null; +} + export interface OnboardingStepForm { validate(): boolean; getValue(): TValue; @@ -341,6 +348,9 @@ export function mapPlanStepToApiRequest( export interface UpdateOrganizationAdminContactApiRequest { AdminEmail?: string | null; AdminFullName?: string | null; + AdminMobile?: string | null; + AdminPhone?: string | null; + AdministratorMobile?: string | null; OrgEmail?: string | null; OrgPhone?: string | null; MarkComplete: boolean; @@ -352,9 +362,14 @@ export function mapAdminContactStepToApiRequest( admin: Partial, markComplete: boolean ): UpdateOrganizationAdminContactApiRequest { + const mobile = admin.administratorMobile || (admin as any)?.adminMobile || (admin as any)?.AdminMobile || (admin as any)?.adminPhone || (admin as any)?.AdminPhone || null; + return { AdminEmail: admin.administratorEmail || null, AdminFullName: admin.administratorFullName || null, + AdminMobile: mobile, + AdminPhone: mobile, + AdministratorMobile: mobile, OrgEmail: admin.organizationEmail || null, OrgPhone: admin.organizationPhone || null, MarkComplete: markComplete, diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.html b/src/app/features/organizations/organization-onboarding/organization-onboarding.html index 563d4284..1b85404c 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.html +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.html @@ -1,8 +1,63 @@ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ + +
+
+ @if (useTabsView()) { + + + + } @else { -
- -
- -
- -
- -
- -
- -
- -
+
+ }
(); + + get length(): number { + return this.store.size; + } + + clear(): void { + this.store.clear(); + } + + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + + key(index: number): string | null { + return [...this.store.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.store.delete(key); + } + + setItem(key: string, value: string): void { + this.store.set(key, value); + } +} + +describe('OrganizationOnboarding', () => { + let mockLocalStorage: InMemoryLocalStorage; + let realLocalStorage: Storage; + + const routerStub = { navigate: vi.fn().mockResolvedValue(true) }; + const toastrStub = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; + const activatedRouteStub = { + snapshot: { queryParamMap: { get: () => null } } + }; + + const createFixture = async (): Promise> => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [OrganizationOnboarding], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Router, useValue: routerStub }, + { provide: ToastrService, useValue: toastrStub }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(OrganizationOnboarding); + fixture.detectChanges(); + return fixture; + }; + + const toggleButton = (fixture: ComponentFixture): HTMLButtonElement => + fixture.nativeElement.querySelector('button[role="switch"]'); + + beforeEach(() => { + mockLocalStorage = new InMemoryLocalStorage(); + realLocalStorage = window.localStorage; + Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'localStorage', { + value: realLocalStorage, + configurable: true, + }); + vi.clearAllMocks(); + }); + + it('defaults to the stepper view when no preference is stored', async () => { + const fixture = await createFixture(); + + expect(fixture.componentInstance.useTabsView()).toBe(false); + expect(fixture.debugElement.query(By.directive(OnboardingStepper))).not.toBeNull(); + expect(fixture.debugElement.query(By.directive(OnboardingTabs))).toBeNull(); + }); + + it('falls back to the stepper view when the stored preference is invalid', async () => { + mockLocalStorage.setItem(VIEW_PREFERENCE_STORAGE_KEY, 'not-a-real-value'); + const fixture = await createFixture(); + + expect(fixture.componentInstance.useTabsView()).toBe(false); + expect(fixture.debugElement.query(By.directive(OnboardingStepper))).not.toBeNull(); + }); + + it('restores the tabs view when a valid preference was previously stored', async () => { + mockLocalStorage.setItem(VIEW_PREFERENCE_STORAGE_KEY, 'tabs'); + const fixture = await createFixture(); + + expect(fixture.componentInstance.useTabsView()).toBe(true); + expect(fixture.debugElement.query(By.directive(OnboardingTabs))).not.toBeNull(); + expect(fixture.debugElement.query(By.directive(OnboardingStepper))).toBeNull(); + }); + + it('toggles the rendered child component and persists the new preference', async () => { + const fixture = await createFixture(); + + toggleButton(fixture).click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.useTabsView()).toBe(true); + expect(fixture.debugElement.query(By.directive(OnboardingTabs))).not.toBeNull(); + expect(fixture.debugElement.query(By.directive(OnboardingStepper))).toBeNull(); + expect(mockLocalStorage.getItem(VIEW_PREFERENCE_STORAGE_KEY)).toBe('tabs'); + + toggleButton(fixture).click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.useTabsView()).toBe(false); + expect(fixture.debugElement.query(By.directive(OnboardingStepper))).not.toBeNull(); + expect(mockLocalStorage.getItem(VIEW_PREFERENCE_STORAGE_KEY)).toBe('stepper'); + }); + + it('reflects aria-checked state on the switch control', async () => { + const fixture = await createFixture(); + + expect(toggleButton(fixture).getAttribute('aria-checked')).toBe('false'); + + toggleButton(fixture).click(); + fixture.detectChanges(); + + expect(toggleButton(fixture).getAttribute('aria-checked')).toBe('true'); + }); + + it('persists the preference across a simulated page reload', async () => { + const fixture = await createFixture(); + + toggleButton(fixture).click(); + fixture.detectChanges(); + expect(fixture.componentInstance.useTabsView()).toBe(true); + + // Simulate a reload: tear down the component tree but keep the same + // localStorage-backed store, then bootstrap a fresh instance. + fixture.destroy(); + const reloadedFixture = await createFixture(); + + expect(reloadedFixture.componentInstance.useTabsView()).toBe(true); + expect(reloadedFixture.debugElement.query(By.directive(OnboardingTabs))).not.toBeNull(); + }); + + it('preserves step index, completed steps, and in-progress field values across a view toggle', async () => { + const fixture = await createFixture(); + const stateService = fixture.debugElement.injector.get(OrganizationOnboardingStateService); + + stateService.markStepCompleted(0); + stateService.updateLocalizationDraft({ defaultLanguage: { id: 'en-us', label: 'English' } }); + fixture.detectChanges(); + + const basicsInstance = fixture.debugElement.query( + By.directive(OrganizationBasicsStepComponent) + ).componentInstance as OrganizationBasicsStepComponent; + basicsInstance.form.controls.organizationName.setValue('Acme Corporation'); + basicsInstance.form.controls.shortName.setValue('Acme'); + + toggleButton(fixture).click(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.currentStepIndex()).toBe(0); + expect(fixture.componentInstance.completedStepIndexes()).toContain(0); + + const basicsAfterToggle = fixture.debugElement.query( + By.directive(OrganizationBasicsStepComponent) + ).componentInstance as OrganizationBasicsStepComponent; + expect(basicsAfterToggle.form.controls.organizationName.value).toBe('Acme Corporation'); + expect(basicsAfterToggle.form.controls.shortName.value).toBe('Acme'); + + const localizationAfterToggle = fixture.debugElement.query( + By.directive(OrganizationLocalizationStepComponent) + ).componentInstance as OrganizationLocalizationStepComponent; + expect(localizationAfterToggle.form.controls.defaultLanguageId.value).toBe('en-us'); + }); +}); diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts index fbf259f9..53ae34e3 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts @@ -10,11 +10,13 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { catchError, finalize, Observable, of, switchMap } from 'rxjs'; +import { catchError, finalize, forkJoin, Observable, of, switchMap } from 'rxjs'; +import { NgTemplateOutlet } from '@angular/common'; import { OnboardingStep, OnboardingStepper } from './components/onboarding-stepper/onboarding-stepper'; +import { OnboardingTabs } from './components/onboarding-tabs/onboarding-tabs'; 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'; @@ -33,11 +35,15 @@ import { } from './models/organization-onboarding.model'; import { OrganizationProvisioningRequest } from './models/organization-provisioning.model'; +const VIEW_PREFERENCE_STORAGE_KEY = 'org-onboarding:view-preference'; + @Component({ selector: 'app-organization-onboarding', standalone: true, imports: [ + NgTemplateOutlet, OnboardingStepper, + OnboardingTabs, OrganizationBasicsStepComponent, OrganizationLocalizationStepComponent, OrganizationPlanLimitsStepComponent, @@ -62,6 +68,8 @@ export class OrganizationOnboarding { readonly savingDraft = signal(false); readonly finishing = signal(false); + readonly useTabsView = signal(this.readStoredViewPreference()); + private readonly basicsStep = viewChild(OrganizationBasicsStepComponent); private readonly localizationStep = viewChild(OrganizationLocalizationStepComponent); private readonly planLimitsStep = viewChild(OrganizationPlanLimitsStepComponent); @@ -87,18 +95,19 @@ export class OrganizationOnboarding { const draftId = this.route.snapshot.queryParamMap.get('id'); if (draftId) { + this.stateService.setIsEditMode(true); this.onboardingService.getOrganizationById(draftId) .pipe( catchError(err => { - const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to restore draft from server.'; - this.toastr.error(errorMsg, 'Draft Restore Failed'); + const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to restore organization details from server.'; + this.toastr.error(errorMsg, 'Organization Restoration Failed'); return of(null); }), takeUntilDestroyed(this.destroyRef) ) .subscribe(draft => { if (draft) { - this.stateService.restoreServerDraft(draft); + this.stateService.restoreServerDraft(draft, true); this.savedDraftSnapshot = this.serializeDraftState(); this.hydrateActiveStep(); } @@ -106,6 +115,12 @@ export class OrganizationOnboarding { } } + onToggleView(): void { + this.captureActiveStepDraft(); + this.setUseTabsView(!this.useTabsView()); + this.hydrateAllSteps(); + } + onStepSelected(index: number): void { if (!this.stateService.canOpenStep(index) || index === this.currentStepIndex()) { return; @@ -197,7 +212,12 @@ export class OrganizationOnboarding { 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.'); + this.toastr.error('Complete and validate every onboarding step before saving.'); + return; + } + + if (this.stateService.isEditMode()) { + this.onUpdateCurrentStep(); return; } @@ -216,6 +236,93 @@ export class OrganizationOnboarding { } } + onUpdateCurrentStep(): void { + if (this.finishing() || this.savingDraft()) { + return; + } + + const orgId = this.stateService.organizationId(); + if (!orgId) { + this.toastr.error('Organization ID is missing. Unable to update step details.', 'Update Failed'); + return; + } + + const currentIndex = this.currentStepIndex(); + const activeStepComponent = this.getStepComponent(currentIndex); + + if (!activeStepComponent) { + return; + } + + if (!activeStepComponent.validate()) { + const stepLabel = this.steps[currentIndex]?.label ?? `Step ${currentIndex + 1}`; + this.toastr.warning( + `Please fix the validation errors in ${stepLabel} before saving.`, + 'Validation Required' + ); + return; + } + + this.storeValidatedStep(currentIndex, activeStepComponent); + this.finishing.set(true); + + const currentStepLabel = this.steps[currentIndex]?.label ?? `Step ${currentIndex + 1}`; + let update$: Observable; + + switch (currentIndex) { + case 0: { + const data = (activeStepComponent as OrganizationBasicsStepComponent).getValue(); + const payload = mapBasicsStepToApiRequest(data, true); + update$ = this.onboardingService.updateBasics(orgId, payload); + break; + } + case 1: { + const data = (activeStepComponent as OrganizationLocalizationStepComponent).getValue(); + const payload = mapLocalizationStepToApiRequest(data, true); + update$ = this.ensureBasicsCompleted(orgId).pipe( + switchMap(() => this.onboardingService.updateLocalization(orgId, payload)) + ); + break; + } + case 2: { + const data = (activeStepComponent as OrganizationPlanLimitsStepComponent).getValue(); + const payload = mapPlanStepToApiRequest(data, true); + update$ = this.ensureBasicsCompleted(orgId).pipe( + switchMap(() => this.onboardingService.updatePlan(orgId, payload)) + ); + break; + } + case 3: { + const data = (activeStepComponent as OrganizationAdminStepComponent).getValue(); + const payload = mapAdminContactStepToApiRequest(data, true); + update$ = this.ensureBasicsCompleted(orgId).pipe( + switchMap(() => this.onboardingService.updateAdminContact(orgId, payload)) + ); + break; + } + default: + update$ = of(null); + break; + } + + update$ + .pipe( + finalize(() => this.finishing.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.stateService.markStepCompleted(currentIndex); + this.savedDraftSnapshot = this.serializeDraftState(); + this.toastr.success(`${currentStepLabel} details updated successfully.`, 'Changes Saved'); + }, + error: (err) => { + const errorMsg = err?.error?.detail || err?.error?.message || `Failed to update ${currentStepLabel.toLowerCase()} details.`; + this.toastr.error(errorMsg, 'Update Failed'); + } + }); + } + onCancel(): void { this.captureActiveStepDraft(); if (this.serializeDraftState() !== this.savedDraftSnapshot) { @@ -242,13 +349,31 @@ export class OrganizationOnboarding { this.executeFinishAndProvisionFlow(orgId, adminData); } + private ensureBasicsCompleted(orgId: string): Observable { + const basicsDraft = this.basicsStep()?.getDraftValue() + ?? this.stateService.basics() + ?? this.stateService.onboardingDraft().basics; + + if (basicsDraft && (basicsDraft.organizationName || (basicsDraft as any).name || (basicsDraft as any).Name)) { + const payload = mapBasicsStepToApiRequest(basicsDraft, true); + return this.onboardingService.updateBasics(orgId, payload).pipe( + catchError(err => { + console.warn('Unable to mark basics step completed on server prior to updating subsequent step:', err); + return of(null); + }) + ); + } + return of(null); + } + private executeFinishAndProvisionFlow(orgId: string, adminData: OrganizationAdminValue): void { this.finishing.set(true); const adminPayload = mapAdminContactStepToApiRequest(adminData, true); - this.onboardingService.updateAdminContact(orgId, adminPayload) + this.ensureBasicsCompleted(orgId) .pipe( + switchMap(() => this.onboardingService.updateAdminContact(orgId, adminPayload)), switchMap(() => this.onboardingService.finishOnboarding(orgId)), finalize(() => { this.finishing.set(false); @@ -306,17 +431,23 @@ export class OrganizationOnboarding { case 1: { const loc = this.localizationStep()?.getDraftValue() ?? {}; const requestPayload = mapLocalizationStepToApiRequest(loc, markComplete); - return this.onboardingService.updateLocalization(orgId, requestPayload); + return this.ensureBasicsCompleted(orgId).pipe( + switchMap(() => this.onboardingService.updateLocalization(orgId, requestPayload)) + ) as Observable; } case 2: { const plan = this.planLimitsStep()?.getDraftValue() ?? {}; const requestPayload = mapPlanStepToApiRequest(plan, markComplete); - return this.onboardingService.updatePlan(orgId, requestPayload); + return this.ensureBasicsCompleted(orgId).pipe( + switchMap(() => this.onboardingService.updatePlan(orgId, requestPayload)) + ) as Observable; } case 3: { const admin = this.adminStep()?.getDraftValue() ?? {}; const requestPayload = mapAdminContactStepToApiRequest(admin, markComplete); - return this.onboardingService.updateAdminContact(orgId, requestPayload); + return this.ensureBasicsCompleted(orgId).pipe( + switchMap(() => this.onboardingService.updateAdminContact(orgId, requestPayload)) + ) as Observable; } default: return of({ id: orgId, status: 'Draft' }); @@ -399,7 +530,6 @@ export class OrganizationOnboarding { private captureActiveStepDraft(): void { const currentIndex = this.currentStepIndex(); - const validatedValue = this.getValidatedStepValue(currentIndex); switch (currentIndex) { case 0: @@ -415,14 +545,6 @@ export class OrganizationOnboarding { 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 { @@ -458,6 +580,35 @@ export class OrganizationOnboarding { }); } + private hydrateAllSteps(): void { + queueMicrotask(() => { + const draft = this.stateService.onboardingDraft(); + + if (draft.basics) this.basicsStep()?.patchValue(draft.basics); + if (draft.localization) this.localizationStep()?.patchValue(draft.localization); + if (draft.planLimits) this.planLimitsStep()?.patchValue(draft.planLimits); + if (draft.admin) this.adminStep()?.patchValue(draft.admin); + }); + } + + private setUseTabsView(useTabsView: boolean): void { + this.useTabsView.set(useTabsView); + + try { + localStorage.setItem(VIEW_PREFERENCE_STORAGE_KEY, useTabsView ? 'tabs' : 'stepper'); + } catch { + // localStorage may be unavailable (e.g. private browsing); the in-memory signal still works for this session. + } + } + + private readStoredViewPreference(): boolean { + try { + return localStorage.getItem(VIEW_PREFERENCE_STORAGE_KEY) === 'tabs'; + } catch { + return false; + } + } + private applyCountryDefaults(): void { const countryIso2 = this.stateService.basics()?.registrationCountry.iso2 ?? null; this.onboardingService.getCountryLocalizationDefaults(countryIso2) 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 index 9f0ca37b..3e233f97 100644 --- 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 @@ -109,47 +109,37 @@
Organization
- {{ reviewData().basics?.organizationName || 'Not completed' }} + {{ organizationNameSummary() }}
Country
- {{ reviewData().basics?.registrationCountry?.label || 'Not completed' }} + {{ countrySummary() }}
Localization
- {{ reviewData().localization?.timeZone?.label || 'Not completed' }} · - {{ reviewData().localization?.currency?.secondaryLabel || reviewData().localization?.currency?.label || '—' }} + {{ localizationSummary() }}
Plan and limits
- {{ reviewData().planLimits?.subscriptionPlan?.label || 'Not completed' }} - @if (reviewData().planLimits) { - · {{ reviewData().planLimits?.maximumCompanies }} companies · - {{ reviewData().planLimits?.maximumUsers }} users · - {{ reviewData().planLimits?.maximumStorageGb }} GB - } + {{ planSummary() }}
Access dates
- {{ reviewData().planLimits?.systemAccessStartDate || 'Not completed' }} – - {{ reviewData().planLimits?.systemAccessEndDate || 'No end date' }} + {{ accessDatesSummary() }}
Administrator
- {{ form.controls.administratorFullName.value || 'Not entered' }} - @if (form.controls.administratorEmail.value) { - · {{ form.controls.administratorEmail.value }} - } + {{ adminSummary() }}
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 index 941a2f50..3c11ce32 100644 --- 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 @@ -2,10 +2,12 @@ import { ChangeDetectionStrategy, Component, ElementRef, + computed, inject, input, signal, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; @@ -45,6 +47,73 @@ export class OrganizationAdminStepComponent implements OnboardingStepForm { + const basics = this.reviewData()?.basics as Record | null; + return ( + basics?.['organizationName'] || + basics?.['name'] || + basics?.['OrganizationName'] || + basics?.['Name'] || + 'Not completed' + ); + }); + + readonly countrySummary = computed(() => { + const basics = this.reviewData()?.basics as Record | null; + const countryObj = basics?.['registrationCountry']; + const countryLabel = countryObj?.['label'] || countryObj?.['name']; + return ( + countryLabel || + basics?.['countryName'] || + basics?.['CountryName'] || + basics?.['country'] || + basics?.['Country'] || + 'Not completed' + ); + }); + + readonly localizationSummary = computed(() => { + const loc = this.reviewData()?.localization as Record | null; + if (!loc) return 'Not completed'; + const tz = loc['timeZone']?.label || loc['defaultTimezoneName'] || loc['DefaultTimezoneName'] || 'Not completed'; + const currency = loc['currency']?.secondaryLabel || loc['currency']?.label || loc['defaultCurrencyName'] || loc['DefaultCurrencyName'] || '—'; + return `${tz} · ${currency}`; + }); + + readonly planSummary = computed(() => { + const plan = this.reviewData()?.planLimits as Record | null; + if (!plan) return 'Not completed'; + const name = plan['subscriptionPlan']?.label || plan['planName'] || plan['PlanName'] || plan['plan'] || 'Not completed'; + const maxComp = plan['maximumCompanies'] ?? plan['maxCompanies'] ?? plan['MaxCompanies'] ?? 0; + const maxUsers = plan['maximumUsers'] ?? plan['maxUsers'] ?? plan['MaxUsers'] ?? 0; + const maxStorage = plan['maximumStorageGb'] ?? plan['maxStorageGb'] ?? plan['MaxStorageGb'] ?? 0; + return `${name} · ${maxComp} companies · ${maxUsers} users · ${maxStorage} GB`; + }); + + readonly accessDatesSummary = computed(() => { + const plan = this.reviewData()?.planLimits as Record | null; + if (!plan) return 'Not completed'; + const start = plan['systemAccessStartDate'] || plan['systemAccessFrom'] || plan['SystemAccessFrom'] || plan['goLiveDate'] || 'Not completed'; + const end = plan['systemAccessEndDate'] || plan['systemAccessTo'] || plan['SystemAccessTo'] || 'No end date'; + return `${start} – ${end}`; + }); + + readonly adminSummary = computed(() => { + const formVal = this.formValues(); + const adminData = this.reviewData()?.admin as Record | null; + + const fullName = formVal.administratorFullName || adminData?.['administratorFullName'] || adminData?.['adminFullName'] || adminData?.['AdminFullName'] || ''; + const email = formVal.administratorEmail || adminData?.['administratorEmail'] || adminData?.['adminEmail'] || adminData?.['AdminEmail'] || ''; + const mobile = formVal.administratorMobile || adminData?.['administratorMobile'] || adminData?.['adminMobile'] || adminData?.['AdminMobile'] || adminData?.['adminPhone'] || adminData?.['AdminPhone'] || ''; + + const parts = [fullName, email, mobile].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : 'Not entered'; + }); + validate(): boolean { this.submitAttempted.set(true); if (this.form.valid) { @@ -71,12 +140,28 @@ export class OrganizationAdminStepComponent implements OnboardingStepForm & Record): void { + if (!value) return; + this.form.patchValue({ - organizationEmail: value.organizationEmail ?? value['OrgEmail'] ?? value['orgEmail'] ?? '', - organizationPhone: value.organizationPhone ?? value['OrgPhone'] ?? value['orgPhone'] ?? '', - administratorFullName: value.administratorFullName ?? value['AdminFullName'] ?? value['adminFullName'] ?? '', - administratorEmail: value.administratorEmail ?? value['AdminEmail'] ?? value['adminEmail'] ?? '', - administratorMobile: value.administratorMobile ?? value['AdminMobile'] ?? value['adminMobile'] ?? '', + organizationEmail: value.organizationEmail ?? value['OrgEmail'] ?? value['orgEmail'] ?? value['OrganizationEmail'] ?? '', + organizationPhone: value.organizationPhone ?? value['OrgPhone'] ?? value['orgPhone'] ?? value['OrganizationPhone'] ?? '', + administratorFullName: value.administratorFullName ?? value['AdminFullName'] ?? value['adminFullName'] ?? value['AdministratorFullName'] ?? '', + administratorEmail: value.administratorEmail ?? value['AdminEmail'] ?? value['adminEmail'] ?? value['AdministratorEmail'] ?? '', + administratorMobile: + value.administratorMobile ?? + value['AdminMobile'] ?? + value['adminMobile'] ?? + value['AdministratorMobile'] ?? + value['administratorMobile'] ?? + value['AdminPhone'] ?? + value['adminPhone'] ?? + value['AdministratorPhone'] ?? + value['administratorPhone'] ?? + value['mobile'] ?? + value['Mobile'] ?? + value['phone'] ?? + value['Phone'] ?? + '', }, { emitEvent: false }); this.submitAttempted.set(false); } diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts index cfd246c0..9f165f50 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts +++ b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts @@ -1,4 +1,5 @@ -import { ChangeDetectionStrategy, Component, ElementRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; import { catchError, map, of } from 'rxjs'; @@ -24,8 +25,10 @@ import { OrganizationBasicsValue, } from '../../models/organization-onboarding.model'; import { OrganizationOnboardingService } from '../../data-access/services/organization-onboarding.service'; +import { OrganizationOnboardingStateService } from '../../data-access/services/organization-onboarding-state.service'; interface OrganizationBasicsFormModel { + readonly code: string | null; readonly organizationName: string; readonly shortName: string; readonly localLanguageName: string; @@ -42,6 +45,7 @@ interface OrganizationBasicsFormModel { changeDetection: ChangeDetectionStrategy.OnPush, }) export class OrganizationBasicsStepComponent implements OnboardingStepForm { + private readonly destroyRef = inject(DestroyRef); private readonly formBuilder = inject(FormBuilder); private readonly onboardingService = inject(OrganizationOnboardingService); private readonly countryService = inject(CountryService); @@ -49,6 +53,8 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm>(ElementRef); + private readonly stateService = inject(OrganizationOnboardingStateService, { optional: true }); + readonly submitAttempted = signal(false); readonly organizationCodeControl = this.formBuilder.nonNullable.control('Pending generation'); readonly organizationTypeSelection = signal(null); @@ -64,6 +70,22 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm(null, [Validators.required]), }); + constructor() { + if (!this.stateService?.isEditMode()) { + this.fetchNextOrganizationCode(); + } + } + + private fetchNextOrganizationCode(): void { + this.onboardingService.getNextOrganizationCode() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(code => { + if (code && (this.organizationCodeControl.value === 'Pending generation' || !this.organizationCodeControl.value)) { + this.organizationCodeControl.setValue(code); + } + }); + } + readonly searchOrganizationTypes: AutocompleteSearchFn = (term, limit) => this.onboardingService.searchOrganizationTypes(term, limit).pipe( catchError(() => { @@ -152,8 +174,11 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm { const value = this.form.getRawValue() as OrganizationBasicsFormModel; + const country = this.registrationCountrySelection(); + const codeControlVal = this.organizationCodeControl.value; + const codeVal = codeControlVal && codeControlVal !== 'Pending generation' ? codeControlVal : null; return { - organizationCode: null, + organizationCode: codeVal, organizationName: value.organizationName, shortName: value.shortName, localLanguageName: value.localLanguageName || null, organizationType: this.organizationTypeSelection() ?? undefined, industry: this.industrySelection() ?? undefined, - registrationCountry: this.registrationCountrySelection() + registrationCountry: country ? { - id: this.registrationCountrySelection()!.id, - label: this.registrationCountrySelection()!.name, - iso2: this.registrationCountrySelection()!.iso2, - } + id: country.id, + label: country.name, + name: country.name, + iso2: country.iso2, + } as any : undefined, }; } - patchValue(value: Partial): void { + patchValue(value: Partial & Record): void { + if (!value) return; + + const draftBasics = this.stateService?.onboardingDraft()?.basics as Record | null; + const orgCode = + value.organizationCode ?? + value['organizationCode'] ?? + value['OrganizationCode'] ?? + value['code'] ?? + value['Code'] ?? + draftBasics?.['organizationCode'] ?? + draftBasics?.['code'] ?? + draftBasics?.['Code'] ?? + null; + + if (orgCode) { + this.organizationCodeControl.setValue(String(orgCode)); + } + + const orgName = value.organizationName ?? value['OrganizationName'] ?? value['name'] ?? value['Name'] ?? ''; + const shortName = value.shortName ?? value['ShortName'] ?? ''; + const localLanguageName = value.localLanguageName ?? value['LocalLanguageName'] ?? ''; + + // 1. Organization Type + const rawOrgTypeId = value.organizationType?.id ?? value['organizationTypeId'] ?? value['OrganizationTypeId'] ?? value['orgType'] ?? value['OrgType'] ?? null; + const orgTypeParsedId = rawOrgTypeId != null ? String(rawOrgTypeId) : null; + const orgTypeLabel = value.organizationType?.label ?? value['organizationTypeName'] ?? value['OrganizationTypeName'] ?? ''; + + // 2. Industry + const rawIndustryId = value.industry?.id ?? value['industryId'] ?? value['IndustryId'] ?? null; + const industryId = rawIndustryId ? String(rawIndustryId) : null; + const industryLabel = value.industry?.label ?? value['industryName'] ?? value['IndustryName'] ?? ''; + + // 3. Country + const rawCountryId = value.registrationCountry?.id ?? (value.registrationCountry as any)?.id ?? value['registrationCountryId'] ?? value['RegistrationCountryId'] ?? value['countryId'] ?? value['CountryId'] ?? null; + const countryId = rawCountryId ? String(rawCountryId) : null; + const countryName = value.registrationCountry?.label ?? (value.registrationCountry as any)?.name ?? value['registrationCountryName'] ?? value['RegistrationCountryName'] ?? value['countryName'] ?? value['CountryName'] ?? ''; + const countryIso2 = value.registrationCountry?.iso2 ?? value['registrationCountryIso2'] ?? value['RegistrationCountryIso2'] ?? value['countryIso2'] ?? value['CountryIso2'] ?? ''; + this.form.patchValue({ - organizationName: value.organizationName ?? '', - shortName: value.shortName ?? '', - localLanguageName: value.localLanguageName ?? '', - organizationTypeId: value.organizationType?.id ?? null, - industryId: value.industry?.id ?? null, - registrationCountryId: value.registrationCountry?.id ?? null, + organizationName: orgName, + shortName: shortName, + localLanguageName: localLanguageName, + organizationTypeId: orgTypeParsedId, + industryId: industryId, + registrationCountryId: countryId, }, { emitEvent: false }); - this.organizationTypeSelection.set(value.organizationType ?? null); - this.industrySelection.set(value.industry ?? null); - this.registrationCountrySelection.set( - value.registrationCountry - ? { - id: value.registrationCountry.id, - iso2: value.registrationCountry.iso2 ?? '', - name: value.registrationCountry.label, - } - : null - ); + // Handle Organization Type Selection + if (orgTypeParsedId != null) { + if (orgTypeLabel) { + this.organizationTypeSelection.set({ id: orgTypeParsedId, label: orgTypeLabel }); + } else { + this.onboardingService.resolveOrganizationType(orgTypeParsedId).pipe( + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(resolved => { + if (resolved) this.organizationTypeSelection.set(resolved); + }); + } + } else { + this.organizationTypeSelection.set(null); + } + + // Handle Industry Selection + if (industryId) { + if (industryLabel) { + this.industrySelection.set({ id: industryId, label: industryLabel, secondaryLabel: value.industry?.secondaryLabel }); + } else { + this.industryApiService.getIndustryById(industryId).pipe( + map(item => item ? ({ id: item.id, label: item.industryName, secondaryLabel: item.industryCode }) : null), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(resolved => { + if (resolved) this.industrySelection.set(resolved); + }); + } + } else { + this.industrySelection.set(null); + } + + // Handle Country Selection + if (countryId) { + if (countryName) { + this.registrationCountrySelection.set({ id: countryId, iso2: countryIso2, name: countryName }); + } else { + this.countryService.getCountryById(countryId).pipe( + map(country => ({ id: country.id, iso2: country.iso2, name: country.name })), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(resolved => { + if (resolved) this.registrationCountrySelection.set(resolved); + }); + } + } else { + this.registrationCountrySelection.set(null); + } + this.submitAttempted.set(false); } 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 c39e58b9..721307ce 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 @@ -289,12 +289,20 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm } patchValue(value: Partial & Record): void { + if (!value) return; + + const timeZoneId = value.timeZone?.id ?? value['DefaultTimezoneId'] ?? value['defaultTimezoneId'] ?? value['timeZoneId'] ?? null; + const currencyId = value.currency?.id ?? value['DefaultCurrencyId'] ?? value['defaultCurrencyId'] ?? value['currencyId'] ?? null; + const defaultLanguageId = value.defaultLanguage?.id ?? value['DefaultLanguageId'] ?? value['defaultLanguageId'] ?? value['languageId'] ?? null; + const additionalLanguageIds = value.additionalLanguageIds ?? value['AdditionalLanguageIds'] ?? value['additionalLanguageIds'] ?? []; + const dateFormat = value.dateFormat ?? value['DateFormat'] ?? value['dateFormat'] ?? null; + const rawTf = value.timeFormat !== undefined && value.timeFormat !== null ? value.timeFormat : value['TimeFormat'] ?? value['timeFormat']; const timeFormatNormalized: TimeFormatValue | null = rawTf === 1 || rawTf === '1' || rawTf === 'TwentyFourHour' - ? 1 + ? 'TwentyFourHour' : rawTf === 0 || rawTf === '0' || rawTf === 'TwelveHour' - ? 0 + ? 'TwelveHour' : null; const rawFiscalYear = value.fiscalYearConvention ?? value['FiscalYearStart'] ?? value['fiscalYearStart'] ?? null; @@ -304,46 +312,88 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm const numberFormatNormalized = mapApiToNumberFormat(rawNumberFormat); this.form.patchValue({ - timeZoneId: value.timeZone?.id ?? value['DefaultTimezoneId'] ?? value['defaultTimezoneId'] ?? value['timeZoneId'] ?? null, - currencyId: value.currency?.id ?? value['DefaultCurrencyId'] ?? value['defaultCurrencyId'] ?? value['currencyId'] ?? null, - defaultLanguageId: value.defaultLanguage?.id ?? value['DefaultLanguageId'] ?? value['defaultLanguageId'] ?? value['defaultLanguageId'] ?? null, - additionalLanguageIds: value.additionalLanguageIds ?? value['AdditionalLanguageIds'] ?? value['additionalLanguageIds'] ?? [], - dateFormat: value.dateFormat ?? value['DateFormat'] ?? value['dateFormat'] ?? null, + timeZoneId, + currencyId, + defaultLanguageId, + additionalLanguageIds, + dateFormat, timeFormat: timeFormatNormalized, numberFormat: numberFormatNormalized, fiscalYearConvention: fiscalYearNormalized, }, { emitEvent: false }); - this.timeZoneSelection.set( - value.timeZone - ? { - id: value.timeZone.id, - ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label, - displayName: value.timeZone.label, - } - : null - ); - this.currencySelection.set( - value.currency - ? { - id: value.currency.id, - code: value.currency.secondaryLabel ?? value.currency.label, - name: value.currency.label, - symbol: '', - } - : null - ); - this.defaultLanguageSelection.set( - value.defaultLanguage - ? { - id: value.defaultLanguage.id, - code: value.defaultLanguage.secondaryLabel ?? value.defaultLanguage.label, - name: value.defaultLanguage.label, - nativeName: value.defaultLanguage.label, - isRightToLeft: false, - } - : null - ); + // Timezone Selection + if (value.timeZone) { + this.timeZoneSelection.set({ + id: value.timeZone.id, + ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label, + displayName: value.timeZone.label, + }); + } else if (timeZoneId) { + const tzName = value['DefaultTimezoneName'] ?? value['defaultTimezoneName'] ?? value['timeZoneName'] ?? ''; + if (tzName) { + this.timeZoneSelection.set({ id: timeZoneId, ianaId: tzName, displayName: tzName }); + } else { + this.timezoneService.getById(timeZoneId).pipe( + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(tz => { + if (tz) this.timeZoneSelection.set({ id: tz.id, ianaId: tz.ianaId, displayName: tz.displayName }); + }); + } + } else { + this.timeZoneSelection.set(null); + } + + // Currency Selection + if (value.currency) { + this.currencySelection.set({ + id: value.currency.id, + code: value.currency.secondaryLabel ?? value.currency.label, + name: value.currency.label, + symbol: '', + }); + } else if (currencyId) { + const currName = value['DefaultCurrencyName'] ?? value['defaultCurrencyName'] ?? value['currencyName'] ?? ''; + if (currName) { + this.currencySelection.set({ id: currencyId, code: currName, name: currName, symbol: '' }); + } else { + this.currencyService.getCurrencyById(currencyId).pipe( + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(c => { + if (c) this.currencySelection.set({ id: c.id, code: c.code, name: c.name, symbol: c.symbol }); + }); + } + } else { + this.currencySelection.set(null); + } + + // Default Language Selection + if (value.defaultLanguage) { + this.defaultLanguageSelection.set({ + id: value.defaultLanguage.id, + code: value.defaultLanguage.secondaryLabel ?? value.defaultLanguage.label, + name: value.defaultLanguage.label, + nativeName: value.defaultLanguage.label, + isRightToLeft: false, + }); + } else if (defaultLanguageId) { + const langName = value['DefaultLanguageName'] ?? value['defaultLanguageName'] ?? value['languageName'] ?? ''; + if (langName) { + this.defaultLanguageSelection.set({ id: defaultLanguageId, code: langName, name: langName, nativeName: langName, isRightToLeft: false }); + } else { + this.languageService.getById(defaultLanguageId).pipe( + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(l => { + if (l) this.defaultLanguageSelection.set({ id: l.id, code: l.code, name: l.name, nativeName: l.nativeName, isRightToLeft: l.isRightToLeft }); + }); + } + } else { + this.defaultLanguageSelection.set(null); + } + this.additionalLanguageOptions.update(current => this.mergeSelectionOptions(current, value.additionalLanguageSelections ?? [])); this.submitAttempted.set(false); } 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 f66b05a1..ca72906a 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 @@ -114,23 +114,33 @@
+
+ +
+
@if (submitAttempted() && form.errors?.['accessDateRange']) {
- System Access Start Date cannot be later than System Access End Date. + System Access From Date cannot be later than System Access To Date.
} 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 b4fa15be..0815bbf7 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 @@ -38,7 +38,8 @@ interface OrganizationPlanLimitsFormModel { readonly maximumUsers: number | null; readonly maximumStorageGb: number | null; readonly goLiveDate: string | null; - readonly systemAccessPeriod: DateRangeValue | null; + readonly systemAccessStartDate: string | null; + readonly systemAccessEndDate: string | null; } type ThemeSelectValue = @@ -58,11 +59,11 @@ const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationE } const errors: ValidationErrors = {}; - const { licenseType, systemAccessPeriod } = value; - const from = systemAccessPeriod?.from ?? null; - const to = systemAccessPeriod?.to ?? null; + const { licenseType, systemAccessStartDate, systemAccessEndDate } = value; + const from = systemAccessStartDate ?? null; + const to = systemAccessEndDate ?? null; - if (licenseType === 'Trial' && (!systemAccessPeriod || !to)) { + if (licenseType === 'Trial' && !to) { errors['trialEndDateRequired'] = true; } @@ -107,7 +108,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm(null, [Validators.required, Validators.min(1)]), maximumStorageGb: this.formBuilder.control(null, [Validators.required, Validators.min(1)]), goLiveDate: this.formBuilder.control(null, [Validators.required]), - systemAccessPeriod: this.formBuilder.control(null, [Validators.required]), + systemAccessStartDate: this.formBuilder.control(null, [Validators.required]), + systemAccessEndDate: this.formBuilder.control(null), }, { validators: accessDateValidator }); readonly requiresAccessEndDate = computed(() => this.form.controls.licenseType.value === 'Trial'); @@ -132,12 +134,12 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm { if (licenseType === 'Trial') { - this.form.controls.systemAccessPeriod.addValidators([Validators.required]); + this.form.controls.systemAccessEndDate.addValidators([Validators.required]); } else { - this.form.controls.systemAccessPeriod.removeValidators([Validators.required]); + this.form.controls.systemAccessEndDate.removeValidators([Validators.required]); } - this.form.controls.systemAccessPeriod.updateValueAndValidity({ emitEvent: false }); + this.form.controls.systemAccessEndDate.updateValueAndValidity({ emitEvent: false }); }); this.form.controls.subscriptionPlanId.valueChanges @@ -185,9 +187,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm { const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel; - const period = value.systemAccessPeriod; return { subscriptionPlan: this.planSelection() ?? undefined, @@ -217,40 +217,56 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm & Record): void { - const rawLicenseType = value.licenseType ?? value['LicenseType']; + if (!value) return; + + const rawLicenseType = value['licenseType'] ?? value['LicenseType']; const normalizedLicenseType = mapApiToLicenseType(rawLicenseType); - const planId = value.subscriptionPlan?.id ?? value['PlanId'] ?? value['subscriptionPlanId'] ?? null; + const planId = value['subscriptionPlan']?.id ?? value['PlanId'] ?? value['subscriptionPlanId'] ?? value['planId'] ?? null; - const subPlan = value.subscriptionPlan ?? (value['SubscriptionPlan'] ? { - id: value['SubscriptionPlan']?.id ?? planId, - label: value['SubscriptionPlan']?.name ?? value['SubscriptionPlan']?.label ?? '', - code: value['SubscriptionPlan']?.code ?? '', - } : planId ? { id: planId, label: '', code: '' } : null); + const subPlan = value['subscriptionPlan'] ?? (value['SubscriptionPlan'] || value['plan'] || value['Plan'] ? { + id: value['SubscriptionPlan']?.id ?? value['plan']?.id ?? value['Plan']?.id ?? planId, + label: value['SubscriptionPlan']?.name ?? value['SubscriptionPlan']?.label ?? value['planName'] ?? value['PlanName'] ?? value['plan']?.name ?? '', + code: value['SubscriptionPlan']?.code ?? value['plan']?.code ?? value['Plan']?.code ?? '', + } : planId ? { id: planId, label: value['PlanName'] ?? value['planName'] ?? '', code: '' } : null); - const from = value.systemAccessStartDate ?? value['SystemAccessFrom'] ?? null; - const to = value.systemAccessEndDate ?? value['SystemAccessTo'] ?? null; - const systemAccessPeriod: DateRangeValue | null = (from || to) ? { from, to } : null; + const period = value['systemAccessPeriod'] as DateRangeValue | undefined; + const from = value['systemAccessStartDate'] ?? value['SystemAccessFrom'] ?? value['systemAccessFrom'] ?? period?.from ?? null; + const to = value['systemAccessEndDate'] ?? value['SystemAccessTo'] ?? value['systemAccessTo'] ?? period?.to ?? null; this.form.patchValue({ subscriptionPlanId: planId, licenseType: normalizedLicenseType, - maximumCompanies: value.maximumCompanies ?? value['MaxCompanies'] ?? null, - maximumUsers: value.maximumUsers ?? value['MaxUsers'] ?? null, - maximumStorageGb: value.maximumStorageGb ?? value['MaxStorageGb'] ?? null, - goLiveDate: value.goLiveDate ?? value['GoLiveDate'] ?? null, - systemAccessPeriod, + maximumCompanies: value['maximumCompanies'] ?? value['MaxCompanies'] ?? value['maxCompanies'] ?? null, + maximumUsers: value['maximumUsers'] ?? value['MaxUsers'] ?? value['maxUsers'] ?? null, + maximumStorageGb: value['maximumStorageGb'] ?? value['MaxStorageGb'] ?? value['maxStorageGb'] ?? null, + goLiveDate: value['goLiveDate'] ?? value['GoLiveDate'] ?? null, + systemAccessStartDate: from, + systemAccessEndDate: to, }, { emitEvent: false }); - this.planSelection.set(subPlan ?? value.subscriptionPlan ?? null); - this.limitsEditable.set(value.limitsEditable ?? true); + if (subPlan && subPlan.id) { + this.planSelection.set(subPlan); + if (!subPlan.label && planId) { + this.onboardingService.resolveSubscriptionPlan(planId).pipe( + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(plan => { + if (plan) this.planSelection.set(plan); + }); + } + } else { + this.planSelection.set(null); + } + + this.limitsEditable.set(value['limitsEditable'] ?? true); this.applyLimitsEditableState(); this.submitAttempted.set(false); } @@ -271,9 +287,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm ({ diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.html b/src/app/features/tenants/pages/tenant-list/tenant-list.html index 4232f9d5..a64b2dec 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.html +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.html @@ -2,7 +2,7 @@ [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()" - [totalRecords]="tableStore.totalRecords()" + [totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()" [pageSize]="tableStore.queryState.pageSize()" tableTitle="Tenants" diff --git a/src/app/shared/components/button/button.scss b/src/app/shared/components/button/button.scss index fdbe49b0..b023c563 100644 --- a/src/app/shared/components/button/button.scss +++ b/src/app/shared/components/button/button.scss @@ -84,7 +84,9 @@ solid button ('Next') to outline style and remove its glow/box-shadow. ========================================================================== */ :host-context(.modern-modal-footer:has(.ti-btn-outline-primary:hover:not(:disabled))) button.ti-btn-primary-full:not(:hover), -:host-context(.modern-modal-footer:has(.onboarding-save-btn:hover:not(:disabled))) button.ti-btn-primary-full:not(:hover) { +:host-context(.modern-modal-footer:has(.ti-btn-outline-primary:hover:not(:disabled))) button.ti-btn-success-full:not(:hover), +:host-context(.modern-modal-footer:has(.onboarding-save-btn:hover:not(:disabled))) button.ti-btn-primary-full:not(:hover), +:host-context(.modern-modal-footer:has(.onboarding-save-btn:hover:not(:disabled))) button.ti-btn-success-full:not(:hover) { background: #ffffff !important; color: var(--primary, #7c3deb) !important; border: 1.5px solid var(--primary, #7c3deb) !important; diff --git a/src/app/shared/components/data-table/data-table.html b/src/app/shared/components/data-table/data-table.html index 638bb90f..ee1bceb1 100644 --- a/src/app/shared/components/data-table/data-table.html +++ b/src/app/shared/components/data-table/data-table.html @@ -8,16 +8,16 @@
@if(showFilterButton()){ -
+
+ className="!rounded-full shadow-sm !whitespace-nowrap !mb-0" (buttonClicked)="onFilterClick($event)" />
} @if(showAddButton()){ -
+
+ className="!rounded-full shadow-sm !mb-0" (buttonClicked)="onAddClick($event)" />
} @@ -27,7 +27,7 @@
} --> @if (showSearch()) { -
+
@@ -114,7 +114,7 @@ } @else { - @for (row of rows(); track row['id']; let rowIndex = $index) { + @for (row of rows(); track row['id'] ?? $index; let rowIndex = $index) { @for (column of columns(); track column.key) { diff --git a/src/app/shared/components/data-table/data-table.store.ts b/src/app/shared/components/data-table/data-table.store.ts index e881eec4..ce426230 100644 --- a/src/app/shared/components/data-table/data-table.store.ts +++ b/src/app/shared/components/data-table/data-table.store.ts @@ -58,9 +58,8 @@ export class DataTableStore { + const rowsList = response.rows || []; + const mappedRows: TRow[] = rowsList.map((item, index) => { const serialNumber = (currentQuery.page - 1) * currentQuery.pageSize + index + 1; if (options.mapRow) { return options.mapRow(item, serialNumber, currentQuery); @@ -72,8 +71,8 @@ export class DataTableStore