From 430389c61116fad9843b7fbd6d277aa0b565dc12 Mon Sep 17 00:00:00 2001 From: Gagan7900 <54118395+Gagan7900@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:27:50 +0530 Subject: [PATCH] organization dashboard changes --- .../authentication/pages/login/login.html | 6 +- .../organizations/dashboard/dashboard.html | 8 +- .../organizations/dashboard/dashboard.ts | 305 +++++++++++++++--- .../data-access/organization.endpoints.ts | 5 + .../data-access/organization.service.ts | 14 +- .../models/organization.model.ts | 37 +++ .../organization-onboarding-state.service.ts | 88 ++++- .../organization-onboarding.ts | 44 ++- 8 files changed, 441 insertions(+), 66 deletions(-) diff --git a/src/app/features/authentication/pages/login/login.html b/src/app/features/authentication/pages/login/login.html index cb60c4c7..3392bf50 100644 --- a/src/app/features/authentication/pages/login/login.html +++ b/src/app/features/authentication/pages/login/login.html @@ -60,11 +60,11 @@ - Forgot password? + Forgot Password?
-
diff --git a/src/app/features/organizations/dashboard/dashboard.html b/src/app/features/organizations/dashboard/dashboard.html index 55d14354..ef0a35c6 100644 --- a/src/app/features/organizations/dashboard/dashboard.html +++ b/src/app/features/organizations/dashboard/dashboard.html @@ -1,4 +1,4 @@ -
+ -
+
@for (card of statCards(); track card.title) {
-
+
diff --git a/src/app/features/organizations/dashboard/dashboard.ts b/src/app/features/organizations/dashboard/dashboard.ts index 91718d18..0eca0dbe 100644 --- a/src/app/features/organizations/dashboard/dashboard.ts +++ b/src/app/features/organizations/dashboard/dashboard.ts @@ -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); + readonly tableStore = inject(DataTableStore); + + readonly selectedMonths = signal(undefined); + readonly selectedLimit = signal(undefined); readonly statCards = signal([ { 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[]>([ + readonly columns = signal[]>([ { 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('No Organizations'); readonly emptyDescription = signal('Start by adding your first organization'); - readonly actions = signal[]>([ + readonly actions = signal[]>([ { 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): void { + onActionClick(event: DataTableActionEvent): 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}`); } } - - diff --git a/src/app/features/organizations/organization-list/data-access/organization.endpoints.ts b/src/app/features/organizations/organization-list/data-access/organization.endpoints.ts index f00232c6..4f38fddc 100644 --- a/src/app/features/organizations/organization-list/data-access/organization.endpoints.ts +++ b/src/app/features/organizations/organization-list/data-access/organization.endpoints.ts @@ -16,5 +16,10 @@ export const ORGANIZATION_ENDPOINTS = { 'masterAdmin', '/v1/tenants/autocomplete' ), + + dashboard: buildApiUrl( + 'masterAdmin', + '/v1/organizations/dashboard' + ), } as const; diff --git a/src/app/features/organizations/organization-list/data-access/organization.service.ts b/src/app/features/organizations/organization-list/data-access/organization.service.ts index f58fab92..e3120a72 100644 --- a/src/app/features/organizations/organization-list/data-access/organization.service.ts +++ b/src/app/features/organizations/organization-list/data-access/organization.service.ts @@ -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 { + 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(ORGANIZATION_ENDPOINTS.dashboard, { params }); + } + getOrganizationDataTable(query: DataTableQuery, countryId?: string | null): Observable> { const payload = { ...query, diff --git a/src/app/features/organizations/organization-list/models/organization.model.ts b/src/app/features/organizations/organization-list/models/organization.model.ts index aef497d3..9162b601 100644 --- a/src/app/features/organizations/organization-list/models/organization.model.ts +++ b/src/app/features/organizations/organization-list/models/organization.model.ts @@ -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[]; +} + + diff --git a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts index 00488d2d..21126894 100644 --- a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts +++ b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts @@ -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; 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) : ''; +} diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts index 8850229d..53ae34e3 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts @@ -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 { + 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; } 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; } 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; } default: return of({ id: orgId, status: 'Draft' });