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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user