code refactoring, remove duplicate gaurds and other files
This commit is contained in:
+1
-12
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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() ?? '',
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
+126
@@ -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>
|
||||
+159
@@ -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;
|
||||
}
|
||||
}
|
||||
+155
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
@@ -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)}`),
|
||||
|
||||
|
||||
+39
-20
@@ -33,6 +33,21 @@ const INITIAL_DRAFT_STATE: OnboardingDraftState = {
|
||||
admin: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The onboarding API mixes PascalCase (write requests) and camelCase (typed response fields)
|
||||
* for the same logical data. This raw shape lets restoreServerDraft() defensively read both
|
||||
* casings from a server draft payload without resorting to `any`.
|
||||
*/
|
||||
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);
|
||||
@@ -174,40 +189,42 @@ export class OrganizationOnboardingStateService {
|
||||
this.organizationIdState.set(serverDraft.id);
|
||||
this.isEditModeState.set(isEditMode);
|
||||
|
||||
const rawRecord = serverDraft as Record<string, any>;
|
||||
const orgCode = serverDraft.code ?? rawRecord['Code'] ?? rawRecord['code'] ?? rawRecord['organizationCode'] ?? rawRecord['OrganizationCode'] ?? null;
|
||||
const rawRecord = serverDraft as unknown as RawDraftRecord;
|
||||
const orgCode = firstDefined(
|
||||
serverDraft.code, rawRecord['Code'], rawRecord['code'], rawRecord['organizationCode'], rawRecord['OrganizationCode']
|
||||
) ?? null;
|
||||
|
||||
let basicsData = serverDraft.basics ?? rawRecord['Basics'] ?? rawRecord['basics'] ?? (
|
||||
rawRecord['name'] || rawRecord['Name'] || rawRecord['organizationName'] || rawRecord['OrganizationName'] || orgCode
|
||||
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: basicsData['code'] ?? basicsData['Code'] ?? basicsData['organizationCode'] ?? basicsData['OrganizationCode'] ?? orgCode,
|
||||
organizationCode: basicsData['organizationCode'] ?? basicsData['OrganizationCode'] ?? basicsData['code'] ?? basicsData['Code'] ?? orgCode,
|
||||
code: firstDefined(basicsData['code'], basicsData['Code'], basicsData['organizationCode'], basicsData['OrganizationCode'], orgCode),
|
||||
organizationCode: firstDefined(basicsData['organizationCode'], basicsData['OrganizationCode'], basicsData['code'], basicsData['Code'], orgCode),
|
||||
};
|
||||
}
|
||||
|
||||
const localizationData = serverDraft.localization ?? rawRecord['Localization'] ?? rawRecord['localization'] ?? (
|
||||
rawRecord['defaultTimezoneId'] || rawRecord['DefaultTimezoneId'] || rawRecord['defaultCurrencyId'] || rawRecord['DefaultCurrencyId'] || rawRecord['dateFormat'] || rawRecord['DateFormat']
|
||||
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 = serverDraft.planLimits ?? rawRecord['PlanLimits'] ?? rawRecord['planLimits'] ?? rawRecord['plan'] ?? rawRecord['Plan'] ?? (
|
||||
rawRecord['planId'] || rawRecord['PlanId'] || rawRecord['licenseType'] || rawRecord['LicenseType'] || rawRecord['maxCompanies'] || rawRecord['MaxCompanies']
|
||||
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 = serverDraft.admin ?? rawRecord['Admin'] ?? rawRecord['admin'] ?? rawRecord['adminContact'] ?? rawRecord['AdminContact'] ?? (
|
||||
rawRecord['adminEmail'] || rawRecord['AdminEmail'] || rawRecord['orgEmail'] || rawRecord['OrgEmail'] || rawRecord['administratorEmail'] || rawRecord['AdministratorEmail']
|
||||
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: basicsData ? { ...basicsData } : null,
|
||||
@@ -221,17 +238,19 @@ export class OrganizationOnboardingStateService {
|
||||
: (serverDraft.completedStepIndexes ?? []);
|
||||
|
||||
this.onboardingDataState.set({
|
||||
// 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 OrganizationBasicsValue
|
||||
? { ...basicsData } as unknown as OrganizationBasicsValue
|
||||
: null,
|
||||
localization: (isEditMode || completedIndexes.includes(1)) && localizationData
|
||||
? { ...localizationData } as OrganizationLocalizationValue
|
||||
? { ...localizationData } as unknown as OrganizationLocalizationValue
|
||||
: null,
|
||||
planLimits: (isEditMode || completedIndexes.includes(2)) && planLimitsData
|
||||
? { ...planLimitsData } as OrganizationPlanLimitsValue
|
||||
? { ...planLimitsData } as unknown as OrganizationPlanLimitsValue
|
||||
: null,
|
||||
admin: (isEditMode || completedIndexes.includes(3)) && adminData
|
||||
? { ...adminData } as OrganizationAdminValue
|
||||
? { ...adminData } as unknown as OrganizationAdminValue
|
||||
: null,
|
||||
});
|
||||
|
||||
|
||||
+19
@@ -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[]> {
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+57
-15
@@ -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()"
|
||||
@@ -19,22 +74,9 @@
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
+197
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
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);
|
||||
@@ -107,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;
|
||||
@@ -536,6 +550,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)
|
||||
|
||||
+16
@@ -70,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(() => {
|
||||
|
||||
+19
-9
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
+32
-31
@@ -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,8 +217,8 @@ 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(),
|
||||
};
|
||||
}
|
||||
@@ -226,29 +226,30 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
||||
patchValue(value: Partial<OrganizationPlanLimitsValue> & Record<string, any>): void {
|
||||
if (!value) return;
|
||||
|
||||
const rawLicenseType = value.licenseType ?? value['LicenseType'] ?? value['licenseType'];
|
||||
const rawLicenseType = value['licenseType'] ?? value['LicenseType'];
|
||||
const normalizedLicenseType = mapApiToLicenseType(rawLicenseType);
|
||||
|
||||
const planId = value.subscriptionPlan?.id ?? value['PlanId'] ?? value['subscriptionPlanId'] ?? value['planId'] ?? null;
|
||||
const planId = value['subscriptionPlan']?.id ?? value['PlanId'] ?? value['subscriptionPlanId'] ?? value['planId'] ?? null;
|
||||
|
||||
const subPlan = value.subscriptionPlan ?? (value['SubscriptionPlan'] || value['plan'] || value['Plan'] ? {
|
||||
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'] ?? value['systemAccessFrom'] ?? value['systemAccessStartDate'] ?? null;
|
||||
const to = value.systemAccessEndDate ?? value['SystemAccessTo'] ?? value['systemAccessTo'] ?? value['systemAccessEndDate'] ?? 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'] ?? value['maxCompanies'] ?? null,
|
||||
maximumUsers: value.maximumUsers ?? value['MaxUsers'] ?? value['maxUsers'] ?? null,
|
||||
maximumStorageGb: value.maximumStorageGb ?? value['MaxStorageGb'] ?? value['maxStorageGb'] ?? null,
|
||||
goLiveDate: value.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 });
|
||||
|
||||
if (subPlan && subPlan.id) {
|
||||
@@ -265,7 +266,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
||||
this.planSelection.set(null);
|
||||
}
|
||||
|
||||
this.limitsEditable.set(value.limitsEditable ?? true);
|
||||
this.limitsEditable.set(value['limitsEditable'] ?? true);
|
||||
this.applyLimitsEditableState();
|
||||
this.submitAttempted.set(false);
|
||||
}
|
||||
@@ -286,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);
|
||||
@@ -301,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 });
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,4 @@ export const environment: AppEnvironment = {
|
||||
warningAfterMs: 25 * 60 * 1000,
|
||||
logoutAfterMs: 30 * 60 * 1000,
|
||||
},
|
||||
firebase: {
|
||||
apiKey: '********************************',
|
||||
authDomain: '********************************',
|
||||
projectId: '********************************',
|
||||
storageBucket: '********************************',
|
||||
messagingSenderId: '********************************',
|
||||
appId: '********************************',
|
||||
measurementId: '********************************',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user