code refactoring, remove duplicate gaurds and other files

This commit is contained in:
Gagan7900
2026-07-30 17:07:12 +05:30
parent 368d6bd540
commit 2a6f85890f
36 changed files with 943 additions and 1500 deletions
+1 -12
View File
@@ -1,13 +1,8 @@
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { RouterOutlet, provideRouter, withRouterConfig } from '@angular/router';
import { provideRouter, withRouterConfig } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { App_Route } from './app.routes';
import { AngularFireModule } from '@angular/fire/compat';
import { environment } from '../environments/environment';
import { AngularFireAuthModule } from '@angular/fire/compat/auth';
import { AngularFireDatabaseModule } from '@angular/fire/compat/database';
import { AngularFirestoreModule } from '@angular/fire/compat/firestore';
import { ToastrModule } from 'ngx-toastr';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
import { authInterceptor } from './core/interceptors/auth.interceptor';
@@ -18,15 +13,9 @@ export const appConfig: ApplicationConfig = {
providers: [
provideRouter(App_Route, withRouterConfig({ onSameUrlNavigation: 'reload' })),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor,loadingInterceptor])),
RouterOutlet,
provideAnimations(),
AngularFireAuthModule,
AngularFirestoreModule,
AngularFireDatabaseModule,
AngularFireModule,
provideCharts(withDefaultRegisterables()),
importProvidersFrom(
// AngularFireModule.initializeApp(environment.firebase),
ToastrModule.forRoot({
timeOut: 1500,
closeButton: true,
-529
View File
@@ -1,529 +0,0 @@
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
<div>
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome back,
Json Taylor !</p>
<p class="font-normal text-[#8c9097] dark:text-white/50 text-[0.813rem]">Track your sales activity, leads and deals
here.</p>
</div>
<div class="btn-list md:mt-0 mt-2">
<button type="button"
class="ti-btn bg-primary text-white btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-filter-3-fill inline-block"></i>Filters
</button>
<button type="button"
class="ti-btn ti-btn-outline-secondary btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-upload-cloud-line inline-block"></i>Export
</button>
</div>
</div>
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-9 xl:col-span-12 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-4 xl:col-span-4 col-span-12">
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box crm-highlight-card">
<div class="box-body">
<div class="flex items-center justify-between">
<div>
<div class="font-semibold text-[1.125rem] text-white mb-2">Your target is incomplete</div>
<span class="block text-[0.75rem] text-white"><span class="opacity-[0.7]">You have
completed</span>
<span class="font-semibold text-warning"> 48%</span> <span class="opacity-[0.7]"> of the given
target, you can also check your status</span>.</span>
<span class="block font-semibold mt-[0.125rem]"><a class="text-white text-[0.813rem]"
href="javascript:void(0);"><u>Click
here</u></a></span>
</div>
<div>
<spk-apexcharts id="crm-main" [chartOptions]="YourtargetisincompleteChart" />
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header flex justify-between">
<div class="box-title">
Top Deals
</div>
<div class="hs-dropdown ti-dropdown">
<a aria-label="anchor" href="javascript:void(0);"
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
aria-expanded="false">
<i class="fe fe-more-vertical text-[0.8rem]"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Month</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Year</a></li>
</ul>
</div>
</div>
<div class="box-body">
<ul class="list-none crm-top-deals mb-0">
@for (deal of topDeals; track $index) {
<li [class]="!$last ?'mb-[0.9rem]':''">
<div class="flex items-start flex-wrap">
<div class="me-2">
<span class="inline-flex items-center justify-center">
@if (deal.avatarImg) {
<img [src]="deal.avatarImg" [alt]="deal.name"
class="w-[1.75rem] h-[1.75rem] leading-[1.75rem] rounded-full">
} @else {
<span
[class]="`inline-flex items-center justify-center !w-[1.75rem] !h-[1.75rem] leading-[1.75rem] text-[0.65rem] rounded-full font-semibold bg-${deal.color}/10 text-${deal.color}`">
{{ deal.initials }}
</span>
}
</span>
</div>
<div class="flex-grow">
<p class="font-semibold mb-[1.4px] text-[0.813rem]">
{{ deal.name }}
</p>
<p class="text-[#8c9097] dark:text-white/50 text-[0.75rem]">
{{ deal.email }}
</p>
</div>
<div class="font-semibold text-[0.9375rem]">
{{ deal.amount }}
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">Profit Earned</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="px-2 font-normal text-[0.75rem] text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body !py-0 !ps-0">
<spk-apexcharts [chartOptions]="profitEarnedChart" id="crm-profits-earned" />
</div>
</div>
</div>
</div>
<div class="2xl:col-span-8 xl:col-span-8 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
@for (item of crmCards; track $index) {
<div class="2xl:col-span-6 xl:col-span-6 col-span-12">
<div class="box overflow-hidden">
<div class="box-body">
<div class="flex items-top justify-between">
<div>
<span
[class]="`!text-[0.8rem] !w-[2.5rem] !h-[2.5rem] !leading-[2.5rem] !rounded-full inline-flex items-center justify-center bg-${item.color}`">
<i [class]="`${item.icon} text-[1rem] text-white`"></i>
</span>
</div>
<div class="flex-grow ms-4">
<div class="flex items-center justify-between flex-wrap">
<div>
<p class="text-[#8c9097] dark:text-white/50 text-[0.813rem] mb-0">{{item.title}}</p>
<h4 class="font-semibold text-[1.5rem] !mb-2 ">{{item.number}}</h4>
</div>
<spk-apexcharts [id]="item.chartId" [chartOptions]="item.chartoptions" />
</div>
<div class="flex items-center justify-between !mt-1">
<div>
<a [class]="`text-${item.color} text-[0.813rem]`" href="javascript:void(0);">View All<i
class="ti ti-arrow-narrow-right ms-2 font-semibold inline-block"></i></a>
</div>
<div class="text-end">
<p [class]="`mb-0 ${item.percentageColor} text-[0.813rem] font-semibold`">{{item.percentage}}
</p>
<p class="text-[#8c9097] dark:text-white/50 opacity-[0.7] text-[0.6875rem]">this month</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header !gap-0 !m-0 justify-between">
<div class="box-title">
Revenue Analytics
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);"
class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50" aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body !py-5">
<spk-apexcharts [id]="'crm-revenue-analytics'" [chartOptions]="RevenueAnalytics" />
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box custom-card">
<div class="box-header justify-between">
<div class="box-title">
Deals Statistics
</div>
<div class="flex flex-wrap gap-2">
<div>
<input class="ti-form-control form-control-sm" type="text" placeholder="Search Here"
aria-label=".form-control-sm example">
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);"
class="ti-btn ti-btn-primary !bg-primary !text-white !py-1 !px-2 !text-[0.75rem] !m-0 !gap-0 !font-medium"
aria-expanded="false">
Sort By<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">New</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Popular</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Relevant</a></li>
</ul>
</div>
</div>
</div>
<div class="box-body">
<div class="overflow-x-auto">
<div class="table-responsive">
<spk-reusable-tables [columns]="SalesRepData.columns" [showCheckbox]="true"
tableClass="table min-w-full whitespace-nowrap table-hover border table-bordered">
@for (row of SalesRepData.rows; track row.id) {
<tr class="border !border-defaultborder border-solid hover:bg-gray-100 dark:!border-defaultborder/10 dark:hover:!bg-light">
<td scope="row" class="">
<input class="form-check-input" type="checkbox" [checked]="row.checked" aria-label="...">
</td>
<td>
<div class="flex items-center font-semibold">
<span class="!me-2 inline-flex justify-center items-center">
<img [src]="row.img" alt="img" class="w-[1.75rem] h-[1.75rem] rounded-full">
</span>
{{ row.name }}
</div>
</td>
<td>{{ row.category }}</td>
<td>{{ row.mail }}</td>
<td>
<span
[class]="'inline-flex text-' + row.locationClass + ' !py-[0.15rem] !px-[0.45rem] rounded-sm !font-semibold !text-[0.75em] bg-' + row.locationClass + '/10'">
{{ row.location }}
</span>
</td>
<td>{{ row.date }}</td>
<td>
<div class="flex flex-row items-center !gap-2 text-[0.9375rem]">
<a aria-label="anchor" href="javascript:void(0);"
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-success/10 text-success hover:bg-success hover:!text-white hover:!border-success">
<i class="ri-download-2-line"></i>
</a>
<a aria-label="anchor" href="javascript:void(0);"
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-primary/10 text-primary hover:bg-primary hover:!text-white hover:!border-primary">
<i class="ri-edit-line"></i>
</a>
</div>
</td>
</tr>
}
</spk-reusable-tables>
</div>
</div>
</div>
<div class="box-footer">
<div class="sm:flex items-center">
<div class="text-defaulttextcolor dark:text-defaulttextcolor/70">
Showing 5 Entries <i class="bi bi-arrow-right ms-2 font-semibold"></i>
</div>
<div class="ms-auto">
<nav aria-label="Page navigation" class="pagination-style-4">
<ul class="ti-pagination mb-0">
<li class="page-item disabled">
<a class="page-link" href="javascript:void(0);">
Prev
</a>
</li>
<li class="page-item"><a class="page-link active" href="javascript:void(0);">1</a></li>
<li class="page-item"><a class="page-link" href="javascript:void(0);">2</a></li>
<li class="page-item">
<a class="page-link !text-primary" href="javascript:void(0);">
next
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-3 xl:col-span-12 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Leads By Source
</div>
<div class="hs-dropdown ti-dropdown">
<a aria-label="anchor" href="javascript:void(0);"
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] ! !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
aria-expanded="false">
<i class="fe fe-more-vertical text-[0.8rem]"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Month</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Year</a></li>
</ul>
</div>
</div>
<div class="box-body !p-6 mt-4">
<div class="leads-source-chart flex items-center justify-center">
<spk-chartjs [id]="'leads-source'" [class]="'chartjs-chart w-full'" [height]="250" [chartjs]="LeadsBySourceChart" />
<div class="lead-source-value ">
<span class="block text-[0.875rem] ">Total</span>
<span class="block text-[1.5625rem] font-bold">4,145</span>
</div>
</div>
</div>
<div class="grid grid-cols-4 mt-4 border-t border-dashed border-defaultborder dark:border-defaultborder/10">
@for (item of deviceLeads; track item.label) {
<div class="col !p-0">
<div [class]="item.containerClass">
<span class="text-[#8c9097] dark:text-white/50 text-[0.75rem] mb-1 crm-lead-legend inline-block"
[class]="item.legendClass">
{{ item.label }}
</span>
<div>
<span class="text-[1rem] font-semibold">{{ item.value }}</span>
</div>
</div>
</div>
}
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Deals Status
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body">
<div class="flex items-center mb-[0.8rem]">
<h4 class="font-bold mb-0 text-[1.5rem] ">4,289</h4>
<div class="ms-2">
<span
class="py-[0.18rem] px-[0.45rem] rounded-sm text-success !font-medium !text-[0.75em] bg-success/10">1.02<i
class="ri-arrow-up-s-fill align-mmiddle ms-1"></i></span>
<span class="text-[#8c9097] dark:text-white/50 text-[0.813rem] ms-1">compared to last week</span>
</div>
</div>
<div class="flex w-full h-[0.3125rem] mb-6 rounded-full overflow-hidden">
<div class="flex flex-col justify-center rounded-s-[0.625rem] overflow-hidden bg-primary w-[21%]"
aria-valuenow="21" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-info w-[26%]" aria-valuenow="26"
aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-warning w-[35%]"
aria-valuenow="35" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-e-[0.625rem] overflow-hidden bg-success w-[18%]"
aria-valuenow="18" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<ul class="list-none mb-0 pt-2 crm-deals-status">
@for (deal of dealStats; track deal.title) {
<li [class]="deal.liClass">
<div class="flex items-center text-[0.813rem] justify-between">
<div>{{ deal.title }}</div>
<div class="text-[0.75rem] text-[#8c9097] dark:text-white/50">
{{ deal.count }}
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Recent Activity
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body">
<div>
<ul class="list-none mb-0 crm-recent-activity">
@for (item of recentActivity; track $index) {
<li class="crm-recent-activity-content text-defaultsize">
<div class="flex items-start">
<div class="me-4">
<span
class="w-[1.25rem] h-[1.25rem] inline-flex items-center justify-center font-medium leading-[1.25rem] text-[0.65rem] rounded-full"
[class]="item.statusClass">
<i class="bi bi-circle-fill text-[0.5rem]"></i>
</span>
</div>
<div class="crm-timeline-content">
<span [class.font-semibold]="item.boldMain">{{ item.mainText }}</span>
@if (item.linkText) {
<a href="javascript:void(0);" [class]="item.linkClass">{{ item.linkText }}</a>
}
@if (item.boldText) {
<span class="font-semibold">{{ item.boldText }}</span>
}
@if (item.highlightText) {
<span [class]="item.highlightClass">{{ item.highlightText }}</span>
}
@if (item.tagText) {
<span [class]="item.tagClass">{{ item.tagText }}</span>
}
@if (item.afterText) {
<span>{{ item.afterText }}</span>
}
@if (item.showAddIcon) {
<span
class="w-[1.25rem] h-[1.25rem] leading-[1.25rem] text-[0.65rem] inline-flex items-center justify-center font-medium bg-purple/10 rounded-full ms-1">
<i class="ri-add-fill text-purple text-[0.75rem]"></i>
</span>
}
@if (item.showCheckIcon) {
<i class="ri-checkbox-circle-line text-success text-[1rem] align-middle ms-1"></i>
}
@if (item.subText) {
<span class="block text-[0.75rem] text-[#8c9097] dark:text-white/50">
{{ item.subText }}
</span>
}
</div>
<div class="flex-grow text-end">
<span class="block text-[#8c9097] dark:text-white/50 text-[0.6875rem] opacity-[0.7]">
{{ item.time }}
</span>
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
-618
View File
@@ -1,618 +0,0 @@
import { Chart, ChartConfiguration, Plugin } from 'chart.js';
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SpkApexcharts } from "../../../@spk/charts/charts/spk-apexcharts/spk-apexcharts";
import { ApexOptions } from 'ng-apexcharts';
import { SpkReusableTables } from "../../../@spk/tables/spk-reusable-tables/spk-reusable-tables";
import { SpkChartjs } from "../../../@spk/charts/charts/spk-chartjs/spk-chartjs";
@Component({
selector: 'app-crm',
standalone: true,
imports: [RouterModule, SpkApexcharts, SpkReusableTables, SpkChartjs],
templateUrl: './crm.html',
styleUrl: './crm.scss'
})
export class Crm {
YourtargetisincompleteChart: ApexOptions = {
chart: {
height: 127,
width: 100,
type: 'radialBar',
},
series: [48],
// colors: ['#fff'],
plotOptions: {
radialBar: {
hollow: {
margin: 0,
size: '55%',
background: '#fff',
},
dataLabels: {
name: {
offsetY: -10,
color: '#4b9bfa',
fontSize: '.625rem',
show: false,
},
value: {
offsetY: 5,
color: '#4b9bfa',
fontSize: '.875rem',
show: true,
fontWeight: 600,
},
},
},
},
stroke: {
lineCap: 'round',
},
labels: ['Status'],
colors: ['#fff']
}
topDeals = [
{
name: 'Michael Jordan',
email: 'michael.jordan@example.com',
amount: '$2,893',
avatarImg: './assets/images/faces/10.jpg'
},
{
name: 'Emigo Kiaren',
email: 'emigo.kiaren@gmail.com',
amount: '$4,289',
initials: 'EK',
color: 'warning', // Used for text-warning and bg-warning/10
},
{
name: 'Randy Origoan',
email: 'randy.origoan@gmail.com',
amount: '$6,347',
avatarImg: './assets/images/faces/12.jpg'
},
{
name: 'George Pieterson',
email: 'george.pieterson@gmail.com',
amount: '$3,894',
initials: 'GP',
color: 'success',
},
{
name: 'Kiara Advain',
email: 'kiaraadvain214@gmail.com',
amount: '$2,679',
initials: 'KA',
color: 'primary',
}
];
profitEarnedChart: ApexOptions = {
series: [
{
name: 'Profit Earned',
data: [44, 42, 57, 86, 58, 55, 70],
},
{
name: 'Total Sales',
data: [34, 22, 37, 56, 21, 35, 60],
},
],
chart: {
type: 'bar',
height: 180,
toolbar: {
show: false,
},
},
grid: {
borderColor: '#f1f1f1',
strokeDashArray: 3,
},
colors: ['rgb(132, 90, 223)', '#e4e7ed'],
plotOptions: {
bar: {
colors: {
ranges: [
{
from: -100,
to: -46,
color: '#ebeff5',
},
{
from: -45,
to: 0,
color: '#ebeff5',
},
],
},
columnWidth: '60%',
borderRadius: 5,
},
},
dataLabels: {
enabled: false,
},
stroke: {
show: true,
width: 2,
colors: undefined,
},
legend: {
show: false,
position: 'top',
},
yaxis: {
title: {
style: {
color: '#adb5be',
fontSize: '13px',
fontFamily: 'poppins, sans-serif',
fontWeight: 600,
cssClass: 'apexcharts-yaxis-label',
},
},
labels: {
formatter: function (y: number) {
return y.toFixed(0) + '';
},
},
},
xaxis: {
type: 'category',
categories: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
axisBorder: {
show: true,
color: 'rgba(119, 119, 142, 0.05)',
offsetX: 0,
offsetY: 0,
},
axisTicks: {
show: true,
borderType: 'solid',
color: 'rgba(119, 119, 142, 0.05)',
offsetX: 0,
offsetY: 0,
},
labels: {
rotate: -90,
},
},
}
crmCardsChartOptions({ series, colors }: { colors: string[], series: { data: number[], name: string }[] }): ApexOptions {
return {
chart: {
type: 'line',
height: 40,
width: 100,
sparkline: {
enabled: true
}
},
dataLabels: {
enabled: false
},
stroke: {
show: true,
curve: 'smooth',
lineCap: 'butt',
colors: undefined,
width: 1.5,
dashArray: 0,
},
fill: {
type: 'gradient',
gradient: {
opacityFrom: 0.9,
opacityTo: 0.9,
stops: [0, 98],
}
},
series: series,
yaxis: {
min: 0,
show: false,
axisBorder: {
show: false
},
},
xaxis: {
labels: {
show: false,
},
axisBorder: {
show: false
},
},
tooltip: {
enabled: true,
},
colors: colors,
}
}
crmCards = [
{
color: 'primary',
icon: 'ti ti-users',
title: 'Total Customers',
number: '1,02,890',
chartId: 'crm-total-customers',
chartoptions: this.crmCardsChartOptions({
series: [{
name: 'Value',
data: [20, 14, 19, 10, 23, 20, 22, 9, 12]
}], colors: ["rgb(132, 90, 223)"],
}),
viewallTextColor: 'text-primary',
percentage: '+40%',
percentageColor: 'text-success',
},
{
color: 'secondary',
icon: 'ti ti-wallet',
title: 'Total Revenue',
number: '$56,562',
chartId: 'crm-total-revenue',
chartoptions: this.crmCardsChartOptions({
series: [
{
name: 'Value',
data: [20, 14, 20, 22, 9, 12, 19, 10, 25],
},
], colors: ['rgb(35, 183, 229)'],
}),
viewallTextColor: 'text-secondary',
percentage: '+25%',
percentageColor: 'text-success',
},
{
color: 'success',
icon: 'ti ti-wave-square',
title: 'Conversion Ratio',
number: '12.08%',
chartId: 'crm-conversion-ratio',
chartoptions: this.crmCardsChartOptions({
series: [
{
name: 'Value',
data: [20, 20, 22, 9, 14, 19, 10, 25, 12],
},
], colors: ['rgb(38, 191, 148)'],
}),
viewallTextColor: 'text-success',
percentage: '-12%',
percentageColor: 'text-danger',
},
{
color: 'warning',
icon: 'ti ti-briefcase',
title: 'Total Deals',
number: '2,543',
chartId: 'crm-total-deals',
chartoptions: this.crmCardsChartOptions({
series: [
{
name: 'Value',
data: [20, 20, 22, 9, 12, 14, 19, 10, 25],
},
], colors: ['rgb(245, 184, 73)'],
}),
viewallTextColor: 'text-warning',
percentage: '+19%',
percentageColor: 'text-success',
}
];
RevenueAnalytics: ApexOptions = {
series: [
{
type: 'line',
name: 'Profit',
data: [
{ x: 'Jan', y: 100 },
{ x: 'Feb', y: 210 },
{ x: 'Mar', y: 180 },
{ x: 'Apr', y: 454 },
{ x: 'May', y: 230 },
{ x: 'Jun', y: 320 },
{ x: 'Jul', y: 656 },
{ x: 'Aug', y: 830 },
{ x: 'Sep', y: 350 },
{ x: 'Oct', y: 350 },
{ x: 'Nov', y: 210 },
{ x: 'Dec', y: 410 },
],
},
{
type: 'line',
name: 'Revenue',
data: [
{ x: 'Jan', y: 180 },
{ x: 'Feb', y: 620 },
{ x: 'Mar', y: 476 },
{ x: 'Apr', y: 220 },
{ x: 'May', y: 520 },
{ x: 'Jun', y: 780 },
{ x: 'Jul', y: 435 },
{ x: 'Aug', y: 515 },
{ x: 'Sep', y: 738 },
{ x: 'Oct', y: 454 },
{ x: 'Nov', y: 525 },
{ x: 'Dec', y: 230 },
],
},
{
type: 'area',
name: 'Sales',
data: [
{ x: 'Jan', y: 200 },
{ x: 'Feb', y: 530 },
{ x: 'Mar', y: 110 },
{ x: 'Apr', y: 130 },
{ x: 'May', y: 480 },
{ x: 'Jun', y: 520 },
{ x: 'Jul', y: 780 },
{ x: 'Aug', y: 435 },
{ x: 'Sep', y: 475 },
{ x: 'Oct', y: 738 },
{ x: 'Nov', y: 454 },
{ x: 'Dec', y: 480 },
],
},
],
chart: {
type: 'line',
height: 350,
animations: {
speed: 500,
},
toolbar: {
show: true
},
zoom: {
enabled: true,
},
dropShadow: {
enabled: false,
enabledOnSeries: undefined,
top: 8,
left: 0,
blur: 3,
color: '#000',
opacity: 0.1,
},
},
colors: ["rgb(132, 90, 223)", "rgba(35, 183, 229, 0.85)", "rgba(119, 119, 142, 0.05)"],
dataLabels: {
enabled: false,
},
grid: {
padding:{
left:0,
right:0,
top:0,
bottom:0
},
borderColor: '#f1f1f1',
strokeDashArray: 3,
yaxis: {
lines: {
show: false
}
},
},
stroke: {
curve: 'smooth',
width: [2, 2, 0],
dashArray: [0, 5, 0],
},
xaxis: {
axisTicks: {
show: false,
},
},
yaxis: {
labels: {
formatter: (value: number) => `$${value}`,
},
},
tooltip: {
y: [
{
formatter: (value: number) => `$${value}`,
},
{
formatter: (value: number) => `$${value.toFixed(0)}`,
},
{
formatter: (value: number) => `$${value.toFixed(0)}`,
},
],
},
legend: {
show: true,
offsetY: 15,
customLegendItems: ['Profit', 'Revenue', 'Sales'],
inverseOrder: true,
markers: {
size: 5
}
},
title: {
text: 'Revenue Analytics with sales & profit (USD)',
align: 'left',
style: {
fontSize: '.8125rem',
fontWeight: 'semibold',
color: '#8c9097',
},
},
markers: {
hover: {
sizeOffset: 5,
},
},
}
SalesRepData = {
columns: [
{ header: 'Sales Rep', tableHeadColumn: '!text-start !text-[0.85rem] min-w-[200px]' },
{ header: 'Category', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Mail', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Location', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Date', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Action', tableHeadColumn: '!text-start !text-[0.85rem]' }
],
rows: [
{ id: 1, name: 'Mayor Kelly', img: './assets/images/faces/4.jpg', category: 'Manufacture', mail: 'mayorkelly@gmail.com', location: 'Germany', locationClass: 'info', date: 'Sep 15 - Oct 12, 2023', checked: false },
{ id: 2, name: 'Andrew Garfield', img: './assets/images/faces/15.jpg', category: 'Development', mail: 'andrewgarfield@gmail.com', location: 'Canada', locationClass: 'primary', date: 'Apr 10 - Dec 12, 2023', checked: true },
{ id: 3, name: 'Simon Cowel', img: './assets/images/faces/11.jpg', category: 'Service', mail: 'simoncowel234@gmail.com', location: 'Europe', locationClass: 'danger', date: 'Sep 15 - Oct 12, 2023', checked: false },
{ id: 4, name: 'Mirinda Hers', img: './assets/images/faces/8.jpg', category: 'Marketing', mail: 'mirindahers@gmail.com', location: 'USA', locationClass: 'warning', date: 'Apr 14 - Dec 14, 2023', checked: true },
{ id: 5, name: 'Jacob Smith', img: './assets/images/faces/9.jpg', category: 'Social Plataform', mail: 'jacobsmith@gmail.com', location: 'Singapore', locationClass: 'success', date: 'Feb 25 - Nov 25, 2023', checked: true }
]
};
LeadsBySourceChart: ChartConfiguration<'doughnut'> = {
type: 'doughnut',
data: {
datasets: [{
data: [32, 27, 25, 16],
backgroundColor: [
'rgb(132, 90, 223)', // Purple
'rgb(35, 183, 229)', // Blue
'rgb(245, 184, 73)', // Orange
'rgb(38, 191, 148)', // Green
],
// 1. INCREASE SPACING: This creates the clear gap between segments
spacing: -20,
// 2. ROUNDED BORDER: 10-20 is usually the sweet spot for this thinness
borderRadius: 20,
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
// 3. THINNESS: Ensure this is high enough (80% to 90%)
cutout: '86%',
plugins: {
legend: { display: false },
tooltip: { enabled: true }
}
}
};
recentActivity = [
{
statusClass: 'text-primary bg-primary/10',
time: '4:45PM',
mainText: 'Update of calendar events & ',
boldMain: true,
linkText: 'Added new events in next week.',
linkClass: 'text-primary font-semibold'
},
{
statusClass: 'text-secondary bg-secondary/10',
time: '3 hrs',
mainText: 'New theme for ',
boldText: 'Spruko Website',
afterText: ' completed',
subText: 'Lorem ipsum, dolor sit amet.'
},
{
statusClass: 'text-success bg-success/10',
time: '22 hrs',
mainText: 'Created a ',
highlightText: 'New Task',
highlightClass: 'text-success font-semibold',
afterText: ' today',
showAddIcon: true
},
{
statusClass: 'text-pink bg-pink/10',
time: 'Today',
mainText: 'New member ',
tagText: '@andreas gurrero',
tagClass: 'py-[0.2rem] px-[0.45rem] font-semibold rounded-sm text-pink text-[0.75em] bg-pink/10',
afterText: ' added today to AI Summit.'
},
{
statusClass: 'text-warning bg-warning/10',
time: '22 hrs',
mainText: '32 New people joined summit.'
},
{
statusClass: 'text-info bg-info/10',
time: '12 hrs',
mainText: 'Neon Tarly added ',
highlightText: 'Robert Bright',
highlightClass: 'text-info font-semibold',
afterText: ' to AI summit project.'
},
{
statusClass: 'text-[#232323] dark:text-white bg-[#232323]/10 dark:bg-white/20',
time: '4 hrs',
mainText: 'Replied to new support request ',
showCheckIcon: true
},
{
statusClass: 'text-purple bg-purple/10',
time: '4 hrs',
mainText: 'Completed documentation of ',
linkText: 'AI Summit.',
linkClass: 'text-purple underline font-semibold'
}
];
deviceLeads = [
{
label: 'Mobile',
value: '1,624',
legendClass: 'mobile',
containerClass: '!ps-4 p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
},
{
label: 'Desktop',
value: '1,267',
legendClass: 'desktop',
containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
},
{
label: 'Laptop',
value: '1,153',
legendClass: 'laptop',
containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
},
{
label: 'Tablet',
value: '679',
legendClass: 'tablet',
containerClass: '!pe-4 p-[0.95rem] text-center'
}
];
dealStats = [
{ title: 'Successful Deals', count: '987 deals', liClass: 'primary' },
{ title: 'Pending Deals', count: '1,073 deals', liClass: 'info' },
{ title: 'Rejected Deals', count: '1,674 deals', liClass: 'warning' },
{ title: 'Upcoming Deals', count: '921 deals', liClass: 'success' }
];
}
@@ -1,12 +0,0 @@
import { Routes } from '@angular/router';
export const dashboardRoutingModule: Routes = [
{
path: 'crm',
loadComponent: () => import('./crm/crm').then((m) => m.Crm),
title: 'YNEX - Crm',
},
];
-15
View File
@@ -1,15 +0,0 @@
import { Routes } from '@angular/router';
export const errorRoutingModule: Routes = [
{
path: 'error', children: [
{
path: 'error404',
loadComponent: () => import('./error404/error404').then( (m) => m.Error404 ),
title: 'YNEX - Error 404'
},
]
}
];
@@ -1,27 +0,0 @@
<div class="page error-bg dark:!bg-bodybg" id="particles-js">
<!-- Start::error-page -->
<spk-particles id="tsparticles" [options]="particlesOptions" />
<div class="error-page">
<div class="container text-defaulttextcolo dark:text-defaulttextcolor/70r text-defaultsize">
<div class="text-center p-5 my-auto">
<div class="flex items-center justify-center h-full !text-defaulttextcolor">
<div class="xl:col-span-3"></div>
<div class="xl:col-span-6 col-span-12">
<p class="error-text sm:mb-0 mb-2">404</p>
<p class="text-[1.125rem] font-semibold mb-4">Oops 😭,The page you are looking for is not available.</p>
<div class="flex justify-center items-center mb-[3rem]">
<div class="xl:col-span-6 w-[50%]">
<p class="mb-0 opacity-[0.7]">We are sorry for the inconvenience,The page you are trying to access has
been removed or never been existed.</p>
</div>
</div>
<a routerLink="/dashboards/crm" class="ti-btn bg-primary text-white font-semibold"><i
class="ri-arrow-left-line align-middle inline-block"></i>BACK TO HOME</a>
</div>
<div class="xl:col-span-3"></div>
</div>
</div>
</div>
</div>
</div>
@@ -1,16 +0,0 @@
import { Component } from '@angular/core';
import {RouterModule} from'@angular/router';
import { SpkParticles } from "../../../@spk/plugins&reusable/spk-particles/spk-particles";
import {particlesOptions} from "../particleoptions"
@Component({
selector: 'app-error404',
standalone: true,
imports: [RouterModule, SpkParticles],
templateUrl: './error404.html',
styleUrls: ['./error404.scss']
})
export class Error404 {
particlesOptions=particlesOptions
}
@@ -1,36 +0,0 @@
export const particlesOptions = {
fpsLimit: 60, // 200 is excessive; 60 is smooth and efficient
interactivity: {
events: {
onClick: { enable: true },
onHover: { enable: true },
resize: { enable: true }
},
modes: {
push: { quantity: 4 },
repulse: { distance: 200, duration: 0.4 }
}
},
particles: {
number: {
value: 80,
density: { enable: true, value_area: 800 }
},
color: { value: "#845adf" },
shape: { type: "circle" },
opacity: { value: 0.5 },
size: { value: 2, random: true },
line_linked: {
enable: true,
distance: 150,
color: "#d1d9e0",
opacity: 0.4,
width: 1
},
move: {
enable: true,
speed: 2,
out_mode: "out"
}
}
};
-37
View File
@@ -1,37 +0,0 @@
import { inject } from '@angular/core';
import { CanActivateChildFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../../features/authentication/data-access/auth.service';
import { TokenStorageService } from '../services/auth/token-storage.service';
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
const authService = inject(AuthService);
const tokenStorage = inject(TokenStorageService);
const router = inject(Router);
const loginUrlTree = router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url },
});
if (!authService.accessToken || !authService.currentUser) {
authService.logout();
return loginUrlTree;
}
if (!tokenStorage.isAccessTokenExpired()) {
return true;
}
if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
authService.logout();
return loginUrlTree;
}
return authService.refreshAccessToken().pipe(
map(() => true),
catchError(() => {
authService.logout();
return of(loginUrlTree);
})
);
};
-53
View File
@@ -1,53 +0,0 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../../features/authentication/data-access/auth.service';
import { TokenStorageService } from '../services/auth/token-storage.service';
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
function resolveSafeReturnUrl(returnUrl: string | null): string {
const candidate = returnUrl?.trim();
if (!candidate) {
return DEFAULT_AUTHENTICATED_REDIRECT;
}
const lowerCandidate = candidate.toLowerCase();
const isSafeInternal =
candidate.startsWith('/') &&
!candidate.startsWith('//') &&
!lowerCandidate.includes('http://') &&
!lowerCandidate.includes('https://');
return isSafeInternal ? candidate : DEFAULT_AUTHENTICATED_REDIRECT;
}
export const guestGuard: CanActivateFn = (route) => {
const authService = inject(AuthService);
const tokenStorage = inject(TokenStorageService);
const router = inject(Router);
const targetUrl = resolveSafeReturnUrl(route.queryParamMap.get('returnUrl'));
const targetUrlTree = router.createUrlTree([targetUrl]);
if (!authService.accessToken || !authService.currentUser) {
return true;
}
if (!tokenStorage.isAccessTokenExpired()) {
return targetUrlTree;
}
if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
authService.logout();
return true;
}
return authService.refreshAccessToken().pipe(
map(() => targetUrlTree),
catchError(() => {
authService.logout();
return of(true);
})
);
};
@@ -1,27 +0,0 @@
import { Injectable } from '@angular/core';
import { AngularFireModule } from '@angular/fire/compat';
import { AngularFirestoreModule } from '@angular/fire/compat/firestore';
import { AngularFireDatabaseModule } from '@angular/fire/compat/database';
import { AngularFireAuthModule } from '@angular/fire/compat/auth';
import { environment } from '../../../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class FirebaseService {
constructor() {
// AngularFireModule.initializeApp(environment.firebase);
}
getFirestore() {
return AngularFirestoreModule;
}
getDatabase() {
return AngularFireDatabaseModule;
}
getAuth() {
return AngularFireAuthModule;
}
}
@@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { map, Observable, tap } from 'rxjs';
import { API_CONFIG } from '../../config/api.config';
import { PermissionContext } from '../../models/context/context.model';
interface PermissionResponse {
@@ -15,7 +16,9 @@ export class PermissionService {
readonly permissionContext = signal<PermissionContext | null>(null);
loadPermissions(): Observable<PermissionContext> {
return this.http.get<PermissionResponse>(``).pipe(
return this.http.get<PermissionResponse>(
`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}`
).pipe(
map((response) => ({
roles: response.roles ?? [],
permissions: response.permissions ?? [],
@@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { API_CONFIG } from '../../config/api.config';
import { TenantContext } from '../../models/context/context.model';
@Injectable({ providedIn: 'root' })
@@ -9,7 +10,9 @@ export class TenantContextService {
readonly tenantContext = signal<TenantContext | null>(null);
loadTenantContext(): Observable<TenantContext> {
return this.http.get<TenantContext>(``).pipe(
return this.http.get<TenantContext>(
`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`
).pipe(
tap((context) => {
this.tenantContext.set(context);
})
@@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { map, Observable, tap } from 'rxjs';
import { API_CONFIG } from '../../config/api.config';
import { CurrentUserContext } from '../../models/context/context.model';
@Injectable({ providedIn: 'root' })
@@ -9,7 +10,9 @@ export class UserContextService {
readonly currentUserContext = signal<CurrentUserContext | null>(null);
loadCurrentUserProfile(): Observable<CurrentUserContext> {
return this.http.get<Partial<CurrentUserContext>>(``).pipe(
return this.http.get<Partial<CurrentUserContext>>(
`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}`
).pipe(
map((response) => ({
id: response.id?.trim() ?? '',
email: response.email?.trim() ?? '',
@@ -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: () => {
@@ -0,0 +1,126 @@
<div class="w-full">
<!-- Tab Bar Navigation Card -->
<div class="tabs-header-card mb-4 sm:mb-6">
<!-- Mobile Current Step Indicator Banner (< 640px) -->
<div
class="tabs-mobile-banner mb-3 flex items-center justify-between rounded-lg px-3 py-2 text-xs font-medium sm:hidden">
<span class="flex items-center gap-1.5 min-w-0 truncate">
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[0.65rem] font-bold text-white">
{{ currentStepIndex() + 1 }}
</span>
<span class="truncate">Step {{ currentStepIndex() + 1 }} of {{ steps().length }}: <strong
class="font-semibold">{{ currentStepLabel() }}</strong></span>
</span>
<span class="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[0.6875rem] font-semibold text-primary ms-2">
{{ getProgressPercent() }}%
</span>
</div>
<!-- Tab Bar Track -->
<nav aria-label="Organization onboarding progress" class="tabs-bar-nav">
<ul class="tabs-bar-list" role="tablist">
@for (step of steps(); track step.key; let index = $index) {
<li class="tabs-bar-item" role="presentation">
<button type="button" role="tab" class="tab-btn group"
[class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)"
[class.cursor-pointer]="canOpenStep(index)" [class.cursor-default]="!canOpenStep(index)"
[attr.aria-selected]="isCurrentStep(index)"
[disabled]="!canOpenStep(index)" (click)="selectStep(index)">
<span class="tab-index-badge">
@if (isCompletedStep(index) && !isCurrentStep(index)) {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="size-3.5">
<path d="M5 13l4 4L19 7" />
</svg>
} @else {
<span>{{ index + 1 }}</span>
}
</span>
<span class="tab-label">{{ step.label }}</span>
</button>
</li>
}
</ul>
</nav>
</div>
<!-- End Tab Bar Navigation Card -->
<!-- Tab Form Content -->
<div>
<div class="tabs-content-box rounded-xl p-3.5 sm:p-6">
<ng-content />
</div>
<!-- Button Group Footer -->
<footer class="modern-modal-footer mt-5 px-2 sm:px-6">
@if (isEditMode()) {
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
<!-- Cancel -->
<div class="w-full sm:w-auto">
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestCancel()"></app-button>
</div>
<!-- Save Changes -->
<div class="w-full sm:w-auto">
<app-button action="update" label="Update" loadingLabel="Updating..." [showIcon]="true"
[loading]="finishing() || savingDraft()" [disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true" className="onboarding-nav-btn justify-center sm:w-44"
(buttonClicked)="requestSaveChanges()"></app-button>
</div>
</div>
} @else {
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
<!-- Secondary Actions: Cancel/Back & Save Draft (Left group on Desktop, Bottom row on Mobile) -->
<div class="grid grid-cols-2 gap-2.5 w-full sm:flex sm:w-auto sm:items-center sm:gap-3">
<!-- Cancel on Step 1, or Back on Step > 1 -->
<div class="min-w-0 sm:w-auto">
@if (isFirstStep()) {
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestCancel()"></app-button>
} @else {
<app-button action="previous" [variant]="'custom'" label="Back" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestBack()"></app-button>
}
</div>
<!-- Save Draft -->
<div class="min-w-0 sm:w-auto">
<app-button action="save" [variant]="'custom'" label="Save Draft" loadingLabel="Saving..." [showIcon]="true"
[loading]="savingDraft()" [disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-36"
(buttonClicked)="requestSaveDraft()"></app-button>
</div>
</div>
<!-- Primary Action: Next or Finish & Provision (Top row on Mobile, Far Right on Desktop) -->
<div class="w-full sm:w-auto">
@if (!isLastStep()) {
<app-button action="next" label="Next" [showIcon]="true" [iconPosition]="'right'"
[disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
className="onboarding-nav-btn justify-center sm:w-36" (buttonClicked)="requestNext()"></app-button>
} @else {
<app-button action="approve" label="Finish & Provision" loadingLabel="Preparing..." [showIcon]="true"
[loading]="finishing()" [disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
className="onboarding-nav-btn justify-center sm:w-44" (buttonClicked)="requestFinish()"></app-button>
}
</div>
</div>
}
</footer>
<!-- End Button Group Footer -->
</div>
<!-- End Tab Form Content -->
</div>
@@ -0,0 +1,159 @@
.tabs-header-card {
background-color: transparent;
border: none;
box-shadow: none;
padding: 0.25rem 0 0.75rem;
@media (min-width: 640px) {
padding: 0.5rem 0 1.25rem;
}
}
.tabs-mobile-banner {
background-color: rgba(124, 61, 235, 0.08);
color: var(--primary, #7c3deb);
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
background-color: rgba(168, 85, 247, 0.15) !important;
color: #c084fc !important;
}
}
.tabs-bar-nav {
position: relative;
}
.tabs-bar-list {
position: relative;
z-index: 10;
display: flex;
width: 100%;
list-style: none;
margin: 0;
padding: 0;
gap: 0.5rem;
overflow-x: auto;
border-bottom: 1px solid #e2e8f0;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
border-bottom-color: rgba(255, 255, 255, 0.12);
}
}
.tabs-bar-item {
flex: 1 1 0;
min-width: 0;
}
.tab-btn {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
padding: 0.625rem 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
transition: all 0.25s ease;
outline: none;
@media (min-width: 640px) {
font-size: 0.875rem;
padding: 0.75rem 1rem;
}
&:disabled {
cursor: default;
}
// Pending Tab
&.pending {
color: #64748b;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
color: #94a3b8 !important;
}
}
// Completed Tab
&.completed {
color: #334155;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
color: #cbd5e1 !important;
}
}
// Active Tab
&.active {
color: var(--primary, #7c3deb);
border-bottom-color: var(--primary, #7c3deb);
font-weight: 700;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
color: #c084fc !important;
border-bottom-color: #c084fc !important;
}
}
}
.tab-index-badge {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 700;
background-color: #f1f5f9;
color: inherit;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.tab-btn.active & {
background: var(--primary, #7c3deb);
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
color: #ffffff;
}
.tab-btn.completed & {
background: var(--primary, #7c3deb);
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
color: #ffffff;
}
}
.tab-label {
overflow: hidden;
text-overflow: ellipsis;
@media (max-width: 639px) {
display: none;
}
}
.tabs-content-box {
background-color: #ffffff;
border: 1px dashed #e2e8f0;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
background-color: var(--bg-body, #1a1c1e) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
color: #e2e8f0;
}
}
@@ -0,0 +1,155 @@
import {
ChangeDetectionStrategy,
Component,
input,
output
} from '@angular/core';
import { Button } from '../../../../../shared/components/button/button';
export interface OnboardingStep {
readonly key: string;
readonly label: string;
}
@Component({
selector: 'onboarding-tabs',
standalone: true,
imports: [Button],
templateUrl: './onboarding-tabs.html',
styleUrl: './onboarding-tabs.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnboardingTabs {
readonly steps = input.required<readonly OnboardingStep[]>();
readonly currentStepIndex = input.required<number>();
readonly completedStepIndexes = input<readonly number[]>([]);
readonly isEditMode = input(false);
readonly savingDraft = input(false);
readonly finishing = input(false);
readonly navigationDisabled = input(false);
readonly stepSelected = output<number>();
readonly backClicked = output<void>();
readonly nextClicked = output<void>();
readonly saveDraftClicked = output<void>();
readonly saveChangesClicked = output<void>();
readonly finishClicked = output<void>();
readonly cancelClicked = output<void>();
currentStepLabel(): string {
return this.steps()[this.currentStepIndex()]?.label ?? '';
}
getProgressPercent(): number {
const total = this.steps().length;
if (total === 0) return 0;
return Math.round(((this.currentStepIndex() + 1) / total) * 100);
}
getTrackLineLeft(): string {
const total = this.steps().length;
if (total <= 0) return '0%';
return `${100 / (total * 2)}%`;
}
getProgressLineWidth(): string {
const total = this.steps().length;
if (total <= 1) return '0%';
const maxSpan = (100 * (total - 1)) / total;
const progressRatio = Math.min(
1,
Math.max(0, this.currentStepIndex() / (total - 1))
);
return `${maxSpan * progressRatio}%`;
}
isFirstStep(): boolean {
return this.currentStepIndex() === 0;
}
isLastStep(): boolean {
return this.currentStepIndex() === this.steps().length - 1;
}
isCurrentStep(index: number): boolean {
return index === this.currentStepIndex();
}
isCompletedStep(index: number): boolean {
return this.completedStepIndexes().includes(index);
}
isPendingStep(index: number): boolean {
return !this.isCurrentStep(index) && !this.isCompletedStep(index);
}
canOpenStep(index: number): boolean {
if (this.navigationDisabled()) {
return false;
}
return index >= 0 && index < this.steps().length;
}
selectStep(index: number): void {
if (!this.canOpenStep(index)) {
return;
}
this.stepSelected.emit(index);
}
requestBack(): void {
if (this.navigationDisabled() || this.isFirstStep()) {
return;
}
this.backClicked.emit();
}
requestNext(): void {
if (this.navigationDisabled() || this.isLastStep()) {
return;
}
this.nextClicked.emit();
}
requestSaveDraft(): void {
if (this.navigationDisabled() || this.savingDraft()) {
return;
}
this.saveDraftClicked.emit();
}
requestSaveChanges(): void {
if (this.navigationDisabled() || this.finishing() || this.savingDraft()) {
return;
}
this.saveChangesClicked.emit();
}
requestFinish(): void {
if (
this.navigationDisabled() ||
this.finishing() ||
!this.isLastStep()
) {
return;
}
this.finishClicked.emit();
}
requestCancel(): void {
if (this.navigationDisabled()) {
return;
}
this.cancelClicked.emit();
}
}
@@ -3,6 +3,8 @@ import { buildApiUrl } from '../../../../core/config/api-url.util';
export const ORGANIZATION_ONBOARDING_ENDPOINTS = {
createDraft: buildApiUrl('masterAdmin', '/v1/organizations'),
getNextCode: buildApiUrl('masterAdmin', '/v1/organizations/next-code'),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}`),
@@ -33,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,
});
@@ -10,6 +10,7 @@ import {
FiscalYearConventionValue,
NumberFormatValue,
OnboardingLookupValue,
OrganizationCodePreviewDto,
OrganizationLicenseType,
OrganizationPlanDefaults,
OrganizationPlanLookupValue,
@@ -164,6 +165,24 @@ export class OrganizationOnboardingService {
return this.http.post<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.finish(id), {});
}
getNextOrganizationCode(): Observable<string | null> {
return this.http.get<OrganizationCodePreviewDto | string>(ORGANIZATION_ONBOARDING_ENDPOINTS.getNextCode).pipe(
map(res => {
if (typeof res === 'string') {
return res;
}
if (res && typeof res === 'object') {
return res.code ?? res.nextCode ?? res.organizationCode ?? res.value ?? null;
}
return null;
}),
catchError(err => {
console.error('Failed to fetch next organization code from server', err);
return of(null);
})
);
}
// --- Lookups and Defaults ---
searchOrganizationTypes(term: string | null, limit = 10): Observable<readonly OnboardingLookupValue[]> {
@@ -205,6 +205,13 @@ export interface OrganizationPlanDefaults {
readonly limitsEditable: boolean;
}
export interface OrganizationCodePreviewDto {
code?: string | null;
nextCode?: string | null;
organizationCode?: string | null;
value?: string | null;
}
export interface OnboardingStepForm<TValue> {
validate(): boolean;
getValue(): TValue;
@@ -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);
}
}
@@ -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)
@@ -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(() => {
@@ -114,23 +114,33 @@
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<app-form-date-picker
formControlName="systemAccessPeriod"
inputId="organization-system-access-period"
formControlName="systemAccessStartDate"
inputId="organization-system-access-start-date"
variant="floating"
label="System Access Period"
mode="range"
label="System Access From"
mode="single"
[required]="true"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'System Access Period is required.',
trialEndDateRequired: 'System Access End Date is required for Trial.'
}"
[validationMessages]="{ required: 'System Access From Date is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<app-form-date-picker
formControlName="systemAccessEndDate"
inputId="organization-system-access-end-date"
variant="floating"
label="System Access To"
mode="single"
[required]="requiresAccessEndDate()"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'System Access To Date is required for Trial.' }"
/>
</div>
@if (submitAttempted() && form.errors?.['accessDateRange']) {
<div class="col-span-12 text-sm text-danger">
System Access Start Date cannot be later than System Access End Date.
System Access From Date cannot be later than System Access To Date.
</div>
}
@@ -38,7 +38,8 @@ interface OrganizationPlanLimitsFormModel {
readonly maximumUsers: number | null;
readonly maximumStorageGb: number | null;
readonly goLiveDate: string | null;
readonly systemAccessPeriod: DateRangeValue | null;
readonly systemAccessStartDate: string | null;
readonly systemAccessEndDate: string | null;
}
type ThemeSelectValue =
@@ -58,11 +59,11 @@ const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationE
}
const errors: ValidationErrors = {};
const { licenseType, systemAccessPeriod } = value;
const from = systemAccessPeriod?.from ?? null;
const to = systemAccessPeriod?.to ?? null;
const { licenseType, systemAccessStartDate, systemAccessEndDate } = value;
const from = systemAccessStartDate ?? null;
const to = systemAccessEndDate ?? null;
if (licenseType === 'Trial' && (!systemAccessPeriod || !to)) {
if (licenseType === 'Trial' && !to) {
errors['trialEndDateRequired'] = true;
}
@@ -107,7 +108,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
maximumUsers: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
maximumStorageGb: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
goLiveDate: this.formBuilder.control<string | null>(null, [Validators.required]),
systemAccessPeriod: this.formBuilder.control<DateRangeValue | null>(null, [Validators.required]),
systemAccessStartDate: this.formBuilder.control<string | null>(null, [Validators.required]),
systemAccessEndDate: this.formBuilder.control<string | null>(null),
}, { validators: accessDateValidator });
readonly requiresAccessEndDate = computed(() => this.form.controls.licenseType.value === 'Trial');
@@ -132,12 +134,12 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(licenseType => {
if (licenseType === 'Trial') {
this.form.controls.systemAccessPeriod.addValidators([Validators.required]);
this.form.controls.systemAccessEndDate.addValidators([Validators.required]);
} else {
this.form.controls.systemAccessPeriod.removeValidators([Validators.required]);
this.form.controls.systemAccessEndDate.removeValidators([Validators.required]);
}
this.form.controls.systemAccessPeriod.updateValueAndValidity({ emitEvent: false });
this.form.controls.systemAccessEndDate.updateValueAndValidity({ emitEvent: false });
});
this.form.controls.subscriptionPlanId.valueChanges
@@ -185,9 +187,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
getValue(): OrganizationPlanLimitsValue {
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
const subscriptionPlan = this.planSelection();
const period = value.systemAccessPeriod;
const systemAccessStartDate = period?.from ?? null;
const systemAccessEndDate = period?.to ?? null;
const systemAccessStartDate = value.systemAccessStartDate;
const systemAccessEndDate = value.systemAccessEndDate ?? null;
if (!subscriptionPlan || !value.licenseType || !value.maximumCompanies || !value.maximumUsers || !value.maximumStorageGb || !value.goLiveDate || !systemAccessStartDate) {
throw new Error('Organization plan and limits values are incomplete.');
@@ -208,7 +209,6 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
getDraftValue(): Partial<OrganizationPlanLimitsValue> {
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
const period = value.systemAccessPeriod;
return {
subscriptionPlan: this.planSelection() ?? undefined,
@@ -217,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)">
-11
View File
@@ -1,13 +1,3 @@
export interface AppFirebaseConfig {
apiKey: string;
authDomain: string;
projectId: string;
storageBucket: string;
messagingSenderId: string;
appId: string;
measurementId: string;
}
export interface AppSessionTimeoutConfig {
warningAfterMs: number;
logoutAfterMs: number;
@@ -20,5 +10,4 @@ export interface AppEnvironment {
masterAdmin: string;
};
sessionTimeout: AppSessionTimeoutConfig;
firebase: AppFirebaseConfig;
}
-9
View File
@@ -10,13 +10,4 @@ export const environment: AppEnvironment = {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
},
firebase: {
apiKey: '********************************',
authDomain: '********************************',
projectId: '********************************',
storageBucket: '********************************',
messagingSenderId: '********************************',
appId: '********************************',
measurementId: '********************************',
},
};
-9
View File
@@ -16,15 +16,6 @@ export const environment: AppEnvironment = {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
},
firebase: {
apiKey: '********************************',
authDomain: '********************************',
projectId: '********************************',
storageBucket: '********************************',
messagingSenderId: '********************************',
appId: '********************************',
measurementId: '********************************',
},
};
/*
+2 -2
View File
@@ -54,8 +54,8 @@ body {
line-height: 1.25;
}
/* Remove unwanted bottom margin from primary-full buttons */
.ti-btn-primary-full {
/* Remove unwanted bottom margin from buttons */
.ti-btn {
margin-bottom: 0 !important;
}