feat: add lease end page and resolution forms with necessary services and components

- Implemented LeaseEndPage component for managing lease end processes.
- Created LeaseEndResolutionForms component for handling agreement and closure submissions.
- Added civil date formatting utilities for French date representation.
- Developed styles for lease end resolution forms and rent due correction components.
- Integrated HTTP service for lease end operations including notice recording, withdrawal acceptance, and closure.
- Added unit tests for new components and services to ensure functionality.
This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 3d42c9a256
commit c1e6b905d5
114 files changed
+6582 -345

No files matched your search

@@ -0,0 +1,239 @@
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 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;
}