organization dashboard changes
This commit is contained in:
@@ -60,11 +60,11 @@
|
|||||||
<label for="signin-password" class="block text-sm font-semibold text-slate-800">Password</label>
|
<label for="signin-password" class="block text-sm font-semibold text-slate-800">Password</label>
|
||||||
<a routerLink="/authentication/reset-password/basic"
|
<a routerLink="/authentication/reset-password/basic"
|
||||||
class="text-xs sm:text-sm font-semibold text-[#7c3aed] hover:text-[#6d28d9] transition-colors">
|
class="text-xs sm:text-sm font-semibold text-[#7c3aed] hover:text-[#6d28d9] transition-colors">
|
||||||
Forgot password?
|
Forgot Password?
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-center">
|
<div class="relative flex items-center">
|
||||||
<input [type]="visibilityMap['Angular'] ? 'text' : 'password'" id="signin-password" placeholder="password"
|
<input [type]="visibilityMap['Angular'] ? 'text' : 'password'" id="signin-password" placeholder="Password"
|
||||||
formControlName="password" autocomplete="current-password"
|
formControlName="password" autocomplete="current-password"
|
||||||
class="login-input w-full pl-4 pr-11 py-3 bg-white border border-slate-300 rounded-xl text-base font-semibold text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-400/50 focus:border-purple-500 focus:bg-white transition-all duration-200" />
|
class="login-input w-full pl-4 pr-11 py-3 bg-white border border-slate-300 rounded-xl text-base font-semibold text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-400/50 focus:border-purple-500 focus:bg-white transition-all duration-200" />
|
||||||
<button type="button" (click)="toggleVisibility('Angular')"
|
<button type="button" (click)="toggleVisibility('Angular')"
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<input type="checkbox" id="rememberMe" formControlName="rememberMe"
|
<input type="checkbox" id="rememberMe" formControlName="rememberMe"
|
||||||
class="w-4 h-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500 cursor-pointer" />
|
class="w-4 h-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500 cursor-pointer" />
|
||||||
<label for="rememberMe" class="text-xs sm:text-sm text-slate-700 font-semibold cursor-pointer select-none">
|
<label for="rememberMe" class="text-xs sm:text-sm text-slate-700 font-semibold cursor-pointer select-none">
|
||||||
Remember password?
|
Remember Password?
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
|
<!-- <div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome
|
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome
|
||||||
back,
|
back,
|
||||||
@@ -17,13 +17,13 @@
|
|||||||
<i class="ri-upload-cloud-line inline-block"></i>Export
|
<i class="ri-upload-cloud-line inline-block"></i>Export
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<div class="grid grid-cols-12 gap-6">
|
<div class="grid grid-cols-12 gap-x-6 gap-y-4">
|
||||||
|
|
||||||
@for (card of statCards(); track card.title) {
|
@for (card of statCards(); track card.title) {
|
||||||
<div class="xxl:col-span-2 xl:col-span-2 md:col-span-6 col-span-12">
|
<div class="xxl:col-span-2 xl:col-span-2 md:col-span-6 col-span-12">
|
||||||
<div [class]="'box overflow-hidden border-t-[3px] ' + card.accentClass">
|
<div [class]="'box overflow-hidden border-t-[3px] !mb-0 ' + card.accentClass">
|
||||||
<div class="box-body">
|
<div class="box-body">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { map, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
import { DataTable } from '../../../shared/components/data-table/data-table';
|
import { DataTable } from '../../../shared/components/data-table/data-table';
|
||||||
import { DataTableStore } from '../../../shared/components/data-table/data-table.store';
|
import { DataTableStore } from '../../../shared/components/data-table/data-table.store';
|
||||||
@@ -8,10 +9,33 @@ import {
|
|||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
|
DataTableRecord,
|
||||||
} from '../../../shared/components/data-table/data-table.types';
|
} from '../../../shared/components/data-table/data-table.types';
|
||||||
import { OrganizationDto, OrganizationStatus, OrganizationTableRow } from '../organization-list/models/organization.model';
|
import {
|
||||||
|
OrganizationStatusCountsDto,
|
||||||
|
RecentOrganizationDto,
|
||||||
|
} from '../organization-list/models/organization.model';
|
||||||
import { OrganizationService } from '../organization-list/data-access/organization.service';
|
import { OrganizationService } from '../organization-list/data-access/organization.service';
|
||||||
|
|
||||||
|
export interface RecentOrganizationTableRow extends DataTableRecord {
|
||||||
|
readonly id: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly organizationName: string;
|
||||||
|
readonly country: string;
|
||||||
|
readonly countryName: string;
|
||||||
|
readonly plan: string;
|
||||||
|
readonly planName: string;
|
||||||
|
readonly status: string;
|
||||||
|
readonly rawStatus: number | string;
|
||||||
|
readonly subscriptionStatus: string;
|
||||||
|
readonly expiry: string;
|
||||||
|
readonly planExpiresOn: string;
|
||||||
|
readonly isActive: boolean;
|
||||||
|
readonly createdOn: string;
|
||||||
|
readonly serialNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardStatCard {
|
interface DashboardStatCard {
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -22,6 +46,49 @@ interface DashboardStatCard {
|
|||||||
helperClass: string;
|
helperClass: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTenantStatus(status: number | string | undefined | null): string {
|
||||||
|
if (status === null || status === undefined) return 'Draft';
|
||||||
|
if (typeof status === 'number') {
|
||||||
|
switch (status) {
|
||||||
|
case 0: return 'Draft';
|
||||||
|
case 1: return 'Active';
|
||||||
|
case 2: return 'Suspended';
|
||||||
|
case 3: return 'Cancelled';
|
||||||
|
case 4: return 'Trial';
|
||||||
|
case 5: return 'Awaiting Database';
|
||||||
|
case 6: return 'Awaiting Activation';
|
||||||
|
default: return 'Draft';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(status).replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubscriptionStatus(subStatus: number | string | undefined | null): string {
|
||||||
|
if (subStatus === null || subStatus === undefined) return '—';
|
||||||
|
if (typeof subStatus === 'number') {
|
||||||
|
switch (subStatus) {
|
||||||
|
case 0: return 'Trialing';
|
||||||
|
case 1: return 'Active';
|
||||||
|
case 2: return 'Past Due';
|
||||||
|
case 3: return 'Cancelled';
|
||||||
|
case 4: return 'Expired';
|
||||||
|
default: return 'Active';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(subStatus).replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateDisplay(dateStr: string | undefined | null): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
|
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'dashboard',
|
selector: 'dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -34,12 +101,15 @@ export class Dashboard implements OnInit {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
private readonly organizationApi = inject(OrganizationService);
|
private readonly organizationApi = inject(OrganizationService);
|
||||||
readonly tableStore = inject(DataTableStore<OrganizationDto, OrganizationTableRow>);
|
readonly tableStore = inject(DataTableStore<RecentOrganizationDto, RecentOrganizationTableRow>);
|
||||||
|
|
||||||
|
readonly selectedMonths = signal<number | undefined>(undefined);
|
||||||
|
readonly selectedLimit = signal<number | undefined>(undefined);
|
||||||
|
|
||||||
readonly statCards = signal<DashboardStatCard[]>([
|
readonly statCards = signal<DashboardStatCard[]>([
|
||||||
{
|
{
|
||||||
title: 'Total Organizations',
|
title: 'Total Organizations',
|
||||||
value: '24',
|
value: '0',
|
||||||
helper: 'All registered',
|
helper: 'All registered',
|
||||||
accentClass: 'border-primary',
|
accentClass: 'border-primary',
|
||||||
iconClass: 'ti ti-building-community',
|
iconClass: 'ti ti-building-community',
|
||||||
@@ -48,7 +118,7 @@ export class Dashboard implements OnInit {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Active',
|
title: 'Active',
|
||||||
value: '19',
|
value: '0',
|
||||||
helper: 'Currently operational',
|
helper: 'Currently operational',
|
||||||
accentClass: 'border-primary',
|
accentClass: 'border-primary',
|
||||||
iconClass: 'ti ti-circle-check',
|
iconClass: 'ti ti-circle-check',
|
||||||
@@ -57,16 +127,25 @@ export class Dashboard implements OnInit {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Trial',
|
title: 'Trial',
|
||||||
value: '3',
|
value: '0',
|
||||||
helper: 'Trial subscriptions',
|
helper: 'Trial subscriptions',
|
||||||
accentClass: 'border-primary',
|
accentClass: 'border-primary',
|
||||||
iconClass: 'ri-wallet-2-line',
|
iconClass: 'ri-wallet-2-line',
|
||||||
iconBackgroundClass: 'bg-primary',
|
iconBackgroundClass: 'bg-primary',
|
||||||
helperClass: 'bg-primary/10 text-primary',
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Draft',
|
||||||
|
value: '0',
|
||||||
|
helper: 'Incomplete onboarding',
|
||||||
|
accentClass: 'border-primary',
|
||||||
|
iconClass: 'ti ti-file-text',
|
||||||
|
iconBackgroundClass: 'bg-primary',
|
||||||
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Suspended',
|
title: 'Suspended',
|
||||||
value: '1',
|
value: '0',
|
||||||
helper: 'Temporarily disabled',
|
helper: 'Temporarily disabled',
|
||||||
accentClass: 'border-primary',
|
accentClass: 'border-primary',
|
||||||
iconClass: 'ti ti-player-pause',
|
iconClass: 'ti ti-player-pause',
|
||||||
@@ -74,26 +153,35 @@ export class Dashboard implements OnInit {
|
|||||||
helperClass: 'bg-primary/10 text-primary',
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Expired License',
|
title: 'Cancelled',
|
||||||
value: '1',
|
value: '0',
|
||||||
helper: 'Needs renewal action',
|
helper: 'Cancelled subscriptions',
|
||||||
accentClass: 'border-primary',
|
accentClass: 'border-primary',
|
||||||
iconClass: 'ri ri-pass-expired-line',
|
iconClass: 'ti ti-square-x',
|
||||||
iconBackgroundClass: 'bg-primary',
|
|
||||||
helperClass: 'bg-primary/10 text-primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Active Users',
|
|
||||||
value: '482',
|
|
||||||
helper: 'Users with access',
|
|
||||||
accentClass: 'border-primary',
|
|
||||||
iconClass: 'ri ri-group-line',
|
|
||||||
iconBackgroundClass: 'bg-primary',
|
iconBackgroundClass: 'bg-primary',
|
||||||
helperClass: 'bg-primary/10 text-primary',
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// title: 'Awaiting Database',
|
||||||
|
// value: '0',
|
||||||
|
// helper: 'DB provisioning pending',
|
||||||
|
// accentClass: 'border-primary',
|
||||||
|
// iconClass: 'ti ti-database',
|
||||||
|
// iconBackgroundClass: 'bg-primary',
|
||||||
|
// helperClass: 'bg-primary/10 text-primary',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'Awaiting Activation',
|
||||||
|
// value: '0',
|
||||||
|
// helper: 'Activation pending',
|
||||||
|
// accentClass: 'border-primary',
|
||||||
|
// iconClass: 'ti ti-clock-check',
|
||||||
|
// iconBackgroundClass: 'bg-primary',
|
||||||
|
// helperClass: 'bg-primary/10 text-primary',
|
||||||
|
// },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly columns = signal<DataTableColumn<OrganizationTableRow>[]>([
|
readonly columns = signal<DataTableColumn<RecentOrganizationTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||||
{
|
{
|
||||||
@@ -111,7 +199,7 @@ export class Dashboard implements OnInit {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
badgeClass: value =>
|
badgeClass: value =>
|
||||||
value === 'Trial' || value === OrganizationStatus.Trial
|
value === 'Trial' || value === 'Basic'
|
||||||
? 'badge bg-warning/10 text-warning'
|
? 'badge bg-warning/10 text-warning'
|
||||||
: 'badge bg-light text-defaulttextcolor',
|
: 'badge bg-light text-defaulttextcolor',
|
||||||
},
|
},
|
||||||
@@ -122,9 +210,24 @@ export class Dashboard implements OnInit {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
badgeClass: value =>
|
badgeClass: value =>
|
||||||
value === 'Active' || value === OrganizationStatus.Active
|
value === 'Active'
|
||||||
? 'badge bg-success/10 text-success'
|
? 'badge bg-success/10 text-success'
|
||||||
: value === 'Trial' || value === OrganizationStatus.Trial
|
: value === 'Trial'
|
||||||
|
? 'badge bg-warning/10 text-warning'
|
||||||
|
: value === 'Draft'
|
||||||
|
? 'badge bg-info/10 text-info'
|
||||||
|
: 'badge bg-danger/10 text-danger',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'subscriptionStatus',
|
||||||
|
label: 'Subscription',
|
||||||
|
header: 'Subscription',
|
||||||
|
sortable: true,
|
||||||
|
badge: true,
|
||||||
|
badgeClass: value =>
|
||||||
|
value === 'Active'
|
||||||
|
? 'badge bg-success/10 text-success'
|
||||||
|
: value === 'Trialing'
|
||||||
? 'badge bg-warning/10 text-warning'
|
? 'badge bg-warning/10 text-warning'
|
||||||
: 'badge bg-danger/10 text-danger',
|
: 'badge bg-danger/10 text-danger',
|
||||||
},
|
},
|
||||||
@@ -134,7 +237,7 @@ export class Dashboard implements OnInit {
|
|||||||
readonly emptyMessage = signal<string>('No Organizations');
|
readonly emptyMessage = signal<string>('No Organizations');
|
||||||
readonly emptyDescription = signal<string>('Start by adding your first organization');
|
readonly emptyDescription = signal<string>('Start by adding your first organization');
|
||||||
|
|
||||||
readonly actions = signal<DataTableAction<OrganizationTableRow>[]>([
|
readonly actions = signal<DataTableAction<RecentOrganizationTableRow>[]>([
|
||||||
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
||||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||||
{
|
{
|
||||||
@@ -142,22 +245,66 @@ export class Dashboard implements OnInit {
|
|||||||
label: 'Suspend',
|
label: 'Suspend',
|
||||||
icon: 'ti ti-player-pause',
|
icon: 'ti ti-player-pause',
|
||||||
className: 'text-warning',
|
className: 'text-warning',
|
||||||
visible: row => row.status !== 'Suspended' && row.status !== OrganizationStatus.Suspended,
|
visible: row => row.status !== 'Suspended',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.tableStore.initialize({
|
this.tableStore.initialize({
|
||||||
fetcher: query => this.organizationApi.getOrganizationDataTable(query),
|
fetcher: query =>
|
||||||
mapRow: (item, serialNumber) => {
|
this.organizationApi.getRecentOrganizationsDashboard(this.selectedMonths(), this.selectedLimit()).pipe(
|
||||||
const statusText = typeof item.status === 'number'
|
tap(res => {
|
||||||
? (OrganizationStatus[item.status] ?? 'Active')
|
if (res?.counts) {
|
||||||
: (item.status || 'Active');
|
this.updateStatCards(res.counts);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
map(res => {
|
||||||
|
let orgs = [...(res.organizations || [])];
|
||||||
|
const total = orgs.length;
|
||||||
|
|
||||||
const resolvedName = item.name || item.organizationName || '—';
|
if (query.search) {
|
||||||
const resolvedCountry = item.countryName || item.country || '—';
|
const term = query.search.toLowerCase().trim();
|
||||||
const resolvedPlan = item.planName || item.plan || '—';
|
orgs = orgs.filter(o =>
|
||||||
const resolvedExpiry = item.expiryDate || item.expiry || '—';
|
(o.name && o.name.toLowerCase().includes(term)) ||
|
||||||
|
(o.code && o.code.toLowerCase().includes(term)) ||
|
||||||
|
(o.countryName && o.countryName.toLowerCase().includes(term)) ||
|
||||||
|
(o.planName && o.planName.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = orgs.length;
|
||||||
|
|
||||||
|
if (query.sortBy) {
|
||||||
|
const sortKey = query.sortBy as keyof RecentOrganizationDto;
|
||||||
|
const dir = query.sortDir === 'desc' ? -1 : 1;
|
||||||
|
orgs.sort((a: any, b: any) => {
|
||||||
|
const valA = a[sortKey] ?? '';
|
||||||
|
const valB = b[sortKey] ?? '';
|
||||||
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||||
|
return valA.localeCompare(valB) * dir;
|
||||||
|
}
|
||||||
|
return (valA > valB ? 1 : valA < valB ? -1 : 0) * dir;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (query.page - 1) * query.pageSize;
|
||||||
|
const pagedOrgs = orgs.slice(start, start + query.pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draw: query.draw,
|
||||||
|
rows: pagedOrgs as RecentOrganizationDto[],
|
||||||
|
total,
|
||||||
|
filtered,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
),
|
||||||
|
mapRow: (item, serialNumber) => {
|
||||||
|
const statusText = formatTenantStatus(item.status);
|
||||||
|
const subStatusText = formatSubscriptionStatus(item.subscriptionStatus);
|
||||||
|
const resolvedName = item.name || '—';
|
||||||
|
const resolvedCountry = item.countryName || '—';
|
||||||
|
const resolvedPlan = item.planName || '—';
|
||||||
|
const resolvedExpiry = item.planExpiresOn ? String(item.planExpiresOn) : '—';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
@@ -165,31 +312,109 @@ export class Dashboard implements OnInit {
|
|||||||
code: item.code || '—',
|
code: item.code || '—',
|
||||||
name: resolvedName,
|
name: resolvedName,
|
||||||
organizationName: resolvedName,
|
organizationName: resolvedName,
|
||||||
countryId: item.countryId || null,
|
|
||||||
country: resolvedCountry,
|
country: resolvedCountry,
|
||||||
countryName: resolvedCountry,
|
countryName: resolvedCountry,
|
||||||
plan: resolvedPlan,
|
plan: resolvedPlan,
|
||||||
planName: resolvedPlan,
|
planName: resolvedPlan,
|
||||||
status: statusText,
|
status: statusText,
|
||||||
|
rawStatus: item.status,
|
||||||
|
subscriptionStatus: subStatusText,
|
||||||
expiry: resolvedExpiry,
|
expiry: resolvedExpiry,
|
||||||
expiryDate: resolvedExpiry,
|
planExpiresOn: resolvedExpiry,
|
||||||
dataRegion: item.dataRegion || null,
|
|
||||||
isActive: item.isActive ?? true,
|
isActive: item.isActive ?? true,
|
||||||
|
createdOn: item.createdOn ? formatDateDisplay(item.createdOn) : '—',
|
||||||
serialNumber,
|
serialNumber,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
const msg = err?.error?.message || err?.error?.title || 'Failed to load dashboard organizations.';
|
const msg = err?.error?.message || err?.error?.title || 'Failed to load dashboard organizations.';
|
||||||
this.toastr.error(msg);
|
this.toastr.error(msg);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateStatCards(counts: OrganizationStatusCountsDto): void {
|
||||||
|
this.statCards.set([
|
||||||
|
{
|
||||||
|
title: 'Total Organizations',
|
||||||
|
value: String(counts.totalOrganizations ?? 0),
|
||||||
|
helper: 'All registered',
|
||||||
|
accentClass: 'border-primary',
|
||||||
|
iconClass: 'ti ti-building-community',
|
||||||
|
iconBackgroundClass: 'bg-primary',
|
||||||
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active',
|
||||||
|
value: String(counts.active ?? 0),
|
||||||
|
helper: 'Currently operational',
|
||||||
|
accentClass: 'border-primary',
|
||||||
|
iconClass: 'ti ti-circle-check',
|
||||||
|
iconBackgroundClass: 'bg-primary',
|
||||||
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trial',
|
||||||
|
value: String(counts.trial ?? 0),
|
||||||
|
helper: 'Trial subscriptions',
|
||||||
|
accentClass: 'border-primary',
|
||||||
|
iconClass: 'ri-wallet-2-line',
|
||||||
|
iconBackgroundClass: 'bg-primary',
|
||||||
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Draft',
|
||||||
|
value: String(counts.draft ?? 0),
|
||||||
|
helper: 'Incomplete onboarding',
|
||||||
|
accentClass: 'border-primary',
|
||||||
|
iconClass: 'ti ti-file-text',
|
||||||
|
iconBackgroundClass: 'bg-primary',
|
||||||
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Suspended',
|
||||||
|
value: String(counts.suspended ?? 0),
|
||||||
|
helper: 'Temporarily disabled',
|
||||||
|
accentClass: 'border-primary',
|
||||||
|
iconClass: 'ti ti-player-pause',
|
||||||
|
iconBackgroundClass: 'bg-primary',
|
||||||
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cancelled',
|
||||||
|
value: String(counts.cancelled ?? 0),
|
||||||
|
helper: 'Cancelled subscriptions',
|
||||||
|
accentClass: 'border-primary',
|
||||||
|
iconClass: 'ti ti-square-x',
|
||||||
|
iconBackgroundClass: 'bg-primary',
|
||||||
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: 'Awaiting Database',
|
||||||
|
// value: String(counts.awaitingDatabase ?? 0),
|
||||||
|
// helper: 'DB provisioning pending',
|
||||||
|
// accentClass: 'border-primary',
|
||||||
|
// iconClass: 'ti ti-database',
|
||||||
|
// iconBackgroundClass: 'bg-primary',
|
||||||
|
// helperClass: 'bg-primary/10 text-primary',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'Awaiting Activation',
|
||||||
|
// value: String(counts.awaitingActivation ?? 0),
|
||||||
|
// helper: 'Activation pending',
|
||||||
|
// accentClass: 'border-primary',
|
||||||
|
// iconClass: 'ti ti-clock-check',
|
||||||
|
// iconBackgroundClass: 'bg-primary',
|
||||||
|
// helperClass: 'bg-primary/10 text-primary',
|
||||||
|
// },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
onAddOrganization(): void {
|
onAddOrganization(): void {
|
||||||
void this.router.navigate(['/organizations/onboarding']);
|
void this.router.navigate(['/organizations/onboarding']);
|
||||||
}
|
}
|
||||||
|
|
||||||
onActionClick(event: DataTableActionEvent<OrganizationTableRow>): void {
|
onActionClick(event: DataTableActionEvent<RecentOrganizationTableRow>): void {
|
||||||
if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') {
|
if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') {
|
||||||
void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } });
|
void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } });
|
||||||
return;
|
return;
|
||||||
@@ -197,5 +422,3 @@ export class Dashboard implements OnInit {
|
|||||||
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
|
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,10 @@ export const ORGANIZATION_ENDPOINTS = {
|
|||||||
'masterAdmin',
|
'masterAdmin',
|
||||||
'/v1/tenants/autocomplete'
|
'/v1/tenants/autocomplete'
|
||||||
),
|
),
|
||||||
|
|
||||||
|
dashboard: buildApiUrl(
|
||||||
|
'masterAdmin',
|
||||||
|
'/v1/organizations/dashboard'
|
||||||
|
),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -7,7 +7,8 @@ import { DataTableQuery, DataTableResult } from '../../../../shared/components/d
|
|||||||
import {
|
import {
|
||||||
OrganizationDto,
|
OrganizationDto,
|
||||||
OrganizationLookupDto,
|
OrganizationLookupDto,
|
||||||
OrganizationTableRow
|
OrganizationTableRow,
|
||||||
|
RecentOrganizationsDashboardDto
|
||||||
} from '../models/organization.model';
|
} from '../models/organization.model';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -16,6 +17,17 @@ import {
|
|||||||
export class OrganizationService {
|
export class OrganizationService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
getRecentOrganizationsDashboard(months?: number, limit?: number): Observable<RecentOrganizationsDashboardDto> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (months !== undefined && months !== null) {
|
||||||
|
params = params.set('months', months.toString());
|
||||||
|
}
|
||||||
|
if (limit !== undefined && limit !== null) {
|
||||||
|
params = params.set('limit', limit.toString());
|
||||||
|
}
|
||||||
|
return this.http.get<RecentOrganizationsDashboardDto>(ORGANIZATION_ENDPOINTS.dashboard, { params });
|
||||||
|
}
|
||||||
|
|
||||||
getOrganizationDataTable(query: DataTableQuery, countryId?: string | null): Observable<DataTableResult<OrganizationDto>> {
|
getOrganizationDataTable(query: DataTableQuery, countryId?: string | null): Observable<DataTableResult<OrganizationDto>> {
|
||||||
const payload = {
|
const payload = {
|
||||||
...query,
|
...query,
|
||||||
|
|||||||
@@ -65,3 +65,40 @@ export interface OrganizationTableRow extends DataTableRecord {
|
|||||||
|
|
||||||
export type OrganizationModalMode = 'create' | 'edit' | 'view';
|
export type OrganizationModalMode = 'create' | 'edit' | 'view';
|
||||||
|
|
||||||
|
export interface OrganizationStatusCountsDto {
|
||||||
|
totalOrganizations: number;
|
||||||
|
trial: number;
|
||||||
|
active: number;
|
||||||
|
suspended: number;
|
||||||
|
cancelled: number;
|
||||||
|
draft: number;
|
||||||
|
awaitingDatabase: number;
|
||||||
|
awaitingActivation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentOrganizationDto {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
status: number | string;
|
||||||
|
countryId?: string | null;
|
||||||
|
countryName?: string | null;
|
||||||
|
planId?: string | null;
|
||||||
|
planCode?: string | null;
|
||||||
|
planName?: string | null;
|
||||||
|
subscriptionStatus?: number | string | null;
|
||||||
|
planExpiresOn?: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdOn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentOrganizationsDashboardDto {
|
||||||
|
months: number;
|
||||||
|
limit: number;
|
||||||
|
from: string;
|
||||||
|
generatedOn: string;
|
||||||
|
counts: OrganizationStatusCountsDto;
|
||||||
|
organizations: RecentOrganizationDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+78
-10
@@ -33,11 +33,6 @@ const INITIAL_DRAFT_STATE: OnboardingDraftState = {
|
|||||||
admin: null,
|
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>;
|
type RawDraftRecord = Record<string, unknown>;
|
||||||
|
|
||||||
function firstDefined(...values: readonly unknown[]): unknown {
|
function firstDefined(...values: readonly unknown[]): unknown {
|
||||||
@@ -233,9 +228,21 @@ export class OrganizationOnboardingStateService {
|
|||||||
admin: adminData ? { ...adminData } : null,
|
admin: adminData ? { ...adminData } : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const completedIndexes = isEditMode
|
const statusStr = String(serverDraft.status || rawRecord['status'] || rawRecord['Status'] || '').toLowerCase();
|
||||||
? [0, 1, 2, 3]
|
const isNonDraftActive = isEditMode && statusStr !== '' && statusStr !== 'draft' && statusStr !== '0';
|
||||||
: (serverDraft.completedStepIndexes ?? []);
|
|
||||||
|
const explicitIndexes = serverDraft.completedStepIndexes ?? [];
|
||||||
|
|
||||||
|
const isStep0Done = isNonDraftActive || evaluateSectionCompletion(basicsData, 'basics', rawRecord, explicitIndexes, 0);
|
||||||
|
const isStep1Done = isNonDraftActive || evaluateSectionCompletion(localizationData, 'localization', rawRecord, explicitIndexes, 1);
|
||||||
|
const isStep2Done = isNonDraftActive || evaluateSectionCompletion(planLimitsData, 'plan', rawRecord, explicitIndexes, 2) || evaluateSectionCompletion(planLimitsData, 'planLimits', rawRecord, explicitIndexes, 2);
|
||||||
|
const isStep3Done = isNonDraftActive || evaluateSectionCompletion(adminData, 'admin', rawRecord, explicitIndexes, 3) || evaluateSectionCompletion(adminData, 'adminContact', rawRecord, explicitIndexes, 3);
|
||||||
|
|
||||||
|
const completedIndexes: number[] = [];
|
||||||
|
if (isStep0Done) completedIndexes.push(0);
|
||||||
|
if (isStep1Done) completedIndexes.push(1);
|
||||||
|
if (isStep2Done) completedIndexes.push(2);
|
||||||
|
if (isStep3Done) completedIndexes.push(3);
|
||||||
|
|
||||||
this.onboardingDataState.set({
|
this.onboardingDataState.set({
|
||||||
// Trust boundary: the server draft payload is only validated at runtime by the API,
|
// Trust boundary: the server draft payload is only validated at runtime by the API,
|
||||||
@@ -254,8 +261,15 @@ export class OrganizationOnboardingStateService {
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stepIndex = typeof serverDraft.currentStepIndex === 'number' ? serverDraft.currentStepIndex : 0;
|
let initialStepIndex = 0;
|
||||||
this.currentStepIndexState.set(Math.min(3, Math.max(0, stepIndex)));
|
if (typeof serverDraft.currentStepIndex === 'number' && serverDraft.currentStepIndex > 0 && serverDraft.currentStepIndex <= 3) {
|
||||||
|
initialStepIndex = serverDraft.currentStepIndex;
|
||||||
|
} else {
|
||||||
|
const firstUncompletedIndex = [0, 1, 2, 3].find(idx => !completedIndexes.includes(idx));
|
||||||
|
initialStepIndex = firstUncompletedIndex !== undefined ? firstUncompletedIndex : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentStepIndexState.set(Math.min(3, Math.max(0, initialStepIndex)));
|
||||||
this.setCompletedStepIndexes(completedIndexes);
|
this.setCompletedStepIndexes(completedIndexes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,3 +325,57 @@ export class OrganizationOnboardingStateService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidDateOrTrue(val: unknown): boolean {
|
||||||
|
if (val === true || val === 1 || val === '1' || val === 'true' || val === 'True') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof val === 'string' && val.trim().length > 0) {
|
||||||
|
const d = Date.parse(val);
|
||||||
|
return !isNaN(d);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateSectionCompletion(
|
||||||
|
sectionData: RawDraftRecord | null,
|
||||||
|
sectionKey: string,
|
||||||
|
rawRecord: RawDraftRecord,
|
||||||
|
explicitCompletedIndexes?: readonly number[],
|
||||||
|
stepIndex?: number
|
||||||
|
): boolean {
|
||||||
|
if (explicitCompletedIndexes && typeof stepIndex === 'number' && explicitCompletedIndexes.includes(stepIndex)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionData) {
|
||||||
|
const sectionCompletionVal = firstDefined(
|
||||||
|
sectionData['completedAt'], sectionData['CompletedAt'],
|
||||||
|
sectionData['completedOn'], sectionData['CompletedOn'],
|
||||||
|
sectionData['markComplete'], sectionData['MarkComplete'],
|
||||||
|
sectionData['isCompleted'], sectionData['IsCompleted']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValidDateOrTrue(sectionCompletionVal)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topKeyCap = capitalize(sectionKey);
|
||||||
|
const topLevelVal = firstDefined(
|
||||||
|
rawRecord[`${sectionKey}CompletedAt`], rawRecord[`${topKeyCap}CompletedAt`],
|
||||||
|
rawRecord[`${sectionKey}CompletedOn`], rawRecord[`${topKeyCap}CompletedOn`],
|
||||||
|
rawRecord[`is${topKeyCap}Completed`], rawRecord[`Is${topKeyCap}Completed`],
|
||||||
|
rawRecord[`${sectionKey}MarkedComplete`], rawRecord[`${topKeyCap}MarkedComplete`]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValidDateOrTrue(topLevelVal)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(str: string): string {
|
||||||
|
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -279,19 +279,25 @@ export class OrganizationOnboarding {
|
|||||||
case 1: {
|
case 1: {
|
||||||
const data = (activeStepComponent as OrganizationLocalizationStepComponent).getValue();
|
const data = (activeStepComponent as OrganizationLocalizationStepComponent).getValue();
|
||||||
const payload = mapLocalizationStepToApiRequest(data, true);
|
const payload = mapLocalizationStepToApiRequest(data, true);
|
||||||
update$ = this.onboardingService.updateLocalization(orgId, payload);
|
update$ = this.ensureBasicsCompleted(orgId).pipe(
|
||||||
|
switchMap(() => this.onboardingService.updateLocalization(orgId, payload))
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 2: {
|
case 2: {
|
||||||
const data = (activeStepComponent as OrganizationPlanLimitsStepComponent).getValue();
|
const data = (activeStepComponent as OrganizationPlanLimitsStepComponent).getValue();
|
||||||
const payload = mapPlanStepToApiRequest(data, true);
|
const payload = mapPlanStepToApiRequest(data, true);
|
||||||
update$ = this.onboardingService.updatePlan(orgId, payload);
|
update$ = this.ensureBasicsCompleted(orgId).pipe(
|
||||||
|
switchMap(() => this.onboardingService.updatePlan(orgId, payload))
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 3: {
|
case 3: {
|
||||||
const data = (activeStepComponent as OrganizationAdminStepComponent).getValue();
|
const data = (activeStepComponent as OrganizationAdminStepComponent).getValue();
|
||||||
const payload = mapAdminContactStepToApiRequest(data, true);
|
const payload = mapAdminContactStepToApiRequest(data, true);
|
||||||
update$ = this.onboardingService.updateAdminContact(orgId, payload);
|
update$ = this.ensureBasicsCompleted(orgId).pipe(
|
||||||
|
switchMap(() => this.onboardingService.updateAdminContact(orgId, payload))
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -343,13 +349,31 @@ export class OrganizationOnboarding {
|
|||||||
this.executeFinishAndProvisionFlow(orgId, adminData);
|
this.executeFinishAndProvisionFlow(orgId, adminData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureBasicsCompleted(orgId: string): Observable<unknown> {
|
||||||
|
const basicsDraft = this.basicsStep()?.getDraftValue()
|
||||||
|
?? this.stateService.basics()
|
||||||
|
?? this.stateService.onboardingDraft().basics;
|
||||||
|
|
||||||
|
if (basicsDraft && (basicsDraft.organizationName || (basicsDraft as any).name || (basicsDraft as any).Name)) {
|
||||||
|
const payload = mapBasicsStepToApiRequest(basicsDraft, true);
|
||||||
|
return this.onboardingService.updateBasics(orgId, payload).pipe(
|
||||||
|
catchError(err => {
|
||||||
|
console.warn('Unable to mark basics step completed on server prior to updating subsequent step:', err);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
private executeFinishAndProvisionFlow(orgId: string, adminData: OrganizationAdminValue): void {
|
private executeFinishAndProvisionFlow(orgId: string, adminData: OrganizationAdminValue): void {
|
||||||
this.finishing.set(true);
|
this.finishing.set(true);
|
||||||
|
|
||||||
const adminPayload = mapAdminContactStepToApiRequest(adminData, true);
|
const adminPayload = mapAdminContactStepToApiRequest(adminData, true);
|
||||||
|
|
||||||
this.onboardingService.updateAdminContact(orgId, adminPayload)
|
this.ensureBasicsCompleted(orgId)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
switchMap(() => this.onboardingService.updateAdminContact(orgId, adminPayload)),
|
||||||
switchMap(() => this.onboardingService.finishOnboarding(orgId)),
|
switchMap(() => this.onboardingService.finishOnboarding(orgId)),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
this.finishing.set(false);
|
this.finishing.set(false);
|
||||||
@@ -407,17 +431,23 @@ export class OrganizationOnboarding {
|
|||||||
case 1: {
|
case 1: {
|
||||||
const loc = this.localizationStep()?.getDraftValue() ?? {};
|
const loc = this.localizationStep()?.getDraftValue() ?? {};
|
||||||
const requestPayload = mapLocalizationStepToApiRequest(loc, markComplete);
|
const requestPayload = mapLocalizationStepToApiRequest(loc, markComplete);
|
||||||
return this.onboardingService.updateLocalization(orgId, requestPayload);
|
return this.ensureBasicsCompleted(orgId).pipe(
|
||||||
|
switchMap(() => this.onboardingService.updateLocalization(orgId, requestPayload))
|
||||||
|
) as Observable<OrganizationServerDraftResponse>;
|
||||||
}
|
}
|
||||||
case 2: {
|
case 2: {
|
||||||
const plan = this.planLimitsStep()?.getDraftValue() ?? {};
|
const plan = this.planLimitsStep()?.getDraftValue() ?? {};
|
||||||
const requestPayload = mapPlanStepToApiRequest(plan, markComplete);
|
const requestPayload = mapPlanStepToApiRequest(plan, markComplete);
|
||||||
return this.onboardingService.updatePlan(orgId, requestPayload);
|
return this.ensureBasicsCompleted(orgId).pipe(
|
||||||
|
switchMap(() => this.onboardingService.updatePlan(orgId, requestPayload))
|
||||||
|
) as Observable<OrganizationServerDraftResponse>;
|
||||||
}
|
}
|
||||||
case 3: {
|
case 3: {
|
||||||
const admin = this.adminStep()?.getDraftValue() ?? {};
|
const admin = this.adminStep()?.getDraftValue() ?? {};
|
||||||
const requestPayload = mapAdminContactStepToApiRequest(admin, markComplete);
|
const requestPayload = mapAdminContactStepToApiRequest(admin, markComplete);
|
||||||
return this.onboardingService.updateAdminContact(orgId, requestPayload);
|
return this.ensureBasicsCompleted(orgId).pipe(
|
||||||
|
switchMap(() => this.onboardingService.updateAdminContact(orgId, requestPayload))
|
||||||
|
) as Observable<OrganizationServerDraftResponse>;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return of({ id: orgId, status: 'Draft' });
|
return of({ id: orgId, status: 'Draft' });
|
||||||
|
|||||||
Reference in New Issue
Block a user