import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { Observable, finalize } from 'rxjs'; import { browserTimeZone, formatFrenchCivilDate, formatFrenchCivilMonth, todayInTimeZone, } from '../../services/civil-date'; import { httpErrorMessage } from '../../services/http-error-message'; import { withoutRecordKey } from '../../services/immutable-state'; import { AgreedEarlyEndRequest, AppendEvidenceRequest, CloseLeaseRequest, InvalidateNoticeRequest, LeaseEndPhase, LeaseEndService, LeaseEndState, RecordNoticeRequest, AcceptWithdrawalRequest, } from '../../services/lease-end.service'; import { ConfirmationDialog, ConfirmationDialogData, } from '../confirmation-dialog/confirmation-dialog'; import { LeaseEndNoticeForm } from '../lease-end-notice-form/lease-end-notice-form'; import { LeaseEndNoticeCard, NoticeAction } from '../lease-end-notice-card/lease-end-notice-card'; import { LeaseEndResolutionForms } from '../lease-end-resolution-forms/lease-end-resolution-forms'; @Component({ selector: 'app-lease-end-page', imports: [ MatButtonModule, RouterLink, LeaseEndNoticeForm, LeaseEndNoticeCard, LeaseEndResolutionForms, ], templateUrl: './lease-end-page.html', styleUrl: './lease-end-page.css', }) export class LeaseEndPage { private readonly route = inject(ActivatedRoute); private readonly endService = inject(LeaseEndService); private readonly dialog = inject(MatDialog); private readonly destroyRef = inject(DestroyRef); readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? ''; readonly timeZone = browserTimeZone; readonly today = todayInTimeZone(this.timeZone); readonly state = signal(null); readonly loading = signal(true); readonly loadError = signal(''); readonly pendingOperations = signal>(new Set()); readonly confirmingOperations = signal>(new Set()); readonly operationErrors = signal>({}); readonly lifecycleMutationsEnabled = computed(() => { const state = this.state(); return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure; }); readonly canRecordNotice = computed( () => this.lifecycleMutationsEnabled() && this.state()?.phase === 'ACTIVE' && !this.state()?.notices.some((notice) => notice.status === 'ACTIVE'), ); readonly canRecordAgreement = computed(() => { const state = this.state(); return ( this.lifecycleMutationsEnabled() && !!state && (state.phase === 'ACTIVE' || state.phase === 'NOTICE_PERIOD') ); }); readonly canClose = computed(() => { const state = this.state(); return ( this.lifecycleMutationsEnabled() && !!state && (state.phase === 'AWAITING_CLOSURE' || state.phase === 'UNAUTHORIZED_OCCUPANCY') ); }); readonly exitReportDate = computed(() => { const report = this.state()?.exitConditionReport; return report?.finalized ? report.occurredOn : null; }); readonly closureReady = computed(() => this.canClose() && !!this.exitReportDate()); readonly civilDate = formatFrenchCivilDate; readonly civilMonth = formatFrenchCivilMonth; constructor() { this.load(); } load(): void { this.loading.set(true); this.loadError.set(''); this.endService .getState(this.leaseId, this.timeZone) .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => this.loading.set(false)), ) .subscribe({ next: (state) => this.state.set(state), error: (error: unknown) => this.loadError.set(httpErrorMessage(error, 'Impossible de charger la fin de ce bail.')), }); } phaseLabel(phase: LeaseEndPhase): string { return { ACTIVE: 'Bail actif', NOTICE_PERIOD: 'Préavis en cours', AWAITING_CLOSURE: 'En attente de clôture', UNAUTHORIZED_OCCUPANCY: 'Occupation sans droit ni titre', ARCHIVED: 'Bail clôturé et archivé', }[phase]; } recordNotice(request: RecordNoticeRequest): void { this.mutate( 'notice', this.endService.recordNotice(this.leaseId, request), 'Impossible d’enregistrer ce congé.', ); } requestWithdrawal(action: NoticeAction): void { const key = `withdrawal:${action.noticeId}`; this.confirm( key, { title: 'Accepter le retrait de ce congé ?', message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`, confirmLabel: 'Accepter le retrait', }, () => this.mutate( key, this.endService.acceptWithdrawal(this.leaseId, action.noticeId, action.request), 'Impossible d’accepter ce retrait.', ), ); } requestInvalidation(action: NoticeAction): void { const key = `invalidation:${action.noticeId}`; this.confirm( key, { title: 'Invalider administrativement ce congé ?', message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`, confirmLabel: 'Invalider le congé', }, () => this.mutate( key, this.endService.invalidateNotice(this.leaseId, action.noticeId, action.request), 'Impossible d’invalider ce congé.', ), ); } appendEvidence(action: NoticeAction): void { this.mutate( `evidence:${action.noticeId}`, this.endService.appendEvidence(this.leaseId, action.noticeId, action.request), 'Impossible d’ajouter cette preuve.', ); } recordAgreement(request: AgreedEarlyEndRequest): void { this.mutate( 'agreement', this.endService.recordAgreedEarlyEnd(this.leaseId, request), 'Impossible d’enregistrer cette fin anticipée.', ); } closeLease(request: CloseLeaseRequest): void { this.mutate( 'closure', this.endService.close(this.leaseId, request), 'Impossible de clôturer ce bail.', ); } operationPending(key: string): boolean { return this.pendingOperations().has(key) || this.confirmingOperations().has(key); } operationError(key: string): string { return this.operationErrors()[key] ?? ''; } private confirm(key: string, data: ConfirmationDialogData, action: () => void): void { if (this.operationPending(key)) return; this.confirmingOperations.update((keys) => new Set(keys).add(key)); this.dialog .open(ConfirmationDialog, { data, role: 'alertdialog', width: 'min(440px, calc(100vw - 32px))', maxWidth: 'calc(100vw - 32px)', }) .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((confirmed) => { this.confirmingOperations.update((keys) => withoutKey(keys, key)); if (confirmed) action(); }); } private mutate(key: string, request: Observable, fallback: string): void { if (this.pendingOperations().has(key)) return; this.pendingOperations.update((keys) => new Set(keys).add(key)); this.operationErrors.update((errors) => withoutRecordKey(errors, key)); request .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => this.pendingOperations.update((keys) => withoutKey(keys, key))), ) .subscribe({ next: (state) => this.state.set(state), error: (error: unknown) => this.operationErrors.update((errors) => ({ ...errors, [key]: httpErrorMessage(error, fallback), })), }); } } function withoutKey(keys: ReadonlySet, key: string): ReadonlySet { const next = new Set(keys); next.delete(key); return next; }