Merge pull request 'Feature/onboarding workflow sc' (#9) from feature/onboarding-workflow-sc into dev

Reviewed-on: sc/syscom-master-admin-ui#9
This commit is contained in:
2026-07-30 14:59:10 +00:00
53 changed files with 2060 additions and 1941 deletions
+1 -12
View File
@@ -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,
-529
View File
@@ -1,529 +0,0 @@
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
<div>
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome back,
Json Taylor !</p>
<p class="font-normal text-[#8c9097] dark:text-white/50 text-[0.813rem]">Track your sales activity, leads and deals
here.</p>
</div>
<div class="btn-list md:mt-0 mt-2">
<button type="button"
class="ti-btn bg-primary text-white btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-filter-3-fill inline-block"></i>Filters
</button>
<button type="button"
class="ti-btn ti-btn-outline-secondary btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-upload-cloud-line inline-block"></i>Export
</button>
</div>
</div>
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-9 xl:col-span-12 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-4 xl:col-span-4 col-span-12">
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box crm-highlight-card">
<div class="box-body">
<div class="flex items-center justify-between">
<div>
<div class="font-semibold text-[1.125rem] text-white mb-2">Your target is incomplete</div>
<span class="block text-[0.75rem] text-white"><span class="opacity-[0.7]">You have
completed</span>
<span class="font-semibold text-warning"> 48%</span> <span class="opacity-[0.7]"> of the given
target, you can also check your status</span>.</span>
<span class="block font-semibold mt-[0.125rem]"><a class="text-white text-[0.813rem]"
href="javascript:void(0);"><u>Click
here</u></a></span>
</div>
<div>
<spk-apexcharts id="crm-main" [chartOptions]="YourtargetisincompleteChart" />
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header flex justify-between">
<div class="box-title">
Top Deals
</div>
<div class="hs-dropdown ti-dropdown">
<a aria-label="anchor" href="javascript:void(0);"
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
aria-expanded="false">
<i class="fe fe-more-vertical text-[0.8rem]"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Month</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Year</a></li>
</ul>
</div>
</div>
<div class="box-body">
<ul class="list-none crm-top-deals mb-0">
@for (deal of topDeals; track $index) {
<li [class]="!$last ?'mb-[0.9rem]':''">
<div class="flex items-start flex-wrap">
<div class="me-2">
<span class="inline-flex items-center justify-center">
@if (deal.avatarImg) {
<img [src]="deal.avatarImg" [alt]="deal.name"
class="w-[1.75rem] h-[1.75rem] leading-[1.75rem] rounded-full">
} @else {
<span
[class]="`inline-flex items-center justify-center !w-[1.75rem] !h-[1.75rem] leading-[1.75rem] text-[0.65rem] rounded-full font-semibold bg-${deal.color}/10 text-${deal.color}`">
{{ deal.initials }}
</span>
}
</span>
</div>
<div class="flex-grow">
<p class="font-semibold mb-[1.4px] text-[0.813rem]">
{{ deal.name }}
</p>
<p class="text-[#8c9097] dark:text-white/50 text-[0.75rem]">
{{ deal.email }}
</p>
</div>
<div class="font-semibold text-[0.9375rem]">
{{ deal.amount }}
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">Profit Earned</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="px-2 font-normal text-[0.75rem] text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body !py-0 !ps-0">
<spk-apexcharts [chartOptions]="profitEarnedChart" id="crm-profits-earned" />
</div>
</div>
</div>
</div>
<div class="2xl:col-span-8 xl:col-span-8 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
@for (item of crmCards; track $index) {
<div class="2xl:col-span-6 xl:col-span-6 col-span-12">
<div class="box overflow-hidden">
<div class="box-body">
<div class="flex items-top justify-between">
<div>
<span
[class]="`!text-[0.8rem] !w-[2.5rem] !h-[2.5rem] !leading-[2.5rem] !rounded-full inline-flex items-center justify-center bg-${item.color}`">
<i [class]="`${item.icon} text-[1rem] text-white`"></i>
</span>
</div>
<div class="flex-grow ms-4">
<div class="flex items-center justify-between flex-wrap">
<div>
<p class="text-[#8c9097] dark:text-white/50 text-[0.813rem] mb-0">{{item.title}}</p>
<h4 class="font-semibold text-[1.5rem] !mb-2 ">{{item.number}}</h4>
</div>
<spk-apexcharts [id]="item.chartId" [chartOptions]="item.chartoptions" />
</div>
<div class="flex items-center justify-between !mt-1">
<div>
<a [class]="`text-${item.color} text-[0.813rem]`" href="javascript:void(0);">View All<i
class="ti ti-arrow-narrow-right ms-2 font-semibold inline-block"></i></a>
</div>
<div class="text-end">
<p [class]="`mb-0 ${item.percentageColor} text-[0.813rem] font-semibold`">{{item.percentage}}
</p>
<p class="text-[#8c9097] dark:text-white/50 opacity-[0.7] text-[0.6875rem]">this month</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header !gap-0 !m-0 justify-between">
<div class="box-title">
Revenue Analytics
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);"
class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50" aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body !py-5">
<spk-apexcharts [id]="'crm-revenue-analytics'" [chartOptions]="RevenueAnalytics" />
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box custom-card">
<div class="box-header justify-between">
<div class="box-title">
Deals Statistics
</div>
<div class="flex flex-wrap gap-2">
<div>
<input class="ti-form-control form-control-sm" type="text" placeholder="Search Here"
aria-label=".form-control-sm example">
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);"
class="ti-btn ti-btn-primary !bg-primary !text-white !py-1 !px-2 !text-[0.75rem] !m-0 !gap-0 !font-medium"
aria-expanded="false">
Sort By<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">New</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Popular</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Relevant</a></li>
</ul>
</div>
</div>
</div>
<div class="box-body">
<div class="overflow-x-auto">
<div class="table-responsive">
<spk-reusable-tables [columns]="SalesRepData.columns" [showCheckbox]="true"
tableClass="table min-w-full whitespace-nowrap table-hover border table-bordered">
@for (row of SalesRepData.rows; track row.id) {
<tr class="border !border-defaultborder border-solid hover:bg-gray-100 dark:!border-defaultborder/10 dark:hover:!bg-light">
<td scope="row" class="">
<input class="form-check-input" type="checkbox" [checked]="row.checked" aria-label="...">
</td>
<td>
<div class="flex items-center font-semibold">
<span class="!me-2 inline-flex justify-center items-center">
<img [src]="row.img" alt="img" class="w-[1.75rem] h-[1.75rem] rounded-full">
</span>
{{ row.name }}
</div>
</td>
<td>{{ row.category }}</td>
<td>{{ row.mail }}</td>
<td>
<span
[class]="'inline-flex text-' + row.locationClass + ' !py-[0.15rem] !px-[0.45rem] rounded-sm !font-semibold !text-[0.75em] bg-' + row.locationClass + '/10'">
{{ row.location }}
</span>
</td>
<td>{{ row.date }}</td>
<td>
<div class="flex flex-row items-center !gap-2 text-[0.9375rem]">
<a aria-label="anchor" href="javascript:void(0);"
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-success/10 text-success hover:bg-success hover:!text-white hover:!border-success">
<i class="ri-download-2-line"></i>
</a>
<a aria-label="anchor" href="javascript:void(0);"
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-primary/10 text-primary hover:bg-primary hover:!text-white hover:!border-primary">
<i class="ri-edit-line"></i>
</a>
</div>
</td>
</tr>
}
</spk-reusable-tables>
</div>
</div>
</div>
<div class="box-footer">
<div class="sm:flex items-center">
<div class="text-defaulttextcolor dark:text-defaulttextcolor/70">
Showing 5 Entries <i class="bi bi-arrow-right ms-2 font-semibold"></i>
</div>
<div class="ms-auto">
<nav aria-label="Page navigation" class="pagination-style-4">
<ul class="ti-pagination mb-0">
<li class="page-item disabled">
<a class="page-link" href="javascript:void(0);">
Prev
</a>
</li>
<li class="page-item"><a class="page-link active" href="javascript:void(0);">1</a></li>
<li class="page-item"><a class="page-link" href="javascript:void(0);">2</a></li>
<li class="page-item">
<a class="page-link !text-primary" href="javascript:void(0);">
next
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-3 xl:col-span-12 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Leads By Source
</div>
<div class="hs-dropdown ti-dropdown">
<a aria-label="anchor" href="javascript:void(0);"
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] ! !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
aria-expanded="false">
<i class="fe fe-more-vertical text-[0.8rem]"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Month</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Year</a></li>
</ul>
</div>
</div>
<div class="box-body !p-6 mt-4">
<div class="leads-source-chart flex items-center justify-center">
<spk-chartjs [id]="'leads-source'" [class]="'chartjs-chart w-full'" [height]="250" [chartjs]="LeadsBySourceChart" />
<div class="lead-source-value ">
<span class="block text-[0.875rem] ">Total</span>
<span class="block text-[1.5625rem] font-bold">4,145</span>
</div>
</div>
</div>
<div class="grid grid-cols-4 mt-4 border-t border-dashed border-defaultborder dark:border-defaultborder/10">
@for (item of deviceLeads; track item.label) {
<div class="col !p-0">
<div [class]="item.containerClass">
<span class="text-[#8c9097] dark:text-white/50 text-[0.75rem] mb-1 crm-lead-legend inline-block"
[class]="item.legendClass">
{{ item.label }}
</span>
<div>
<span class="text-[1rem] font-semibold">{{ item.value }}</span>
</div>
</div>
</div>
}
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Deals Status
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body">
<div class="flex items-center mb-[0.8rem]">
<h4 class="font-bold mb-0 text-[1.5rem] ">4,289</h4>
<div class="ms-2">
<span
class="py-[0.18rem] px-[0.45rem] rounded-sm text-success !font-medium !text-[0.75em] bg-success/10">1.02<i
class="ri-arrow-up-s-fill align-mmiddle ms-1"></i></span>
<span class="text-[#8c9097] dark:text-white/50 text-[0.813rem] ms-1">compared to last week</span>
</div>
</div>
<div class="flex w-full h-[0.3125rem] mb-6 rounded-full overflow-hidden">
<div class="flex flex-col justify-center rounded-s-[0.625rem] overflow-hidden bg-primary w-[21%]"
aria-valuenow="21" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-info w-[26%]" aria-valuenow="26"
aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-warning w-[35%]"
aria-valuenow="35" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-e-[0.625rem] overflow-hidden bg-success w-[18%]"
aria-valuenow="18" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<ul class="list-none mb-0 pt-2 crm-deals-status">
@for (deal of dealStats; track deal.title) {
<li [class]="deal.liClass">
<div class="flex items-center text-[0.813rem] justify-between">
<div>{{ deal.title }}</div>
<div class="text-[0.75rem] text-[#8c9097] dark:text-white/50">
{{ deal.count }}
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Recent Activity
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body">
<div>
<ul class="list-none mb-0 crm-recent-activity">
@for (item of recentActivity; track $index) {
<li class="crm-recent-activity-content text-defaultsize">
<div class="flex items-start">
<div class="me-4">
<span
class="w-[1.25rem] h-[1.25rem] inline-flex items-center justify-center font-medium leading-[1.25rem] text-[0.65rem] rounded-full"
[class]="item.statusClass">
<i class="bi bi-circle-fill text-[0.5rem]"></i>
</span>
</div>
<div class="crm-timeline-content">
<span [class.font-semibold]="item.boldMain">{{ item.mainText }}</span>
@if (item.linkText) {
<a href="javascript:void(0);" [class]="item.linkClass">{{ item.linkText }}</a>
}
@if (item.boldText) {
<span class="font-semibold">{{ item.boldText }}</span>
}
@if (item.highlightText) {
<span [class]="item.highlightClass">{{ item.highlightText }}</span>
}
@if (item.tagText) {
<span [class]="item.tagClass">{{ item.tagText }}</span>
}
@if (item.afterText) {
<span>{{ item.afterText }}</span>
}
@if (item.showAddIcon) {
<span
class="w-[1.25rem] h-[1.25rem] leading-[1.25rem] text-[0.65rem] inline-flex items-center justify-center font-medium bg-purple/10 rounded-full ms-1">
<i class="ri-add-fill text-purple text-[0.75rem]"></i>
</span>
}
@if (item.showCheckIcon) {
<i class="ri-checkbox-circle-line text-success text-[1rem] align-middle ms-1"></i>
}
@if (item.subText) {
<span class="block text-[0.75rem] text-[#8c9097] dark:text-white/50">
{{ item.subText }}
</span>
}
</div>
<div class="flex-grow text-end">
<span class="block text-[#8c9097] dark:text-white/50 text-[0.6875rem] opacity-[0.7]">
{{ item.time }}
</span>
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
-618
View File
@@ -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' }
];
}
@@ -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',
},
];
-15
View File
@@ -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'
},
]
}
];
@@ -1,27 +0,0 @@
<div class="page error-bg dark:!bg-bodybg" id="particles-js">
<!-- Start::error-page -->
<spk-particles id="tsparticles" [options]="particlesOptions" />
<div class="error-page">
<div class="container text-defaulttextcolo dark:text-defaulttextcolor/70r text-defaultsize">
<div class="text-center p-5 my-auto">
<div class="flex items-center justify-center h-full !text-defaulttextcolor">
<div class="xl:col-span-3"></div>
<div class="xl:col-span-6 col-span-12">
<p class="error-text sm:mb-0 mb-2">404</p>
<p class="text-[1.125rem] font-semibold mb-4">Oops 😭,The page you are looking for is not available.</p>
<div class="flex justify-center items-center mb-[3rem]">
<div class="xl:col-span-6 w-[50%]">
<p class="mb-0 opacity-[0.7]">We are sorry for the inconvenience,The page you are trying to access has
been removed or never been existed.</p>
</div>
</div>
<a routerLink="/dashboards/crm" class="ti-btn bg-primary text-white font-semibold"><i
class="ri-arrow-left-line align-middle inline-block"></i>BACK TO HOME</a>
</div>
<div class="xl:col-span-3"></div>
</div>
</div>
</div>
</div>
</div>
@@ -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
}
@@ -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"
}
}
};
-37
View File
@@ -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);
})
);
};
-53
View File
@@ -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);
})
);
};
@@ -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;
}
}
@@ -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<PermissionContext | null>(null);
loadPermissions(): Observable<PermissionContext> {
return this.http.get<PermissionResponse>(``).pipe(
return this.http.get<PermissionResponse>(
`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}`
).pipe(
map((response) => ({
roles: response.roles ?? [],
permissions: response.permissions ?? [],
@@ -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<TenantContext | null>(null);
loadTenantContext(): Observable<TenantContext> {
return this.http.get<TenantContext>(``).pipe(
return this.http.get<TenantContext>(
`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`
).pipe(
tap((context) => {
this.tenantContext.set(context);
})
@@ -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<CurrentUserContext | null>(null);
loadCurrentUserProfile(): Observable<CurrentUserContext> {
return this.http.get<Partial<CurrentUserContext>>(``).pipe(
return this.http.get<Partial<CurrentUserContext>>(
`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}`
).pipe(
map((response) => ({
id: response.id?.trim() ?? '',
email: response.email?.trim() ?? '',
@@ -60,11 +60,11 @@
<label for="signin-password" class="block text-sm font-semibold text-slate-800">Password</label>
<a routerLink="/authentication/reset-password/basic"
class="text-xs sm:text-sm font-semibold text-[#7c3aed] hover:text-[#6d28d9] transition-colors">
Forgot password?
Forgot Password?
</a>
</div>
<div class="relative flex items-center">
<input [type]="visibilityMap['Angular'] ? 'text' : 'password'" id="signin-password" placeholder="password"
<input [type]="visibilityMap['Angular'] ? 'text' : 'password'" id="signin-password" placeholder="Password"
formControlName="password" autocomplete="current-password"
class="login-input w-full pl-4 pr-11 py-3 bg-white border border-slate-300 rounded-xl text-base font-semibold text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-400/50 focus:border-purple-500 focus:bg-white transition-all duration-200" />
<button type="button" (click)="toggleVisibility('Angular')"
@@ -94,7 +94,7 @@
<input type="checkbox" id="rememberMe" formControlName="rememberMe"
class="w-4 h-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500 cursor-pointer" />
<label for="rememberMe" class="text-xs sm:text-sm text-slate-700 font-semibold cursor-pointer select-none">
Remember password?
Remember Password?
</label>
</div>
@@ -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: () => {
@@ -1,4 +1,4 @@
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
<!-- <div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
<div>
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome
back,
@@ -17,13 +17,13 @@
<i class="ri-upload-cloud-line inline-block"></i>Export
</button>
</div>
</div>
</div> -->
<div class="grid grid-cols-12 gap-6">
<div class="grid grid-cols-12 gap-x-6 gap-y-4">
@for (card of statCards(); track card.title) {
<div class="xxl:col-span-2 xl:col-span-2 md:col-span-6 col-span-12">
<div [class]="'box overflow-hidden border-t-[3px] ' + card.accentClass">
<div [class]="'box overflow-hidden border-t-[3px] !mb-0 ' + card.accentClass">
<div class="box-body">
<div class="flex items-start justify-between gap-4">
<div class="text-center">
@@ -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"
@@ -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<OrganizationListRow, OrganizationListRow>);
private readonly organizationApi = inject(OrganizationService);
readonly tableStore = inject(DataTableStore<RecentOrganizationDto, RecentOrganizationTableRow>);
readonly selectedMonths = signal<number | undefined>(undefined);
readonly selectedLimit = signal<number | undefined>(undefined);
readonly statCards = signal<DashboardStatCard[]>([
{
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<OrganizationListRow[]>([
{
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<DataTableColumn<OrganizationListRow>[]>([
readonly columns = signal<DataTableColumn<RecentOrganizationTableRow>[]>([
{ 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<string>('No Organizations');
readonly emptyDescription = signal<string>('Start by adding your first organization');
readonly actions = signal<DataTableAction<OrganizationListRow>[]>([
readonly actions = signal<DataTableAction<RecentOrganizationTableRow>[]>([
{ 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<OrganizationListRow> = {
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<OrganizationListRow>): void {
onActionClick(event: DataTableActionEvent<RecentOrganizationTableRow>): 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}`);
}
}
@@ -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;
@@ -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<RecentOrganizationsDashboardDto> {
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<RecentOrganizationsDashboardDto>(ORGANIZATION_ENDPOINTS.dashboard, { params });
}
getOrganizationDataTable(query: DataTableQuery, countryId?: string | null): Observable<DataTableResult<OrganizationDto>> {
const payload = {
...query,
...(countryId ? { countryId } : {})
};
return this.http.post<DataTableResult<OrganizationDto>>(ORGANIZATION_ENDPOINTS.dataTable, payload);
}
getOrganizationById(id: string): Observable<OrganizationDto> {
return this.http.get<OrganizationDto>(ORGANIZATION_ENDPOINTS.getById(id));
}
autocomplete(term?: string, limit = 10): Observable<readonly OrganizationLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly OrganizationLookupDto[]>(ORGANIZATION_ENDPOINTS.autocomplete, { params });
}
}
@@ -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[];
}
@@ -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"
@@ -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<OrganizationListRow, OrganizationListRow>);
private readonly organizationApi = inject(OrganizationService);
readonly tableStore = inject(DataTableStore<OrganizationDto, OrganizationTableRow>);
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
readonly appliedCountryId = signal<string | null>(null);
@@ -59,104 +45,11 @@ export class OrganizationList implements OnInit {
countryId: [''],
});
readonly allOrganizations = signal<OrganizationListRow[]>([
{
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<CountryLookupDto> = (term, limit) => this.countryApi.autocomplete(term, limit);
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
readonly columns = signal<DataTableColumn<OrganizationListRow>[]>([
readonly columns = signal<DataTableColumn<OrganizationTableRow>[]>([
{ 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<DataTableAction<OrganizationListRow>[]>([
readonly actions = signal<DataTableAction<OrganizationTableRow>[]>([
{ 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<OrganizationListRow> = {
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<OrganizationListRow>): void {
onActionClick(event: DataTableActionEvent<OrganizationTableRow>): 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 {
}
}
@@ -110,6 +110,25 @@
<!-- Button Group Footer -->
<footer class="modern-modal-footer mt-5 px-2 sm:px-6">
@if (isEditMode()) {
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
<!-- Cancel -->
<div class="w-full sm:w-auto">
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestCancel()"></app-button>
</div>
<!-- Save Changes -->
<div class="w-full sm:w-auto">
<app-button action="update" label="Update" loadingLabel="Updating..." [showIcon]="true"
[loading]="finishing() || savingDraft()" [disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true" className="onboarding-nav-btn justify-center sm:w-44"
(buttonClicked)="requestSaveChanges()"></app-button>
</div>
</div>
} @else {
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
<!-- Secondary Actions: Cancel/Back & Save Draft (Left group on Desktop, Bottom row on Mobile) -->
<div class="grid grid-cols-2 gap-2.5 w-full sm:flex sm:w-auto sm:items-center sm:gap-3">
@@ -153,6 +172,7 @@
}
</div>
</div>
}
</footer>
<!-- End Button Group Footer -->
</div>
@@ -24,6 +24,8 @@ export class OnboardingStepper {
readonly currentStepIndex = input.required<number>();
readonly completedStepIndexes = input<readonly number[]>([]);
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<void>();
readonly nextClicked = output<void>();
readonly saveDraftClicked = output<void>();
readonly saveChangesClicked = output<void>();
readonly finishClicked = output<void>();
readonly cancelClicked = output<void>();
@@ -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() ||
@@ -0,0 +1,126 @@
<div class="w-full">
<!-- Tab Bar Navigation Card -->
<div class="tabs-header-card mb-4 sm:mb-6">
<!-- Mobile Current Step Indicator Banner (< 640px) -->
<div
class="tabs-mobile-banner mb-3 flex items-center justify-between rounded-lg px-3 py-2 text-xs font-medium sm:hidden">
<span class="flex items-center gap-1.5 min-w-0 truncate">
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[0.65rem] font-bold text-white">
{{ currentStepIndex() + 1 }}
</span>
<span class="truncate">Step {{ currentStepIndex() + 1 }} of {{ steps().length }}: <strong
class="font-semibold">{{ currentStepLabel() }}</strong></span>
</span>
<span class="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[0.6875rem] font-semibold text-primary ms-2">
{{ getProgressPercent() }}%
</span>
</div>
<!-- Tab Bar Track -->
<nav aria-label="Organization onboarding progress" class="tabs-bar-nav">
<ul class="tabs-bar-list" role="tablist">
@for (step of steps(); track step.key; let index = $index) {
<li class="tabs-bar-item" role="presentation">
<button type="button" role="tab" class="tab-btn group"
[class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)"
[class.cursor-pointer]="canOpenStep(index)" [class.cursor-default]="!canOpenStep(index)"
[attr.aria-selected]="isCurrentStep(index)"
[disabled]="!canOpenStep(index)" (click)="selectStep(index)">
<span class="tab-index-badge">
@if (isCompletedStep(index) && !isCurrentStep(index)) {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="size-3.5">
<path d="M5 13l4 4L19 7" />
</svg>
} @else {
<span>{{ index + 1 }}</span>
}
</span>
<span class="tab-label">{{ step.label }}</span>
</button>
</li>
}
</ul>
</nav>
</div>
<!-- End Tab Bar Navigation Card -->
<!-- Tab Form Content -->
<div>
<div class="tabs-content-box rounded-xl p-3.5 sm:p-6">
<ng-content />
</div>
<!-- Button Group Footer -->
<footer class="modern-modal-footer mt-5 px-2 sm:px-6">
@if (isEditMode()) {
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
<!-- Cancel -->
<div class="w-full sm:w-auto">
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestCancel()"></app-button>
</div>
<!-- Save Changes -->
<div class="w-full sm:w-auto">
<app-button action="update" label="Update" loadingLabel="Updating..." [showIcon]="true"
[loading]="finishing() || savingDraft()" [disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true" className="onboarding-nav-btn justify-center sm:w-44"
(buttonClicked)="requestSaveChanges()"></app-button>
</div>
</div>
} @else {
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
<!-- Secondary Actions: Cancel/Back & Save Draft (Left group on Desktop, Bottom row on Mobile) -->
<div class="grid grid-cols-2 gap-2.5 w-full sm:flex sm:w-auto sm:items-center sm:gap-3">
<!-- Cancel on Step 1, or Back on Step > 1 -->
<div class="min-w-0 sm:w-auto">
@if (isFirstStep()) {
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestCancel()"></app-button>
} @else {
<app-button action="previous" [variant]="'custom'" label="Back" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestBack()"></app-button>
}
</div>
<!-- Save Draft -->
<div class="min-w-0 sm:w-auto">
<app-button action="save" [variant]="'custom'" label="Save Draft" loadingLabel="Saving..." [showIcon]="true"
[loading]="savingDraft()" [disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-36"
(buttonClicked)="requestSaveDraft()"></app-button>
</div>
</div>
<!-- Primary Action: Next or Finish & Provision (Top row on Mobile, Far Right on Desktop) -->
<div class="w-full sm:w-auto">
@if (!isLastStep()) {
<app-button action="next" label="Next" [showIcon]="true" [iconPosition]="'right'"
[disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
className="onboarding-nav-btn justify-center sm:w-36" (buttonClicked)="requestNext()"></app-button>
} @else {
<app-button action="approve" label="Finish & Provision" loadingLabel="Preparing..." [showIcon]="true"
[loading]="finishing()" [disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
className="onboarding-nav-btn justify-center sm:w-44" (buttonClicked)="requestFinish()"></app-button>
}
</div>
</div>
}
</footer>
<!-- End Button Group Footer -->
</div>
<!-- End Tab Form Content -->
</div>
@@ -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;
}
}
@@ -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 OnboardingStep[]>();
readonly currentStepIndex = input.required<number>();
readonly completedStepIndexes = input<readonly number[]>([]);
readonly isEditMode = input(false);
readonly savingDraft = input(false);
readonly finishing = input(false);
readonly navigationDisabled = input(false);
readonly stepSelected = output<number>();
readonly backClicked = output<void>();
readonly nextClicked = output<void>();
readonly saveDraftClicked = output<void>();
readonly saveChangesClicked = output<void>();
readonly finishClicked = output<void>();
readonly cancelClicked = output<void>();
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();
}
}
@@ -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)}`),
@@ -33,8 +33,19 @@ const INITIAL_DRAFT_STATE: OnboardingDraftState = {
admin: null,
};
type RawDraftRecord = Record<string, unknown>;
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<string | null>(null);
private readonly onboardingDataState = signal<OrganizationOnboardingData>(INITIAL_DATA);
private readonly onboardingDraftState = signal<OnboardingDraftState>(INITIAL_DRAFT_STATE);
@@ -43,6 +54,7 @@ export class OrganizationOnboardingStateService {
private readonly savedDraftIdState = signal<string | null>(null);
private readonly savedAtState = signal<string | null>(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) : '';
}
@@ -10,6 +10,7 @@ import {
FiscalYearConventionValue,
NumberFormatValue,
OnboardingLookupValue,
OrganizationCodePreviewDto,
OrganizationLicenseType,
OrganizationPlanDefaults,
OrganizationPlanLookupValue,
@@ -164,6 +165,24 @@ export class OrganizationOnboardingService {
return this.http.post<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.finish(id), {});
}
getNextOrganizationCode(): Observable<string | null> {
return this.http.get<OrganizationCodePreviewDto | string>(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<readonly OnboardingLookupValue[]> {
@@ -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<TValue> {
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<OrganizationAdminValue>,
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,
@@ -1,8 +1,63 @@
<ng-template #stepFormContent>
<div [class.hidden]="currentStep().key !== 'basics'" [attr.aria-hidden]="currentStep().key !== 'basics'">
<app-organization-basics-step />
</div>
<div [class.hidden]="currentStep().key !== 'localization'" [attr.aria-hidden]="currentStep().key !== 'localization'">
<app-organization-localization-step />
</div>
<div [class.hidden]="currentStep().key !== 'plan-limits'" [attr.aria-hidden]="currentStep().key !== 'plan-limits'">
<app-organization-plan-limits-step />
</div>
<div [class.hidden]="currentStep().key !== 'admin-user'" [attr.aria-hidden]="currentStep().key !== 'admin-user'">
<app-organization-admin-step [reviewData]="stateService.onboardingData()" />
</div>
</ng-template>
<div class="onboarding-header mb-3 flex items-center justify-end gap-2 sm:mb-4">
<span class="view-toggle-label hidden text-xs font-medium text-[#334155] dark:text-[#cbd5e1] sm:inline sm:text-sm">
Tabs view
</span>
<button
type="button"
role="switch"
[attr.aria-checked]="useTabsView()"
aria-label="Tabs view"
class="view-toggle-switch"
[class.is-on]="useTabsView()"
(click)="onToggleView()"
>
<span class="view-toggle-thumb" aria-hidden="true"></span>
</button>
</div>
<section
class="rounded-xl border border-defaultborder bg-white
p-3.5 sm:p-5 shadow-sm dark:border-white/10 dark:bg-bodybg"
>
@if (useTabsView()) {
<onboarding-tabs
[steps]="steps"
[currentStepIndex]="currentStepIndex()"
[completedStepIndexes]="completedStepIndexes()"
[savingDraft]="savingDraft()"
[finishing]="finishing()"
[navigationDisabled]="navigationDisabled()"
[isEditMode]="stateService.isEditMode()"
(stepSelected)="onStepSelected($event)"
(backClicked)="onBack()"
(nextClicked)="onNext()"
(saveDraftClicked)="onSaveDraft()"
(saveChangesClicked)="onUpdateCurrentStep()"
(finishClicked)="onFinish()"
(cancelClicked)="onCancel()"
>
<ng-container [ngTemplateOutlet]="stepFormContent" />
</onboarding-tabs>
} @else {
<onboarding-stepper
[steps]="steps"
[currentStepIndex]="currentStepIndex()"
@@ -10,29 +65,18 @@
[savingDraft]="savingDraft()"
[finishing]="finishing()"
[navigationDisabled]="navigationDisabled()"
[isEditMode]="stateService.isEditMode()"
(stepSelected)="onStepSelected($event)"
(backClicked)="onBack()"
(nextClicked)="onNext()"
(saveDraftClicked)="onSaveDraft()"
(saveChangesClicked)="onUpdateCurrentStep()"
(finishClicked)="onFinish()"
(cancelClicked)="onCancel()"
>
<div [class.hidden]="currentStep().key !== 'basics'" [attr.aria-hidden]="currentStep().key !== 'basics'">
<app-organization-basics-step />
</div>
<div [class.hidden]="currentStep().key !== 'localization'" [attr.aria-hidden]="currentStep().key !== 'localization'">
<app-organization-localization-step />
</div>
<div [class.hidden]="currentStep().key !== 'plan-limits'" [attr.aria-hidden]="currentStep().key !== 'plan-limits'">
<app-organization-plan-limits-step />
</div>
<div [class.hidden]="currentStep().key !== 'admin-user'" [attr.aria-hidden]="currentStep().key !== 'admin-user'">
<app-organization-admin-step [reviewData]="stateService.onboardingData()" />
</div>
<ng-container [ngTemplateOutlet]="stepFormContent" />
</onboarding-stepper>
}
</section>
<app-confirm-dialog
@@ -0,0 +1,45 @@
.view-toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: 2.5rem;
height: 1.375rem;
border-radius: 9999px;
border: none;
padding: 0;
cursor: pointer;
background-color: #cbd5e1;
transition: background-color 0.2s ease;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px var(--primary, #7c3deb);
}
&.is-on {
background: var(--primary, #7c3deb);
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
}
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
background-color: rgba(255, 255, 255, 0.2);
}
}
.view-toggle-thumb {
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 1rem;
height: 1rem;
border-radius: 9999px;
background-color: #ffffff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: transform 0.2s ease;
.view-toggle-switch.is-on & {
transform: translateX(1.125rem);
}
}
@@ -0,0 +1,197 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { OrganizationOnboarding } from './organization-onboarding';
import { 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 { OrganizationOnboardingStateService } from './data-access/services/organization-onboarding-state.service';
const VIEW_PREFERENCE_STORAGE_KEY = 'org-onboarding:view-preference';
class InMemoryLocalStorage implements Storage {
private readonly store = new Map<string, string>();
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<ComponentFixture<OrganizationOnboarding>> => {
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<OrganizationOnboarding>): 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');
});
});
@@ -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<boolean>(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<unknown>;
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<unknown> {
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<OrganizationServerDraftResponse>;
}
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<OrganizationServerDraftResponse>;
}
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<OrganizationServerDraftResponse>;
}
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)
@@ -109,47 +109,37 @@
<div>
<dt class="text-xs text-textmuted">Organization</dt>
<dd class="font-medium text-defaulttextcolor dark:text-white">
{{ reviewData().basics?.organizationName || 'Not completed' }}
{{ organizationNameSummary() }}
</dd>
</div>
<div>
<dt class="text-xs text-textmuted">Country</dt>
<dd class="font-medium text-defaulttextcolor dark:text-white">
{{ reviewData().basics?.registrationCountry?.label || 'Not completed' }}
{{ countrySummary() }}
</dd>
</div>
<div>
<dt class="text-xs text-textmuted">Localization</dt>
<dd class="font-medium text-defaulttextcolor dark:text-white">
{{ reviewData().localization?.timeZone?.label || 'Not completed' }} ·
{{ reviewData().localization?.currency?.secondaryLabel || reviewData().localization?.currency?.label || '—' }}
{{ localizationSummary() }}
</dd>
</div>
<div>
<dt class="text-xs text-textmuted">Plan and limits</dt>
<dd class="font-medium text-defaulttextcolor dark:text-white">
{{ reviewData().planLimits?.subscriptionPlan?.label || 'Not completed' }}
@if (reviewData().planLimits) {
· {{ reviewData().planLimits?.maximumCompanies }} companies ·
{{ reviewData().planLimits?.maximumUsers }} users ·
{{ reviewData().planLimits?.maximumStorageGb }} GB
}
{{ planSummary() }}
</dd>
</div>
<div>
<dt class="text-xs text-textmuted">Access dates</dt>
<dd class="font-medium text-defaulttextcolor dark:text-white">
{{ reviewData().planLimits?.systemAccessStartDate || 'Not completed' }}
{{ reviewData().planLimits?.systemAccessEndDate || 'No end date' }}
{{ accessDatesSummary() }}
</dd>
</div>
<div>
<dt class="text-xs text-textmuted">Administrator</dt>
<dd class="font-medium text-defaulttextcolor dark:text-white">
{{ form.controls.administratorFullName.value || 'Not entered' }}
@if (form.controls.administratorEmail.value) {
· {{ form.controls.administratorEmail.value }}
}
{{ adminSummary() }}
</dd>
</div>
</dl>
@@ -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<Organi
administratorMobile: ['', [Validators.required]],
});
private readonly formValues = toSignal(this.form.valueChanges, {
initialValue: this.form.getRawValue(),
});
readonly organizationNameSummary = computed(() => {
const basics = this.reviewData()?.basics as Record<string, any> | null;
return (
basics?.['organizationName'] ||
basics?.['name'] ||
basics?.['OrganizationName'] ||
basics?.['Name'] ||
'Not completed'
);
});
readonly countrySummary = computed(() => {
const basics = this.reviewData()?.basics as Record<string, any> | 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<string, any> | 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<string, any> | 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<string, any> | 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<string, any> | 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<Organi
}
patchValue(value: Partial<OrganizationAdminValue> & Record<string, any>): 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);
}
@@ -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<OrganizationBasicsValue> {
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<Organ
private readonly toastr = inject(ToastrService);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly stateService = inject(OrganizationOnboardingStateService, { optional: true });
readonly submitAttempted = signal(false);
readonly organizationCodeControl = this.formBuilder.nonNullable.control('Pending generation');
readonly organizationTypeSelection = signal<OnboardingLookupValue | null>(null);
@@ -64,6 +70,22 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
registrationCountryId: this.formBuilder.control<string | null>(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<OnboardingLookupValue> = (term, limit) =>
this.onboardingService.searchOrganizationTypes(term, limit).pipe(
catchError(() => {
@@ -152,8 +174,11 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
throw new Error('Organization basics selections are incomplete.');
}
const codeControlVal = this.organizationCodeControl.value;
const codeVal = codeControlVal && codeControlVal !== 'Pending generation' ? codeControlVal : null;
return {
organizationCode: null,
organizationCode: codeVal,
organizationName: value.organizationName.trim(),
shortName: value.shortName.trim(),
localLanguageName: value.localLanguageName.trim() || null,
@@ -169,45 +194,126 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
getDraftValue(): Partial<OrganizationBasicsValue> {
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<OrganizationBasicsValue>): void {
patchValue(value: Partial<OrganizationBasicsValue> & Record<string, any>): void {
if (!value) return;
const draftBasics = this.stateService?.onboardingDraft()?.basics as Record<string, any> | 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);
}
@@ -289,12 +289,20 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
}
patchValue(value: Partial<OrganizationLocalizationValue> & Record<string, any>): 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);
}
@@ -114,23 +114,33 @@
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<app-form-date-picker
formControlName="systemAccessPeriod"
inputId="organization-system-access-period"
formControlName="systemAccessStartDate"
inputId="organization-system-access-start-date"
variant="floating"
label="System Access Period"
mode="range"
label="System Access From"
mode="single"
[required]="true"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'System Access Period is required.',
trialEndDateRequired: 'System Access End Date is required for Trial.'
}"
[validationMessages]="{ required: 'System Access From Date is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<app-form-date-picker
formControlName="systemAccessEndDate"
inputId="organization-system-access-end-date"
variant="floating"
label="System Access To"
mode="single"
[required]="requiresAccessEndDate()"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'System Access To Date is required for Trial.' }"
/>
</div>
@if (submitAttempted() && form.errors?.['accessDateRange']) {
<div class="col-span-12 text-sm text-danger">
System Access Start Date cannot be later than System Access End Date.
System Access From Date cannot be later than System Access To Date.
</div>
}
@@ -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<O
maximumUsers: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
maximumStorageGb: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
goLiveDate: this.formBuilder.control<string | null>(null, [Validators.required]),
systemAccessPeriod: this.formBuilder.control<DateRangeValue | null>(null, [Validators.required]),
systemAccessStartDate: this.formBuilder.control<string | null>(null, [Validators.required]),
systemAccessEndDate: this.formBuilder.control<string | null>(null),
}, { validators: accessDateValidator });
readonly requiresAccessEndDate = computed(() => this.form.controls.licenseType.value === 'Trial');
@@ -132,12 +134,12 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(licenseType => {
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<O
getValue(): OrganizationPlanLimitsValue {
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
const subscriptionPlan = this.planSelection();
const period = value.systemAccessPeriod;
const systemAccessStartDate = period?.from ?? null;
const systemAccessEndDate = period?.to ?? null;
const systemAccessStartDate = value.systemAccessStartDate;
const systemAccessEndDate = value.systemAccessEndDate ?? null;
if (!subscriptionPlan || !value.licenseType || !value.maximumCompanies || !value.maximumUsers || !value.maximumStorageGb || !value.goLiveDate || !systemAccessStartDate) {
throw new Error('Organization plan and limits values are incomplete.');
@@ -208,7 +209,6 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
getDraftValue(): Partial<OrganizationPlanLimitsValue> {
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<O
maximumUsers: value.maximumUsers ?? undefined,
maximumStorageGb: value.maximumStorageGb ?? undefined,
goLiveDate: value.goLiveDate ?? undefined,
systemAccessStartDate: period?.from ?? undefined,
systemAccessEndDate: period?.to ?? undefined,
systemAccessStartDate: value.systemAccessStartDate ?? undefined,
systemAccessEndDate: value.systemAccessEndDate ?? undefined,
limitsEditable: this.limitsEditable(),
};
}
patchValue(value: Partial<OrganizationPlanLimitsValue> & Record<string, any>): 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<O
this.planSelection.set(defaults.plan);
this.limitsEditable.set(defaults.limitsEditable);
const currentPeriod = this.form.controls.systemAccessPeriod.value;
let from = currentPeriod?.from ?? this.form.controls.goLiveDate.value ?? new Date().toISOString().split('T')[0];
let to = currentPeriod?.to ?? null;
let from = this.form.controls.systemAccessStartDate.value ?? this.form.controls.goLiveDate.value ?? new Date().toISOString().split('T')[0];
let to = this.form.controls.systemAccessEndDate.value ?? null;
if (defaults.licenseType === 'Trial' && defaults.defaultTrialDays && !to) {
const startDate = new Date(from);
@@ -286,7 +301,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
maximumCompanies: defaults.maximumCompanies,
maximumUsers: defaults.maximumUsers,
maximumStorageGb: defaults.maximumStorageGb,
systemAccessPeriod: (from || to) ? { from, to } : null,
systemAccessStartDate: from,
systemAccessEndDate: to,
};
this.form.patchValue(patch, { emitEvent: false });
@@ -265,10 +265,6 @@ export class TenantCurrencies {
return;
}
if (response.draw !== query.draw) {
return;
}
const rows: TenantCurrencyTableRow[] =
response.rows.map(
(tenantCurrency, index) => ({
@@ -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"
+3 -1
View File
@@ -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;
@@ -8,16 +8,16 @@
</h5>
<div class="flex flex-wrap items-center gap-3">
@if(showFilterButton()){
<div animate.enter="filter-btn-enter" animate.leave="filter-btn-leave">
<div animate.enter="filter-btn-enter" animate.leave="filter-btn-leave" class="flex items-center">
<app-button action="custom" icon="ti ti-filter" label="Filter" size="sm" iconClass="!text-[1rem]"
[variant]="filterActive() ? 'primary-full' : 'outline-primary'"
className="!rounded-full shadow-sm !whitespace-nowrap" (buttonClicked)="onFilterClick($event)" />
className="!rounded-full shadow-sm !whitespace-nowrap !mb-0" (buttonClicked)="onFilterClick($event)" />
</div>
}
@if(showAddButton()){
<div>
<div class="flex items-center">
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
className="!rounded-full shadow-sm" (buttonClicked)="onAddClick($event)" />
className="!rounded-full shadow-sm !mb-0" (buttonClicked)="onAddClick($event)" />
</div>
}
@@ -27,7 +27,7 @@
</div>
} -->
@if (showSearch()) {
<div class="search-wrapper">
<div class="search-wrapper flex items-center">
<i class="ri-search-line search-icon"></i>
<input #searchInput class="form-control form-control-sm brdr-clr" type="text"
[placeholder]="searchPlaceholder()" aria-label="table search" (input)="onSearch(searchInput.value)">
@@ -114,7 +114,7 @@
</tr>
} @else
{
@for (row of rows(); track row['id']; let rowIndex = $index) {
@for (row of rows(); track row['id'] ?? $index; let rowIndex = $index) {
<tr [class]="getRowClass(row, $index)" (click)="onRowClick(row)">
@for (column of columns(); track column.key) {
<td [class]="getCellClass(column)">
@@ -58,9 +58,8 @@ export class DataTableStore<TItem, TRow extends DataTableRecord = TItem & DataTa
if (!response) return;
const currentQuery = this.queryState.getQuery();
if (response.draw !== currentQuery.draw) return;
const mappedRows: TRow[] = response.rows.map((item, index) => {
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<TItem, TRow extends DataTableRecord = TItem & DataTa
});
this.rows.set(mappedRows);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
this.totalRecords.set(response.total ?? 0);
this.filteredRecords.set(response.filtered ?? response.total ?? 0);
});
this.load(this.queryState.getQuery());
-11
View File
@@ -1,13 +1,3 @@
export interface AppFirebaseConfig {
apiKey: string;
authDomain: string;
projectId: string;
storageBucket: string;
messagingSenderId: string;
appId: string;
measurementId: string;
}
export interface AppSessionTimeoutConfig {
warningAfterMs: number;
logoutAfterMs: number;
@@ -20,5 +10,4 @@ export interface AppEnvironment {
masterAdmin: string;
};
sessionTimeout: AppSessionTimeoutConfig;
firebase: AppFirebaseConfig;
}
-9
View File
@@ -10,13 +10,4 @@ export const environment: AppEnvironment = {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
},
firebase: {
apiKey: '********************************',
authDomain: '********************************',
projectId: '********************************',
storageBucket: '********************************',
messagingSenderId: '********************************',
appId: '********************************',
measurementId: '********************************',
},
};
-9
View File
@@ -16,15 +16,6 @@ export const environment: AppEnvironment = {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
},
firebase: {
apiKey: '********************************',
authDomain: '********************************',
projectId: '********************************',
storageBucket: '********************************',
messagingSenderId: '********************************',
appId: '********************************',
measurementId: '********************************',
},
};
/*
+2 -2
View File
@@ -54,8 +54,8 @@ body {
line-height: 1.25;
}
/* Remove unwanted bottom margin from primary-full buttons */
.ti-btn-primary-full {
/* Remove unwanted bottom margin from buttons */
.ti-btn {
margin-bottom: 0 !important;
}