feat: finalize lease documents and IRL revisions

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

No files matched your search

@@ -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(),
};
}
}