Files
lmnp-organizer/frontend/src/app/components/lease-end-page/lease-end-page.ts
T

245 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}