feat: finalize lease documents and IRL revisions
This commit is contained in:
1 parent
f127c69a28
commit
1bf6a3449c
196 files changed
+20564
-666
No files matched your search
+87
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+115
@@ -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);
|
||||
}
|
||||
+123
@@ -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>
|
||||
+323
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+180
@@ -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>
|
||||
+18
@@ -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>
|
||||
+147
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user