import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal, viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { catchError, finalize, of } from 'rxjs'; import { OnboardingStep, OnboardingStepper } from './components/onboarding-stepper/onboarding-stepper'; import { OrganizationBasicsStepComponent } from './steps/organization-basics/organization-basics'; import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization'; import { OrganizationPlanLimitsStepComponent } from './steps/organization-plan-limits/organization-plan-limits'; import { OrganizationAdminStepComponent } from './steps/organization-admin/organization-admin'; import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service'; import { OrganizationOnboardingService } from './services/organization-onboarding.service'; import { ConfirmDialog } from '../../../shared/components/confirm-dialog/confirm-dialog'; import { OnboardingStepForm } from './models/organization-onboarding.model'; import { OrganizationProvisioningRequest } from './models/organization-provisioning.model'; @Component({ selector: 'app-organization-onboarding', standalone: true, imports: [ OnboardingStepper, OrganizationBasicsStepComponent, OrganizationLocalizationStepComponent, OrganizationPlanLimitsStepComponent, OrganizationAdminStepComponent, ConfirmDialog, ], templateUrl: './organization-onboarding.html', styleUrl: './organization-onboarding.scss', changeDetection: ChangeDetectionStrategy.OnPush, providers: [OrganizationOnboardingStateService, OrganizationOnboardingService] }) export class OrganizationOnboarding { private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly toastr = inject(ToastrService); readonly stateService = inject(OrganizationOnboardingStateService); private readonly onboardingService = inject(OrganizationOnboardingService); readonly steps: readonly OnboardingStep[] = this.stateService.createStepDefinitions(); readonly savingDraft = signal(false); readonly finishing = signal(false); private readonly basicsStep = viewChild(OrganizationBasicsStepComponent); private readonly localizationStep = viewChild(OrganizationLocalizationStepComponent); private readonly planLimitsStep = viewChild(OrganizationPlanLimitsStepComponent); private readonly adminStep = viewChild(OrganizationAdminStepComponent); private readonly cancelConfirmDialog = viewChild('cancelConfirmDialog'); private readonly finishConfirmDialog = viewChild('finishConfirmDialog'); private savedDraftSnapshot = ''; readonly provisioningRequest = signal(null); readonly currentStepIndex = this.stateService.currentStepIndex; readonly completedStepIndexes = this.stateService.completedStepIndexes; readonly currentStep = computed( () => this.steps[this.currentStepIndex()] ); readonly navigationDisabled = computed( () => this.savingDraft() || this.finishing() ); constructor() { this.onboardingService.loadDraft() .pipe( catchError(() => { this.toastr.error('Unable to restore the onboarding draft.', 'Draft restore failed'); return of(null); }), takeUntilDestroyed(this.destroyRef) ) .subscribe(draft => { if (!draft) { this.savedDraftSnapshot = this.serializeDraftState(); return; } this.stateService.restoreDraft(draft); this.savedDraftSnapshot = this.serializeDraftState(); this.hydrateActiveStep(); }); } onStepSelected(index: number): void { if (!this.stateService.canOpenStep(index)) { return; } this.captureActiveStepDraft(); this.stateService.setCurrentStepIndex(index); this.hydrateActiveStep(); } onBack(): void { const currentIndex = this.currentStepIndex(); if (currentIndex <= 0) { return; } this.captureActiveStepDraft(); this.stateService.setCurrentStepIndex(currentIndex - 1); this.hydrateActiveStep(); } onNext(): void { const currentIndex = this.currentStepIndex(); if (currentIndex >= this.steps.length - 1) { return; } const activeStep = this.getActiveStep(); if (!activeStep?.validate()) { this.stateService.unmarkStepCompleted(currentIndex); return; } this.storeValidatedActiveStep(activeStep); this.stateService.markStepCompleted(currentIndex); this.stateService.setCurrentStepIndex(currentIndex + 1); this.hydrateActiveStep(); } onSaveDraft(): void { if (this.savingDraft()) { return; } this.captureActiveStepDraft(); this.savingDraft.set(true); this.onboardingService.saveDraft(this.stateService.buildDraft()) .pipe( finalize(() => { this.savingDraft.set(false); }), takeUntilDestroyed(this.destroyRef) ) .subscribe({ next: () => { this.savedDraftSnapshot = this.serializeDraftState(); this.toastr.success('Onboarding draft saved successfully.', 'Draft saved'); }, error: () => { this.toastr.error('Unable to save the onboarding draft.', 'Draft save failed'); } }); } onFinish(): void { if (this.finishing()) { return; } const activeStep = this.adminStep(); if (!activeStep?.validate()) { this.stateService.unmarkStepCompleted(3); return; } this.stateService.updateAdmin(activeStep.getValue()); this.stateService.markStepCompleted(3); const data = this.stateService.onboardingData(); if (!data.basics || !data.localization || !data.planLimits || !data.admin) { this.toastr.error('Complete and validate every onboarding step before provisioning.'); return; } this.provisioningRequest.set({ basics: data.basics, localization: data.localization, planLimits: data.planLimits, admin: data.admin, }); void this.finishConfirmDialog()?.open(); } onCancel(): void { this.captureActiveStepDraft(); if (this.serializeDraftState() !== this.savedDraftSnapshot) { void this.cancelConfirmDialog()?.open(); return; } void this.leaveOnboarding(false); } onDiscardAndLeave(): void { void this.leaveOnboarding(true); } onProvisioningConfirmed(): void { this.toastr.info( 'The provisioning request is ready. Backend provisioning is not connected yet.', 'Provisioning pending' ); } private getActiveStep(): OnboardingStepForm | undefined { switch (this.currentStepIndex()) { case 0: return this.basicsStep(); case 1: return this.localizationStep(); case 2: return this.planLimitsStep(); case 3: return this.adminStep(); default: return undefined; } } private storeValidatedActiveStep(step: OnboardingStepForm): void { switch (this.currentStepIndex()) { case 0: this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue()); break; case 1: this.stateService.updateLocalization((step as OrganizationLocalizationStepComponent).getValue()); break; case 2: this.stateService.updatePlanLimits((step as OrganizationPlanLimitsStepComponent).getValue()); break; case 3: this.stateService.updateAdmin((step as OrganizationAdminStepComponent).getValue()); break; } } private captureActiveStepDraft(): void { const currentIndex = this.currentStepIndex(); const validatedValue = this.getValidatedStepValue(currentIndex); switch (currentIndex) { case 0: this.stateService.updateBasicsDraft(this.basicsStep()?.getDraftValue() ?? null); break; case 1: this.stateService.updateLocalizationDraft(this.localizationStep()?.getDraftValue() ?? null); break; case 2: this.stateService.updatePlanLimitsDraft(this.planLimitsStep()?.getDraftValue() ?? null); break; case 3: this.stateService.updateAdminDraft(this.adminStep()?.getDraftValue() ?? null); break; } const draftValue = this.getDraftStepValue(currentIndex); if ( this.stateService.isStepCompleted(currentIndex) && JSON.stringify(draftValue) !== JSON.stringify(validatedValue) ) { this.stateService.invalidateStep(currentIndex); } } private getValidatedStepValue(index: number): unknown { const data = this.stateService.onboardingData(); return [data.basics, data.localization, data.planLimits, data.admin][index] ?? null; } private getDraftStepValue(index: number): unknown { const draft = this.stateService.onboardingDraft(); return [draft.basics, draft.localization, draft.planLimits, draft.admin][index] ?? null; } private hydrateActiveStep(): void { queueMicrotask(() => { const draft = this.stateService.onboardingDraft(); switch (this.currentStepIndex()) { case 0: if (draft.basics) this.basicsStep()?.patchValue(draft.basics); break; case 1: if (draft.localization) { this.localizationStep()?.patchValue(draft.localization); } this.applyCountryDefaults(); break; case 2: if (draft.planLimits) this.planLimitsStep()?.patchValue(draft.planLimits); break; case 3: if (draft.admin) this.adminStep()?.patchValue(draft.admin); break; } }); } private applyCountryDefaults(): void { const countryIso2 = this.stateService.basics()?.registrationCountry.iso2 ?? null; this.onboardingService.getCountryLocalizationDefaults(countryIso2) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(defaults => { if (defaults) { this.localizationStep()?.applyCountryDefaults(defaults, false); } }); } private serializeDraftState(): string { const draft = this.stateService.buildDraft(); return JSON.stringify({ currentStepIndex: draft.currentStepIndex, completedStepIndexes: draft.completedStepIndexes, basics: draft.basics, localization: draft.localization, planLimits: draft.planLimits, admin: draft.admin, }); } private async leaveOnboarding(clearDraft: boolean): Promise { if (clearDraft) { try { await new Promise((resolve, reject) => { this.onboardingService.clearDraft() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: resolve, error: reject }); }); } catch { this.toastr.error('Unable to clear the saved draft.'); return; } } this.stateService.clear(); await this.router.navigate(['/organizations']); } }