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