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
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user