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,96 @@
:host {
display: block;
min-width: 0;
}
.lease-end-page {
max-width: 62rem;
}
.lease-end-skeleton {
display: grid;
gap: 1rem;
}
.lease-end-skeleton span {
min-height: 5rem;
border-radius: 1rem;
}
.phase-summary,
.unsupported-state,
.timeline,
.correction-warning {
min-width: 0;
padding: 1.25rem;
border: 1px solid var(--border, #dfe3e6);
border-radius: 1rem;
background: var(--surface, #fff);
}
.phase-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.phase-summary h2,
.timeline h2,
.notice-history > h2,
.unsupported-state h2 {
margin-block-start: 0;
}
.student-summary {
border-color: var(--accent, #236b5d);
}
.student-final-month {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background: var(--surface-accent, #eaf5f1);
}
.student-final-month span {
color: var(--text-muted, #5f6368);
font-size: 0.75rem;
}
.student-final-month strong {
font-size: 1.1rem;
}
.unsupported-state,
.correction-warning,
.timeline,
.notice-history,
app-lease-end-notice-form,
app-lease-end-resolution-forms {
margin-block-start: 1rem;
}
.unsupported-state {
background: var(--surface-soft, #f6f7f8);
}
.correction-warning {
color: var(--danger, #b42318);
}
.timeline ol {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.timeline li {
display: grid;
gap: 0.25rem;
padding-inline-start: 0.75rem;
border-inline-start: 0.2rem solid var(--accent, #236b5d);
}
.timeline span {
color: var(--text-muted, #5f6368);
font-size: 0.75rem;
}
.timeline small {
color: var(--text-muted, #5f6368);
overflow-wrap: anywhere;
}
.notice-history {
display: grid;
gap: 1rem;
}
@@ -0,0 +1,150 @@
<section class="form-page lease-end-page">
<header class="page-heading">
<div>
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
<p class="eyebrow">Bail</p>
<h1>Fin du bail</h1>
<p>Suivez les faits datés jusqu’à la restitution effective des clés.</p>
</div>
</header>
@if (loading()) {
<div class="lease-end-skeleton" aria-label="Chargement de la fin du bail">
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
</div>
} @else if (loadError()) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ loadError() }}</p>
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
</article>
} @else if (state(); as endState) {
<article class="phase-summary" [class.student-summary]="endState.leaseType === 'STUDENT'">
<div>
<p class="eyebrow">Situation actuelle</p>
<h2>{{ phaseLabel(endState.phase) }}</h2>
</div>
@if (endState.leaseType === 'STUDENT' && endState.fixedEndOn) {
<p class="student-final-month">
<span>Dernier mois du bail étudiant</span>
<strong>{{ civilMonth(endState.fixedEndOn) }}</strong>
</p>
}
</article>
@if (!endState.supported) {
<article class="unsupported-state" role="status">
<h2>Gestion non prise en charge</h2>
<p>{{ endState.unsupportedReason ?? 'Ce type de bail ne peut pas être modifié ici.' }}</p>
<p>
Aucun nouveau fait de fin, retrait ou clôture n’est proposé. Les justificatifs des congés
historiques peuvent toujours être complétés.
</p>
</article>
}
@if (endState.correctionRequired) {
<article class="correction-warning" role="alert">
<p>
Une correction financière est requise : contrepasser le paiement concerné, puis
enregistrer le bon paiement.
</p>
<a mat-stroked-button routerLink="/rent-payments">Corriger les paiements</a>
</article>
}
<section class="timeline" aria-labelledby="timeline-title">
<h2 id="timeline-title">Chronologie</h2>
<ol>
@if (endState.fixedEndOn) {
<li>
<span>Terme fixe du bail étudiant</span
><strong>{{ civilDate(endState.fixedEndOn) }}</strong>
</li>
}
@for (notice of endState.notices; track notice.id) {
<li>
<span>Congé reçu</span><strong>{{ civilDate(notice.receivedOn) }}</strong>
</li>
<li>
<span>Départ demandé</span><strong>{{ civilDate(notice.requestedDepartureOn) }}</strong>
</li>
}
@for (agreement of endState.agreedEarlyEnds; track agreement.recordedAt) {
<li>
<span>Fin anticipée convenue</span>
<strong>{{ civilDate(agreement.endOn) }}</strong>
<small>
{{ agreement.reason }} · {{ agreement.authorId }} · {{ agreement.recordedAt }}
</small>
</li>
}
@if (endState.effectiveEndOn) {
<li>
<span>Fin effective retenue</span
><strong>{{ civilDate(endState.effectiveEndOn) }}</strong>
</li>
}
@if (endState.closure) {
<li>
<span>Clés restituées</span
><strong>{{ civilDate(endState.closure.keysReturnedOn) }}</strong>
</li>
}
</ol>
@if (endState.closure) {
<p>
Clôture confirmée par {{ endState.closure.authorId }} · {{ endState.closure.recordedAt }}
</p>
}
</section>
@if (endState.notices.length) {
<section class="notice-history" aria-labelledby="notice-history-title">
<h2 id="notice-history-title">Historique des congés</h2>
@for (notice of endState.notices; track notice.id) {
<app-lease-end-notice-card
[leaseId]="leaseId"
[notice]="notice"
[timeZone]="timeZone"
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
[evidenceUploadEnabled]="true"
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
[invalidationPending]="operationPending('invalidation:' + notice.id)"
[evidencePending]="operationPending('evidence:' + notice.id)"
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
[evidenceOperationError]="operationError('evidence:' + notice.id)"
(withdrawalRequested)="requestWithdrawal($event)"
(invalidationRequested)="requestInvalidation($event)"
(evidenceRequested)="appendEvidence($event)"
/>
}
</section>
}
@if (canRecordNotice()) {
<app-lease-end-notice-form
[timeZone]="timeZone"
[today]="today"
[submitting]="operationPending('notice')"
[operationError]="operationError('notice')"
(noticeRequested)="recordNotice($event)"
/>
}
@if (canRecordAgreement() || canClose()) {
<app-lease-end-resolution-forms
[timeZone]="timeZone"
[showAgreement]="canRecordAgreement()"
[showClosure]="canClose()"
[agreementPending]="operationPending('agreement')"
[closurePending]="operationPending('closure')"
[agreementOperationError]="operationError('agreement')"
[closureOperationError]="operationError('closure')"
(agreedEndRequested)="recordAgreement($event)"
(closureRequested)="closeLease($event)"
/>
}
}
</section>
@@ -0,0 +1,258 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
import {
LeaseEndService,
LeaseEndState,
RecordNoticeRequest,
} from '../../services/lease-end.service';
import { LeaseEndPage } from './lease-end-page';
describe('LeaseEndPage', () => {
const state: LeaseEndState = {
leaseId: 'lease-1',
supported: true,
unsupportedReason: null,
phase: 'NOTICE_PERIOD',
leaseType: 'STUDENT',
fixedEndOn: '2026-07-31',
effectiveEndOn: '2026-07-31',
correctionRequired: false,
agreedEarlyEnds: [],
agreedEarlyEnd: null,
closure: null,
notices: [
{
id: 'notice-1',
status: 'ACTIVE',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'REGISTERED_LETTER',
otherDeliveryMode: null,
noticeMonths: 1,
minimumEndOn: '2026-08-19',
ruleVersion: '2026-07',
recordedWithoutEvidenceConfirmed: true,
authorId: 'owner-1',
recordedAt: '2026-07-19T10:00:00Z',
evidenceVersions: [],
withdrawal: null,
invalidation: null,
},
],
};
const getState = vi.fn();
const recordNotice = vi.fn();
const acceptWithdrawal = vi.fn();
const invalidateNotice = vi.fn();
const appendEvidence = vi.fn();
const recordAgreedEarlyEnd = vi.fn();
const close = vi.fn();
const dialog = { open: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
getState.mockReturnValue(of(state));
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
},
{
provide: LeaseEndService,
useValue: {
getState,
recordNotice,
acceptWithdrawal,
invalidateNotice,
appendEvidence,
recordAgreedEarlyEnd,
close,
evidenceDownloading: vi.fn().mockReturnValue(false),
evidenceDownloadError: vi.fn().mockReturnValue(''),
downloadEvidence: vi.fn(),
},
},
{ provide: MatDialog, useValue: dialog },
],
});
});
it('shows a skeleton, then a retryable load error', () => {
const response = new Subject<LeaseEndState>();
getState.mockReturnValueOnce(response);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(getState).toHaveBeenCalledWith('lease-1', fixture.componentInstance.timeZone);
expect(fixture.nativeElement.querySelector('.lease-end-skeleton')).not.toBeNull();
response.error({ error: { message: 'Bail introuvable' } });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
getState.mockReturnValueOnce(of(state));
fixture.nativeElement.querySelector('.load-error button').click();
fixture.detectChanges();
expect(getState).toHaveBeenCalledTimes(2);
});
it('renders the authoritative phase, civil timeline and student final month', () => {
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('Préavis en cours');
expect(text).toContain('19 juillet 2026');
expect(text).toContain('Juillet 2026');
expect(text).toContain('enregistré sans justificatif initial');
expect(text).not.toContain('18 juillet 2026');
});
it('disables unsupported lifecycle mutations but keeps historical evidence append-only', () => {
getState.mockReturnValueOnce(
of({
...state,
supported: false,
unsupportedReason: 'La fin de bail en colocation doit être gérée locataire par locataire.',
}),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('doit être gérée locataire par locataire');
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
expect(fixture.nativeElement.querySelector('app-lease-end-resolution-forms')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
});
it('keeps archived evidence append-only while hiding lifecycle mutations', () => {
getState.mockReturnValueOnce(
of({
...state,
phase: 'ARCHIVED',
closure: {
keysReturnedOn: '2026-07-31',
authorId: 'owner-1',
recordedAt: '2026-07-31T18:00:00Z',
},
}),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
expect(fixture.nativeElement.textContent).not.toContain('Clôturer le bail');
});
it('links a financial correction to the existing payment reversal journey', () => {
getState.mockReturnValueOnce(of({ ...state, correctionRequired: true }));
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const correction: HTMLAnchorElement =
fixture.nativeElement.querySelector('.correction-warning a');
expect(fixture.nativeElement.textContent).toContain('contrepasser le paiement concerné');
expect(correction.getAttribute('href')).toBe('/rent-payments');
});
it('keeps an earlier agreement actionable before the effective end without offering a new notice', () => {
getState.mockReturnValueOnce(
of({
...state,
notices: [],
agreedEarlyEnds: [
{
endOn: '2026-08-10',
reason: 'Premier accord',
authorId: 'owner-1',
recordedAt: '2026-07-10T10:00:00Z',
},
{
endOn: '2026-07-31',
reason: 'Accord avancé',
authorId: 'owner-1',
recordedAt: '2026-07-12T10:00:00Z',
},
],
}),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Premier accord');
expect(fixture.nativeElement.textContent).toContain('Accord avancé');
expect(fixture.nativeElement.textContent).toContain('owner-1');
expect(fixture.nativeElement.textContent).toContain('Enregistrer l’accord');
});
it('keeps notice loading and failures local without publishing false success', () => {
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
const response = new Subject<LeaseEndState>();
recordNotice.mockReturnValue(response);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const request: RecordNoticeRequest = {
timeZone: 'Europe/Paris',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'HAND_DELIVERY',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
};
fixture.componentInstance.recordNotice(request);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form').textContent).toContain(
'Enregistrement…',
);
response.error({ error: { message: 'Congé refusé' } });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Congé refusé');
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
});
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const request = {
timeZone: 'Europe/Paris',
acceptedOn: '2026-07-20',
reason: 'Retrait écrit accepté',
};
fixture.componentInstance.requestWithdrawal({ noticeId: 'notice-1', request });
expect(dialog.open).toHaveBeenCalledOnce();
expect(acceptWithdrawal).toHaveBeenCalledWith('lease-1', 'notice-1', request);
});
it('keeps a closure failure actionable', () => {
close.mockReturnValue(throwError(() => ({ error: { message: 'Clôture refusée' } })));
getState.mockReturnValueOnce(of({ ...state, phase: 'AWAITING_CLOSURE' }));
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
fixture.componentInstance.closeLease({
timeZone: 'Europe/Paris',
keysReturnedOn: '2026-08-19',
confirmed: true,
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Clôture refusée');
expect(fixture.componentInstance.state()?.phase).toBe('AWAITING_CLOSURE');
});
});
@@ -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;
}