feat: finalize lease documents and IRL revisions

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent f127c69a28
commit 1bf6a3449c
196 files changed
+20564 -666

No files matched your search

@@ -0,0 +1,87 @@
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { finalize } from 'rxjs';
import { ApartmentDocument, ApartmentService } from '../../services/apartment.service';
import { FileDownloadService } from '../../services/file-download.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { withoutRecordKey, withoutSetValue } from '../../services/immutable-state';
import {
ConfirmationDialog,
ConfirmationDialogData,
} from '../confirmation-dialog/confirmation-dialog';
@Injectable()
export class ApartmentDocumentActions {
private readonly apartments = inject(ApartmentService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog);
private readonly downloads = inject(FileDownloadService);
readonly confirmingDeleteIds = signal<ReadonlySet<string>>(new Set());
readonly deletingIds = signal<ReadonlySet<string>>(new Set());
readonly deleteErrors = signal<Record<string, string>>({});
download(apartmentId: string, document: ApartmentDocument): void {
this.downloads.download(
this.downloadKey(document.id),
this.apartments.downloadDocument(apartmentId, document.id),
document.fileName,
'Impossible de télécharger ce document.',
);
}
downloadPending(documentId: string): boolean {
return this.downloads.isPending(this.downloadKey(documentId));
}
downloadError(documentId: string): string {
return this.downloads.error(this.downloadKey(documentId));
}
requestDelete(apartmentId: string, document: ApartmentDocument, onDeleted: () => void): void {
if (this.confirmingDeleteIds().has(document.id) || this.deletingIds().has(document.id)) return;
this.confirmingDeleteIds.update((ids) => new Set(ids).add(document.id));
const data: ConfirmationDialogData = {
title: 'Supprimer ce document ?',
message: `Le fichier « ${document.fileName} » ne sera plus disponible pour les prochains baux.`,
confirmLabel: 'Supprimer',
};
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.confirmingDeleteIds.update((ids) => withoutSetValue(ids, document.id));
if (confirmed) this.delete(apartmentId, document.id, onDeleted);
});
}
private delete(apartmentId: string, documentId: string, onDeleted: () => void): void {
this.deletingIds.update((ids) => new Set(ids).add(documentId));
this.deleteErrors.update((errors) => withoutRecordKey(errors, documentId));
this.apartments
.deleteDocument(apartmentId, documentId)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.deletingIds.update((ids) => withoutSetValue(ids, documentId))),
)
.subscribe({
next: onDeleted,
error: (error: unknown) =>
this.deleteErrors.update((errors) => ({
...errors,
[documentId]: httpErrorMessage(error, 'Impossible de supprimer ce document.'),
})),
});
}
private downloadKey(documentId: string): string {
return `apartment-document:${documentId}`;
}
}
@@ -0,0 +1,115 @@
:host {
display: block;
}
.document-manager {
margin-block-start: var(--space-6, 1.5rem);
padding: var(--space-5, 1.25rem);
border: 1px solid var(--border, #dce5df);
border-radius: var(--radius-xl, 1.375rem);
background: var(--surface, #ffffff);
}
header p {
margin: 0;
color: var(--text-muted, #687670);
}
.document-list {
display: grid;
gap: var(--space-3, 0.75rem);
margin: var(--space-5, 1.25rem) 0 0;
padding: 0;
list-style: none;
}
.document-list li {
min-width: 0;
padding: var(--space-4, 1rem);
border-radius: var(--radius-md, 0.8125rem);
background: var(--surface-soft, #e9efeb);
overflow-wrap: anywhere;
}
.document-list h3,
.file-name {
margin-block-end: var(--space-2, 0.5rem);
}
.file-name,
.document-list dt {
color: var(--text-muted, #687670);
font-size: 0.8rem;
}
.document-list dl {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
gap: var(--space-3, 0.75rem);
margin: 0;
}
.document-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1, 0.25rem);
}
.document-list dd {
margin: var(--space-1, 0.25rem) 0 0;
}
.upload-form {
display: grid;
gap: var(--space-3, 0.75rem);
margin-block-start: var(--space-6, 1.5rem);
padding-block-start: var(--space-5, 1.25rem);
border-block-start: 1px solid var(--border, #dce5df);
}
.upload-form h3,
.form-error {
margin: 0;
}
.upload-form fieldset {
display: grid;
gap: var(--space-3, 0.75rem);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.form-error {
color: var(--danger, #b42318);
}
.upload-submit {
justify-self: start;
}
.file-picker {
display: grid;
gap: var(--space-2, 0.5rem);
min-width: 0;
color: var(--text, #17211e);
font-weight: 700;
}
.file-picker input {
min-height: 2.75rem;
max-width: 100%;
}
.file-picker small {
color: var(--text-muted, #687670);
font-weight: 400;
overflow-wrap: anywhere;
}
.date-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr));
gap: var(--space-3, 0.75rem);
}
@@ -0,0 +1,123 @@
<section
class="document-manager"
aria-labelledby="apartment-documents-heading"
[attr.aria-busy]="loading()"
>
<header>
<h2 id="apartment-documents-heading">Documents du logement</h2>
<p>Conservez ici les diagnostics et pièces utiles au bail.</p>
</header>
@if (loading()) {
<p role="status">Chargement des documents…</p>
} @else if (loadError()) {
<div class="load-error" role="alert">
<p>{{ loadError() }}</p>
<button type="button" (click)="retry()">Réessayer</button>
</div>
} @else if (documents().length === 0) {
<p>Aucun document enregistré.</p>
} @else {
<ul class="document-list">
@for (document of documents(); track document.id) {
<li>
<h3>{{ typeLabel(document.type) }}</h3>
<p class="file-name">{{ document.fileName }}</p>
<dl>
<div>
<dt>Établi le</dt>
<dd>{{ civilDate(document.establishedOn) }}</dd>
</div>
<div>
<dt>Expiration</dt>
<dd>
{{ document.expiresOn ? civilDate(document.expiresOn) : 'Sans expiration' }}
</dd>
</div>
</dl>
<div class="document-actions">
<button
mat-button
type="button"
class="download-document"
[attr.aria-label]="'Télécharger ' + document.fileName"
[disabled]="actions.downloadPending(document.id)"
(click)="download(document)"
>
{{ actions.downloadPending(document.id) ? 'Téléchargement…' : 'Télécharger' }}
</button>
<button
mat-button
type="button"
class="delete-document"
[attr.aria-label]="'Supprimer ' + document.fileName"
[disabled]="
actions.confirmingDeleteIds().has(document.id) ||
actions.deletingIds().has(document.id)
"
(click)="requestDelete(document)"
>
{{ actions.deletingIds().has(document.id) ? 'Suppression…' : 'Supprimer' }}
</button>
</div>
@if (
actions.downloadError(document.id) || actions.deleteErrors()[document.id];
as actionError
) {
<p class="form-error" role="alert">{{ actionError }}</p>
}
</li>
}
</ul>
}
<form
class="upload-form"
[formRoot]="uploadForm"
[attr.aria-busy]="uploading()"
(submit)="submitUpload(); $event.preventDefault()"
>
<h3>Ajouter ou remplacer un document</h3>
<fieldset [disabled]="uploading()">
<mat-form-field appearance="outline">
<mat-label>Type de document</mat-label>
<mat-select [formField]="uploadForm.type">
<mat-option value="">Choisissez un type</mat-option>
@for (type of documentTypes; track type.value) {
<mat-option [value]="type.value">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<label class="file-picker">
<span>Fichier PDF</span>
<input type="file" accept=".pdf,application/pdf" (change)="selectFile($event)" />
<small>{{ selectedFile()?.name ?? 'Aucun fichier sélectionné' }}</small>
</label>
<div class="date-fields">
<mat-form-field appearance="outline">
<mat-label>Date d’établissement</mat-label>
<input matInput id="establishedOn" type="date" [formField]="uploadForm.establishedOn" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Date d’expiration (facultative)</mat-label>
<input matInput id="expiresOn" type="date" [formField]="uploadForm.expiresOn" />
</mat-form-field>
</div>
@if (validationError() || uploadError()) {
<p class="form-error" role="alert">{{ validationError() || uploadError() }}</p>
}
<button
mat-flat-button
type="submit"
class="primary-action upload-submit"
[disabled]="uploading()"
>
@if (uploading()) {
Envoi…
} @else {
Téléverser / remplacer
}
</button>
</fieldset>
</form>
</section>
@@ -0,0 +1,323 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ApartmentDocumentManager } from './apartment-document-manager';
describe('ApartmentDocumentManager', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => {
http.verify();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('shows a truthful initial loading state before documents are known', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
const region = fixture.nativeElement.querySelector('section');
expect(region.getAttribute('aria-busy')).toBe('true');
expect(region.textContent).toContain('Chargement des documents');
expect(region.textContent).not.toContain('Aucun document');
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
});
it('lists current documents with French labels and civil dates', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([
{
id: 'document-1',
type: 'DPE',
establishedOn: '2026-07-19',
expiresOn: '2036-07-19',
fileName: 'dpe.pdf',
sha256: 'a'.repeat(64),
authorId: 'owner-1',
uploadedAt: '2026-07-19T10:00:00Z',
},
{
id: 'document-2',
type: 'CO_OWNERSHIP_EXTRACT',
establishedOn: '2026-07-18',
expiresOn: null,
fileName: 'reglement.pdf',
sha256: 'b'.repeat(64),
authorId: 'owner-1',
uploadedAt: '2026-07-19T11:00:00Z',
},
]);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('li');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('Diagnostic de performance énergétique (DPE)');
expect(rows[0].textContent).toContain('19 juillet 2026');
expect(rows[0].textContent).toContain('19 juillet 2036');
expect(rows[1].textContent).toContain('Extrait du règlement de copropriété');
expect(rows[1].textContent).toContain('Sans expiration');
});
it('explains that a document type is required before upload', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
fixture.nativeElement.querySelector('.upload-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Choisissez un type de document.');
http.expectNone((request) => request.method === 'POST');
});
it('requires a PDF after the document type is selected', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
fixture.nativeElement.querySelector('mat-select').click();
fixture.detectChanges();
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
option.textContent?.includes('Diagnostic de performance énergétique'),
);
if (!dpeOption) throw new Error('Option DPE introuvable.');
(dpeOption as HTMLElement).click();
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement.querySelector('.upload-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Sélectionnez un fichier PDF.');
http.expectNone((request) => request.method === 'POST');
});
it('requires a civil establishment date before upload', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
fixture.nativeElement.querySelector('mat-select').click();
fixture.detectChanges();
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
option.textContent?.includes('Diagnostic de performance énergétique'),
);
if (!dpeOption) throw new Error('Option DPE introuvable.');
(dpeOption as HTMLElement).click();
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', { configurable: true, value: { item: () => file } });
fileInput.dispatchEvent(new Event('change'));
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement.querySelector('.upload-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Renseignez la date d’établissement du document.',
);
http.expectNone((request) => request.method === 'POST');
});
it('rejects an expiry date before the establishment date', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
fixture.nativeElement.querySelector('mat-select').click();
fixture.detectChanges();
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
option.textContent?.includes('Diagnostic de performance énergétique'),
);
if (!dpeOption) throw new Error('Option DPE introuvable.');
(dpeOption as HTMLElement).click();
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', { configurable: true, value: { item: () => file } });
fileInput.dispatchEvent(new Event('change'));
const establishedOn: HTMLInputElement = fixture.nativeElement.querySelector('#establishedOn');
const expiresOn: HTMLInputElement = fixture.nativeElement.querySelector('#expiresOn');
if (!establishedOn || !expiresOn) throw new Error('Champs de dates introuvables.');
establishedOn.value = '2026-07-19';
establishedOn.dispatchEvent(new Event('input'));
expiresOn.value = '2026-07-18';
expiresOn.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement.querySelector('.upload-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
);
http.expectNone((request) => request.method === 'POST');
});
it('uploads once, shows local progress, and publishes the replacement', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
fixture.nativeElement.querySelector('mat-select').click();
fixture.detectChanges();
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
option.textContent?.includes('Diagnostic de performance énergétique'),
);
if (!dpeOption) throw new Error('Option DPE introuvable.');
(dpeOption as HTMLElement).click();
const file = new File(['pdf'], 'dpe-2026.pdf', { type: 'application/pdf' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', { configurable: true, value: { item: () => file } });
fileInput.dispatchEvent(new Event('change'));
const establishedOn: HTMLInputElement = fixture.nativeElement.querySelector('#establishedOn');
establishedOn.value = '2026-07-19';
establishedOn.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
const submit: HTMLButtonElement = fixture.nativeElement.querySelector('.upload-submit');
submit.click();
submit.click();
fixture.detectChanges();
const upload = http.expectOne('/api/apartments/apartment-1/documents');
expect(upload.request.method).toBe('POST');
const formData = upload.request.body as FormData;
expect(formData.get('file')).toBe(file);
expect(formData.get('type')).toBe('DPE');
expect(formData.get('establishedOn')).toBe('2026-07-19');
expect(submit.disabled).toBe(true);
expect(submit.textContent).toContain('Envoi…');
upload.flush({
id: 'document-2',
type: 'DPE',
establishedOn: '2026-07-19',
expiresOn: null,
fileName: 'dpe-2026.pdf',
sha256: 'c'.repeat(64),
authorId: 'owner-1',
uploadedAt: '2026-07-19T12:00:00Z',
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('dpe-2026.pdf');
expect(submit.disabled).toBe(false);
});
it('shows a load failure and retries without presenting an empty result', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http
.expectOne('/api/apartments/apartment-1/documents')
.flush({}, { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Impossible de charger les documents du logement.',
);
expect(fixture.nativeElement.textContent).not.toContain('Aucun document enregistré.');
fixture.nativeElement.querySelector('.load-error button').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Chargement des documents');
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
});
it('downloads a document once with local pending feedback', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([
{
id: 'document-1',
type: 'DPE',
establishedOn: '2026-07-19',
expiresOn: null,
fileName: 'dpe.pdf',
sha256: 'a'.repeat(64),
authorId: 'owner-1',
uploadedAt: '2026-07-19T10:00:00Z',
},
]);
fixture.detectChanges();
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:document'),
revokeObjectURL: vi.fn(),
});
const download: HTMLButtonElement = fixture.nativeElement.querySelector('.download-document');
download.click();
download.click();
fixture.detectChanges();
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
expect(request.request.method).toBe('GET');
expect(download.disabled).toBe(true);
expect(download.textContent).toContain('Téléchargement…');
request.flush(new Blob(['pdf'], { type: 'application/pdf' }));
fixture.detectChanges();
expect(URL.createObjectURL).toHaveBeenCalledOnce();
expect(download.disabled).toBe(false);
});
it('deletes a document only after an explicit confirmation', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([
{
id: 'document-1',
type: 'DPE',
establishedOn: '2026-07-19',
expiresOn: null,
fileName: 'dpe.pdf',
sha256: 'a'.repeat(64),
authorId: 'owner-1',
uploadedAt: '2026-07-19T10:00:00Z',
},
]);
fixture.detectChanges();
vi.useFakeTimers();
fixture.nativeElement.querySelector('.delete-document').click();
fixture.detectChanges();
http.expectNone((request) => request.method === 'DELETE');
const confirm: HTMLButtonElement | null = document.querySelector('.confirm-action');
if (!confirm) throw new Error('Confirmation de suppression introuvable.');
confirm.click();
await vi.runAllTimersAsync();
fixture.detectChanges();
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
expect(request.request.method).toBe('DELETE');
const deleteButton: HTMLButtonElement = fixture.nativeElement.querySelector('.delete-document');
expect(deleteButton.disabled).toBe(true);
expect(deleteButton.textContent).toContain('Suppression…');
request.flush(null);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
expect(fixture.nativeElement.textContent).toContain('Aucun document enregistré.');
});
});
@@ -0,0 +1,197 @@
import { Component, DestroyRef, effect, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { finalize } from 'rxjs';
import {
ApartmentDocument,
ApartmentDocumentType,
ApartmentService,
} from '../../services/apartment.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { ApartmentDocumentActions } from './apartment-document-actions.service';
import {
apartmentDocumentTypeLabel,
apartmentDocumentTypes,
} from '../../services/apartment-document-label';
const frenchDate = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
@Component({
selector: 'app-apartment-document-manager',
imports: [
FormsModule,
FormField,
FormRoot,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
],
templateUrl: './apartment-document-manager.html',
styleUrl: './apartment-document-manager.css',
providers: [ApartmentDocumentActions],
})
export class ApartmentDocumentManager {
private readonly apartments = inject(ApartmentService);
private readonly destroyRef = inject(DestroyRef);
private requestId = 0;
readonly actions = inject(ApartmentDocumentActions);
readonly apartmentId = input.required<string>();
readonly documents = signal<ApartmentDocument[]>([]);
readonly loading = signal(true);
readonly loadError = signal('');
readonly documentTypes = apartmentDocumentTypes;
readonly uploadModel = signal({
type: '' as ApartmentDocumentType | '',
establishedOn: '',
expiresOn: '',
});
readonly uploadForm = form(this.uploadModel, (fields) => {
required(fields.type);
required(fields.establishedOn);
validate(fields.expiresOn, ({ value, valueOf }) =>
value() && value() < valueOf(fields.establishedOn)
? {
kind: 'expiry-before-establishment',
message:
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
}
: null,
);
});
readonly selectedFile = signal<File | null>(null);
readonly uploading = signal(false);
readonly validationError = signal('');
readonly uploadError = signal('');
constructor() {
effect(() => this.load(this.apartmentId()));
}
retry(): void {
this.load(this.apartmentId());
}
typeLabel(type: ApartmentDocumentType): string {
return apartmentDocumentTypeLabel(type);
}
civilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
}
download(document: ApartmentDocument): void {
this.actions.download(this.apartmentId(), document);
}
requestDelete(document: ApartmentDocument): void {
this.actions.requestDelete(this.apartmentId(), document, () =>
this.documents.update((documents) =>
documents.filter((candidate) => candidate.id !== document.id),
),
);
}
submitUpload(): void {
if (this.uploading()) return;
this.uploadForm().markAsTouched();
const model = this.uploadModel();
const file = this.selectedFile();
if (!model.type) {
this.validationError.set('Choisissez un type de document.');
} else if (!file) {
this.validationError.set('Sélectionnez un fichier PDF.');
} else if (!model.establishedOn) {
this.validationError.set('Renseignez la date d’établissement du document.');
} else if (model.expiresOn && model.expiresOn < model.establishedOn) {
this.validationError.set(
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
);
} else {
this.validationError.set('');
this.uploadError.set('');
this.uploading.set(true);
this.apartments
.uploadDocument(this.apartmentId(), {
file,
type: model.type,
establishedOn: model.establishedOn,
expiresOn: model.expiresOn || null,
})
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.uploading.set(false)),
)
.subscribe({
next: (uploaded) => {
this.documents.update((documents) =>
[...documents.filter((document) => document.type !== uploaded.type), uploaded].sort(
(left, right) =>
apartmentDocumentTypes.findIndex((type) => type.value === left.type) -
apartmentDocumentTypes.findIndex((type) => type.value === right.type),
),
);
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
this.selectedFile.set(null);
},
error: (error: unknown) =>
this.uploadError.set(httpErrorMessage(error, 'Impossible de téléverser ce document.')),
});
}
}
selectFile(event: Event): void {
const input = event.target;
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
if (
file &&
file.name.toLowerCase().endsWith('.pdf') &&
(file.type === 'application/pdf' || file.type === '')
) {
this.selectedFile.set(file);
this.validationError.set('');
} else {
this.selectedFile.set(null);
if (file) this.validationError.set('Sélectionnez un fichier PDF.');
}
if (input instanceof HTMLInputElement) input.value = '';
}
private load(apartmentId: string): void {
const requestId = ++this.requestId;
this.loading.set(true);
this.loadError.set('');
this.apartments
.getDocuments(apartmentId)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
if (requestId === this.requestId) this.loading.set(false);
}),
)
.subscribe({
next: (documents) => {
if (requestId === this.requestId) this.documents.set(documents);
},
error: () => {
if (requestId === this.requestId) {
this.loadError.set('Impossible de charger les documents du logement.');
}
},
});
}
}
@@ -2,10 +2,28 @@
display: block;
}
.form-actions {
margin-top: 6px;
margin-top: 28px;
}
.form-section + .form-section {
margin-top: 30px;
padding-top: 28px;
border-top: 1px solid var(--border);
}
.section-heading {
margin-bottom: 20px;
}
.section-heading h2 {
margin: 0 0 4px;
font-size: 1rem;
}
.section-heading p {
margin: 0;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.6;
}
@media (min-width: 1180px) {
.form-page {
max-width: 900px;
max-width: 980px;
}
}
@@ -12,7 +12,7 @@
</button>
<p class="eyebrow">Portefeuille</p>
<h1>{{ apartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}</h1>
<p>Renseignez les informations principales du bien.</p>
<p>Renseignez les informations principales et juridiques du bien.</p>
</div>
</header>
<form
@@ -31,45 +31,54 @@
{{ saving() ? 'Enregistrement en cours.' : '' }}
</p>
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
id="name"
[formField]="apartmentForm.name"
placeholder="T2 Paris 15"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
id="purchasePrice"
[formField]="apartmentForm.purchasePrice"
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
id="address"
[formField]="apartmentForm.address"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Description ou notes</mat-label
><textarea
matInput
id="description"
[formField]="apartmentForm.description"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<section class="form-section" aria-labelledby="general-heading">
<div class="section-heading">
<h2 id="general-heading">Informations générales</h2>
<p>Les informations utilisées dans votre portefeuille.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
id="name"
[formField]="apartmentForm.name"
placeholder="T2 Paris 15"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
id="purchasePrice"
[formField]="apartmentForm.purchasePrice"
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
id="address"
[formField]="apartmentForm.address"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Description ou notes</mat-label
><textarea
matInput
id="description"
[formField]="apartmentForm.description"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
</section>
<app-apartment-legal-profile-form [legalProfileForm]="apartmentForm.legalProfile" />
<div class="form-actions">
<button mat-button type="button" (click)="closed.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">
@@ -1,8 +1,30 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { ApartmentForm } from './apartment-form';
import { emptyApartmentLegalProfile } from '../../services/apartment.service';
describe('ApartmentForm', () => {
const completeLegalProfile = {
taxIdentifier: '751150001234',
buildingType: 'APARTMENT_BUILDING' as const,
ownershipRegime: 'Copropriété',
constructionPeriod: '1990_2005' as const,
habitableSurface: 42,
roomCount: 2,
otherPrivateAreas: 'Balcon',
equipment: 'Cuisine équipée',
heating: 'Individuel électrique',
hotWater: 'Ballon électrique',
energyRating: 'D' as const,
estimatedAnnualEnergyCost: 850,
estimatedAnnualEnergyCostReferenceYear: 2021,
destination: 'Habitation',
privateAccessories: 'Cave',
commonAreas: 'Local vélos',
internetAccess: 'Fibre',
departmentCode: '75',
};
it('initializes its fields from the apartment being edited', () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('apartment', {
@@ -11,12 +33,14 @@ describe('ApartmentForm', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: 'Cinquième étage',
legalProfile: completeLegalProfile,
});
fixture.detectChanges();
const form = fixture.componentInstance;
expect(form.apartmentModel().name).toBe('T2 Paris 15');
expect(form.apartmentModel().purchasePrice).toBe(250000);
expect(form.apartmentModel().legalProfile).toEqual(completeLegalProfile);
});
it('shows a validation error instead of emitting an incomplete apartment', () => {
@@ -41,6 +65,7 @@ describe('ApartmentForm', () => {
address: ' ',
purchasePrice: 250000,
description: '',
legalProfile: emptyApartmentLegalProfile(),
});
form.save();
@@ -59,6 +84,12 @@ describe('ApartmentForm', () => {
address: ' 45 rue des Entrepreneurs ',
purchasePrice: 250000,
description: ' Cinquième étage ',
legalProfile: {
...completeLegalProfile,
taxIdentifier: ' 751150001234 ',
equipment: ' Cuisine équipée ',
departmentCode: ' 2a ',
},
});
form.save();
@@ -68,6 +99,12 @@ describe('ApartmentForm', () => {
address: '45 rue des Entrepreneurs',
purchasePrice: 250000,
description: 'Cinquième étage',
legalProfile: {
...completeLegalProfile,
taxIdentifier: '751150001234',
equipment: 'Cuisine équipée',
departmentCode: '2A',
},
});
});
@@ -95,6 +132,7 @@ describe('ApartmentForm', () => {
address: '45 rue des Entrepreneurs',
purchasePrice: 265000,
description: '',
legalProfile: emptyApartmentLegalProfile(),
});
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
fixture.detectChanges();
@@ -108,4 +146,95 @@ describe('ApartmentForm', () => {
'Impossible d’enregistrer cet appartement.',
);
});
it('rejects negative legal-profile numbers', () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
const saved = vi.fn();
form.saved.subscribe(saved);
form.apartmentModel.set({
name: 'T2',
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
legalProfile: { ...emptyApartmentLegalProfile(), habitableSurface: -1 },
});
form.save();
expect(form.error()).toContain('surface habitable');
expect(saved).not.toHaveBeenCalled();
});
it('turns an emptied nullable number field back into null', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.detectChanges();
await fixture.whenStable();
const surfaceInput: HTMLInputElement =
fixture.nativeElement.querySelector('input#habitableSurface');
surfaceInput.value = '42';
surfaceInput.dispatchEvent(new Event('input'));
surfaceInput.value = '';
surfaceInput.dispatchEvent(new Event('input'));
await fixture.whenStable();
expect(fixture.componentInstance.apartmentModel().legalProfile.habitableSurface).toBeNull();
});
it('asks for the energy reference year when an estimated cost is provided', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('apartment', {
id: 'apartment-1',
name: 'T2',
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
legalProfile: {
...emptyApartmentLegalProfile(),
estimatedAnnualEnergyCost: 850,
},
});
fixture.detectChanges();
await fixture.whenStable();
const saved = vi.fn();
fixture.componentInstance.saved.subscribe(saved);
fixture.nativeElement.querySelector('button[type=submit]').click();
fixture.detectChanges();
expect(saved).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
"Renseignez l'année de référence du coût énergétique.",
);
});
it('clears an energy reference year that has no estimated cost', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('apartment', {
id: 'apartment-1',
name: 'T2',
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
legalProfile: {
...emptyApartmentLegalProfile(),
estimatedAnnualEnergyCostReferenceYear: 2021,
},
});
fixture.detectChanges();
await fixture.whenStable();
const saved = vi.fn();
fixture.componentInstance.saved.subscribe(saved);
fixture.nativeElement.querySelector('button[type=submit]').click();
expect(saved).toHaveBeenCalledWith(
expect.objectContaining({
legalProfile: expect.objectContaining({
estimatedAnnualEnergyCostReferenceYear: null,
}),
}),
);
});
});
@@ -1,11 +1,16 @@
import { Component, effect, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, required, min } from '@angular/forms/signals';
import { FormRoot, FormField, form, max, min, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import {
Apartment,
ApartmentLegalProfile,
emptyApartmentLegalProfile,
} from '../../services/apartment.service';
import { ApartmentLegalProfileForm } from '../apartment-legal-profile-form/apartment-legal-profile-form';
const requiredFieldsError =
'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).';
@@ -20,6 +25,7 @@ const requiredFieldsError =
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
ApartmentLegalProfileForm,
],
templateUrl: './apartment-form.html',
styleUrl: './apartment-form.css',
@@ -36,6 +42,7 @@ export class ApartmentForm {
address: '',
purchasePrice: 0,
description: '',
legalProfile: emptyApartmentLegalProfile(),
});
readonly apartmentForm = form(this.apartmentModel, (f) => {
@@ -43,6 +50,25 @@ export class ApartmentForm {
required(f.address, { message: requiredFieldsError });
required(f.purchasePrice, { message: requiredFieldsError });
min(f.purchasePrice, 1, { message: requiredFieldsError });
min(f.legalProfile.habitableSurface, 0, {
message: 'La surface habitable doit être positive ou nulle.',
});
min(f.legalProfile.roomCount, 0, {
message: 'Le nombre de pièces doit être positif ou nul.',
});
min(f.legalProfile.estimatedAnnualEnergyCost, 0, {
message: 'Le coût annuel énergétique doit être positif ou nul.',
});
required(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, {
when: ({ valueOf }) => valueOf(f.legalProfile.estimatedAnnualEnergyCost) !== null,
message: "Renseignez l'année de référence du coût énergétique.",
});
min(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 1900, {
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
});
max(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 2100, {
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
});
});
readonly error = signal('');
@@ -70,6 +96,7 @@ export class ApartmentForm {
address: this.apartmentModel().address.trim(),
purchasePrice: this.apartmentModel().purchasePrice,
description: this.apartmentModel().description.trim(),
legalProfile: this.normalizedLegalProfile(),
});
}
@@ -80,7 +107,30 @@ export class ApartmentForm {
address: apartment?.address ?? '',
purchasePrice: apartment?.purchasePrice ?? 0,
description: apartment?.description ?? '',
legalProfile: apartment?.legalProfile ?? emptyApartmentLegalProfile(),
});
this.error.set('');
}
private normalizedLegalProfile(): ApartmentLegalProfile {
const profile = this.apartmentModel().legalProfile;
return {
...profile,
estimatedAnnualEnergyCostReferenceYear:
profile.estimatedAnnualEnergyCost === null
? null
: profile.estimatedAnnualEnergyCostReferenceYear,
taxIdentifier: profile.taxIdentifier.trim(),
ownershipRegime: profile.ownershipRegime.trim(),
otherPrivateAreas: profile.otherPrivateAreas.trim(),
equipment: profile.equipment.trim(),
heating: profile.heating.trim(),
hotWater: profile.hotWater.trim(),
destination: profile.destination.trim(),
privateAccessories: profile.privateAccessories.trim(),
commonAreas: profile.commonAreas.trim(),
internetAccess: profile.internetAccess.trim(),
departmentCode: profile.departmentCode.trim().toUpperCase(),
};
}
}
@@ -0,0 +1,24 @@
:host {
display: block;
margin-top: 30px;
padding-top: 28px;
border-top: 1px solid var(--border);
}
.form-section + .form-section {
margin-top: 30px;
padding-top: 28px;
border-top: 1px solid var(--border);
}
.section-heading {
margin-bottom: 20px;
}
.section-heading h2 {
margin: 0 0 4px;
font-size: 1rem;
}
.section-heading p {
margin: 0;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.6;
}
@@ -0,0 +1,180 @@
<section class="form-section" aria-labelledby="legal-heading">
<div class="section-heading">
<h2 id="legal-heading">Identification juridique</h2>
<p>Ces champs facultatifs seront réutilisés dans les baux.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Identifiant fiscal du logement</mat-label
><input
matInput
type="text"
id="taxIdentifier"
[formField]="legalProfileForm().taxIdentifier"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Département</mat-label
><input
matInput
type="text"
id="departmentCode"
[formField]="legalProfileForm().departmentCode"
placeholder="75, 2A, 971…"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Type de bâtiment</mat-label
><mat-select id="buildingType" [formField]="legalProfileForm().buildingType">
<mat-option [value]="null">Non renseigné</mat-option>
<mat-option value="APARTMENT_BUILDING">Immeuble collectif</mat-option>
<mat-option value="INDIVIDUAL_HOUSE">Maison individuelle</mat-option>
</mat-select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Période de construction</mat-label
><mat-select id="constructionPeriod" [formField]="legalProfileForm().constructionPeriod">
<mat-option [value]="null">Non renseignée</mat-option>
<mat-option value="BEFORE_1949">Avant 1949</mat-option>
<mat-option value="1949_1974">De 1949 à 1974</mat-option>
<mat-option value="1975_1989">De 1975 à 1989</mat-option>
<mat-option value="1990_2005">De 1990 à 2005</mat-option>
<mat-option value="2006_2012">De 2006 à 2012</mat-option>
<mat-option value="AFTER_2012">Après 2012</mat-option>
</mat-select></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Régime juridique de l'immeuble</mat-label
><input
matInput
type="text"
id="ownershipRegime"
[formField]="legalProfileForm().ownershipRegime"
placeholder="Copropriété, monopropriété…"
/></mat-form-field>
</div>
</section>
<section class="form-section" aria-labelledby="characteristics-heading">
<div class="section-heading">
<h2 id="characteristics-heading">Caractéristiques du logement</h2>
<p>Surface, usage et performance énergétique.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Surface habitable</mat-label
><input
matInput
type="number"
id="habitableSurface"
[formField]="legalProfileForm().habitableSurface"
/><span matTextSuffix>m²</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Nombre de pièces principales</mat-label
><input
matInput
type="number"
step="1"
id="roomCount"
[formField]="legalProfileForm().roomCount"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Classe énergétique</mat-label
><mat-select id="energyRating" [formField]="legalProfileForm().energyRating">
<mat-option [value]="null">Non renseignée</mat-option>
@for (rating of energyRatings; track rating) {
<mat-option [value]="rating">{{ rating }}</mat-option>
}
</mat-select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Coût annuel énergétique estimé</mat-label
><input
matInput
type="number"
id="estimatedAnnualEnergyCost"
[formField]="legalProfileForm().estimatedAnnualEnergyCost"
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Année de référence du coût énergétique</mat-label
><input
matInput
type="number"
id="estimatedAnnualEnergyCostReferenceYear"
[formField]="legalProfileForm().estimatedAnnualEnergyCostReferenceYear"
placeholder="2021"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Destination des locaux</mat-label
><input
matInput
type="text"
id="destination"
[formField]="legalProfileForm().destination"
placeholder="Usage d'habitation"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Autres parties privatives</mat-label
><textarea
matInput
id="otherPrivateAreas"
[formField]="legalProfileForm().otherPrivateAreas"
rows="2"
placeholder="Balcon, terrasse, jardin…"
></textarea>
</mat-form-field>
</div>
</section>
<section class="form-section" aria-labelledby="equipment-heading">
<div class="section-heading">
<h2 id="equipment-heading">Équipements et accès</h2>
<p>Décrivez librement les installations disponibles.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="full-field"
><mat-label>Équipements du logement</mat-label
><textarea
matInput
id="equipment"
[formField]="legalProfileForm().equipment"
rows="2"
></textarea>
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Chauffage</mat-label
><input matInput type="text" id="heating" [formField]="legalProfileForm().heating"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Eau chaude</mat-label
><input matInput type="text" id="hotWater" [formField]="legalProfileForm().hotWater"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Accessoires privatifs</mat-label
><input
matInput
type="text"
id="privateAccessories"
[formField]="legalProfileForm().privateAccessories"
placeholder="Cave, parking, garage…"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Parties et équipements communs</mat-label
><textarea
matInput
id="commonAreas"
[formField]="legalProfileForm().commonAreas"
rows="2"
></textarea>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Accès internet et communications</mat-label
><input
matInput
type="text"
id="internetAccess"
[formField]="legalProfileForm().internetAccess"
placeholder="Fibre, câble, antenne…"
/></mat-form-field>
</div>
</section>
@@ -0,0 +1,18 @@
import { Component, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FieldTree, FormField } from '@angular/forms/signals';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { ApartmentLegalProfile } from '../../services/apartment.service';
@Component({
selector: 'app-apartment-legal-profile-form',
imports: [FormsModule, FormField, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './apartment-legal-profile-form.html',
styleUrl: './apartment-legal-profile-form.css',
})
export class ApartmentLegalProfileForm {
readonly legalProfileForm = input.required<FieldTree<ApartmentLegalProfile>>();
readonly energyRatings = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const;
}
@@ -18,5 +18,8 @@
(closed)="closeForm()"
(saved)="saveApartment($event)"
/>
@if (editingApartment()?.id; as apartmentId) {
<app-apartment-document-manager [apartmentId]="apartmentId" />
}
}
</section>
@@ -3,9 +3,12 @@ import { signal } from '@angular/core';
import { vi } from 'vitest';
import { ApartmentsPage } from './apartments-page';
import { WorkspaceService } from '../../services/workspace.service';
import { Apartment } from '../../services/apartment.service';
import { Apartment, emptyApartmentLegalProfile } from '../../services/apartment.service';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
describe('ApartmentsPage', () => {
let http: HttpTestingController;
const workspace = {
apartments: signal<Apartment[]>([]),
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
@@ -25,10 +28,17 @@ describe('ApartmentsPage', () => {
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
workspace.deletingApartmentIds.set(new Set());
TestBed.configureTestingModule({
providers: [{ provide: WorkspaceService, useValue: workspace }],
providers: [
{ provide: WorkspaceService, useValue: workspace },
provideHttpClient(),
provideHttpClientTesting(),
],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('shows apartment-shaped placeholders instead of a false empty state on first load', () => {
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
@@ -71,6 +81,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
legalProfile: emptyApartmentLegalProfile(),
},
]);
workspace.apartmentLoadState.set({ loaded: true, loading: true, error: '' });
@@ -92,6 +103,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
legalProfile: emptyApartmentLegalProfile(),
},
]);
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
@@ -112,6 +124,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
legalProfile: emptyApartmentLegalProfile(),
};
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
@@ -123,6 +136,28 @@ describe('ApartmentsPage', () => {
expect(page.editingApartment()).toEqual(apartment);
});
it('manages documents only while editing an existing apartment', () => {
const apartment = {
id: 'apt-1',
name: 'T2 Paris 15',
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
legalProfile: emptyApartmentLegalProfile(),
};
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
fixture.componentInstance.openAddForm();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-apartment-document-manager')).toBeNull();
fixture.componentInstance.openEditForm(apartment);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-apartment-document-manager')).not.toBeNull();
http.expectOne('/api/apartments/apt-1/documents').flush([]);
});
it('keeps the form open until the edited apartment is confirmed', () => {
const apartment = {
id: 'apt-1',
@@ -130,6 +165,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
legalProfile: emptyApartmentLegalProfile(),
};
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
@@ -3,10 +3,11 @@ import { Apartment } from '../../services/apartment.service';
import { WorkspaceService } from '../../services/workspace.service';
import { ApartmentForm } from '../apartment-form/apartment-form';
import { ApartmentList } from '../apartment-list/apartment-list';
import { ApartmentDocumentManager } from '../apartment-document-manager/apartment-document-manager';
@Component({
selector: 'app-apartments-page',
imports: [ApartmentForm, ApartmentList],
imports: [ApartmentDocumentManager, ApartmentForm, ApartmentList],
templateUrl: './apartments-page.html',
})
export class ApartmentsPage {
@@ -0,0 +1,47 @@
:host {
display: block;
}
.section-heading p,
details p,
.uploaded-deed span,
.selected-file {
color: var(--text-muted);
}
.guarantee-card {
margin-top: 14px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 14px;
}
.guarantee-card h3 {
margin: 0 0 10px;
font-size: 0.95rem;
}
details {
margin-bottom: 14px;
font-size: 0.82rem;
}
summary {
cursor: pointer;
font-weight: 700;
}
.file-action,
.uploaded-deed {
display: grid;
gap: 6px;
}
.file-action span {
font-weight: 700;
}
.selected-file,
.operation-error {
margin: 8px 0;
font-size: 0.8rem;
}
mat-checkbox {
display: block;
margin: 12px 0;
}
.uploaded-deed {
margin: 0;
}
@@ -0,0 +1,75 @@
<section aria-labelledby="guarantees-heading">
<div class="section-heading">
<h2 id="guarantees-heading">Actes de cautionnement</h2>
<p>Un acte signé est demandé pour chaque paire garant–locataire.</p>
</div>
@for (requirement of requirements(); track key(requirement)) {
<article class="guarantee-card" [attr.aria-busy]="uploadingKeys().has(key(requirement))">
<h3>{{ requirement.guarantorName }} garantit {{ requirement.tenantName }}</h3>
<details>
<summary>ⓘ Aide pour préparer l'acte</summary>
<p>
Utilisez si besoin le
<a [href]="simulatorUrl()" target="_blank" rel="noopener noreferrer"
>simulateur officiel Service-Public.fr</a
>. L'application conserve le PDF transmis sans vérifier les signatures.
</p>
</details>
@if (requirement.deed; as deed) {
<p class="uploaded-deed">
<strong>{{ deed.fileName }}</strong>
<span>Acte téléversé le {{ deed.uploadedAt }}</span>
</p>
} @else {
<label class="file-action">
<span>Acte signé au format PDF</span>
<input
type="file"
accept="application/pdf,.pdf"
[disabled]="uploadingKeys().has(key(requirement))"
[attr.aria-label]="
'Acte de cautionnement de ' +
requirement.guarantorName +
' pour ' +
requirement.tenantName
"
(change)="selectFile($event, requirement)"
/>
</label>
@if (selectedFiles()[key(requirement)]; as file) {
<p class="selected-file">{{ file.name }}</p>
}
<mat-checkbox
[checked]="attestedKeys().has(key(requirement))"
[disabled]="uploadingKeys().has(key(requirement))"
(change)="setAttestation(requirement, $event.checked)"
>
J'atteste transmettre l'acte signé correspondant à ce garant et ce locataire.
</mat-checkbox>
<button
mat-stroked-button
type="button"
[disabled]="
!selectedFiles()[key(requirement)] ||
!attestedKeys().has(key(requirement)) ||
uploadingKeys().has(key(requirement))
"
(click)="upload(requirement)"
>
@if (uploadingKeys().has(key(requirement))) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléversement…
} @else {
Téléverser l'acte
}
</button>
@if (errors()[key(requirement)]) {
<p class="operation-error" role="alert">{{ errors()[key(requirement)] }}</p>
}
}
</article>
} @empty {
<p>Aucun acte de cautionnement n'est requis pour ce bail.</p>
}
</section>
@@ -0,0 +1,109 @@
import { TestBed } from '@angular/core/testing';
import { Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
import {
GuaranteeDeed,
GuaranteeRequirement,
LeaseFinalizationService,
} from '../../services/lease-finalization.service';
import { GuaranteeDeedList } from './guarantee-deed-list';
describe('GuaranteeDeedList', () => {
const requirement: GuaranteeRequirement = {
guarantorId: 'guarantor-1',
guarantorName: 'Alex Durand',
tenantId: 'tenant-1',
tenantName: 'Camille Martin',
deed: null,
};
const deed: GuaranteeDeed = {
guarantorId: 'guarantor-1',
tenantId: 'tenant-1',
fileName: 'cautionnement.pdf',
sha256: 'sha256',
authorId: 'owner-1',
uploadedAt: '2026-07-19T10:00:00Z',
};
const uploadGuaranteeDeed = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [{ provide: LeaseFinalizationService, useValue: { uploadGuaranteeDeed } }],
});
});
function createList(requirements = [requirement]) {
const fixture = TestBed.createComponent(GuaranteeDeedList);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('requirements', requirements);
fixture.componentRef.setInput('simulatorUrl', 'https://www.service-public.fr/simulateur');
fixture.detectChanges();
return fixture;
}
it('shows each pair and an accessible official simulator link', () => {
const fixture = createList();
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('details a');
expect(fixture.nativeElement.textContent).toContain('Alex Durand garantit Camille Martin');
expect(link.href).toBe('https://www.service-public.fr/simulateur');
expect(link.target).toBe('_blank');
expect(link.rel).toContain('noopener');
expect(fixture.nativeElement.textContent).toContain('sans vérifier les signatures');
});
it('requires both a PDF and the attestation, with local pending feedback', () => {
const response = new Subject<GuaranteeDeed>();
uploadGuaranteeDeed.mockReturnValue(response);
const fixture = createList();
const component = fixture.componentInstance;
const file = new File(['deed'], 'cautionnement.pdf', { type: 'application/pdf' });
const uploaded = vi.fn();
component.deedUploaded.subscribe(uploaded);
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
component.selectedFiles.set({ 'guarantor-1:tenant-1': file });
component.setAttestation(requirement, true);
fixture.detectChanges();
button.click();
fixture.detectChanges();
expect(uploadGuaranteeDeed).toHaveBeenCalledWith('lease-1', file, 'guarantor-1', 'tenant-1');
expect(fixture.nativeElement.textContent).toContain('Téléversement…');
response.next(deed);
response.complete();
expect(uploaded).toHaveBeenCalledWith({
guarantorId: 'guarantor-1',
tenantId: 'tenant-1',
deed,
});
});
it('keeps an upload failure local to its pair', () => {
uploadGuaranteeDeed.mockReturnValue(throwError(() => ({ error: { message: 'Acte refusé' } })));
const fixture = createList();
const component = fixture.componentInstance;
component.selectedFiles.set({
'guarantor-1:tenant-1': new File(['deed'], 'cautionnement.pdf', {
type: 'application/pdf',
}),
});
component.setAttestation(requirement, true);
component.upload(requirement);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Acte refusé');
expect(fixture.nativeElement.querySelector('[role=alert]')).not.toBeNull();
});
it('renders an already uploaded deed without another upload form', () => {
const fixture = createList([{ ...requirement, deed }]);
expect(fixture.nativeElement.textContent).toContain('cautionnement.pdf');
expect(fixture.nativeElement.querySelector('input[type=file]')).toBeNull();
});
});
@@ -0,0 +1,89 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { finalize } from 'rxjs';
import {
GuaranteeDeed,
GuaranteeRequirement,
LeaseFinalizationService,
} from '../../services/lease-finalization.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { withoutRecordKey, withoutSetValue } from '../../services/immutable-state';
export interface GuaranteeDeedUploaded {
guarantorId: string;
tenantId: string;
deed: GuaranteeDeed;
}
@Component({
selector: 'app-guarantee-deed-list',
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
templateUrl: './guarantee-deed-list.html',
styleUrl: './guarantee-deed-list.css',
})
export class GuaranteeDeedList {
private readonly finalizationService = inject(LeaseFinalizationService);
readonly leaseId = input.required<string>();
readonly requirements = input.required<GuaranteeRequirement[]>();
readonly simulatorUrl = input.required<string>();
readonly deedUploaded = output<GuaranteeDeedUploaded>();
readonly selectedFiles = signal<Record<string, File | undefined>>({});
readonly attestedKeys = signal<ReadonlySet<string>>(new Set());
readonly uploadingKeys = signal<ReadonlySet<string>>(new Set());
readonly errors = signal<Record<string, string>>({});
key(requirement: GuaranteeRequirement): string {
return `${requirement.guarantorId}:${requirement.tenantId}`;
}
selectFile(event: Event, requirement: GuaranteeRequirement): void {
const input = event.target as HTMLInputElement;
const file = input.files?.item(0);
const key = this.key(requirement);
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
this.errors.update((errors) => ({ ...errors, [key]: 'Sélectionnez un fichier PDF.' }));
} else {
this.errors.update((errors) => withoutRecordKey(errors, key));
this.selectedFiles.update((files) => ({ ...files, [key]: file ?? undefined }));
}
input.value = '';
}
setAttestation(requirement: GuaranteeRequirement, checked: boolean): void {
const key = this.key(requirement);
this.attestedKeys.update((keys) => {
const next = new Set(keys);
if (checked) next.add(key);
else next.delete(key);
return next;
});
}
upload(requirement: GuaranteeRequirement): void {
const key = this.key(requirement);
const file = this.selectedFiles()[key];
if (!file || !this.attestedKeys().has(key) || this.uploadingKeys().has(key)) return;
this.uploadingKeys.update((keys) => new Set(keys).add(key));
this.errors.update((errors) => withoutRecordKey(errors, key));
this.finalizationService
.uploadGuaranteeDeed(this.leaseId(), file, requirement.guarantorId, requirement.tenantId)
.pipe(finalize(() => this.uploadingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({
next: (deed) =>
this.deedUploaded.emit({
guarantorId: requirement.guarantorId,
tenantId: requirement.tenantId,
deed,
}),
error: (error) =>
this.errors.update((errors) => ({
...errors,
[key]: httpErrorMessage(error, 'Impossible de téléverser cet acte de cautionnement.'),
})),
});
}
}
@@ -34,6 +34,43 @@
font-size: 0.86rem;
font-weight: 700;
}
.revision-metadata {
color: var(--text-muted);
font-size: 0.76rem;
}
.revision-manifest {
margin-block-start: 0.75rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--surface-soft);
font-size: 0.78rem;
}
.revision-manifest summary {
cursor: pointer;
font-weight: 750;
}
.annex-list,
.revision-warnings ul {
display: grid;
gap: 0.5rem;
margin: 0.75rem 0 0;
padding-inline-start: 1.1rem;
}
.annex-list li {
min-width: 0;
}
.annex-list span {
display: block;
color: var(--text-muted);
overflow-wrap: anywhere;
}
.annex-hash {
font-family: monospace;
}
.revision-warnings {
margin-block-start: 0.75rem;
color: var(--danger, #b42318);
}
.terms {
display: grid;
gap: 12px;
@@ -50,6 +87,22 @@
margin: 3px 0 0;
font-size: 0.84rem;
}
.open-rent-revision {
margin-block-end: 1rem;
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.2em;
cursor: pointer;
}
.open-rent-revision:focus-visible {
outline: 2px solid currentColor;
outline-offset: 0.25rem;
}
.guarantor-summary {
display: flex;
flex-wrap: wrap;
@@ -1,70 +1,156 @@
<article class="lease-card" [attr.aria-busy]="deleting() || downloading() || activating()">
<div class="lease-topline">
<span class="status-dot"></span><span>{{ statusLabel() }}</span><strong>{{
lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
</div>
<h2>{{ apartmentName() }}</h2>
<p class="tenant-name">{{ tenantNames() }}</p>
<dl class="terms">
<div>
<dt>Période</dt>
<dd>
{{ lease().startDate | date: 'dd MMMM yyyy' }} →
{{ lease().endDate | date: 'dd MMMM yyyy' }}
</dd>
</div>
<div>
<dt>Charges</dt>
<dd>{{ lease().monthlyCharges.toLocaleString('fr-FR') }} € / mois</dd>
</div>
<div>
<dt>Dépôt</dt>
<dd>{{ lease().securityDeposit.toLocaleString('fr-FR') }} €</dd>
</div>
</dl>
@if (lease().guarantors.length) {
<div class="guarantor-summary">
<span>Garant</span>
@for (guarantor of lease().guarantors; track $index) {
<strong>{{ guarantor.firstName }} {{ guarantor.lastName }}</strong>
}
</div>
}
@if (deleteError() || downloadError() || activationError()) {
<p class="card-error" role="alert">
{{ deleteError() || downloadError() || activationError() }}
</p>
}
<div class="card-actions" aria-live="polite">
<button mat-button type="button" [disabled]="deleting() || downloading() || activating()"
(click)="pdfRequested.emit(lease())">
@if (downloading()) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
} @else {
Télécharger le bail
}
</button>
@if (lease().id && lease().status === 'DRAFT') {
<button mat-button type="button" [disabled]="deleting() || downloading() || activating()"
(click)="editRequested.emit(lease())">
Modifier
</button>
<button mat-flat-button type="button" [disabled]="deleting() || downloading() || activating()"
(click)="activationRequested.emit(lease().id!)">
@if (activating()) {
<mat-spinner diameter="16" aria-hidden="true" /> Activation…
} @else {
Activer
}
</button>
<button mat-button type="button" class="danger-action" [disabled]="deleting() || downloading() || activating()"
(click)="deleteRequested.emit(lease().id!)">
@if (deleting()) {
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
} @else {
Supprimer
}
</button>
}
</div>
</article>
<article class="lease-card" [attr.aria-busy]="deleting() || downloading() || dossierDownloading()">
<div class="lease-topline">
<span class="status-dot"></span><span>{{ statusLabel() }}</span
><strong>{{ lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
</div>
<h2>{{ apartmentName() }}</h2>
<p class="tenant-name">{{ tenantNames() }}</p>
@if (revision(); as revision) {
<p class="revision-metadata">
Révision {{ revision.number }} · modèle {{ revision.templateVersion }}
</p>
<details class="revision-manifest">
<summary>Contenu du dossier ({{ revision.annexes.length }} annexes)</summary>
@if (revision.annexes.length) {
<ul class="annex-list">
@for (annex of revision.annexes; track annex.category + ':' + annex.sourceId) {
<li>
<strong>{{ annexLabel(annex) }}</strong>
<span>{{ annex.fileName }}</span>
@if (annex.version) {
<span>Version {{ annex.version }}</span>
}
@if (annex.establishedOn) {
<span>Établi le {{ civilDate(annex.establishedOn) }}</span>
}
@if (annex.expiresOn) {
<span>Expire le {{ civilDate(annex.expiresOn) }}</span>
}
<span class="annex-hash">Empreinte SHA-256 : {{ annex.sha256 }}</span>
</li>
}
</ul>
} @else {
<p>Aucune annexe dans cette révision.</p>
}
@if (revision.warnings.length) {
<div class="revision-warnings" role="status">
<strong>Avertissements du dossier</strong>
<ul>
@for (warning of revision.warnings; track warning.code + ':' + warning.ruleVersion) {
<li>
{{ warningLabel(warning.code) }}
<span>Version de règle : {{ warning.ruleVersion }}</span>
</li>
}
</ul>
</div>
}
</details>
}
<dl class="terms">
<div>
<dt>Période</dt>
<dd>
{{ civilDate(lease().startDate) }} →
{{ civilDate(lease().endDate) }}
</dd>
</div>
<div>
<dt>Charges</dt>
<dd>{{ lease().monthlyCharges.toLocaleString('fr-FR') }} € / mois</dd>
</div>
<div>
<dt>Dépôt</dt>
<dd>{{ lease().securityDeposit.toLocaleString('fr-FR') }} €</dd>
</div>
</dl>
@if (lease().id; as leaseId) {
@if (lease().status === 'ACTIVE' && lease().legalTerms.rentRevision.enabled) {
<button
type="button"
class="open-rent-revision"
[attr.aria-expanded]="rentRevisionOpen()"
(click)="toggleRentRevision()"
>
{{ rentRevisionOpen() ? 'Masquer la révision du loyer' : 'Réviser le loyer selon l’IRL' }}
</button>
@if (rentRevisionOpen()) {
<app-rent-revision-preview [leaseId]="leaseId" />
}
}
}
@if (lease().guarantors.length) {
<div class="guarantor-summary">
<span>Garant</span>
@for (guarantor of lease().guarantors; track $index) {
<strong>{{ guarantor.firstName }} {{ guarantor.lastName }}</strong>
}
</div>
}
@if (deleteError() || downloadError() || dossierDownloadError()) {
<p class="card-error" role="alert">
{{ deleteError() || downloadError() || dossierDownloadError() }}
</p>
}
<div class="card-actions" aria-live="polite">
<button
mat-button
type="button"
[disabled]="deleting() || downloading()"
(click)="pdfRequested.emit(lease())"
>
@if (downloading()) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
} @else {
{{ lease().status === 'DRAFT' ? 'Générer / télécharger le bail' : 'Télécharger le bail' }}
}
</button>
@if (lease().id; as leaseId) {
<button
mat-button
type="button"
class="dossier-download"
[disabled]="deleting() || dossierDownloading()"
(click)="dossierRequested.emit(lease())"
>
@if (dossierDownloading()) {
<mat-spinner diameter="16" aria-hidden="true" /> Préparation du dossier…
} @else {
Télécharger le dossier complet (.zip)
}
</button>
@if (lease().status === 'DRAFT') {
<button
mat-button
type="button"
[disabled]="deleting()"
(click)="editRequested.emit(lease())"
>
Modifier
</button>
<button
mat-flat-button
type="button"
[disabled]="deleting()"
(click)="finalizationRequested.emit(leaseId)"
>
Finaliser le bail
</button>
<button
mat-button
type="button"
class="danger-action"
[disabled]="deleting() || downloading() || dossierDownloading()"
(click)="deleteRequested.emit(leaseId)"
>
@if (deleting()) {
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
} @else {
Supprimer
}
</button>
}
}
</div>
</article>
@@ -1,11 +1,24 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseCard } from './lease-card';
import { emptyApartmentLegalProfile } from '../../services/apartment.service';
import { emptyLeaseLegalTerms } from '../../services/lease.service';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { WorkspaceService } from '../../services/workspace.service';
describe('LeaseCard', () => {
it('resolves apartment, tenants, and every status label', () => {
const fixture = TestBed.createComponent(LeaseCard);
fixture.componentRef.setInput('apartments', [
{ id: 'apartment-1', name: 'T2', address: '', purchasePrice: 0, description: '' },
{
id: 'apartment-1',
name: 'T2',
address: '',
purchasePrice: 0,
description: '',
legalProfile: emptyApartmentLegalProfile(),
},
]);
fixture.componentRef.setInput('tenants', [
{
@@ -22,11 +35,17 @@ describe('LeaseCard', () => {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1', 'missing', 'tenant-2'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '',
endDate: '',
monthlyRent: 0,
monthlyCharges: 0,
securityDeposit: 0,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'DRAFT',
});
const component = fixture.componentInstance;
@@ -54,14 +73,58 @@ describe('LeaseCard', () => {
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
startDate: '2026-01-01',
endDate: '2027-01-01',
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-07-19',
endDate: '2027-07-19',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
});
fixture.componentRef.setInput('downloading', true);
fixture.componentRef.setInput('revision', {
number: 3,
templateVersion: '2026.10',
sourceFingerprint: 'source',
sha256: 'sha256',
generatedAt: '2026-07-19T10:00:00Z',
authorId: 'owner-1',
annexes: [
{
category: 'NOTICE',
documentType: null,
sourceId: '',
version: 'FR-NOTICE-2023-03',
fileName: 'notice-information.pdf',
sha256: 'notice-sha256',
establishedOn: null,
expiresOn: null,
sourceUploadedAt: null,
},
{
category: 'APARTMENT_DOCUMENT',
documentType: 'DPE',
sourceId: 'document-1',
version: '',
fileName: 'dpe.pdf',
sha256: 'dpe-sha256',
establishedOn: '2026-07-19',
expiresOn: '2036-07-19',
sourceUploadedAt: '2026-07-19T10:00:00Z',
},
],
warningCodes: ['DPE_EXPIRED', 'CO_OWNERSHIP_RULES_EXTRACT_MISSING'],
warnings: [
{ code: 'DPE_EXPIRED', ruleVersion: '2026-07-01' },
{ code: 'CO_OWNERSHIP_RULES_EXTRACT_MISSING', ruleVersion: '2026-07-01' },
],
});
fixture.componentRef.setInput('dossierDownloading', true);
fixture.detectChanges();
const download = fixture.nativeElement.querySelector('.card-actions button');
@@ -72,9 +135,39 @@ describe('LeaseCard', () => {
fixture.componentRef.setInput('downloadError', 'Impossible de télécharger ce bail.');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Impossible de télécharger ce bail.');
expect(fixture.nativeElement.textContent).toContain('Révision 3 · modèle 2026.10');
expect(fixture.nativeElement.textContent).toContain('Notice d’information');
expect(fixture.nativeElement.textContent).toContain(
'Diagnostic de performance énergétique (DPE)',
);
expect(fixture.nativeElement.textContent).toContain('dpe-sha256');
expect(fixture.nativeElement.textContent).toContain('19 juillet 2026');
expect(fixture.nativeElement.textContent).not.toContain('18 juillet 2026');
expect(fixture.nativeElement.textContent).toContain(
'Le DPE présent dans le dossier est expiré.',
);
expect(fixture.nativeElement.textContent).toContain(
'L’extrait du règlement de copropriété manque au dossier.',
);
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07-01');
expect(fixture.nativeElement.textContent).not.toContain('DPE_EXPIRED');
const dossier: HTMLButtonElement = fixture.nativeElement.querySelector('.dossier-download');
expect(dossier.disabled).toBe(true);
expect(dossier.textContent).toContain('Préparation du dossier…');
expect(download.textContent).toContain('Télécharger le bail');
fixture.componentRef.setInput('downloadError', '');
fixture.componentRef.setInput(
'dossierDownloadError',
'Impossible de télécharger le dossier complet du bail.',
);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Impossible de télécharger le dossier complet du bail.',
);
});
it('offers draft lifecycle actions with activation progress and an actionable failure', () => {
it('offers draft editing and dedicated finalization actions', () => {
const fixture = TestBed.createComponent(LeaseCard);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
@@ -83,36 +176,101 @@ describe('LeaseCard', () => {
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-01-01',
endDate: '2027-01-01',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'DRAFT',
});
fixture.componentRef.setInput('activating', true);
fixture.componentRef.setInput('activationError', 'Un bail actif chevauche cette période.');
fixture.detectChanges();
const requested = vi.fn();
fixture.componentInstance.finalizationRequested.subscribe(requested);
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('Générer / télécharger le bail');
expect(text).toContain('Modifier');
expect(text).toContain('Supprimer');
expect(text).toContain('Activation…');
expect(text).toContain('Un bail actif chevauche cette période.');
const activation = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
button.textContent.includes('Activation…'),
expect(text).toContain('Finaliser le bail');
expect(text).not.toContain('Activer');
const finalization = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
button.textContent.includes('Finaliser le bail'),
);
expect(activation.disabled).toBe(true);
finalization.click();
expect(requested).toHaveBeenCalledWith('lease-1');
fixture.componentRef.setInput('lease', {
...fixture.componentInstance.lease(),
status: 'ACTIVE',
});
fixture.componentRef.setInput('activating', false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Modifier');
expect(fixture.nativeElement.textContent).not.toContain('Supprimer');
expect(fixture.nativeElement.textContent).not.toContain('Activer');
expect(fixture.nativeElement.textContent).not.toContain('Finaliser le bail');
});
it('offers an IRL preview only for an active lease with the revision clause enabled', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: WorkspaceService, useValue: { acceptRentRevisionApplication: vi.fn() } },
],
});
const http = TestBed.inject(HttpTestingController);
const initialLegalTerms = emptyLeaseLegalTerms();
const legalTerms = {
...initialLegalTerms,
rentRevision: { ...initialLegalTerms.rentRevision, enabled: true },
};
const fixture = TestBed.createComponent(LeaseCard);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
specialClauses: [],
legalTerms,
leaseType: 'STANDARD',
startDate: '2026-07-19',
endDate: '2027-07-19',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).toBeNull();
http.expectNone('/api/leases/lease-1/rent-revisions');
const openRevision: HTMLButtonElement =
fixture.nativeElement.querySelector('.open-rent-revision');
expect(openRevision.getAttribute('aria-expanded')).toBe('false');
openRevision.click();
fixture.detectChanges();
expect(openRevision.getAttribute('aria-expanded')).toBe('true');
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).not.toBeNull();
http.expectOne('/api/leases/lease-1/rent-revisions').flush([]);
fixture.nativeElement.querySelector('.preview-submit').click();
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush({});
fixture.componentRef.setInput('lease', {
...fixture.componentInstance.lease(),
status: 'DRAFT',
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).toBeNull();
http.verify();
});
});
@@ -1,14 +1,23 @@
import { Component, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Component, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import { Lease, RentDue } from '../../services/lease.service';
import { Lease, LeaseRevisionMetadata, RentDue } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
import { apartmentDocumentTypeLabel } from '../../services/apartment-document-label';
import { leaseWarningDescription } from '../../services/lease-warning-label';
import { RentRevisionPreviewComponent } from '../rent-revision-preview/rent-revision-preview';
const frenchCivilDate = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
@Component({
selector: 'app-lease-card',
imports: [MatButtonModule, MatProgressSpinnerModule, DatePipe],
imports: [MatButtonModule, MatProgressSpinnerModule, RentRevisionPreviewComponent],
templateUrl: './lease-card.html',
styleUrl: './lease-card.css',
})
@@ -20,13 +29,20 @@ export class LeaseCard {
readonly deleteError = input('');
readonly downloading = input(false);
readonly downloadError = input('');
readonly dossierDownloading = input(false);
readonly dossierDownloadError = input('');
readonly revision = input<LeaseRevisionMetadata>();
readonly rentDues = input<RentDue[]>([]);
readonly activating = input(false);
readonly activationError = input('');
readonly deleteRequested = output<string>();
readonly pdfRequested = output<Lease>();
readonly dossierRequested = output<Lease>();
readonly editRequested = output<Lease>();
readonly activationRequested = output<string>();
readonly finalizationRequested = output<string>();
readonly rentRevisionOpen = signal(false);
toggleRentRevision(): void {
this.rentRevisionOpen.update((open) => !open);
}
apartmentName(): string {
return (
@@ -48,4 +64,21 @@ export class LeaseCard {
this.lease().status
];
}
annexLabel(annex: LeaseRevisionMetadata['annexes'][number]): string {
return annex.category === 'NOTICE'
? 'Notice d’information'
: annex.documentType
? apartmentDocumentTypeLabel(annex.documentType)
: 'Document du logement';
}
warningLabel(code: string): string {
return leaseWarningDescription(code);
}
civilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return frenchCivilDate.format(new Date(Date.UTC(year, month - 1, day)));
}
}
@@ -0,0 +1,86 @@
:host {
display: block;
margin-top: 30px;
padding-top: 28px;
border-top: 1px solid var(--border);
}
.section-heading {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.section-heading > span {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 30px;
place-items: center;
border-radius: 10px;
background: var(--surface-accent);
color: var(--accent);
font-size: 0.7rem;
font-weight: 800;
}
.section-heading h2,
.section-heading p {
margin: 0;
}
.section-heading h2 {
margin-bottom: 4px;
font-size: 1rem;
}
.section-heading p,
.catalog-clause p {
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.6;
}
.local-loading,
.local-error,
.unavailable-clause,
.custom-clause {
display: flex;
align-items: center;
gap: 12px;
}
.local-loading {
min-height: 72px;
justify-content: center;
color: var(--text-muted);
}
.local-error,
.unavailable-clause {
justify-content: space-between;
}
.catalog-clause {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.catalog-clause p {
margin: 4px 0 0 32px;
}
.parameter-grid {
display: grid;
gap: 8px;
margin: 14px 0 0 32px;
}
.parameter-grid mat-form-field,
.custom-clause mat-form-field {
width: 100%;
}
.custom-clauses,
.validation-error,
.unavailable-clause {
margin-top: 16px;
}
.custom-clause {
align-items: flex-start;
}
.custom-clause button {
margin-top: 8px;
}
@media (min-width: 700px) {
.parameter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -0,0 +1,131 @@
<section class="clause-editor" aria-labelledby="clause-editor-heading">
<div class="section-heading">
<span>04</span>
<div>
<h2 id="clause-editor-heading">Clauses particulières</h2>
<p>Sélectionnez une clause proposée ou rédigez une clause personnalisée.</p>
</div>
</div>
@if (loading()) {
<div class="local-loading" role="status">
<mat-spinner diameter="24" aria-hidden="true" /> Chargement des clauses…
</div>
} @else if (loadError()) {
<div class="alert-error local-error" role="alert">
<span>{{ loadError() }}</span>
<button mat-button type="button" (click)="retry()">Réessayer</button>
</div>
} @else {
<div class="catalog-clauses">
@for (definition of definitions(); track definition.id) {
<article class="catalog-clause">
<mat-checkbox
[checked]="isSelected(definition.id)"
(change)="toggleCatalog(definition.id, $event.checked)"
>
{{ definition.label }}
</mat-checkbox>
<p>{{ definition.description }}</p>
@if (isSelected(definition.id) && definition.parameters.length) {
<div class="parameter-grid">
@for (parameter of definition.parameters; track parameter.name) {
<mat-form-field appearance="outline">
<mat-label>{{ parameter.label }}</mat-label>
@switch (parameter.type) {
@case ('CHOICE') {
<mat-select
[required]="parameter.required"
[value]="parameterValue(definition.id, parameter.name)"
(selectionChange)="
setParameter(definition.id, parameter.name, $event.value)
"
>
@for (choice of parameter.choices; track choice.value) {
<mat-option [value]="choice.value">{{ choice.label }}</mat-option>
}
</mat-select>
}
@case ('NUMBER') {
<input
matInput
type="number"
[required]="parameter.required"
[value]="parameterValue(definition.id, parameter.name)"
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
/>
}
@case ('DATE') {
<input
matInput
type="date"
[required]="parameter.required"
[value]="parameterValue(definition.id, parameter.name)"
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
/>
}
@case ('TIME') {
<input
matInput
type="time"
[required]="parameter.required"
[value]="parameterValue(definition.id, parameter.name)"
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
/>
}
@default {
<input
matInput
type="text"
[required]="parameter.required"
[value]="parameterValue(definition.id, parameter.name)"
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
/>
}
}
</mat-form-field>
}
</div>
}
</article>
} @empty {
<p class="empty-catalog">Aucune clause proposée pour ce type de bail.</p>
}
</div>
}
@for (item of unavailableCatalogClauses(); track item.index) {
@if (!loading()) {
<div class="unavailable-clause alert-error">
<span>La clause « {{ item.clause.catalogId }} » n'est plus disponible.</span>
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
</div>
}
}
<div class="custom-clauses">
@for (item of customClauses(); track item.index) {
<div class="custom-clause">
<mat-form-field appearance="outline">
<mat-label>Clause personnalisée {{ $index + 1 }}</mat-label>
<textarea
matInput
rows="3"
required
[value]="item.clause.customText"
(input)="updateCustomClauseFromEvent(item.index, $event)"
></textarea>
</mat-form-field>
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
</div>
}
<button mat-stroked-button type="button" (click)="addCustomClause()">
Ajouter une clause personnalisée
</button>
</div>
@if (showValidationError() && validationMessage()) {
<div class="alert-error validation-error" role="alert">{{ validationMessage() }}</div>
}
</section>
@@ -0,0 +1,127 @@
import { TestBed } from '@angular/core/testing';
import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
import { LeaseClauseDefinition, LeaseService } from '../../services/lease.service';
import { LeaseClauseEditor } from './lease-clause-editor';
describe('LeaseClauseEditor', () => {
const definitions: LeaseClauseDefinition[] = [
{
id: 'VISITS',
label: 'Visites du logement',
description: 'Autorise des visites dans les limites légales.',
parameters: [
{ name: 'note', label: 'Précision', type: 'TEXT', required: false, choices: [] },
{ name: 'hours', label: 'Durée', type: 'NUMBER', required: false, choices: [] },
{ name: 'date', label: 'Date', type: 'DATE', required: false, choices: [] },
{ name: 'startTime', label: 'Heure de début', type: 'TIME', required: true, choices: [] },
{
name: 'day',
label: 'Jour',
type: 'CHOICE',
required: false,
choices: [{ value: 'MONDAY', label: 'Lundi' }],
},
],
},
];
const getLeaseClauses = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
getLeaseClauses.mockReturnValue(of(definitions));
TestBed.configureTestingModule({
providers: [{ provide: LeaseService, useValue: { getLeaseClauses } }],
});
});
function createEditor() {
const fixture = TestBed.createComponent(LeaseClauseEditor);
fixture.componentRef.setInput('context', 'STANDARD');
fixture.componentInstance.specialClausesChange.subscribe((clauses) => {
fixture.componentRef.setInput('specialClauses', clauses);
});
fixture.detectChanges();
return fixture;
}
it('shows local loading content until the catalog arrives', () => {
const response = new Subject<LeaseClauseDefinition[]>();
getLeaseClauses.mockReturnValue(response);
const fixture = createEditor();
expect(fixture.nativeElement.textContent).toContain('Chargement des clauses');
expect(fixture.nativeElement.querySelector('[role=status]')).not.toBeNull();
response.next(definitions);
response.complete();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
expect(fixture.nativeElement.querySelector('[role=status]')).toBeNull();
});
it('shows a retryable catalog error', () => {
getLeaseClauses
.mockReturnValueOnce(throwError(() => new Error('network')))
.mockReturnValueOnce(of(definitions));
const fixture = createEditor();
expect(fixture.nativeElement.textContent).toContain('Impossible de charger');
fixture.nativeElement.querySelector('.local-error button').click();
fixture.detectChanges();
expect(getLeaseClauses).toHaveBeenCalledTimes(2);
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
});
it('renders typed parameters and requires mandatory values', () => {
const fixture = createEditor();
const editor = fixture.componentInstance;
editor.toggleCatalog('VISITS', true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('input[type=text]')).not.toBeNull();
expect(fixture.nativeElement.querySelector('input[type=number]')).not.toBeNull();
expect(fixture.nativeElement.querySelector('input[type=date]')).not.toBeNull();
expect(fixture.nativeElement.querySelector('input[type=time]').required).toBe(true);
expect(fixture.nativeElement.querySelector('mat-select')).not.toBeNull();
expect(editor.validate()).toBe(false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Heure de début');
editor.setParameter('VISITS', 'startTime', '18:00');
expect(editor.validate()).toBe(true);
expect(editor.specialClauses()).toEqual([
{ catalogId: 'VISITS', parameters: { startTime: '18:00' }, customText: '' },
]);
});
it('adds, validates, edits, and removes a custom clause', () => {
const fixture = createEditor();
const editor = fixture.componentInstance;
editor.addCustomClause();
expect(editor.validate()).toBe(false);
editor.updateCustomClause(0, ' Interdiction de fumer. ');
expect(editor.validate()).toBe(true);
expect(editor.specialClauses()).toEqual([
{ catalogId: null, parameters: {}, customText: ' Interdiction de fumer. ' },
]);
editor.removeClause(0);
expect(editor.specialClauses()).toEqual([]);
});
it('reloads the catalog when the lease context changes', () => {
const fixture = createEditor();
fixture.componentRef.setInput('context', 'COLOCATION');
fixture.detectChanges();
expect(getLeaseClauses).toHaveBeenNthCalledWith(1, 'STANDARD');
expect(getLeaseClauses).toHaveBeenNthCalledWith(2, 'COLOCATION');
});
});
@@ -0,0 +1,188 @@
import {
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import {
LeaseClauseContext,
LeaseClauseDefinition,
LeaseService,
LeaseSpecialClause,
} from '../../services/lease.service';
@Component({
selector: 'app-lease-clause-editor',
imports: [
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
],
templateUrl: './lease-clause-editor.html',
styleUrl: './lease-clause-editor.css',
})
export class LeaseClauseEditor {
private readonly leaseService = inject(LeaseService);
private readonly destroyRef = inject(DestroyRef);
private requestId = 0;
readonly context = input.required<LeaseClauseContext>();
readonly specialClauses = input<LeaseSpecialClause[]>([]);
readonly specialClausesChange = output<LeaseSpecialClause[]>();
readonly definitions = signal<LeaseClauseDefinition[]>([]);
readonly loading = signal(true);
readonly loadError = signal('');
readonly showValidationError = signal(false);
readonly customClauses = computed(() =>
this.specialClauses()
.map((clause, index) => ({ clause, index }))
.filter(({ clause }) => clause.catalogId === null),
);
readonly unavailableCatalogClauses = computed(() => {
const availableIds = new Set(this.definitions().map(({ id }) => id));
return this.specialClauses()
.map((clause, index) => ({ clause, index }))
.filter(({ clause }) => clause.catalogId !== null && !availableIds.has(clause.catalogId));
});
readonly validationMessage = computed(() => {
const custom = this.customClauses().find(({ clause }) => !clause.customText.trim());
if (custom) return 'Renseignez le texte de chaque clause personnalisée ou retirez-la.';
const selectedCatalog = this.specialClauses().filter(({ catalogId }) => catalogId !== null);
if (selectedCatalog.length && this.loading())
return 'Attendez la fin du chargement des clauses.';
if (selectedCatalog.length && this.loadError()) {
return 'Rechargez le catalogue pour vérifier les clauses sélectionnées.';
}
for (const clause of selectedCatalog) {
const definition = this.definitions().find(({ id }) => id === clause.catalogId);
if (!definition)
return `La clause « ${clause.catalogId} » n'est plus disponible. Retirez-la.`;
const missing = definition.parameters.find(
(parameter) => parameter.required && !clause.parameters[parameter.name]?.trim(),
);
if (missing) return `Renseignez « ${missing.label} » pour la clause « ${definition.label} ».`;
}
return '';
});
constructor() {
effect(() => {
this.context();
this.load();
});
}
retry(): void {
this.load();
}
validate(): boolean {
this.showValidationError.set(true);
return !this.validationMessage();
}
isSelected(id: string): boolean {
return this.specialClauses().some(({ catalogId }) => catalogId === id);
}
toggleCatalog(id: string, selected: boolean): void {
const clauses = this.specialClauses().filter(({ catalogId }) => catalogId !== id);
this.emit(selected ? [...clauses, { catalogId: id, parameters: {}, customText: '' }] : clauses);
}
parameterValue(catalogId: string, parameterName: string): string {
return (
this.specialClauses().find((clause) => clause.catalogId === catalogId)?.parameters[
parameterName
] ?? ''
);
}
setParameter(catalogId: string, parameterName: string, value: string): void {
this.emit(
this.specialClauses().map((clause) =>
clause.catalogId === catalogId
? { ...clause, parameters: { ...clause.parameters, [parameterName]: value } }
: clause,
),
);
}
setParameterFromEvent(catalogId: string, parameterName: string, event: Event): void {
this.setParameter(catalogId, parameterName, this.eventValue(event));
}
addCustomClause(): void {
this.emit([...this.specialClauses(), { catalogId: null, parameters: {}, customText: '' }]);
}
updateCustomClause(index: number, customText: string): void {
this.emit(
this.specialClauses().map((clause, clauseIndex) =>
clauseIndex === index ? { ...clause, customText } : clause,
),
);
}
updateCustomClauseFromEvent(index: number, event: Event): void {
this.updateCustomClause(index, this.eventValue(event));
}
removeClause(index: number): void {
this.emit(this.specialClauses().filter((_, clauseIndex) => clauseIndex !== index));
}
private load(): void {
const requestId = ++this.requestId;
this.loading.set(true);
this.loadError.set('');
this.definitions.set([]);
this.leaseService
.getLeaseClauses(this.context())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (definitions) => {
if (requestId !== this.requestId) return;
this.definitions.set(definitions);
this.loading.set(false);
},
error: () => {
if (requestId !== this.requestId) return;
this.loading.set(false);
this.loadError.set('Impossible de charger les clauses proposées.');
},
});
}
private emit(clauses: LeaseSpecialClause[]): void {
this.showValidationError.set(false);
this.specialClausesChange.emit(clauses);
}
private eventValue(event: Event): string {
const target = event.target;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
? target.value
: '';
}
}
@@ -0,0 +1,49 @@
:host {
display: block;
margin-top: 30px;
padding-top: 28px;
border-top: 1px solid var(--border);
}
fieldset {
display: grid;
gap: 14px;
padding: 0;
border: 0;
}
.section-heading p,
.selected-file,
.signature-notice {
color: var(--text-muted);
}
.file-action,
.date-field {
display: grid;
gap: 6px;
font-weight: 700;
}
.date-field input {
max-width: 260px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font: inherit;
}
.selected-file,
.signature-notice,
.operation-error {
margin: 0;
font-size: 0.82rem;
}
.warnings {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 12px;
background: var(--surface-soft);
}
.warnings h3 {
margin: 0;
font-size: 0.9rem;
}
@@ -0,0 +1,79 @@
<section aria-labelledby="signed-lease-heading">
<div class="section-heading">
<h2 id="signed-lease-heading">Bail signé</h2>
<p>
Document attendu : révision {{ revision().number }} · modèle {{ revision().templateVersion }}.
</p>
</div>
<form (submit)="submit(); $event.preventDefault()" [attr.aria-busy]="submitting()">
<fieldset [disabled]="submitting()">
<label class="file-action">
<span>Bail signé au format PDF</span>
<input
type="file"
accept="application/pdf,.pdf"
aria-label="Bail signé au format PDF"
(change)="selectSignedLease($event)"
/>
</label>
@if (signedLease(); as file) {
<p class="selected-file">{{ file.name }}</p>
}
<label class="date-field" for="lastSignatureDate">
<span>Date de la dernière signature</span>
<input
id="lastSignatureDate"
type="date"
required
[value]="lastSignatureDate()"
(input)="setLastSignatureDate($event)"
/>
</label>
<mat-checkbox [checked]="allPartiesSigned()" (change)="allPartiesSigned.set($event.checked)">
J'atteste que toutes les parties ont signé le PDF transmis.
</mat-checkbox>
<mat-checkbox [checked]="revisionMatches()" (change)="revisionMatches.set($event.checked)">
J'ai vérifié que le PDF signé correspond à la révision {{ revision().number }} · modèle
{{ revision().templateVersion }}.
</mat-checkbox>
@if (warnings().length) {
<div class="warnings" aria-labelledby="warnings-heading">
<h3 id="warnings-heading">Avertissements à confirmer</h3>
@for (warning of warnings(); track warning.code + ':' + warning.ruleVersion) {
<mat-checkbox
[checked]="confirmedWarningCodes().has(warning.code)"
(change)="setWarning(warning.code, $event.checked)"
>
{{ warningLabel(warning.code) }}
<small>Version de règle : {{ warning.ruleVersion }}</small>
</mat-checkbox>
}
</div>
}
<p class="signature-notice">
L'application conserve le document et vos attestations ; elle ne vérifie pas les signatures
contenues dans le PDF.
</p>
@if (!guaranteesComplete()) {
<p class="operation-error" role="alert">
Téléversez tous les actes de cautionnement requis avant de finaliser.
</p>
}
@if (validationError() || submitError()) {
<p class="operation-error" role="alert">{{ validationError() || submitError() }}</p>
}
<button mat-flat-button type="submit" [disabled]="submitting() || !guaranteesComplete()">
@if (submitting()) {
<mat-spinner diameter="18" aria-hidden="true" /> Finalisation…
} @else {
Finaliser le bail
}
</button>
</fieldset>
</form>
</section>
@@ -0,0 +1,123 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseRevisionMetadata } from '../../services/lease.service';
import { LeaseFinalizationForm } from './lease-finalization-form';
describe('LeaseFinalizationForm', () => {
const revision: LeaseRevisionMetadata = {
number: 4,
templateVersion: '2026.10',
sourceFingerprint: 'source',
sha256: 'sha256',
generatedAt: '2026-07-19T10:00:00Z',
authorId: 'owner-1',
annexes: [],
warningCodes: [],
warnings: [],
};
function createForm(warningCodes: string[] = []) {
const fixture = TestBed.createComponent(LeaseFinalizationForm);
fixture.componentRef.setInput('revision', revision);
fixture.componentRef.setInput('warningCodes', warningCodes);
fixture.componentRef.setInput(
'warnings',
warningCodes.map((code) => ({ code, ruleVersion: '2026-07-01' })),
);
fixture.detectChanges();
return fixture;
}
it('emits the exact civil date string and confirmations', () => {
const fixture = createForm([
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
'IRL_REVISION_WITH_F_OR_G_DPE',
'FUTURE_WARNING',
]);
const component = fixture.componentInstance;
const file = new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' });
const requested = vi.fn();
component.finalizationRequested.subscribe(requested);
component.signedLease.set(file);
component.lastSignatureDate.set('2026-07-19');
component.allPartiesSigned.set(true);
component.revisionMatches.set(true);
component.setWarning('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', true);
component.setWarning('IRL_REVISION_WITH_F_OR_G_DPE', true);
component.setWarning('FUTURE_WARNING', true);
component.submit();
expect(requested).toHaveBeenCalledWith({
signedLease: file,
revisionNumber: 4,
lastSignatureDate: '2026-07-19',
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: [
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
'IRL_REVISION_WITH_F_OR_G_DPE',
'FUTURE_WARNING',
],
});
});
it('uses actionable French warning labels without exposing raw codes', () => {
const fixture = createForm([
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
'IRL_REVISION_WITH_F_OR_G_DPE',
'FUTURE_WARNING',
]);
const text = fixture.nativeElement.textContent;
expect(text).toContain('dépasse deux mois de loyer hors charges');
expect(text).toContain('confirme forcer ce montant');
expect(text).toContain('classé F ou G');
expect(text).toContain('maintenir la clause de révision IRL');
expect(text).toContain('avertissement légal signalé');
expect(text).not.toContain('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS');
expect(text).not.toContain('FUTURE_WARNING');
expect(text).not.toContain('IRL_REVISION_WITH_F_OR_G_DPE');
expect(text).toContain('Version de règle : 2026-07-01');
});
it('explains every document warning precisely in French', () => {
const warningCodes = [
'DPE_MISSING',
'DPE_EXPIRED',
'DIAGNOSTIC_EXPIRED',
'CO_OWNERSHIP_RULES_EXTRACT_MISSING',
'PRIOR_RENTAL_AUTHORIZATION_MISSING',
];
const fixture = createForm(warningCodes);
const text = fixture.nativeElement.textContent;
expect(text).toContain('DPE est absent du dossier');
expect(text).toContain('DPE présent dans le dossier est expiré');
expect(text).toContain('diagnostic autre que le DPE est expiré');
expect(text).toContain('extrait du règlement de copropriété manque');
expect(text).toContain('autorisation préalable de mise en location manque');
for (const code of warningCodes) expect(text).not.toContain(code);
});
it('blocks an incomplete submission with an actionable error', () => {
const fixture = createForm(['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS']);
const requested = vi.fn();
fixture.componentInstance.finalizationRequested.subscribe(requested);
fixture.componentInstance.submit();
fixture.detectChanges();
expect(requested).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Sélectionnez le bail signé');
});
it('blocks finalization while a required guarantee deed is missing', () => {
const fixture = createForm();
fixture.componentRef.setInput('guaranteesComplete', false);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Téléversez tous les actes');
});
});
@@ -0,0 +1,95 @@
import { Component, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FinalizeLeaseRequest } from '../../services/lease-finalization.service';
import { LeaseRevisionMetadata, LeaseRevisionWarning } from '../../services/lease.service';
import { leaseWarningConfirmation } from '../../services/lease-warning-label';
@Component({
selector: 'app-lease-finalization-form',
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
templateUrl: './lease-finalization-form.html',
styleUrl: './lease-finalization-form.css',
})
export class LeaseFinalizationForm {
readonly revision = input.required<LeaseRevisionMetadata>();
readonly warningCodes = input<string[]>([]);
readonly warnings = input<LeaseRevisionWarning[]>([]);
readonly guaranteesComplete = input(true);
readonly submitting = input(false);
readonly submitError = input('');
readonly finalizationRequested = output<FinalizeLeaseRequest>();
readonly signedLease = signal<File | null>(null);
readonly lastSignatureDate = signal('');
readonly allPartiesSigned = signal(false);
readonly revisionMatches = signal(false);
readonly confirmedWarningCodes = signal<ReadonlySet<string>>(new Set());
readonly validationError = signal('');
selectSignedLease(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.item(0) ?? null;
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
this.signedLease.set(null);
this.validationError.set('Sélectionnez le bail signé au format PDF.');
} else {
this.signedLease.set(file);
this.validationError.set('');
}
input.value = '';
}
setLastSignatureDate(event: Event): void {
const target = event.target;
this.lastSignatureDate.set(target instanceof HTMLInputElement ? target.value : '');
}
setWarning(code: string, checked: boolean): void {
this.confirmedWarningCodes.update((codes) => {
const next = new Set(codes);
if (checked) next.add(code);
else next.delete(code);
return next;
});
}
warningLabel(code: string): string {
return leaseWarningConfirmation(code);
}
submit(): void {
if (this.submitting()) return;
const error = this.errorMessage();
this.validationError.set(error);
const signedLease = this.signedLease();
if (error || !signedLease) return;
this.finalizationRequested.emit({
signedLease,
revisionNumber: this.revision().number,
lastSignatureDate: this.lastSignatureDate(),
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: this.warningCodes(),
});
}
private errorMessage(): string {
if (!this.guaranteesComplete()) {
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
}
if (!this.signedLease()) return 'Sélectionnez le bail signé au format PDF.';
if (!this.lastSignatureDate()) return 'Renseignez la date de la dernière signature.';
if (!this.allPartiesSigned()) {
return 'Attestez que toutes les parties ont signé le document transmis.';
}
if (!this.revisionMatches()) {
return `Confirmez que le document signé correspond à la révision ${this.revision().number}.`;
}
const missingWarning = this.warningCodes().find(
(code) => !this.confirmedWarningCodes().has(code),
);
return missingWarning ? 'Confirmez chaque avertissement légal avant de finaliser.' : '';
}
}
@@ -0,0 +1,52 @@
:host {
display: block;
}
.finalization-page {
max-width: 900px;
}
.finalization-skeleton {
display: grid;
gap: 14px;
}
.finalization-skeleton span {
height: 90px;
border-radius: 14px;
}
.revision-summary,
.missing-revision,
.finalized-summary {
padding: 20px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface);
}
.revision-summary {
display: grid;
gap: 4px;
margin-bottom: 28px;
}
.revision-summary span,
.finalized-summary dt,
.finalized-summary > p:last-child {
color: var(--text-muted);
}
.finalized-summary dl {
display: grid;
gap: 14px;
margin: 22px 0;
}
.finalized-summary dt {
font-size: 0.75rem;
}
.finalized-summary dd {
margin: 3px 0 0;
}
.hash {
overflow-wrap: anywhere;
font-family: monospace;
}
@media (min-width: 700px) {
.finalized-summary dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -0,0 +1,95 @@
<section class="form-page finalization-page">
<header class="page-heading">
<div>
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
<p class="eyebrow">Bail</p>
<h1>Finalisation du bail</h1>
<p>Téléversez les documents signés et confirmez leur correspondance.</p>
</div>
</header>
@if (loading()) {
<div class="finalization-skeleton" aria-label="Chargement de la finalisation">
<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 finalizationState) {
@if (finalizationState.finalization; as finalization) {
<article class="finalized-summary">
<p class="eyebrow">Finalisé</p>
<h2>Bail finalisé et désormais immuable</h2>
<dl>
<div>
<dt>Révision</dt>
<dd>{{ finalization.revisionNumber }}</dd>
</div>
<div>
<dt>Modèle</dt>
<dd>{{ finalization.templateVersion }}</dd>
</div>
<div>
<dt>Dernière signature</dt>
<dd>{{ finalization.lastSignatureDate }}</dd>
</div>
<div>
<dt>Finalisé le</dt>
<dd>{{ finalization.finalizedAt }}</dd>
</div>
<div>
<dt>Fichier</dt>
<dd>{{ finalization.signedFileName }}</dd>
</div>
<div>
<dt>Auteur</dt>
<dd>{{ finalization.authorId }}</dd>
</div>
<div>
<dt>Empreinte SHA-256</dt>
<dd class="hash">{{ finalization.signedSha256 }}</dd>
</div>
</dl>
<p>Ces informations et le document signé ne peuvent plus être modifiés.</p>
</article>
} @else if (!finalizationState.revision) {
<article class="missing-revision">
<h2>Générez d'abord le bail</h2>
<p>
Une révision PDF doit être générée depuis la carte du bail avant de téléverser sa version
signée.
</p>
<a mat-flat-button routerLink="/leases">Revenir aux baux</a>
</article>
} @else {
<article class="revision-summary">
<span>Document à signer</span>
<strong>
Révision {{ finalizationState.revision.number }} · modèle
{{ finalizationState.revision.templateVersion }}
</strong>
</article>
<app-guarantee-deed-list
[leaseId]="leaseId"
[requirements]="finalizationState.guaranteeRequirements"
[simulatorUrl]="finalizationState.guaranteeSimulatorUrl"
(deedUploaded)="acceptDeed($event)"
/>
<app-lease-finalization-form
[revision]="finalizationState.revision"
[warningCodes]="finalizationState.warningCodes"
[warnings]="finalizationState.warnings"
[guaranteesComplete]="guaranteesComplete()"
[submitting]="submitting() || confirming()"
[submitError]="submitError()"
(finalizationRequested)="requestFinalization($event)"
/>
}
}
</section>
@@ -0,0 +1,180 @@
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 {
FinalizeLeaseRequest,
FinalizationMetadata,
LeaseFinalizationService,
LeaseFinalizationState,
} from '../../services/lease-finalization.service';
import { WorkspaceService } from '../../services/workspace.service';
import { LeaseFinalizationPage } from './lease-finalization-page';
describe('LeaseFinalizationPage', () => {
const revision = {
number: 4,
templateVersion: '2026.10',
sourceFingerprint: 'source',
sha256: 'sha256',
generatedAt: '2026-07-19T10:00:00Z',
authorId: 'owner-1',
annexes: [],
warningCodes: [],
warnings: [],
};
const state: LeaseFinalizationState = {
revision,
guaranteeSimulatorUrl: 'https://www.service-public.fr/simulateur',
warningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
warnings: [{ code: 'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', ruleVersion: '2026-07-01' }],
guaranteeRequirements: [],
finalization: null,
};
const finalization: FinalizationMetadata = {
revisionNumber: 4,
templateVersion: '2026.10',
lastSignatureDate: '2026-07-19',
signedFileName: 'bail-signe.pdf',
signedSha256: 'signed-sha256',
authorId: 'owner-1',
finalizedAt: '2026-07-19T10:30:00Z',
overrides: [],
};
const lease = {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
leaseType: 'STANDARD' as const,
startDate: '2026-07-01',
endDate: '2027-06-30',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE' as const,
indexFlatRate: false,
status: 'ACTIVE' as const,
};
const getState = vi.fn();
const uploadGuaranteeDeed = vi.fn();
const finalize = vi.fn();
const workspace = { acceptLeaseFinalization: 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: LeaseFinalizationService,
useValue: { getState, uploadGuaranteeDeed, finalize },
},
{ provide: WorkspaceService, useValue: workspace },
{ provide: MatDialog, useValue: dialog },
],
});
});
it('shows a skeleton, then a retryable load error', () => {
const response = new Subject<LeaseFinalizationState>();
getState.mockReturnValueOnce(response);
const fixture = TestBed.createComponent(LeaseFinalizationPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.finalization-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);
expect(fixture.nativeElement.textContent).toContain('Révision 4 · modèle 2026.10');
});
it('instructs the landlord to generate a revision when none exists', () => {
getState.mockReturnValueOnce(of({ ...state, revision: null }));
const fixture = TestBed.createComponent(LeaseFinalizationPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Générez d'abord le bail");
expect(fixture.nativeElement.querySelector('app-lease-finalization-form')).toBeNull();
});
it('asks for final confirmation, prevents duplicates, and publishes success', () => {
const response = new Subject<{
lease: typeof lease;
rentDues: [];
finalization: FinalizationMetadata;
}>();
finalize.mockReturnValue(response);
const fixture = TestBed.createComponent(LeaseFinalizationPage);
fixture.detectChanges();
const request: FinalizeLeaseRequest = {
signedLease: new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' }),
revisionNumber: 4,
lastSignatureDate: '2026-07-19',
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
};
fixture.componentInstance.requestFinalization(request);
fixture.componentInstance.requestFinalization(request);
expect(dialog.open).toHaveBeenCalledOnce();
expect(finalize).toHaveBeenCalledWith('lease-1', request);
expect(fixture.componentInstance.submitting()).toBe(true);
response.next({ lease, rentDues: [], finalization });
response.complete();
fixture.detectChanges();
expect(workspace.acceptLeaseFinalization).toHaveBeenCalledWith(lease, []);
expect(fixture.nativeElement.textContent).toContain('Bail finalisé et désormais immuable');
expect(fixture.nativeElement.querySelector('app-lease-finalization-form')).toBeNull();
});
it('renders an immutable summary when the lease was already finalized', () => {
getState.mockReturnValueOnce(of({ ...state, finalization }));
const fixture = TestBed.createComponent(LeaseFinalizationPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('bail-signe.pdf');
expect(fixture.nativeElement.textContent).toContain('signed-sha256');
expect(fixture.nativeElement.querySelector('input')).toBeNull();
});
it('keeps a finalization failure actionable without showing success', () => {
finalize.mockReturnValue(throwError(() => ({ error: { message: 'PDF signé refusé' } })));
const fixture = TestBed.createComponent(LeaseFinalizationPage);
fixture.detectChanges();
const request: FinalizeLeaseRequest = {
signedLease: new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' }),
revisionNumber: 4,
lastSignatureDate: '2026-07-19',
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
};
fixture.componentInstance.requestFinalization(request);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('PDF signé refusé');
expect(fixture.nativeElement.textContent).not.toContain('désormais immuable');
expect(fixture.componentInstance.submitting()).toBe(false);
});
});
@@ -0,0 +1,122 @@
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { finalize as rxFinalize } from 'rxjs';
import {
ConfirmationDialog,
ConfirmationDialogData,
} from '../confirmation-dialog/confirmation-dialog';
import {
FinalizeLeaseRequest,
LeaseFinalizationService,
LeaseFinalizationState,
} from '../../services/lease-finalization.service';
import { WorkspaceService } from '../../services/workspace.service';
import { httpErrorMessage } from '../../services/http-error-message';
import {
GuaranteeDeedList,
GuaranteeDeedUploaded,
} from '../guarantee-deed-list/guarantee-deed-list';
import { LeaseFinalizationForm } from '../lease-finalization-form/lease-finalization-form';
@Component({
selector: 'app-lease-finalization-page',
imports: [MatButtonModule, RouterLink, GuaranteeDeedList, LeaseFinalizationForm],
templateUrl: './lease-finalization-page.html',
styleUrl: './lease-finalization-page.css',
})
export class LeaseFinalizationPage {
private readonly route = inject(ActivatedRoute);
private readonly finalizationService = inject(LeaseFinalizationService);
private readonly workspace = inject(WorkspaceService);
private readonly dialog = inject(MatDialog);
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
readonly state = signal<LeaseFinalizationState | null>(null);
readonly loading = signal(true);
readonly loadError = signal('');
readonly submitting = signal(false);
readonly confirming = signal(false);
readonly submitError = signal('');
readonly guaranteesComplete = computed(
() => this.state()?.guaranteeRequirements.every(({ deed }) => deed !== null) ?? false,
);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.loadError.set('');
this.finalizationService
.getState(this.leaseId)
.pipe(rxFinalize(() => this.loading.set(false)))
.subscribe({
next: (state) => this.state.set(state),
error: (error) =>
this.loadError.set(
httpErrorMessage(error, 'Impossible de charger la finalisation de ce bail.'),
),
});
}
acceptDeed(event: GuaranteeDeedUploaded): void {
this.state.update((state) =>
state
? {
...state,
guaranteeRequirements: state.guaranteeRequirements.map((requirement) =>
requirement.guarantorId === event.guarantorId &&
requirement.tenantId === event.tenantId
? { ...requirement, deed: event.deed }
: requirement,
),
}
: state,
);
}
requestFinalization(request: FinalizeLeaseRequest): void {
if (this.submitting() || this.confirming()) return;
this.confirming.set(true);
const data: ConfirmationDialogData = {
title: 'Finaliser définitivement ce bail ?',
message:
'Le bail signé, les attestations et leurs empreintes seront conservés sans possibilité de modification.',
confirmLabel: 'Finaliser le bail',
};
this.dialog
.open(ConfirmationDialog, {
data,
role: 'alertdialog',
width: 'min(440px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
})
.afterClosed()
.subscribe((confirmed) => {
this.confirming.set(false);
if (confirmed) this.finalize(request);
});
}
private finalize(request: FinalizeLeaseRequest): void {
if (this.submitting()) return;
this.submitting.set(true);
this.submitError.set('');
this.finalizationService
.finalize(this.leaseId, request)
.pipe(rxFinalize(() => this.submitting.set(false)))
.subscribe({
next: (result) => {
this.workspace.acceptLeaseFinalization(result.lease, result.rentDues);
this.state.update((state) =>
state ? { ...state, finalization: result.finalization } : state,
);
},
error: (error) =>
this.submitError.set(httpErrorMessage(error, 'Impossible de finaliser ce bail.')),
});
}
}
@@ -9,6 +9,23 @@
.guarantor-section {
margin-top: 18px;
}
.lease-duration {
display: grid;
align-content: center;
min-height: 56px;
padding: 0 16px;
border: 1px solid var(--border);
border-radius: 4px;
}
.lease-duration > span,
.lease-duration small {
color: var(--text-muted);
font-size: 0.75rem;
}
.lease-duration output {
margin: 2px 0;
font-weight: 700;
}
.form-section + .form-section {
margin-top: 30px;
padding-top: 28px;
@@ -25,9 +25,6 @@
@if (saveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
@if (error()) {
<div class="alert-error" role="alert">{{ error() }}</div>
}
<p class="visually-hidden" role="status" aria-live="polite">
{{ saving() ? 'Enregistrement en cours.' : '' }}
</p>
@@ -67,33 +64,31 @@
<span>02</span>
<div>
<h2>Conditions</h2>
<p>Définissez la période et les montants mensuels.</p>
<p>Choisissez le type, la date d'entrée et les montants mensuels.</p>
</div>
</div>
<div class="form-grid three-columns">
<mat-form-field appearance="outline"
><mat-label>Date de début</mat-label
><input
matInput
id="startDate"
[matDatepicker]="startDatePicker"
[matDatepickerFilter]="dateFilter"
[formField]="$any(leaseForm.startDate)" /><mat-datepicker-toggle
matIconSuffix
[for]="startDatePicker" /><mat-datepicker #startDatePicker
/></mat-form-field>
><mat-label>Type de bail</mat-label
><mat-select id="leaseType" [formField]="leaseForm.leaseType">
<mat-option value="STANDARD">Location meublée classique</mat-option>
<mat-option value="STUDENT">Location meublée étudiante</mat-option>
</mat-select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Date de fin</mat-label
><input
matInput
id="endDate"
[matDatepicker]="endDatePicker"
[matDatepickerFilter]="dateFilter"
[min]="leaseModel().startDate"
[formField]="$any(leaseForm.endDate)" /><mat-datepicker-toggle
matIconSuffix
[for]="endDatePicker" /><mat-datepicker #endDatePicker
/></mat-form-field>
><mat-label>Date d'entrée</mat-label
><input matInput type="date" id="startDate" [formField]="leaseForm.startDate" />
@if (leaseForm.startDate().touched() && leaseForm.startDate().invalid()) {
<mat-error>{{ leaseForm.startDate().errors()[0]?.message }}</mat-error>
}
</mat-form-field>
<div class="lease-duration" aria-live="polite">
<span id="lease-end-date-label">Date de fin calculée</span>
<output aria-labelledby="lease-end-date-label">
{{ leaseDuration().endDateLabel }}
</output>
<small>{{ leaseDuration().renewalLabel }}</small>
</div>
<mat-form-field appearance="outline"
><mat-label>Loyer mensuel</mat-label
><input
@@ -121,14 +116,43 @@
[formField]="leaseForm.securityDeposit"
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Mode de récupération des charges</mat-label
><mat-select id="chargeMethod" [formField]="leaseForm.chargeMethod">
<mat-option value="FLAT_RATE">Forfait de charges</mat-option>
<mat-option value="PROVISION">Provision avec régularisation annuelle</mat-option>
</mat-select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Jour d’échéance</mat-label
><input matInput type="number" id="dueDay" [formField]="leaseForm.dueDay" /><mat-hint
>Entre le 1er et le 28 du mois</mat-hint
></mat-form-field
>
@if (leaseModel().chargeMethod === 'FLAT_RATE') {
<mat-checkbox id="indexFlatRate" [formField]="leaseForm.indexFlatRate">
Indexer le forfait de charges avec le loyer
</mat-checkbox>
}
</div>
</section>
<app-lease-legal-terms
[legalTermsForm]="leaseForm.legalTerms"
[legalTerms]="leaseModel().legalTerms"
[tenantCount]="leaseModel().tenantIds.length"
[energyRating]="selectedEnergyRating()"
/>
<app-lease-clause-editor
[context]="clauseContext()"
[specialClauses]="specialClauses()"
(specialClausesChange)="specialClauses.set($event)"
/>
</fieldset>
</form>
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<section class="guarantor-section">
<div class="section-heading">
<span>03</span>
<span>05</span>
<div>
<h2>Garants</h2>
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
@@ -1,9 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { provideNativeDateAdapter } from '@angular/material/core';
import { vi } from 'vitest';
import { emptyApartmentLegalProfile } from '../../services/apartment.service';
import { emptyLeaseLegalTerms } from '../../services/lease.service';
import { LeaseForm } from './lease-form';
describe('LeaseForm', () => {
it('serializes selected dates as ISO values', () => {
it('derives and serializes the standard lease end date', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
@@ -12,12 +15,17 @@ describe('LeaseForm', () => {
component.leaseModel.set({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
startDate: new Date(2026, 8, 1),
endDate: new Date(2027, 7, 31),
leaseType: 'STANDARD',
startDate: '2026-09-01',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: true,
legalTerms: emptyLeaseLegalTerms(),
});
fixture.detectChanges();
let submitted: unknown;
component.submitted.subscribe((lease) => (submitted = lease));
@@ -27,12 +35,20 @@ describe('LeaseForm', () => {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: true,
});
expect(fixture.nativeElement.querySelector('#endDate')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('reconductible tacitement');
});
it('allows overlapping draft dates but leaves active dates unavailable', () => {
@@ -46,11 +62,16 @@ describe('LeaseForm', () => {
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
specialClauses: [],
leaseType: 'STANDARD',
startDate: '2026-01-01',
endDate: '2026-01-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'DRAFT',
},
{
@@ -58,11 +79,16 @@ describe('LeaseForm', () => {
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
specialClauses: [],
leaseType: 'STANDARD',
startDate: '2026-02-01',
endDate: '2026-02-28',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
},
]);
@@ -71,8 +97,8 @@ describe('LeaseForm', () => {
apartmentId: 'apartment-1',
}));
expect(fixture.componentInstance.dateFilter(new Date(2026, 0, 15))).toBe(true);
expect(fixture.componentInstance.dateFilter(new Date(2026, 1, 15))).toBe(false);
expect(fixture.componentInstance.dateFilter('2026-01-15')).toBe(true);
expect(fixture.componentInstance.dateFilter('2026-02-15')).toBe(false);
});
it('loads a draft for editing', () => {
@@ -85,19 +111,73 @@ describe('LeaseForm', () => {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [
{ catalogId: null, parameters: {}, customText: 'Animaux admis avec accord.' },
],
leaseType: 'STUDENT',
startDate: '2026-09-01',
endDate: '2027-08-31',
endDate: '2099-12-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'PROVISION',
indexFlatRate: false,
status: 'DRAFT',
});
fixture.detectChanges();
expect(fixture.componentInstance.leaseModel().apartmentId).toBe('apartment-1');
expect(fixture.componentInstance.leaseModel().leaseType).toBe('STUDENT');
expect(fixture.componentInstance.leaseDuration().endDate).toBe('2027-05-31');
expect(fixture.componentInstance.specialClauses()).toEqual([
{ catalogId: null, parameters: {}, customText: 'Animaux admis avec accord.' },
]);
expect(fixture.nativeElement.textContent).toContain('Modifier le brouillon');
});
it('falls back to a standard duration when editing a legacy draft', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'DRAFT',
});
fixture.detectChanges();
expect(fixture.componentInstance.leaseModel().leaseType).toBe('STANDARD');
expect(fixture.componentInstance.leaseDuration().endDate).toBe('2027-08-31');
expect(fixture.componentInstance.specialClauses()).toEqual([]);
});
it('clamps a student duration at month end and announces that it is not renewable', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentInstance.leaseModel.update((lease) => ({
...lease,
leaseType: 'STUDENT',
startDate: '2026-05-31',
}));
fixture.detectChanges();
expect(fixture.componentInstance.leaseDuration().endDate).toBe('2027-02-27');
expect(fixture.nativeElement.textContent).toContain('non reconductible');
});
it('keeps guarantors in the lease form module', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
@@ -113,4 +193,163 @@ describe('LeaseForm', () => {
expect(fixture.componentInstance.guarantors()).toHaveLength(1);
});
it('uses the lease type context unless several tenants make it a colocation', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentInstance.leaseModel.update((lease) => ({
...lease,
leaseType: 'STUDENT',
tenantIds: ['tenant-1'],
}));
expect(fixture.componentInstance.clauseContext()).toBe('STUDENT');
fixture.componentInstance.leaseModel.update((lease) => ({
...lease,
tenantIds: ['tenant-1', 'tenant-2'],
}));
expect(fixture.componentInstance.clauseContext()).toBe('COLOCATION');
});
it('blocks submission when a custom clause is empty', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
const component = fixture.componentInstance;
component.leaseModel.set({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
leaseType: 'STANDARD',
startDate: '2026-09-01',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
legalTerms: emptyLeaseLegalTerms(),
});
component.specialClauses.set([{ catalogId: null, parameters: {}, customText: ' ' }]);
const submitted = vi.fn();
component.submitted.subscribe(submitted);
fixture.detectChanges();
component.save();
fixture.detectChanges();
expect(submitted).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Renseignez le texte');
});
it('shows the finalization warning only when an IRL clause concerns an F or G apartment', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
const apartment = {
id: 'apartment-1',
name: 'T2',
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
legalProfile: { ...emptyApartmentLegalProfile(), energyRating: 'F' as const },
};
fixture.componentRef.setInput('apartments', [apartment]);
fixture.componentRef.setInput('tenants', []);
fixture.componentInstance.leaseModel.update((lease) => ({
...lease,
apartmentId: apartment.id,
legalTerms: {
...lease.legalTerms,
rentRevision: {
enabled: true,
annualRevisionDate: '2026-09-01',
referenceQuarter: 2,
referenceYear: 2026,
seriesId: '001515333',
},
},
}));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('à confirmer lors de la finalisation');
fixture.componentRef.setInput('apartments', [
{ ...apartment, legalProfile: { ...apartment.legalProfile, energyRating: 'D' as const } },
]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('à confirmer lors de la finalisation');
});
it('clears an unfinished rent supplement when the reference-rent scheme is disabled', async () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: {
...emptyLeaseLegalTerms(),
rentControl: {
evolutionCapApplicable: false,
referenceRentApplicable: true,
baseReferenceRent: 20,
increasedReferenceRent: 24,
supplement: null,
supplementJustification: '',
},
},
leaseType: 'STANDARD',
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'DRAFT',
});
fixture.detectChanges();
await fixture.whenStable();
const supplementLabel = [...fixture.nativeElement.querySelectorAll('mat-label')].find(
(label: Element) => label.textContent?.trim() === 'Complément de loyer',
);
const supplementInput = supplementLabel?.closest('mat-form-field')?.querySelector('input');
if (!supplementInput) throw new Error('Champ complément de loyer introuvable.');
supplementInput.value = '-100';
supplementInput.dispatchEvent(new Event('input'));
await fixture.whenStable();
const referenceRentCheckbox = [...fixture.nativeElement.querySelectorAll('mat-checkbox')]
.find((checkbox: Element) => checkbox.textContent?.includes('loyer de référence'))
?.querySelector('input');
if (!referenceRentCheckbox) throw new Error('Option loyer de référence introuvable.');
referenceRentCheckbox.click();
await fixture.whenStable();
fixture.detectChanges();
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.nativeElement.querySelector('button[form=lease-details]').click();
expect(submitted).toHaveBeenCalledWith(
expect.objectContaining({
legalTerms: expect.objectContaining({
rentControl: expect.objectContaining({
referenceRentApplicable: false,
supplement: null,
supplementJustification: '',
}),
}),
}),
);
});
});
@@ -1,16 +1,35 @@
import { Component, effect, input, output, signal } from '@angular/core';
import { Component, computed, effect, input, output, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, required, min } from '@angular/forms/signals';
import { FormRoot, FormField, form, max, min, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import { Guarantor, Lease, LeaseInput } from '../../services/lease.service';
import {
ChargeMethod,
emptyLeaseLegalTerms,
Guarantor,
Lease,
LeaseClauseContext,
LeaseInput,
LeaseSpecialClause,
LeaseType,
} from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
import { GuarantorForm } from '../guarantor-form/guarantor-form';
import { LeaseClauseEditor } from '../lease-clause-editor/lease-clause-editor';
import { LeaseLegalTermsEditor } from '../lease-legal-terms/lease-legal-terms';
import { normalizeLeaseLegalTerms } from '../../services/lease-legal-terms';
const frenchDate = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
@Component({
selector: 'app-lease-form',
@@ -19,12 +38,14 @@ import { GuarantorForm } from '../guarantor-form/guarantor-form';
FormRoot,
FormField,
MatButtonModule,
MatDatepickerModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatProgressSpinnerModule,
GuarantorForm,
LeaseClauseEditor,
LeaseLegalTermsEditor,
],
templateUrl: './lease-form.html',
styleUrl: './lease-form.css',
@@ -38,30 +59,67 @@ export class LeaseForm {
readonly saveError = input('');
readonly cancelled = output<void>();
readonly submitted = output<LeaseInput>();
readonly clauseEditor = viewChild(LeaseClauseEditor);
readonly legalTermsEditor = viewChild(LeaseLegalTermsEditor);
readonly leaseModel = signal({
apartmentId: '',
tenantIds: [] as string[],
startDate: null as Date | null,
endDate: null as Date | null,
leaseType: 'STANDARD' as LeaseType,
startDate: '',
monthlyRent: 0,
monthlyCharges: 0,
securityDeposit: 0,
dueDay: 5,
chargeMethod: 'FLAT_RATE' as ChargeMethod,
indexFlatRate: false,
legalTerms: emptyLeaseLegalTerms(),
});
readonly leaseForm = form(this.leaseModel, (f) => {
required(f.apartmentId);
required(f.tenantIds);
required(f.leaseType);
required(f.startDate);
required(f.endDate);
validate(f.startDate, ({ value }) =>
!value() || this.dateFilter(value())
? null
: {
kind: 'occupied-period',
message: 'Cette date est comprise dans la période d’un bail déjà finalisé.',
},
);
required(f.monthlyRent);
min(f.monthlyRent, 1);
required(f.monthlyCharges);
required(f.securityDeposit);
required(f.dueDay);
min(f.dueDay, 1);
max(f.dueDay, 28);
});
readonly guarantors = signal<Guarantor[]>([]);
readonly error = signal('');
readonly specialClauses = signal<LeaseSpecialClause[]>([]);
readonly clauseContext = computed<LeaseClauseContext>(() =>
this.leaseModel().tenantIds.length > 1 ? 'COLOCATION' : this.leaseModel().leaseType,
);
readonly selectedEnergyRating = computed(
() =>
this.apartments().find((apartment) => apartment.id === this.leaseModel().apartmentId)
?.legalProfile?.energyRating ?? null,
);
readonly leaseDuration = computed(() => {
const { startDate, leaseType } = this.leaseModel();
const endDate = startDate ? this.calculateEndDate(startDate, leaseType) : null;
return {
endDate,
endDateLabel: endDate ? this.formatCivilDate(endDate) : 'À calculer après la date de début',
renewalLabel:
leaseType === 'STANDARD'
? "Bail d'un an, reconductible tacitement."
: 'Bail étudiant de neuf mois, non reconductible.',
};
});
constructor() {
effect(() => {
@@ -70,32 +128,31 @@ export class LeaseForm {
this.leaseModel.set({
apartmentId: lease.apartmentId,
tenantIds: lease.tenantIds,
startDate: this.fromIsoDate(lease.startDate),
endDate: this.fromIsoDate(lease.endDate),
leaseType: lease.leaseType ?? 'STANDARD',
startDate: lease.startDate,
monthlyRent: lease.monthlyRent,
monthlyCharges: lease.monthlyCharges,
securityDeposit: lease.securityDeposit,
dueDay: lease.dueDay,
chargeMethod: lease.chargeMethod,
indexFlatRate: lease.indexFlatRate,
legalTerms: lease.legalTerms ?? emptyLeaseLegalTerms(),
});
this.guarantors.set(lease.guarantors);
this.specialClauses.set(lease.specialClauses ?? []);
});
}
readonly dateFilter = (d: Date | null): boolean => {
if (!d) return false;
readonly dateFilter = (date: string): boolean => {
const aptId = this.leaseModel().apartmentId;
if (!aptId) return true;
const time = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const overlapping = this.leases().some((lease) => {
if (lease.status === 'DRAFT' || lease.apartmentId !== aptId) return false;
const [startYear, startMonth, startDay] = lease.startDate.split('-').map(Number);
const [endYear, endMonth, endDay] = lease.endDate.split('-').map(Number);
const start = new Date(startYear, startMonth - 1, startDay).getTime();
const end = new Date(endYear, endMonth - 1, endDay).getTime();
return time >= start && time <= end;
});
return !overlapping;
return !this.leases().some(
(lease) =>
lease.status !== 'DRAFT' &&
lease.apartmentId === aptId &&
date >= lease.startDate &&
date <= lease.endDate,
);
};
addGuarantor(guarantor: Guarantor): void {
@@ -111,32 +168,45 @@ export class LeaseForm {
save(): void {
if (this.saving()) return;
this.leaseForm().markAsTouched();
if (this.leaseForm().invalid()) {
if (
this.leaseForm().invalid() ||
this.legalTermsEditor()?.validate() === false ||
this.clauseEditor()?.validate() === false
) {
return;
}
const modelVal = this.leaseModel();
if (modelVal.endDate! < modelVal.startDate!) {
this.error.set('La date de fin doit être postérieure à la date de début.');
return;
}
this.submitted.emit({
apartmentId: modelVal.apartmentId,
tenantIds: modelVal.tenantIds,
guarantors: this.guarantors(),
startDate: this.toIsoDate(modelVal.startDate!),
endDate: this.toIsoDate(modelVal.endDate!),
specialClauses: this.specialClauses(),
legalTerms: normalizeLeaseLegalTerms(modelVal.legalTerms, modelVal.tenantIds.length),
leaseType: modelVal.leaseType,
startDate: modelVal.startDate,
endDate: this.leaseDuration().endDate!,
monthlyRent: modelVal.monthlyRent,
monthlyCharges: modelVal.monthlyCharges,
securityDeposit: modelVal.securityDeposit,
dueDay: modelVal.dueDay,
chargeMethod: modelVal.chargeMethod,
indexFlatRate: modelVal.chargeMethod === 'FLAT_RATE' && modelVal.indexFlatRate,
});
}
private toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
private formatCivilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
}
private fromIsoDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day);
private calculateEndDate(startDate: string, leaseType: LeaseType): string {
const [year, month, day] = startDate.split('-').map(Number);
const months = leaseType === 'STUDENT' ? 9 : 12;
const targetMonthIndex = month - 1 + months;
const targetYear = year + Math.floor(targetMonthIndex / 12);
const targetMonth = targetMonthIndex % 12;
const lastDay = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate();
const endDate = new Date(Date.UTC(targetYear, targetMonth, Math.min(day, lastDay) - 1));
return `${endDate.getUTCFullYear()}-${String(endDate.getUTCMonth() + 1).padStart(2, '0')}-${String(endDate.getUTCDate()).padStart(2, '0')}`;
}
}
@@ -0,0 +1,60 @@
:host {
display: block;
margin-top: 30px;
padding-top: 28px;
border-top: 1px solid var(--border);
}
.section-heading {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.section-heading > span {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 30px;
place-items: center;
border-radius: 10px;
background: var(--surface-accent);
color: var(--accent);
font-size: 0.7rem;
font-weight: 800;
}
.section-heading h2,
.section-heading p,
.terms-block h3 {
margin: 0;
}
.section-heading h2 {
margin-bottom: 4px;
font-size: 1rem;
}
.section-heading p {
color: var(--text-muted);
font-size: 0.8rem;
}
.terms-block {
display: grid;
gap: 12px;
padding: 16px 0;
border-top: 1px solid var(--border);
}
.terms-block h3 {
font-size: 0.9rem;
}
.full-width {
width: 100%;
}
.structured-options {
display: grid;
gap: 8px;
}
.legal-warning {
padding: 12px;
border-radius: 10px;
background: var(--surface-soft);
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.5;
}
@@ -0,0 +1,179 @@
<section aria-labelledby="legal-terms-heading">
<div class="section-heading">
<span>03</span>
<div>
<h2 id="legal-terms-heading">Conditions légales</h2>
<p>Renseignez uniquement les dispositifs applicables à ce logement et à ce bail.</p>
</div>
</div>
<div class="terms-block">
<h3>Encadrement et complément de loyer</h3>
<mat-checkbox [formField]="legalTermsForm().rentControl.evolutionCapApplicable">
Le plafonnement de l'évolution du loyer s'applique.
</mat-checkbox>
<mat-checkbox [formField]="legalTermsForm().rentControl.referenceRentApplicable">
Le loyer de référence s'applique dans la commune.
</mat-checkbox>
@if (legalTerms().rentControl.referenceRentApplicable) {
<div class="form-grid three-columns">
<mat-form-field appearance="outline">
<mat-label>Loyer de référence</mat-label>
<input
matInput
type="number"
[formField]="legalTermsForm().rentControl.baseReferenceRent"
/><span matTextSuffix>€/m²</span>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Loyer de référence majoré</mat-label>
<input
matInput
type="number"
[formField]="legalTermsForm().rentControl.increasedReferenceRent"
/><span matTextSuffix>€/m²</span>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Complément de loyer</mat-label>
<input
matInput
type="number"
[formField]="legalTermsForm().rentControl.supplement"
/><span matTextSuffix>€</span>
</mat-form-field>
</div>
@if ((legalTerms().rentControl.supplement ?? 0) > 0) {
<mat-form-field appearance="outline" class="full-width">
<mat-label>Justification du complément de loyer</mat-label>
<textarea
matInput
rows="2"
[formField]="legalTermsForm().rentControl.supplementJustification"
></textarea>
</mat-form-field>
}
}
</div>
<div class="terms-block">
<h3>Précédent loyer</h3>
<mat-checkbox [formField]="legalTermsForm().previousRent.applicable">
Le logement a été loué au cours des dix-huit derniers mois.
</mat-checkbox>
@if (legalTerms().previousRent.applicable) {
<div class="form-grid three-columns">
<mat-form-field appearance="outline">
<mat-label>Montant du précédent loyer</mat-label>
<input matInput type="number" [formField]="legalTermsForm().previousRent.amount" /><span
matTextSuffix
>€</span
>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Dernière révision du précédent loyer</mat-label>
<input
matInput
type="date"
[formField]="legalTermsForm().previousRent.lastRevisionDate"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Date du dernier versement</mat-label>
<input matInput type="date" [formField]="legalTermsForm().previousRent.lastPaymentDate" />
</mat-form-field>
</div>
<mat-checkbox [formField]="legalTermsForm().previousRent.tenantConfirmation">
Je confirme avoir communiqué ces informations au locataire.
</mat-checkbox>
}
</div>
<div class="terms-block">
<h3>Révision annuelle du loyer</h3>
<mat-checkbox [formField]="legalTermsForm().rentRevision.enabled">
Prévoir une révision annuelle selon l'IRL.
</mat-checkbox>
@if (legalTerms().rentRevision.enabled) {
@if (showIrlDpeWarning()) {
<aside class="legal-warning" role="status">
Le logement est classé {{ energyRating() }}. La révision IRL peut être interdite selon les
règles en vigueur ; son maintien sera à confirmer lors de la finalisation.
</aside>
}
<div class="form-grid three-columns">
<mat-form-field appearance="outline">
<mat-label>Date annuelle de révision</mat-label>
<input
matInput
type="date"
[formField]="legalTermsForm().rentRevision.annualRevisionDate"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Trimestre IRL de référence</mat-label>
<mat-select [formField]="legalTermsForm().rentRevision.referenceQuarter">
<mat-option [value]="null">Non renseigné</mat-option>
<mat-option [value]="1">1er trimestre</mat-option>
<mat-option [value]="2">2e trimestre</mat-option>
<mat-option [value]="3">3e trimestre</mat-option>
<mat-option [value]="4">4e trimestre</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Année IRL de référence</mat-label>
<input matInput type="number" [formField]="legalTermsForm().rentRevision.referenceYear" />
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Identifiant de la série INSEE</mat-label>
<input
matInput
type="text"
[formField]="legalTermsForm().rentRevision.seriesId"
placeholder="001515333"
/>
</mat-form-field>
</div>
}
</div>
<div class="terms-block">
<h3>Travaux et autres conditions structurées</h3>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Montant des travaux récents</mat-label>
<input matInput type="number" [formField]="legalTermsForm().recentWork.amount" /><span
matTextSuffix
>€</span
>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Description des travaux récents</mat-label>
<input matInput type="text" [formField]="legalTermsForm().recentWork.description" />
</mat-form-field>
</div>
<div class="structured-options">
<mat-checkbox [formField]="legalTermsForm().coOwnershipRulesApplicable">
Le règlement de copropriété est applicable et communiqué.
</mat-checkbox>
<mat-checkbox [formField]="legalTermsForm().priorRentalAuthorizationApplicable">
Une autorisation préalable de mise en location est applicable.
</mat-checkbox>
<mat-checkbox [formField]="legalTermsForm().primaryResidenceEasementApplicable">
Une servitude de résidence principale est applicable.
</mat-checkbox>
@if (tenantCount() > 1) {
<mat-checkbox [formField]="legalTermsForm().jointAndSeveralLiability">
Prévoir la solidarité et l'indivisibilité entre les colocataires.
</mat-checkbox>
}
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Autres conditions</mat-label>
<textarea matInput rows="3" [formField]="legalTermsForm().otherTerms"></textarea>
</mat-form-field>
</div>
@if (showValidationError() && validationMessage()) {
<div class="alert-error" role="alert">{{ validationMessage() }}</div>
}
</section>
@@ -0,0 +1,107 @@
import { Component, computed, input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FieldTree, FormField } from '@angular/forms/signals';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { EnergyRating } from '../../services/apartment.service';
import { LeaseLegalTerms } from '../../services/lease.service';
@Component({
selector: 'app-lease-legal-terms',
imports: [
FormsModule,
FormField,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
],
templateUrl: './lease-legal-terms.html',
styleUrl: './lease-legal-terms.css',
})
export class LeaseLegalTermsEditor {
readonly legalTermsForm = input.required<FieldTree<LeaseLegalTerms>>();
readonly legalTerms = input.required<LeaseLegalTerms>();
readonly tenantCount = input(0);
readonly energyRating = input<EnergyRating | null>(null);
readonly showValidationError = signal(false);
readonly validationMessage = computed(() => this.issue());
readonly showIrlDpeWarning = computed(() => {
const rating = this.energyRating();
return this.legalTerms().rentRevision.enabled && (rating === 'F' || rating === 'G');
});
validate(): boolean {
this.showValidationError.set(true);
return !this.validationMessage();
}
private issue(): string {
const terms = this.legalTerms();
const amounts = [
terms.rentControl.referenceRentApplicable ? terms.rentControl.baseReferenceRent : null,
terms.rentControl.referenceRentApplicable ? terms.rentControl.increasedReferenceRent : null,
terms.rentControl.referenceRentApplicable ? terms.rentControl.supplement : null,
terms.previousRent.applicable ? terms.previousRent.amount : null,
terms.recentWork.amount,
];
if (amounts.some((amount) => amount !== null && amount < 0)) {
return 'Les montants des conditions légales doivent être positifs ou nuls.';
}
if (
terms.rentControl.referenceRentApplicable &&
(terms.rentControl.baseReferenceRent === null ||
terms.rentControl.increasedReferenceRent === null)
) {
return 'Renseignez les loyers de référence de base et majoré.';
}
if (
terms.rentControl.referenceRentApplicable &&
terms.rentControl.baseReferenceRent !== null &&
terms.rentControl.increasedReferenceRent !== null &&
terms.rentControl.increasedReferenceRent < terms.rentControl.baseReferenceRent
) {
return 'Le loyer de référence majoré ne peut pas être inférieur au loyer de référence.';
}
if (
terms.rentControl.referenceRentApplicable &&
(terms.rentControl.supplement ?? 0) > 0 &&
!terms.rentControl.supplementJustification.trim()
) {
return 'Justifiez le complément de loyer.';
}
if (terms.previousRent.applicable) {
if (
terms.previousRent.amount === null ||
!terms.previousRent.lastRevisionDate ||
!terms.previousRent.lastPaymentDate
) {
return 'Renseignez le précédent loyer et ses dates de révision et de dernier versement.';
}
if (!terms.previousRent.tenantConfirmation) {
return 'Confirmez que le locataire a reçu les informations sur le précédent loyer.';
}
}
if (terms.rentRevision.enabled) {
if (!terms.rentRevision.annualRevisionDate) {
return 'Renseignez la date annuelle de révision du loyer.';
}
if (
terms.rentRevision.referenceQuarter === null ||
terms.rentRevision.referenceQuarter < 1 ||
terms.rentRevision.referenceQuarter > 4 ||
terms.rentRevision.referenceYear === null ||
terms.rentRevision.referenceYear <= 0 ||
!terms.rentRevision.seriesId.trim()
) {
return "Renseignez le trimestre, l'année et la série INSEE de référence.";
}
}
if ((terms.recentWork.amount ?? 0) > 0 && !terms.recentWork.description.trim()) {
return 'Décrivez les travaux récents associés au montant indiqué.';
}
return '';
}
}
@@ -62,12 +62,16 @@
[deleteError]="lease.id ? (workspace.leaseDeleteErrors()[lease.id] ?? '') : ''"
[downloading]="!!lease.id && workspace.leaseDownloading(lease.id)"
[downloadError]="lease.id ? workspace.leaseDownloadError(lease.id) : ''"
[revision]="lease.id ? workspace.leaseRevision(lease.id) : undefined"
[dossierDownloading]="!!lease.id && workspace.leaseDossierDownloading(lease.id)"
[dossierDownloadError]="
lease.id ? workspace.leaseDossierDownloadError(lease.id) : ''
"
[rentDues]="rentDuesFor(lease)"
[activating]="!!lease.id && workspace.leaseActivating(lease.id)"
[activationError]="lease.id ? workspace.leaseActivationError(lease.id) : ''"
(pdfRequested)="workspace.downloadLeasePdf($event)"
(dossierRequested)="workspace.downloadLeaseDossier($event)"
(editRequested)="openForm($event)"
(activationRequested)="workspace.activateLease($event)"
(finalizationRequested)="openFinalization($event)"
(deleteRequested)="workspace.deleteLease($event)"
/>
}
@@ -1,8 +1,9 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { LeasesPage } from './leases-page';
import { WorkspaceService } from '../../services/workspace.service';
import { Lease, RentDue } from '../../services/lease.service';
import { emptyLeaseLegalTerms, Lease, RentDue } from '../../services/lease.service';
describe('LeasesPage', () => {
const workspace = {
@@ -22,16 +23,18 @@ describe('LeasesPage', () => {
loadRentDues: vi.fn(),
saveLease: vi.fn(),
deleteLease: vi.fn(),
activateLease: vi.fn(),
downloadLeasePdf: vi.fn(),
downloadLeaseDossier: vi.fn(),
leaseSaving: vi.fn(() => false),
leaseSaveError: vi.fn(() => ''),
clearLeaseSaveError: vi.fn(),
leaseDownloading: vi.fn(() => false),
leaseDownloadError: vi.fn(() => ''),
leaseActivating: vi.fn(() => false),
leaseActivationError: vi.fn(() => ''),
leaseRevision: vi.fn(),
leaseDossierDownloading: vi.fn(() => false),
leaseDossierDownloadError: vi.fn(() => ''),
};
const router = { navigate: vi.fn().mockResolvedValue(true) };
beforeEach(() => {
vi.clearAllMocks();
@@ -41,8 +44,12 @@ describe('LeasesPage', () => {
workspace.rentDueLoadState.set({ loaded: false, loading: true, error: '' });
workspace.leases.set([]);
workspace.rentDues.set([]);
workspace.leaseRevision.mockReturnValue(undefined);
TestBed.configureTestingModule({
providers: [{ provide: WorkspaceService, useValue: workspace }],
providers: [
{ provide: WorkspaceService, useValue: workspace },
{ provide: Router, useValue: router },
],
});
});
@@ -66,6 +73,14 @@ describe('LeasesPage', () => {
expect(component.showForm()).toBe(false);
});
it('navigates a draft to its dedicated finalization page', () => {
const fixture = TestBed.createComponent(LeasesPage);
fixture.componentInstance.openFinalization('lease-1');
expect(router.navigate).toHaveBeenCalledWith(['/leases', 'lease-1', 'finalize']);
});
it('opens an existing draft for editing', () => {
const fixture = TestBed.createComponent(LeasesPage);
const draft = {
@@ -73,11 +88,17 @@ describe('LeasesPage', () => {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD' as const,
startDate: '2026-01-01',
endDate: '2026-12-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE' as const,
indexFlatRate: false,
status: 'DRAFT' as const,
};
@@ -87,6 +108,51 @@ describe('LeasesPage', () => {
expect(fixture.componentInstance.showForm()).toBe(true);
});
it('passes revision metadata to the card and starts its local PDF operation', () => {
const draft = {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD' as const,
startDate: '2026-01-01',
endDate: '2026-12-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE' as const,
indexFlatRate: false,
status: 'DRAFT' as const,
};
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
workspace.tenantLoadState.set({ loaded: true, loading: false, error: '' });
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
workspace.rentDueLoadState.set({ loaded: true, loading: false, error: '' });
workspace.leases.set([draft]);
workspace.leaseRevision.mockReturnValue({
number: 2,
templateVersion: '2026.10',
sourceFingerprint: 'source',
sha256: 'sha256',
generatedAt: '2026-07-19T10:00:00Z',
authorId: 'owner-1',
annexes: [],
warningCodes: [],
warnings: [],
});
const fixture = TestBed.createComponent(LeasesPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Révision 2 · modèle 2026.10');
fixture.nativeElement.querySelector('.card-actions button').click();
expect(workspace.downloadLeasePdf).toHaveBeenCalledWith(draft);
fixture.nativeElement.querySelector('.dossier-download').click();
expect(workspace.downloadLeaseDossier).toHaveBeenCalledWith(draft);
});
it('shows and retries an échéance load failure', () => {
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
workspace.tenantLoadState.set({ loaded: true, loading: false, error: '' });
@@ -106,5 +172,4 @@ describe('LeasesPage', () => {
fixture.nativeElement.querySelector('.load-error button').click();
expect(workspace.loadRentDues).toHaveBeenCalledOnce();
});
});
@@ -1,4 +1,5 @@
import { Component, computed, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { WorkspaceService } from '../../services/workspace.service';
@@ -14,6 +15,7 @@ import { LeaseForm } from '../lease-form/lease-form';
})
export class LeasesPage {
readonly workspace = inject(WorkspaceService);
private readonly router = inject(Router);
readonly showForm = signal(false);
readonly editingLease = signal<Lease | null>(null);
readonly rentDuesByLease = computed(() => {
@@ -71,6 +73,10 @@ export class LeasesPage {
return lease.id ? (this.rentDuesByLease().get(lease.id) ?? []) : [];
}
openFinalization(leaseId: string): void {
void this.router.navigate(['/leases', leaseId, 'finalize']);
}
retryLoad(): void {
if (!this.workspace.apartmentLoadState().loaded || this.workspace.apartmentLoadState().error)
this.workspace.loadApartments();
@@ -0,0 +1,66 @@
:host {
display: block;
}
.revision-application,
form fieldset,
form label {
display: grid;
gap: var(--space-2, 0.5rem);
min-width: 0;
}
.revision-application {
margin-block-start: var(--space-3, 0.75rem);
padding-block-start: var(--space-3, 0.75rem);
border-block-start: 1px solid var(--border, #dce5df);
}
.revision-application h5,
.revision-application p {
margin: 0;
}
.revision-application > p,
form small {
color: var(--text-muted, #687670);
font-size: 0.75rem;
}
form fieldset {
margin: 0;
padding: 0;
border: 0;
}
form label {
font-size: 0.78rem;
font-weight: 700;
}
select,
input[type='file'] {
box-sizing: border-box;
width: 100%;
min-height: 2.75rem;
max-width: 100%;
border: 1px solid var(--border-strong, #aebbb4);
border-radius: var(--radius-sm, 0.5rem);
background: var(--surface, #fff);
color: var(--text, #17211e);
font: inherit;
}
.attestation {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
font-weight: 500;
}
.operation-error {
color: var(--danger, #b42318);
}
.apply-submit {
justify-self: start;
}
@@ -0,0 +1,47 @@
<section class="revision-application" aria-label="Appliquer la proposition de révision">
<h5>Attester l’envoi</h5>
<p>
La lettre doit avoir été envoyée le {{ civilDate(revision().requestedOn) }}. Une preuve PDF peut
être conservée avec l’application. Le nouveau loyer prendra effet le
{{ civilDate(revision().effectiveOn) }}.
</p>
<form
[formRoot]="applicationForm"
[attr.aria-busy]="pending()"
(submit)="submit(); $event.preventDefault()"
>
<fieldset [disabled]="pending()">
<label>
<span>Mode d’envoi</span>
<select class="delivery-method" [formField]="applicationForm.deliveryMethod">
<option value="">Choisissez un mode</option>
@for (method of deliveryMethods; track method.value) {
<option [value]="method.value">{{ method.label }}</option>
}
</select>
</label>
<label>
<span>Preuve d’envoi PDF (facultative)</span>
<input type="file" accept="application/pdf,.pdf" (change)="selectProof($event)" />
<small>{{ proof()?.name ?? 'Aucun fichier sélectionné' }}</small>
</label>
<label class="attestation">
<input
class="delivery-confirmed"
type="checkbox"
[formField]="applicationForm.deliveryConfirmed"
/>
<span>
J’atteste avoir envoyé cette lettre selon le mode indiqué et avoir vérifié sa prise
d’effet prévue le {{ civilDate(revision().effectiveOn) }}.
</span>
</label>
@if (validationError() || error()) {
<p class="operation-error" role="alert">{{ validationError() || error() }}</p>
}
<button mat-flat-button type="submit" class="apply-submit" [disabled]="pending()">
{{ pending() ? 'Application…' : 'Appliquer la révision' }}
</button>
</fieldset>
</form>
</section>
@@ -0,0 +1,147 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { RentRevisionResponse } from '../../services/lease.service';
import { RentRevisionApplicationComponent } from './rent-revision-application';
const proposedRevision: RentRevisionResponse = {
id: 'revision-1',
leaseId: 'lease-1',
status: 'PROPOSED',
version: 0,
annualRevisionDate: '2026-07-19',
requestDeadline: '2027-07-18',
requestedOn: '2026-07-19',
effectiveOn: '2026-08-05',
seriesId: '001515333',
seriesOverridden: false,
indexSource: 'INSEE',
referenceQuarter: 2,
previousIndexYear: 2025,
newIndexYear: 2026,
previousIndex: 145.17,
newIndex: 146.68,
previousRent: 800,
rentCeiling: 808.32,
chosenRent: 805,
previousCharges: 50,
chargeCeiling: null,
chosenCharges: null,
warningOverrides: [],
letterSha256: 'letter-sha256',
proposedBy: 'owner-1',
proposedAt: '2026-07-19T12:00:00Z',
application: null,
};
describe('RentRevisionApplicationComponent', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
function createComponent() {
const fixture = TestBed.createComponent(RentRevisionApplicationComponent);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('revision', proposedRevision);
fixture.detectChanges();
return fixture;
}
it('requires the delivery method and explicit dispatch attestation', async () => {
const fixture = createComponent();
expect(fixture.nativeElement.textContent).toContain('5 août 2026');
fixture.nativeElement.querySelector('.apply-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Choisissez le mode d’envoi.');
http.expectNone((request) => request.method === 'POST');
const delivery: HTMLSelectElement = fixture.nativeElement.querySelector('.delivery-method');
delivery.value = 'EMAIL';
delivery.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement.querySelector('.apply-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Attestez l’envoi de la lettre et sa prise d’effet prévue le 5 août 2026.',
);
http.expectNone((request) => request.method === 'POST');
});
it('applies once with exact multipart fields and an optional PDF proof', async () => {
const fixture = createComponent();
const applied = vi.fn();
fixture.componentInstance.applied.subscribe(applied);
const delivery: HTMLSelectElement = fixture.nativeElement.querySelector('.delivery-method');
delivery.value = 'REGISTERED_LETTER';
delivery.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.nativeElement.querySelector('.delivery-confirmed').click();
const proof = new File(['pdf'], 'preuve-envoi.pdf', { type: 'application/pdf' });
const proofInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(proofInput, 'files', {
configurable: true,
value: { item: () => proof },
});
proofInput.dispatchEvent(new Event('change'));
fixture.detectChanges();
const submit: HTMLButtonElement = fixture.nativeElement.querySelector('.apply-submit');
submit.click();
submit.click();
fixture.detectChanges();
const request = http.expectOne('/api/leases/lease-1/rent-revisions/revision-1/apply');
const formData = request.request.body as FormData;
expect(formData.get('timeZone')).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone);
expect(formData.get('sentOn')).toBe('2026-07-19');
expect(formData.get('deliveryMethod')).toBe('REGISTERED_LETTER');
expect(formData.get('deliveryConfirmed')).toBe('true');
expect(formData.get('proof')).toBe(proof);
expect(submit.disabled).toBe(true);
expect(submit.textContent).toContain('Application…');
const revision = {
...proposedRevision,
status: 'APPLIED' as const,
version: 1,
application: {
sentOn: '2026-07-19',
deliveryMethod: 'REGISTERED_LETTER' as const,
effectiveOn: '2026-07-19',
proof: {
fileName: 'preuve-envoi.pdf',
sha256: 'proof-sha256',
authorId: 'owner-1',
uploadedAt: '2026-07-19T12:30:00Z',
},
appliedBy: 'owner-1',
appliedAt: '2026-07-19T12:30:00Z',
},
};
request.flush({ revision, rentDues: [] });
expect(applied).toHaveBeenCalledWith({ revision, rentDues: [] });
});
it('rejects a non-PDF proof before any request', () => {
const fixture = createComponent();
const file = new File(['%PDF-'], 'preuve.pdf', { type: '' });
const input: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(input, 'files', { configurable: true, value: { item: () => file } });
input.dispatchEvent(new Event('change'));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('La preuve doit être un fichier PDF.');
expect(fixture.componentInstance.proof()).toBeNull();
http.expectNone((request) => request.method === 'POST');
});
});
@@ -0,0 +1,107 @@
import { Component, DestroyRef, inject, input, output, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { finalize } from 'rxjs';
import {
ApplyRentRevisionResponse,
LeaseService,
RentRevisionDeliveryMethod,
RentRevisionResponse,
} from '../../services/lease.service';
import { httpErrorMessage } from '../../services/http-error-message';
const frenchDate = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
@Component({
selector: 'app-rent-revision-application',
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
templateUrl: './rent-revision-application.html',
styleUrl: './rent-revision-application.css',
})
export class RentRevisionApplicationComponent {
private readonly leases = inject(LeaseService);
private readonly destroyRef = inject(DestroyRef);
readonly leaseId = input.required<string>();
readonly revision = input.required<RentRevisionResponse>();
readonly applied = output<ApplyRentRevisionResponse>();
readonly model = signal({
deliveryMethod: '' as RentRevisionDeliveryMethod | '',
deliveryConfirmed: false,
});
readonly applicationForm = form(this.model);
readonly proof = signal<File | null>(null);
readonly pending = signal(false);
readonly validationError = signal('');
readonly error = signal('');
readonly deliveryMethods = [
{ value: 'REGISTERED_LETTER', label: 'Lettre recommandée' },
{ value: 'POSTAL_LETTER', label: 'Lettre postale' },
{ value: 'HAND_DELIVERY', label: 'Remise en main propre' },
{ value: 'EMAIL', label: 'Courriel' },
{ value: 'OTHER', label: 'Autre mode' },
] as const satisfies readonly { value: RentRevisionDeliveryMethod; label: string }[];
submit(): void {
if (this.pending()) return;
const model = this.model();
if (!model.deliveryMethod) {
this.validationError.set('Choisissez le mode d’envoi.');
return;
}
if (!model.deliveryConfirmed) {
this.validationError.set(
`Attestez l’envoi de la lettre et sa prise d’effet prévue le ${this.civilDate(this.revision().effectiveOn)}.`,
);
return;
}
this.pending.set(true);
this.validationError.set('');
this.error.set('');
const proof = this.proof();
this.leases
.applyRentRevision(this.leaseId(), this.revision().id, {
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
sentOn: this.revision().requestedOn,
deliveryMethod: model.deliveryMethod,
deliveryConfirmed: true,
...(proof ? { proof } : {}),
})
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.pending.set(false)),
)
.subscribe({
next: (response) => this.applied.emit(response),
error: (error: unknown) =>
this.error.set(
httpErrorMessage(error, 'Impossible d’appliquer cette révision de loyer.'),
),
});
}
selectProof(event: Event): void {
const input = event.target;
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
if (file && file.name.toLowerCase().endsWith('.pdf') && file.type === 'application/pdf') {
this.proof.set(file);
this.validationError.set('');
} else {
this.proof.set(null);
if (file) this.validationError.set('La preuve doit être un fichier PDF.');
}
if (input instanceof HTMLInputElement) input.value = '';
}
civilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
}
}
@@ -0,0 +1,124 @@
:host {
display: block;
}
.revision-preview {
display: grid;
gap: var(--space-4, 1rem);
margin-block-start: var(--space-4, 1rem);
padding-block-start: var(--space-4, 1rem);
border-block-start: 1px solid var(--border, #dce5df);
}
.revision-preview h3,
.revision-preview h4,
.revision-preview p {
margin: 0;
}
.revision-preview header p,
.source-label {
color: var(--text-muted, #687670);
font-size: 0.8rem;
}
fieldset,
.manual-indices,
.preview-result {
display: grid;
gap: var(--space-3, 0.75rem);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.advanced-options,
.manual-indices,
.preview-result {
padding: var(--space-3, 0.75rem);
border-radius: var(--radius-md, 0.8125rem);
background: var(--surface-soft, #e9efeb);
}
.advanced-options summary {
cursor: pointer;
font-weight: 700;
}
.advanced-options label,
.manual-indices label {
display: grid;
gap: var(--space-2, 0.5rem);
margin-block-start: var(--space-3, 0.75rem);
font-size: 0.8rem;
font-weight: 700;
}
select,
input[type='number'] {
box-sizing: border-box;
width: 100%;
min-height: 2.75rem;
padding-inline: var(--space-3, 0.75rem);
border: 1px solid var(--border-strong, #aebbb4);
border-radius: var(--radius-sm, 0.5rem);
background: var(--surface, #fff);
color: var(--text, #17211e);
font: inherit;
}
.attestation {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
font-weight: 500;
}
.field-grid,
.preview-result dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: var(--space-3, 0.75rem);
}
.preview-result dl {
margin: 0;
}
.preview-result dd {
margin: var(--space-1, 0.25rem) 0 0;
overflow-wrap: anywhere;
}
.preview-result dt,
.preview-warnings span {
color: var(--text-muted, #687670);
font-size: 0.72rem;
}
.formula {
padding: var(--space-2, 0.5rem);
overflow-wrap: anywhere;
font-weight: 700;
text-align: center;
}
.preview-warnings,
.operation-error {
color: var(--danger, #b42318);
}
.preview-warnings ul {
display: grid;
gap: var(--space-2, 0.5rem);
margin-block-end: 0;
padding-inline-start: 1.1rem;
}
.preview-warnings span {
display: block;
}
.preview-submit {
justify-self: start;
}
@@ -0,0 +1,157 @@
<section
class="revision-preview"
aria-label="Prévision de révision annuelle du loyer"
[attr.aria-busy]="pending()"
>
<header>
<h3>Révision annuelle du loyer</h3>
<p>
Calculez le plafond applicable avant toute décision. Aucun nouveau loyer ne sera appliqué.
</p>
</header>
<form [formRoot]="requestForm" (submit)="prepare(); $event.preventDefault()">
<fieldset [disabled]="pending()">
<details class="advanced-options">
<summary>Changer la série INSEE</summary>
<label>
<span>Nouvelle série officielle</span>
<select class="series-override" [formField]="requestForm.seriesIdOverride">
<option value="">Série prévue dans le bail</option>
<option value="001515333">Métropole — 001515333</option>
<option value="010760507">Corse — 010760507</option>
<option value="010760509">Départements d’outre-mer (article 73) — 010760509</option>
</select>
</label>
@if (requestModel().seriesIdOverride.trim()) {
<label class="attestation">
<input
class="series-confirm"
type="checkbox"
[formField]="requestForm.seriesOverrideConfirmed"
/>
<span>Je confirme remplacer la série INSEE prévue par le bail.</span>
</label>
}
</details>
@if (manualRequired()) {
<div class="manual-indices">
<h4>Indices IRL saisis manuellement</h4>
<div class="field-grid">
<label>
<span>Indice précédent</span>
<input
class="previous-index"
type="number"
step="0.01"
[formField]="requestForm.previousIndex"
/>
</label>
<label>
<span>Nouvel indice</span>
<input
class="new-index"
type="number"
step="0.01"
[formField]="requestForm.newIndex"
/>
</label>
</div>
<label class="attestation">
<input
class="manual-confirm"
type="checkbox"
[formField]="requestForm.manualIndicesConfirmed"
/>
<span>J’atteste avoir vérifié ces deux indices IRL officiels.</span>
</label>
</div>
}
@if (validationError()) {
<p class="operation-error" role="alert">{{ validationError() }}</p>
}
<button mat-flat-button type="submit" class="preview-submit" [disabled]="pending()">
{{ pending() ? 'Calcul en cours…' : preview() ? 'Recalculer' : 'Préparer la révision' }}
</button>
</fieldset>
</form>
@if (error()) {
<div class="operation-error" role="alert">
<p>{{ error() }}</p>
@if (!manualRequired()) {
<button mat-button type="button" class="retry-preview" (click)="prepare()">
Réessayer
</button>
}
</div>
}
@if (preview(); as result) {
<article class="preview-result">
<h4>Simulation de révision</h4>
<p class="source-label">Source : {{ sourceLabel(result.indexSource) }}</p>
<dl>
<div>
<dt>Date annuelle</dt>
<dd>{{ civilDate(result.annualRevisionDate) }}</dd>
</div>
<div>
<dt>Date limite de demande</dt>
<dd>{{ civilDate(result.requestDeadline) }}</dd>
</div>
<div>
<dt>Série et référence</dt>
<dd>
{{ result.seriesId }} · T{{ result.referenceQuarter }} ·
{{ result.previousIndexYear }} → {{ result.newIndexYear }}
@if (result.seriesOverridden) {
<span>Série remplacée après attestation.</span>
}
</dd>
</div>
<div>
<dt>Plafond du nouveau loyer</dt>
<dd>{{ currency(result.rentCeiling) }}</dd>
</div>
<div>
<dt>Plafond des charges</dt>
<dd>
{{ result.chargeCeiling === null ? 'Non applicable' : currency(result.chargeCeiling) }}
</dd>
</div>
</dl>
<p class="formula">
{{ currency(result.previousRent) }} × {{ index(result.newIndex) }} ÷
{{ index(result.previousIndex) }} = {{ currency(result.rentCeiling) }}
</p>
@if (result.chargeCeiling !== null) {
<p class="formula charge-formula">
{{ currency(result.previousCharges) }} × {{ index(result.newIndex) }} ÷
{{ index(result.previousIndex) }} = {{ currency(result.chargeCeiling) }}
</p>
}
@if (result.warnings.length) {
<div class="preview-warnings" role="status">
<strong>Avertissements</strong>
<ul>
@for (warning of result.warnings; track warning.code + ':' + warning.ruleVersion) {
<li>
{{ warningLabel(warning.code) }}
<span>Version de règle : {{ warning.ruleVersion }}</span>
</li>
}
</ul>
</div>
}
</article>
}
<app-rent-revision-proposals
[leaseId]="leaseId()"
[preview]="preview()"
[previewRequest]="lastSuccessfulRequest()"
/>
</section>
@@ -0,0 +1,217 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { RentRevisionPreview } from '../../services/lease.service';
import { WorkspaceService } from '../../services/workspace.service';
import { RentRevisionPreviewComponent } from './rent-revision-preview';
const previewResponse = (overrides: Partial<RentRevisionPreview> = {}): RentRevisionPreview => ({
leaseId: 'lease-1',
annualRevisionDate: '2026-07-19',
requestDeadline: '2027-07-18',
seriesId: '001515333',
seriesOverridden: false,
indexSource: 'INSEE',
referenceQuarter: 2,
previousIndexYear: 2025,
newIndexYear: 2026,
previousIndex: 145.17,
newIndex: 146.68,
previousRent: 800,
rentCeiling: 808.32,
previousCharges: 50,
chargeCeiling: null,
warnings: [],
...overrides,
});
describe('RentRevisionPreviewComponent', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: WorkspaceService, useValue: { acceptRentRevisionApplication: vi.fn() } },
],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
function createComponent() {
const fixture = TestBed.createComponent(RentRevisionPreviewComponent);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.detectChanges();
http.expectOne('/api/leases/lease-1/rent-revisions').flush([]);
fixture.detectChanges();
return fixture;
}
it('prepares once in the user IANA timezone with truthful local progress', () => {
const fixture = createComponent();
const submit: HTMLButtonElement = fixture.nativeElement.querySelector('.preview-submit');
expect(fixture.nativeElement.querySelector('[id]')).toBeNull();
submit.click();
submit.click();
fixture.detectChanges();
const request = http.expectOne('/api/leases/lease-1/rent-revisions/preview');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
expect(submit.disabled).toBe(true);
expect(submit.textContent).toContain('Calcul en cours…');
request.flush(previewResponse());
});
it('shows the source, civil dates, formula, ceilings, and versioned French warnings', () => {
const fixture = createComponent();
fixture.nativeElement.querySelector('.preview-submit').click();
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush(
previewResponse({
indexSource: 'CACHE',
chargeCeiling: 51,
warnings: [{ code: 'IRL_REVISION_WITH_F_OR_G_DPE', ruleVersion: '2026-07-01' }],
}),
);
fixture.detectChanges();
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('Cache local des indices officiels');
expect(text).toContain('19 juillet 2026');
expect(text).toContain('18 juillet 2027');
expect(text).toContain('800,00 € × 146,68 ÷ 145,17');
expect(text).toContain('50,00 € × 146,68 ÷ 145,17 = 51,00 €');
expect(text).toContain('808,32 €');
expect(text).toContain('51,00 €');
expect(text).toContain('Le logement est classé F ou G');
expect(text).toContain('Version de règle : 2026-07-01');
expect(text).not.toContain('IRL_REVISION_WITH_F_OR_G_DPE');
});
it('reveals and confirms manual indices only for the stable unavailable code', () => {
const fixture = createComponent();
fixture.nativeElement.querySelector('.preview-submit').click();
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush(
{
code: 'IRL_INDEX_UNAVAILABLE',
message:
'Les indices IRL officiels sont indisponibles ; saisissez-les manuellement après confirmation.',
},
{ status: 503, statusText: 'Service Unavailable' },
);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.previous-index')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('indices IRL officiels sont indisponibles');
const previous: HTMLInputElement = fixture.nativeElement.querySelector('.previous-index');
const next: HTMLInputElement = fixture.nativeElement.querySelector('.new-index');
expect(previous.min).toBe('0.01');
expect(next.min).toBe('0.01');
previous.value = '145.17';
previous.dispatchEvent(new Event('input'));
next.value = '146.68';
next.dispatchEvent(new Event('input'));
fixture.detectChanges();
fixture.nativeElement.querySelector('.preview-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Confirmez la saisie manuelle des indices IRL.',
);
http.expectNone('/api/leases/lease-1/rent-revisions/preview');
fixture.nativeElement.querySelector('.manual-confirm').click();
fixture.detectChanges();
fixture.nativeElement.querySelector('.preview-submit').click();
const manual = http.expectOne('/api/leases/lease-1/rent-revisions/preview');
expect(manual.request.body).toEqual({
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
previousIndex: 145.17,
newIndex: 146.68,
manualIndicesConfirmed: true,
});
manual.flush(previewResponse({ indexSource: 'MANUAL' }));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Indices saisis manuellement et attestés');
});
it('requires an attestation before overriding the INSEE series', async () => {
const fixture = createComponent();
const series: HTMLSelectElement = fixture.nativeElement.querySelector('.series-override');
expect([...series.options].map((option) => option.value)).toEqual([
'',
'001515333',
'010760507',
'010760509',
]);
expect(series.textContent).toContain('Métropole — 001515333');
expect(series.textContent).toContain('Corse — 010760507');
expect(series.textContent).toContain('Départements d’outre-mer (article 73) — 010760509');
series.value = '001515333';
series.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement.querySelector('.preview-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Confirmez le remplacement de la série INSEE.',
);
http.expectNone('/api/leases/lease-1/rent-revisions/preview');
fixture.nativeElement.querySelector('.series-confirm').click();
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement.querySelector('.preview-submit').click();
const request = http.expectOne('/api/leases/lease-1/rent-revisions/preview');
expect(request.request.body).toEqual({
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
seriesIdOverride: '001515333',
seriesOverrideConfirmed: true,
});
request.flush(previewResponse({ seriesOverridden: true }));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Série remplacée après attestation.');
});
it('shows a retryable generic failure without revealing manual inputs', () => {
const fixture = createComponent();
fixture.nativeElement.querySelector('.preview-submit').click();
http
.expectOne('/api/leases/lease-1/rent-revisions/preview')
.flush({ code: 'OTHER_UNAVAILABLE' }, { status: 503, statusText: 'Service Unavailable' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.previous-index')).toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'Impossible de préparer la révision du loyer.',
);
fixture.nativeElement.querySelector('.retry-preview').click();
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush({});
});
it('does not leave a previous simulation actionable after a recalculation failure', () => {
const fixture = createComponent();
fixture.nativeElement.querySelector('.preview-submit').click();
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush(previewResponse());
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.proposal-form')).not.toBeNull();
fixture.nativeElement.querySelector('.preview-submit').click();
http
.expectOne('/api/leases/lease-1/rent-revisions/preview')
.flush({ message: 'Recalcul impossible' }, { status: 409, statusText: 'Conflict' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.preview-result')).toBeNull();
expect(fixture.nativeElement.querySelector('.proposal-form')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Recalcul impossible');
});
});
@@ -0,0 +1,155 @@
import { Component, DestroyRef, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, min } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { finalize } from 'rxjs';
import {
LeaseService,
RentRevisionPreview,
RentRevisionPreviewRequest,
} from '../../services/lease.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { leaseWarningDescription } from '../../services/lease-warning-label';
import { RentRevisionProposalsComponent } from '../rent-revision-proposals/rent-revision-proposals';
const civilDateFormatter = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const indexFormatter = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 4 });
@Component({
selector: 'app-rent-revision-preview',
imports: [FormsModule, FormField, FormRoot, MatButtonModule, RentRevisionProposalsComponent],
templateUrl: './rent-revision-preview.html',
styleUrl: './rent-revision-preview.css',
})
export class RentRevisionPreviewComponent {
private readonly leases = inject(LeaseService);
private readonly destroyRef = inject(DestroyRef);
readonly leaseId = input.required<string>();
readonly preview = signal<RentRevisionPreview | null>(null);
readonly pending = signal(false);
readonly error = signal('');
readonly validationError = signal('');
readonly manualRequired = signal(false);
readonly lastSuccessfulRequest = signal<RentRevisionPreviewRequest | null>(null);
readonly requestModel = signal({
seriesIdOverride: '',
seriesOverrideConfirmed: false,
previousIndex: null as number | null,
newIndex: null as number | null,
manualIndicesConfirmed: false,
});
readonly requestForm = form(this.requestModel, (fields) => {
min(fields.previousIndex, 0.01);
min(fields.newIndex, 0.01);
});
prepare(): void {
if (this.pending()) return;
const request = this.buildRequest();
if (!request) return;
this.preview.set(null);
this.lastSuccessfulRequest.set(null);
this.pending.set(true);
this.error.set('');
this.validationError.set('');
this.leases
.previewRentRevision(this.leaseId(), request)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.pending.set(false)),
)
.subscribe({
next: (preview) => {
this.preview.set(preview);
this.lastSuccessfulRequest.set(request);
this.manualRequired.set(preview.indexSource === 'MANUAL');
},
error: (error: unknown) => {
if (this.isIndexUnavailable(error)) this.manualRequired.set(true);
this.error.set(httpErrorMessage(error, 'Impossible de préparer la révision du loyer.'));
},
});
}
civilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return civilDateFormatter.format(new Date(Date.UTC(year, month - 1, day)));
}
currency(value: number): string {
return currencyFormatter.format(value);
}
index(value: number): string {
return indexFormatter.format(value);
}
sourceLabel(source: RentRevisionPreview['indexSource']): string {
return {
INSEE: 'Indices officiels INSEE',
CACHE: 'Cache local des indices officiels',
MANUAL: 'Indices saisis manuellement et attestés',
}[source];
}
warningLabel(code: string): string {
return leaseWarningDescription(code);
}
private buildRequest(): RentRevisionPreviewRequest | null {
const model = this.requestModel();
const seriesIdOverride = model.seriesIdOverride.trim();
const previousIndex = model.previousIndex;
const newIndex = model.newIndex;
if (seriesIdOverride && !model.seriesOverrideConfirmed) {
this.validationError.set('Confirmez le remplacement de la série INSEE.');
return null;
}
const baseRequest: RentRevisionPreviewRequest = {
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
...(seriesIdOverride ? { seriesIdOverride, seriesOverrideConfirmed: true } : {}),
};
if (this.manualRequired()) {
if (previousIndex === null || previousIndex <= 0 || newIndex === null || newIndex <= 0) {
this.validationError.set('Renseignez deux indices IRL strictement positifs.');
return null;
}
if (!model.manualIndicesConfirmed) {
this.validationError.set('Confirmez la saisie manuelle des indices IRL.');
return null;
}
return {
...baseRequest,
previousIndex,
newIndex,
manualIndicesConfirmed: true,
};
}
return baseRequest;
}
private isIndexUnavailable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'error' in error &&
typeof error.error === 'object' &&
error.error !== null &&
'code' in error.error &&
error.error.code === 'IRL_INDEX_UNAVAILABLE'
);
}
}
@@ -0,0 +1,110 @@
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs';
import { FileDownloadService } from '../../services/file-download.service';
import { httpErrorMessage } from '../../services/http-error-message';
import {
ApplyRentRevisionResponse,
LeaseService,
RentRevisionResponse,
} from '../../services/lease.service';
import { WorkspaceService } from '../../services/workspace.service';
@Injectable()
export class RentRevisionHistoryState {
private readonly leases = inject(LeaseService);
private readonly workspace = inject(WorkspaceService);
private readonly downloads = inject(FileDownloadService);
private readonly destroyRef = inject(DestroyRef);
private requestId = 0;
readonly revisions = signal<RentRevisionResponse[]>([]);
readonly loading = signal(true);
readonly error = signal('');
load(leaseId: string): void {
const requestId = ++this.requestId;
this.loading.set(true);
this.error.set('');
this.leases
.getRentRevisions(leaseId)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
if (requestId === this.requestId) this.loading.set(false);
}),
)
.subscribe({
next: (revisions) => {
if (requestId === this.requestId) this.revisions.set(revisions);
},
error: (error: unknown) => {
if (requestId === this.requestId) {
this.error.set(
httpErrorMessage(error, 'Impossible de charger les révisions de loyer.'),
);
}
},
});
}
prepend(revision: RentRevisionResponse): void {
this.revisions.update((revisions) => [
revision,
...revisions.filter(({ id }) => id !== revision.id),
]);
}
downloadLetter(leaseId: string, revision: RentRevisionResponse): void {
this.downloads.download(
this.letterKey(revision.id),
this.leases.downloadRentRevisionLetter(leaseId, revision.id),
`revision-loyer-${leaseId}-${revision.requestedOn}.pdf`,
'Impossible de télécharger cette lettre de révision.',
);
}
letterDownloading(revisionId: string): boolean {
return this.downloads.isPending(this.letterKey(revisionId));
}
letterError(revisionId: string): string {
return this.downloads.error(this.letterKey(revisionId));
}
downloadProof(leaseId: string, revision: RentRevisionResponse): void {
const proof = revision.application?.proof;
if (!proof) return;
this.downloads.download(
this.proofKey(revision.id),
this.leases.downloadRentRevisionProof(leaseId, revision.id),
proof.fileName,
'Impossible de télécharger cette preuve d’envoi.',
);
}
proofDownloading(revisionId: string): boolean {
return this.downloads.isPending(this.proofKey(revisionId));
}
proofError(revisionId: string): string {
return this.downloads.error(this.proofKey(revisionId));
}
acceptApplied(response: ApplyRentRevisionResponse): void {
this.revisions.update((revisions) =>
revisions.map((revision) =>
revision.id === response.revision.id ? response.revision : revision,
),
);
this.workspace.acceptRentRevisionApplication(response.rentDues);
}
private letterKey(revisionId: string): string {
return `rent-revision-letter:${revisionId}`;
}
private proofKey(revisionId: string): string {
return `rent-revision-proof:${revisionId}`;
}
}
@@ -0,0 +1,132 @@
:host {
display: block;
}
.rent-revision-proposals,
.proposal-form fieldset,
.revision-history {
display: grid;
gap: var(--space-3, 0.75rem);
min-width: 0;
}
.proposal-form,
.revision-history {
margin-block-start: var(--space-4, 1rem);
padding-block-start: var(--space-4, 1rem);
border-block-start: 1px solid var(--border, #dce5df);
}
.proposal-form fieldset {
margin: 0;
padding: 0;
border: 0;
}
.proposal-form h4,
.revision-history h4,
.revision-history p {
margin: 0;
}
.field-grid,
.revision-list dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: var(--space-3, 0.75rem);
}
.field-grid label,
.proposal-warnings,
.attestation {
display: grid;
gap: var(--space-2, 0.5rem);
}
.field-grid label {
font-size: 0.78rem;
font-weight: 700;
}
.field-grid input {
box-sizing: border-box;
width: 100%;
min-height: 2.75rem;
padding-inline: var(--space-3, 0.75rem);
border: 1px solid var(--border-strong, #aebbb4);
border-radius: var(--radius-sm, 0.5rem);
background: var(--surface, #fff);
color: var(--text, #17211e);
font: inherit;
}
.bounds,
.recalculation-notice,
.hash {
color: var(--text-muted, #687670);
font-size: 0.75rem;
}
.attestation {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
font-size: 0.78rem;
}
.attestation small {
display: block;
color: var(--text-muted, #687670);
}
.revision-list {
display: grid;
gap: var(--space-3, 0.75rem);
margin: 0;
padding: 0;
list-style: none;
}
.revision-list > li {
min-width: 0;
padding: var(--space-3, 0.75rem);
border-radius: var(--radius-md, 0.8125rem);
background: var(--surface-soft, #e9efeb);
}
.revision-list header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-2, 0.5rem);
}
.revision-list dl {
margin-block: var(--space-3, 0.75rem);
}
.revision-list dd {
margin: var(--space-1, 0.25rem) 0 0;
}
.proof-download {
display: grid;
justify-items: start;
gap: var(--space-1, 0.25rem);
}
.revision-list dt {
color: var(--text-muted, #687670);
font-size: 0.7rem;
}
.hash {
overflow-wrap: anywhere;
}
.operation-error {
color: var(--danger, #b42318);
}
.proposal-submit {
justify-self: start;
}
@@ -0,0 +1,197 @@
<section class="rent-revision-proposals" aria-label="Propositions de révision du loyer">
@if (preview(); as currentPreview) {
@if (previewRevisionApplied()) {
<p class="window-notice applied-anniversary" role="status">
Une révision a déjà été appliquée pour cet anniversaire.
</p>
} @else if (history.loading() || history.error()) {
<!-- L’historique doit confirmer qu’aucune révision n’est déjà appliquée. -->
} @else if (proposalWindowIssue()) {
<p class="window-notice" role="status">{{ proposalWindowIssue() }}</p>
} @else {
<form
class="proposal-form"
[formRoot]="proposalForm"
[attr.aria-busy]="creating()"
(submit)="createProposal(); $event.preventDefault()"
>
<fieldset [disabled]="creating()">
<h4>Créer la proposition et sa lettre</h4>
<div class="field-grid">
<label>
<span>Date de la demande</span>
<input type="date" class="requested-on" [formField]="proposalForm.requestedOn" />
</label>
<label>
<span>Nouveau loyer choisi</span>
<input
type="number"
step="0.01"
class="chosen-rent"
[formField]="proposalForm.chosenRent"
/>
</label>
@if (currentPreview.chargeCeiling !== null) {
<label>
<span>Nouvelles charges choisies</span>
<input
type="number"
step="0.01"
class="chosen-charges"
[formField]="proposalForm.chosenCharges"
/>
</label>
}
</div>
<p class="bounds">
Loyer autorisé : {{ currency(currentPreview.previousRent) }} à
{{ currency(currentPreview.rentCeiling) }}.
</p>
@if (currentPreview.warnings.length) {
<div class="proposal-warnings">
<strong>Avertissements à confirmer</strong>
@for (
warning of currentPreview.warnings;
track warning.code + ':' + warning.ruleVersion
) {
<label class="attestation">
<input
type="checkbox"
class="proposal-warning"
[checked]="confirmedWarningCodes().has(warning.code)"
(change)="toggleWarning(warning.code, $event)"
/>
<span>
{{ warningLabel(warning.code) }}
<small>Version de règle : {{ warning.ruleVersion }}</small>
</span>
</label>
}
</div>
}
<p class="recalculation-notice">
Le serveur recalculera les indices et plafonds avant de figer la lettre.
</p>
@if (validationError() || createError()) {
<p class="operation-error" role="alert">{{ validationError() || createError() }}</p>
}
<button mat-flat-button type="submit" class="proposal-submit" [disabled]="creating()">
{{ creating() ? 'Création…' : 'Créer la proposition' }}
</button>
</fieldset>
</form>
}
}
<section
class="revision-history"
aria-label="Historique des révisions"
[attr.aria-busy]="history.loading()"
>
<h4>Historique</h4>
@if (history.loading()) {
<p role="status">Chargement des révisions…</p>
} @else if (history.error()) {
<div class="operation-error" role="alert">
<p>{{ history.error() }}</p>
<button mat-button type="button" (click)="history.load(leaseId())">Réessayer</button>
</div>
} @else if (history.revisions().length === 0) {
<p>Aucune proposition de révision enregistrée.</p>
} @else {
<ul class="revision-list">
@for (revision of history.revisions(); track revision.id) {
<li>
<header>
<strong>{{ statusLabel(revision.status) }}</strong>
<span>{{ currency(revision.chosenRent) }}</span>
</header>
<dl>
<div>
<dt>Demandée le</dt>
<dd>{{ civilDate(revision.requestedOn) }}</dd>
</div>
<div>
<dt>Prise d’effet prévue</dt>
<dd>{{ civilDate(revision.effectiveOn) }}</dd>
</div>
<div>
<dt>Ancien loyer</dt>
<dd>{{ currency(revision.previousRent) }}</dd>
</div>
@if (revision.chosenCharges !== null) {
<div>
<dt>Charges choisies</dt>
<dd>{{ currency(revision.chosenCharges) }}</dd>
</div>
}
</dl>
<p class="hash">Empreinte de la lettre : {{ revision.letterSha256 }}</p>
<button
mat-button
type="button"
class="download-letter"
[disabled]="history.letterDownloading(revision.id)"
(click)="history.downloadLetter(leaseId(), revision)"
>
{{
history.letterDownloading(revision.id)
? 'Téléchargement…'
: 'Télécharger la lettre PDF'
}}
</button>
@if (history.letterError(revision.id)) {
<p class="operation-error" role="alert">{{ history.letterError(revision.id) }}</p>
}
@if (
revision.status === 'PROPOSED' && !hasAppliedRevision(revision.annualRevisionDate)
) {
<app-rent-revision-application
[leaseId]="leaseId()"
[revision]="revision"
(applied)="history.acceptApplied($event)"
/>
} @else if (revision.application; as application) {
<dl class="application-summary">
<div>
<dt>Envoyée par</dt>
<dd>{{ deliveryLabel(application.deliveryMethod) }}</dd>
</div>
<div>
<dt>Prise d’effet le</dt>
<dd>{{ civilDate(application.effectiveOn) }}</dd>
</div>
@if (application.proof) {
<div>
<dt>Preuve conservée</dt>
<dd class="proof-download">
<span>{{ application.proof.fileName }}</span>
<button
mat-button
type="button"
class="download-proof"
[disabled]="history.proofDownloading(revision.id)"
(click)="history.downloadProof(leaseId(), revision)"
>
{{
history.proofDownloading(revision.id)
? 'Téléchargement…'
: 'Télécharger la preuve'
}}
</button>
@if (history.proofError(revision.id)) {
<small class="operation-error" role="alert">
{{ history.proofError(revision.id) }}
</small>
}
</dd>
</div>
}
</dl>
}
</li>
}
</ul>
}
</section>
</section>
@@ -0,0 +1,288 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import {
RentRevisionPreview,
RentRevisionPreviewRequest,
RentRevisionResponse,
} from '../../services/lease.service';
import { WorkspaceService } from '../../services/workspace.service';
import { RentRevisionProposalsComponent } from './rent-revision-proposals';
function civilToday(): string {
const parts = new Intl.DateTimeFormat('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date());
const part = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((candidate) => candidate.type === type)?.value ?? '';
return `${part('year')}-${part('month')}-${part('day')}`;
}
const preview: RentRevisionPreview = {
leaseId: 'lease-1',
annualRevisionDate: civilToday(),
requestDeadline: '2099-12-31',
seriesId: '001515333',
seriesOverridden: false,
indexSource: 'INSEE',
referenceQuarter: 2,
previousIndexYear: 2025,
newIndexYear: 2026,
previousIndex: 145.17,
newIndex: 146.68,
previousRent: 800,
rentCeiling: 808.32,
previousCharges: 50,
chargeCeiling: 51,
warnings: [{ code: 'IRL_REVISION_WITH_F_OR_G_DPE', ruleVersion: '2026-07-01' }],
};
const previewRequest: RentRevisionPreviewRequest = {
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
const proposedRevision: RentRevisionResponse = {
id: 'revision-1',
leaseId: 'lease-1',
status: 'PROPOSED',
version: 0,
annualRevisionDate: preview.annualRevisionDate,
requestDeadline: preview.requestDeadline,
requestedOn: civilToday(),
effectiveOn: '2026-08-05',
seriesId: preview.seriesId,
seriesOverridden: false,
indexSource: 'INSEE',
referenceQuarter: 2,
previousIndexYear: 2025,
newIndexYear: 2026,
previousIndex: 145.17,
newIndex: 146.68,
previousRent: 800,
rentCeiling: 808.32,
chosenRent: 805,
previousCharges: 50,
chargeCeiling: 51,
chosenCharges: 50.5,
warningOverrides: [
{
code: 'IRL_REVISION_WITH_F_OR_G_DPE',
ruleVersion: '2026-07-01',
authorId: 'owner-1',
confirmedAt: '2026-07-19T12:00:00Z',
},
],
letterSha256: 'letter-sha256',
proposedBy: 'owner-1',
proposedAt: '2026-07-19T12:00:00Z',
application: null,
};
describe('RentRevisionProposalsComponent', () => {
const workspace = { acceptRentRevisionApplication: vi.fn() };
let http: HttpTestingController;
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: WorkspaceService, useValue: workspace },
],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => {
http.verify();
vi.unstubAllGlobals();
});
function createComponent(withPreview = true) {
const fixture = TestBed.createComponent(RentRevisionProposalsComponent);
fixture.componentRef.setInput('leaseId', 'lease-1');
if (withPreview) {
fixture.componentRef.setInput('preview', preview);
fixture.componentRef.setInput('previewRequest', previewRequest);
}
fixture.detectChanges();
return fixture;
}
it('loads persisted proposals truthfully and exposes their frozen letter', () => {
const fixture = createComponent(false);
expect(fixture.nativeElement.querySelector('.revision-history').getAttribute('aria-busy')).toBe(
'true',
);
expect(fixture.nativeElement.textContent).not.toContain('Aucune proposition');
http.expectOne('/api/leases/lease-1/rent-revisions').flush([proposedRevision]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Proposition à envoyer');
expect(fixture.nativeElement.textContent.replace(/\s+/g, ' ')).toContain('805,00 €');
expect(fixture.nativeElement.textContent).toContain('Prise d’effet prévue');
expect(fixture.nativeElement.textContent).toContain('5 août 2026');
expect(fixture.nativeElement.textContent).toContain('letter-sha256');
expect(fixture.nativeElement.querySelector('.download-letter')).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-rent-revision-application')).not.toBeNull();
});
it('creates once with bounded choices and every warning explicitly confirmed', () => {
const fixture = createComponent();
http.expectOne('/api/leases/lease-1/rent-revisions').flush([]);
fixture.detectChanges();
fixture.nativeElement.querySelector('.proposal-warning').click();
fixture.detectChanges();
const submit: HTMLButtonElement = fixture.nativeElement.querySelector('.proposal-submit');
submit.click();
submit.click();
fixture.detectChanges();
const request = http.expectOne('/api/leases/lease-1/rent-revisions');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
timeZone: previewRequest.timeZone,
requestedOn: civilToday(),
chosenRent: 808.32,
chosenCharges: 51,
confirmedWarningCodes: ['IRL_REVISION_WITH_F_OR_G_DPE'],
});
expect(submit.disabled).toBe(true);
expect(submit.textContent).toContain('Création…');
request.flush(proposedRevision);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Proposition à envoyer');
});
it('rejects a rent over the preview ceiling before any proposal request', () => {
const fixture = createComponent();
http.expectOne('/api/leases/lease-1/rent-revisions').flush([]);
fixture.detectChanges();
const rent: HTMLInputElement = fixture.nativeElement.querySelector('.chosen-rent');
rent.value = '900';
rent.dispatchEvent(new Event('input'));
fixture.nativeElement.querySelector('.proposal-warning').click();
fixture.detectChanges();
fixture.nativeElement.querySelector('.proposal-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent.replace(/\s+/g, ' ')).toContain(
'Le loyer choisi doit rester compris entre 800,00 € et 808,32 €.',
);
http.expectNone((request) => request.method === 'POST');
});
it('downloads one frozen letter with operation-local progress', () => {
const fixture = createComponent(false);
http.expectOne('/api/leases/lease-1/rent-revisions').flush([proposedRevision]);
fixture.detectChanges();
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:letter'),
revokeObjectURL: vi.fn(),
});
const download: HTMLButtonElement = fixture.nativeElement.querySelector('.download-letter');
download.click();
download.click();
fixture.detectChanges();
const request = http.expectOne('/api/leases/lease-1/rent-revisions/revision-1/letter');
expect(download.disabled).toBe(true);
expect(download.textContent).toContain('Téléchargement…');
request.flush(new Blob(['pdf'], { type: 'application/pdf' }));
expect(URL.createObjectURL).toHaveBeenCalledOnce();
});
it('keeps a stored proof download failure local to that proof', async () => {
const appliedRevision: RentRevisionResponse = {
...proposedRevision,
status: 'APPLIED',
application: {
sentOn: proposedRevision.requestedOn,
deliveryMethod: 'EMAIL',
effectiveOn: proposedRevision.effectiveOn,
proof: {
fileName: 'preuve-envoi.pdf',
sha256: 'proof-sha256',
authorId: 'owner-1',
uploadedAt: '2026-07-19T12:30:00Z',
},
appliedBy: 'owner-1',
appliedAt: '2026-07-19T12:30:00Z',
},
};
const fixture = createComponent(false);
http.expectOne('/api/leases/lease-1/rent-revisions').flush([appliedRevision]);
fixture.detectChanges();
const download: HTMLButtonElement = fixture.nativeElement.querySelector('.download-proof');
download.click();
download.click();
fixture.detectChanges();
const request = http.expectOne('/api/leases/lease-1/rent-revisions/revision-1/proof');
expect(download.disabled).toBe(true);
expect(download.textContent).toContain('Téléchargement…');
request.flush(
new Blob([JSON.stringify({ message: 'Preuve temporairement indisponible' })], {
type: 'application/json',
}),
{ status: 503, statusText: 'Service Unavailable' },
);
await vi.waitFor(() => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Preuve temporairement indisponible');
});
expect(fixture.nativeElement.textContent).not.toContain(
'Impossible de télécharger cette lettre de révision.',
);
});
it('publishes an applied revision, merges its dues, and blocks the same anniversary', async () => {
const competingProposal = { ...proposedRevision, id: 'revision-2' };
const fixture = createComponent();
http
.expectOne('/api/leases/lease-1/rent-revisions')
.flush([proposedRevision, competingProposal]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('app-rent-revision-application')).toHaveLength(2);
expect(fixture.nativeElement.querySelector('.proposal-form')).not.toBeNull();
const delivery: HTMLSelectElement = fixture.nativeElement.querySelector('.delivery-method');
delivery.value = 'EMAIL';
delivery.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.nativeElement.querySelector('.delivery-confirmed').click();
fixture.detectChanges();
fixture.nativeElement.querySelector('.apply-submit').click();
const request = http.expectOne('/api/leases/lease-1/rent-revisions/revision-1/apply');
const revision: RentRevisionResponse = {
...proposedRevision,
status: 'APPLIED',
version: 1,
application: {
sentOn: proposedRevision.requestedOn,
deliveryMethod: 'EMAIL',
effectiveOn: proposedRevision.requestedOn,
proof: null,
appliedBy: 'owner-1',
appliedAt: '2026-07-19T12:30:00Z',
},
};
const rentDues = [{ id: 'due-1' }];
request.flush({ revision, rentDues });
fixture.detectChanges();
expect(workspace.acceptRentRevisionApplication).toHaveBeenCalledWith(rentDues);
expect(fixture.nativeElement.textContent).toContain('Révision appliquée');
expect(fixture.nativeElement.querySelectorAll('app-rent-revision-application')).toHaveLength(0);
expect(fixture.nativeElement.querySelector('.proposal-form')).toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'Une révision a déjà été appliquée pour cet anniversaire.',
);
});
});
@@ -0,0 +1,240 @@
import { Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, min } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { finalize } from 'rxjs';
import { httpErrorMessage } from '../../services/http-error-message';
import {
LeaseService,
RentRevisionDeliveryMethod,
RentRevisionPreview,
RentRevisionPreviewRequest,
RentRevisionProposalRequest,
RentRevisionResponse,
} from '../../services/lease.service';
import { leaseWarningDescription } from '../../services/lease-warning-label';
import { RentRevisionApplicationComponent } from '../rent-revision-application/rent-revision-application';
import { RentRevisionHistoryState } from './rent-revision-history.state';
const frenchDate = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
const euro = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
@Component({
selector: 'app-rent-revision-proposals',
imports: [FormsModule, FormField, FormRoot, MatButtonModule, RentRevisionApplicationComponent],
templateUrl: './rent-revision-proposals.html',
styleUrl: './rent-revision-proposals.css',
providers: [RentRevisionHistoryState],
})
export class RentRevisionProposalsComponent {
private readonly leases = inject(LeaseService);
private readonly destroyRef = inject(DestroyRef);
readonly history = inject(RentRevisionHistoryState);
readonly leaseId = input.required<string>();
readonly preview = input<RentRevisionPreview | null>(null);
readonly previewRequest = input<RentRevisionPreviewRequest | null>(null);
readonly creating = signal(false);
readonly createError = signal('');
readonly validationError = signal('');
readonly confirmedWarningCodes = signal<ReadonlySet<string>>(new Set());
readonly proposalModel = signal({
requestedOn: '',
chosenRent: null as number | null,
chosenCharges: null as number | null,
});
readonly proposalForm = form(this.proposalModel, (fields) => {
min(fields.chosenRent, 0.01);
min(fields.chosenCharges, 0.01);
});
readonly previewRevisionApplied = computed(() => {
const preview = this.preview();
return preview ? this.hasAppliedRevision(preview.annualRevisionDate) : false;
});
readonly proposalWindowIssue = computed(() => {
const preview = this.preview();
const request = this.previewRequest();
if (!preview || !request) return '';
const today = this.today(request.timeZone);
if (today < preview.annualRevisionDate) {
return `La proposition pourra être créée à partir du ${this.civilDate(preview.annualRevisionDate)}.`;
}
if (today > preview.requestDeadline) {
return `La date limite du ${this.civilDate(preview.requestDeadline)} est dépassée.`;
}
return '';
});
constructor() {
effect(() => this.history.load(this.leaseId()));
effect(() => {
const preview = this.preview();
const request = this.previewRequest();
if (!preview || !request) return;
this.proposalModel.set({
requestedOn: this.today(request.timeZone),
chosenRent: preview.rentCeiling,
chosenCharges: preview.chargeCeiling,
});
this.confirmedWarningCodes.set(new Set());
this.validationError.set('');
this.createError.set('');
});
}
createProposal(): void {
if (this.creating()) return;
const preview = this.preview();
const previewRequest = this.previewRequest();
if (
!preview ||
!previewRequest ||
this.history.loading() ||
this.history.error() ||
this.previewRevisionApplied() ||
this.proposalWindowIssue()
)
return;
const model = this.proposalModel();
const validationError = this.validateProposal(preview, previewRequest, model);
if (validationError) {
this.validationError.set(validationError);
return;
}
if (model.chosenRent === null) return;
const confirmedWarningCodes = preview.warnings.map(({ code }) => code);
const request: RentRevisionProposalRequest = {
...previewRequest,
requestedOn: model.requestedOn,
chosenRent: model.chosenRent,
confirmedWarningCodes,
};
if (preview.chargeCeiling !== null) {
if (model.chosenCharges === null) return;
request.chosenCharges = model.chosenCharges;
}
this.creating.set(true);
this.validationError.set('');
this.createError.set('');
this.leases
.createRentRevision(this.leaseId(), request)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.creating.set(false)),
)
.subscribe({
next: (revision) => this.history.prepend(revision),
error: (error: unknown) =>
this.createError.set(
httpErrorMessage(error, 'Impossible de créer cette proposition de révision.'),
),
});
}
setWarning(code: string, checked: boolean): void {
this.confirmedWarningCodes.update((codes) => {
const next = new Set(codes);
if (checked) next.add(code);
else next.delete(code);
return next;
});
}
toggleWarning(code: string, event: Event): void {
const input = event.target;
this.setWarning(code, input instanceof HTMLInputElement && input.checked);
}
statusLabel(status: RentRevisionResponse['status']): string {
return status === 'PROPOSED' ? 'Proposition à envoyer' : 'Révision appliquée';
}
hasAppliedRevision(annualRevisionDate: string): boolean {
return this.history
.revisions()
.some(
(revision) =>
revision.annualRevisionDate === annualRevisionDate && revision.status === 'APPLIED',
);
}
deliveryLabel(method: RentRevisionDeliveryMethod): string {
return {
REGISTERED_LETTER: 'Lettre recommandée',
POSTAL_LETTER: 'Lettre postale',
HAND_DELIVERY: 'Remise en main propre',
EMAIL: 'Courriel',
OTHER: 'Autre mode',
}[method];
}
civilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
}
currency(value: number): string {
return euro.format(value);
}
warningLabel(code: string): string {
return leaseWarningDescription(code);
}
private validateProposal(
preview: RentRevisionPreview,
request: RentRevisionPreviewRequest,
model: { requestedOn: string; chosenRent: number | null; chosenCharges: number | null },
): string {
const today = this.today(request.timeZone);
if (
!model.requestedOn ||
model.requestedOn < preview.annualRevisionDate ||
model.requestedOn > preview.requestDeadline ||
model.requestedOn > today
) {
return 'Choisissez une date de demande comprise dans la période autorisée.';
}
if (
model.chosenRent === null ||
model.chosenRent < preview.previousRent ||
model.chosenRent > preview.rentCeiling
) {
return `Le loyer choisi doit rester compris entre ${this.currency(preview.previousRent)} et ${this.currency(preview.rentCeiling)}.`;
}
if (
preview.chargeCeiling !== null &&
(model.chosenCharges === null ||
model.chosenCharges < preview.previousCharges ||
model.chosenCharges > preview.chargeCeiling)
) {
return `Les charges choisies doivent rester comprises entre ${this.currency(preview.previousCharges)} et ${this.currency(preview.chargeCeiling)}.`;
}
return preview.warnings.some(({ code }) => !this.confirmedWarningCodes().has(code))
? 'Confirmez chaque avertissement avant de créer la proposition.'
: '';
}
private today(timeZone: string): string {
const parts = new Intl.DateTimeFormat('fr-FR', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date());
const value = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return `${value('year')}-${value('month')}-${value('day')}`;
}
}