organization dashboard changes

This commit is contained in:
Gagan7900
2026-07-30 20:27:50 +05:30
parent 2a6f85890f
commit 430389c611
8 changed files with 441 additions and 66 deletions
@@ -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;
@@ -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[];
}
@@ -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' });