245 lines
7.9 KiB
TypeScript
245 lines
7.9 KiB
TypeScript
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<LeaseEndState | null>(null);
|
||
readonly loading = signal(true);
|
||
readonly loadError = signal('');
|
||
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
|
||
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
|
||
readonly operationErrors = signal<Record<string, string>>({});
|
||
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<AcceptWithdrawalRequest>): 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<InvalidateNoticeRequest>): 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<AppendEvidenceRequest>): 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<LeaseEndState>, 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<string>, key: string): ReadonlySet<string> {
|
||
const next = new Set(keys);
|
||
next.delete(key);
|
||
return next;
|
||
}
|