From 41590a6a784bf746b9e4286cd39bd90a3b2c15a3 Mon Sep 17 00:00:00 2001 From: Nessar Date: Fri, 24 Jul 2026 19:25:46 +0200 Subject: [PATCH] Align nested lease validation flows (#50) --- .../adapter/in/web/lease/LeaseController.java | 33 +-- .../lease/LeaseNestedRequestValidator.java | 196 ++++++++++++++++++ .../application/lease/LeaseClauseCatalog.java | 5 + .../LeaseNestedRequestValidatorTest.java | 91 ++++++++ .../guarantor-form/guarantor-form.html | 45 +++- .../guarantor-form/guarantor-form.spec.ts | 28 +++ .../guarantor-form/guarantor-form.ts | 85 +++++++- .../lease-clause-editor.html | 26 ++- .../lease-clause-editor.spec.ts | 18 ++ .../lease-clause-editor.ts | 95 ++++++++- .../app/components/lease-form/lease-form.html | 16 +- .../app/components/lease-form/lease-form.ts | 19 +- .../lease-legal-terms/lease-legal-terms.html | 181 ++++++++++++++-- .../lease-legal-terms/lease-legal-terms.ts | 107 +++++++++- 14 files changed, 886 insertions(+), 59 deletions(-) create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidator.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidatorTest.java diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java index 4bf00cf..28a0507 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java @@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord; import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService; import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService; +import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog; import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService; import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId; @@ -45,18 +46,21 @@ private final AmendLeaseService amendments; private final GenerateLeaseDocumentService documents; private final AuthenticatedLandlord authenticatedLandlord; private final Clock clock; +private final LeaseNestedRequestValidator nestedValidator; public LeaseController( LeaseDraftService drafts, AmendLeaseService amendments, GenerateLeaseDocumentService documents, AuthenticatedLandlord authenticatedLandlord, - Clock clock) { + Clock clock, + LeaseClauseCatalog clauseCatalog) { this.drafts = drafts; this.amendments = amendments; this.documents = documents; this.authenticatedLandlord = authenticatedLandlord; this.clock = clock; + this.nestedValidator = new LeaseNestedRequestValidator(clauseCatalog); } @GetMapping @@ -73,6 +77,7 @@ LeaseApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable ResponseEntity create( @AuthenticationPrincipal OidcUser principal, @Valid @RequestBody LeaseApi.DraftRequest request) { + nestedValidator.validate(request); var created = drafts.create(authenticatedLandlord.id(principal), details(request)); return ResponseEntity.status(HttpStatus.CREATED).body(response(created)); } @@ -82,6 +87,7 @@ LeaseApi.Response revise( @AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @Valid @RequestBody LeaseApi.DraftRequest request) { + nestedValidator.validate(request); return response( drafts.revise(authenticatedLandlord.id(principal), new LeaseId(leaseId), details(request))); } @@ -123,13 +129,10 @@ ResponseEntity amend( } @ExceptionHandler(LeaseRequestValidationFailure.class) -ResponseEntity invalidAmendment( +ResponseEntity invalidRequest( LeaseRequestValidationFailure failure) { return ResponseEntity.badRequest() - .body( - new LeaseRequestValidationError( - "validation_failed", - List.of(new LeaseRequestFieldError(failure.field(), failure.code())))); + .body(new LeaseRequestValidationError("validation_failed", failure.errors())); } @PostMapping("/{leaseId}/revisions") @@ -251,23 +254,21 @@ private static ResponseEntity document(GeneratedDocument document) { private record LeaseRequestValidationError(String code, List errors) {} -private record LeaseRequestFieldError(String field, String code) {} +record LeaseRequestFieldError(String field, String code) {} -private static final class LeaseRequestValidationFailure extends RuntimeException { - private final String field; - private final String code; +static final class LeaseRequestValidationFailure extends RuntimeException { + private final List errors; LeaseRequestValidationFailure(String field, String code) { - this.field = field; - this.code = code; + this(List.of(new LeaseRequestFieldError(field, code))); } - String field() { - return field; + LeaseRequestValidationFailure(List errors) { + this.errors = List.copyOf(errors); } - String code() { - return code; + List errors() { + return errors; } } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidator.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidator.java new file mode 100644 index 0000000..710a1a7 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidator.java @@ -0,0 +1,196 @@ +package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; + +import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog; +import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class LeaseNestedRequestValidator { +private final LeaseClauseCatalog clauses; + +LeaseNestedRequestValidator(LeaseClauseCatalog clauses) { + this.clauses = clauses; +} + +void validate(LeaseApi.DraftRequest request) { + List errors = new ArrayList<>(); + guarantors(request.guarantors(), errors); + clauses(request.specialClauses(), context(request), errors); + legalTerms( + request.legalTerms(), request.tenantIds() == null ? 0 : request.tenantIds().size(), errors); + if (!errors.isEmpty()) throw new LeaseController.LeaseRequestValidationFailure(errors); +} + +private static void guarantors( + List guarantors, + List errors) { + if (guarantors == null) return; + for (int index = 0; index < guarantors.size(); index++) { + if (guarantors.get(index) == null) { + errors.add(error("guarantors[" + index + "]", "not_null")); + continue; + } + try { + new PhoneNumber(guarantors.get(index).phone()); + } catch (IllegalArgumentException exception) { + errors.add(error("guarantors[" + index + "].phone", "invalid")); + } + } +} + +private void clauses( + List requests, + LeaseClauseCatalog.Context context, + List errors) { + if (requests == null) return; + for (int index = 0; index < requests.size(); index++) { + LeaseApi.SpecialClauseRequest request = requests.get(index); + String path = "specialClauses[" + index + "]"; + if (request == null) { + errors.add(error(path, "not_null")); + continue; + } + if (request.catalogId() == null || request.catalogId().isBlank()) { + if (request.parameters() != null && !request.parameters().isEmpty()) { + errors.add(error(path + ".parameters", "not_allowed")); + } + if (request.customText() == null || request.customText().isBlank()) { + errors.add(error(path + ".customText", "required")); + } + continue; + } + if (request.customText() != null && !request.customText().isBlank()) { + errors.add(error(path + ".customText", "server_defined")); + } + LeaseClauseCatalog.ClauseDefinition definition = + clauses.available(request.catalogId(), context); + if (definition == null) { + errors.add(error(path + ".catalogId", "unavailable")); + continue; + } + Map parameters = + request.parameters() == null ? Map.of() : request.parameters(); + for (String name : parameters.keySet()) { + if (definition.parameters().stream() + .noneMatch(parameter -> parameter.name().equals(name))) { + errors.add(error(path + ".parameters." + name, "unknown")); + } + } + for (LeaseClauseCatalog.ParameterDefinition parameter : definition.parameters()) { + String value = parameters.get(parameter.name()); + if (value == null || value.trim().isEmpty()) { + if (parameter.required()) + errors.add(error(path + ".parameters." + parameter.name(), "required")); + continue; + } + if (!valid(parameter, value.trim())) { + errors.add(error(path + ".parameters." + parameter.name(), "invalid")); + } + } + } +} + +private static boolean valid(LeaseClauseCatalog.ParameterDefinition parameter, String value) { + try { + return switch (parameter.type()) { + case TEXT -> true; + case NUMBER -> { + new BigDecimal(value); + yield true; + } + case DATE -> LocalDate.parse(value) != null; + case TIME -> value.length() == 5 && LocalTime.parse(value).getSecond() == 0; + case CHOICE -> + parameter.choices().stream().anyMatch(choice -> choice.value().equals(value)); + }; + } catch (RuntimeException exception) { + return false; + } +} + +private static void legalTerms( + LeaseApi.LegalTermsRequest terms, + int tenantCount, + List errors) { + if (terms == null) return; + LeaseApi.RentControlRequest rentControl = terms.rentControl(); + if (rentControl != null && Boolean.TRUE.equals(rentControl.referenceRentApplicable())) { + if (rentControl.baseReferenceRent() == null) + errors.add(error("legalTerms.rentControl.baseReferenceRent", "required")); + if (rentControl.increasedReferenceRent() == null) + errors.add(error("legalTerms.rentControl.increasedReferenceRent", "required")); + if (rentControl.baseReferenceRent() != null + && rentControl.increasedReferenceRent() != null + && rentControl.increasedReferenceRent().compareTo(rentControl.baseReferenceRent()) < 0) { + errors.add(error("legalTerms.rentControl.increasedReferenceRent", "below_base")); + } + if (positive(rentControl.supplement()) + && (rentControl.supplementJustification() == null + || rentControl.supplementJustification().isBlank())) { + errors.add(error("legalTerms.rentControl.supplementJustification", "required")); + } + } + LeaseApi.PreviousRentRequest previousRent = terms.previousRent(); + if (previousRent != null && Boolean.TRUE.equals(previousRent.applicable())) { + if (previousRent.amount() == null) + errors.add(error("legalTerms.previousRent.amount", "required")); + if (previousRent.lastRevisionDate() == null) + errors.add(error("legalTerms.previousRent.lastRevisionDate", "required")); + if (previousRent.lastPaymentDate() == null) + errors.add(error("legalTerms.previousRent.lastPaymentDate", "required")); + if (!Boolean.TRUE.equals(previousRent.tenantConfirmation())) { + errors.add(error("legalTerms.previousRent.tenantConfirmation", "required")); + } + } + LeaseApi.RentRevisionRequest revision = terms.rentRevision(); + if (revision != null && Boolean.TRUE.equals(revision.enabled())) { + if (revision.annualRevisionDay() == null) + errors.add(error("legalTerms.rentRevision.annualRevisionDay", "required")); + if (revision.annualRevisionMonth() == null) + errors.add(error("legalTerms.rentRevision.annualRevisionMonth", "required")); + if (revision.referenceQuarter() == null) + errors.add(error("legalTerms.rentRevision.referenceQuarter", "required")); + if (revision.referenceYear() == null) + errors.add(error("legalTerms.rentRevision.referenceYear", "required")); + if (revision.seriesId() == null || revision.seriesId().isBlank()) + errors.add(error("legalTerms.rentRevision.seriesId", "required")); + if (revision.annualRevisionDay() != null && revision.annualRevisionMonth() != null) { + try { + java.time.MonthDay.of(revision.annualRevisionMonth(), revision.annualRevisionDay()); + } catch (RuntimeException exception) { + errors.add(error("legalTerms.rentRevision.annualRevisionDay", "invalid_date")); + } + } + } + LeaseApi.RecentWorkRequest work = terms.recentWork(); + if (work != null + && positive(work.amount()) + && (work.description() == null || work.description().isBlank())) { + errors.add(error("legalTerms.recentWork.description", "required")); + } + if (Boolean.TRUE.equals(terms.jointAndSeveralLiability()) && tenantCount < 2) { + errors.add(error("legalTerms.jointAndSeveralLiability", "requires_colocation")); + } +} + +private static boolean positive(BigDecimal value) { + return value != null && value.signum() > 0; +} + +private static LeaseClauseCatalog.Context context(LeaseApi.DraftRequest request) { + if (request.tenantIds() != null && request.tenantIds().size() > 1) { + return LeaseClauseCatalog.Context.COLOCATION; + } + return request.leaseType() == fr.nessar.lmnporganizer.rental.domain.lease.LeaseType.STUDENT + ? LeaseClauseCatalog.Context.STUDENT + : LeaseClauseCatalog.Context.STANDARD; +} + +private static LeaseController.LeaseRequestFieldError error(String field, String code) { + return new LeaseController.LeaseRequestFieldError(field, code); +} +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseClauseCatalog.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseClauseCatalog.java index 34cbdd9..cd36792 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseClauseCatalog.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseClauseCatalog.java @@ -53,6 +53,11 @@ public List available(Context context) { .toList(); } +public ClauseDefinition available(String id, Context context) { + ClauseDefinition clause = clauses.get(id); + return clause != null && available(context).contains(clause) ? clause : null; +} + public RenderedClause render( String clauseId, Context context, Map suppliedValues) { ClauseDefinition clause = clauses.get(clauseId); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidatorTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidatorTest.java new file mode 100644 index 0000000..fc43845 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseNestedRequestValidatorTest.java @@ -0,0 +1,91 @@ +package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog; +import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class LeaseNestedRequestValidatorTest { + +@Test +void reportsIndexedNestedPathsWithoutChangingClauseOrLiabilitySemantics() { + LeaseNestedRequestValidator validator = + new LeaseNestedRequestValidator( + LeaseClauseCatalog.load(getClass().getResourceAsStream("/lease-clauses.yml"))); + + LeaseController.LeaseRequestValidationFailure failure = + assertThrows( + LeaseController.LeaseRequestValidationFailure.class, + () -> + validator.validate( + new LeaseApi.DraftRequest( + "apartment-1", + List.of("tenant-1"), + List.of( + new LeaseApi.GuarantorRequest( + "guarantor-1", "Alex", "Durand", "alex@example.com", "bad", "")), + LocalDate.of(2026, 10, 1), + null, + LeaseType.STANDARD, + BigDecimal.ONE, + BigDecimal.ZERO, + BigDecimal.ZERO, + 5, + null, + false, + List.of( + new LeaseApi.SpecialClauseRequest( + "VISIT_SALE_RELETTING", Map.of(), null)), + new LeaseApi.LegalTermsRequest( + null, null, null, null, false, false, false, true, "")))); + + assertEquals( + List.of( + new LeaseController.LeaseRequestFieldError("guarantors[0].phone", "invalid"), + new LeaseController.LeaseRequestFieldError( + "specialClauses[0].parameters.startTime", "required"), + new LeaseController.LeaseRequestFieldError( + "legalTerms.jointAndSeveralLiability", "requires_colocation")), + failure.errors()); +} + +@Test +void reportsNullNestedElementsWithStableIndexedPaths() { + LeaseNestedRequestValidator validator = + new LeaseNestedRequestValidator( + LeaseClauseCatalog.load(getClass().getResourceAsStream("/lease-clauses.yml"))); + + LeaseController.LeaseRequestValidationFailure failure = + assertThrows( + LeaseController.LeaseRequestValidationFailure.class, + () -> + validator.validate( + new LeaseApi.DraftRequest( + "apartment-1", + List.of("tenant-1"), + java.util.Arrays.asList((LeaseApi.GuarantorRequest) null), + LocalDate.of(2026, 10, 1), + null, + LeaseType.STANDARD, + BigDecimal.ONE, + BigDecimal.ZERO, + BigDecimal.ZERO, + 5, + null, + false, + java.util.Arrays.asList((LeaseApi.SpecialClauseRequest) null), + null))); + + assertEquals( + List.of( + new LeaseController.LeaseRequestFieldError("guarantors[0]", "not_null"), + new LeaseController.LeaseRequestFieldError("specialClauses[0]", "not_null")), + failure.errors()); +} +} diff --git a/frontend/src/app/components/guarantor-form/guarantor-form.html b/frontend/src/app/components/guarantor-form/guarantor-form.html index 1657a40..4e1a82c 100644 --- a/frontend/src/app/components/guarantor-form/guarantor-form.html +++ b/frontend/src/app/components/guarantor-form/guarantor-form.html @@ -1,35 +1,58 @@
Prénom du garant + >Prénom du garant + @if (fieldError('firstName')) { {{ fieldError('firstName') }} } + Nom du garant + > + @if (fieldError('lastName')) { {{ fieldError('lastName') }} } + E-mail du garant + (input)="valueChanged('email')" + /> + @if (fieldError('email')) { {{ fieldError('email') }} } + Téléphone du garant + (input)="valueChanged('phone')" + /> + @if (fieldError('phone')) { {{ fieldError('phone') }} } + Adresse du garant + > +
diff --git a/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts b/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts index 5049ea0..ac85cb8 100644 --- a/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts +++ b/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts @@ -38,4 +38,32 @@ describe('GuarantorForm', () => { }); expect(component.guarantorModel().firstName).toBe(''); }); + + it('maps and clears only the edited persisted guarantor field', () => { + const fixture = TestBed.createComponent(GuarantorForm); + const changed = vi.fn(); + fixture.componentRef.setInput('guarantor', { + id: 'guarantor-1', + firstName: 'Alex', + lastName: 'Durand', + email: 'alex@example.com', + phone: 'bad', + address: '', + }); + fixture.componentRef.setInput('index', 1); + fixture.componentRef.setInput('saveFieldErrors', [ + { field: 'guarantors[1].phone', code: 'invalid' }, + { field: 'guarantors[1].email', code: 'email' }, + ]); + fixture.componentInstance.fieldChanged.subscribe(changed); + fixture.detectChanges(); + + expect(fixture.componentInstance.fieldError('phone')).toContain('téléphone'); + expect(fixture.componentInstance.fieldError('email')).toContain('e-mail'); + + fixture.componentInstance.valueChanged('phone'); + + expect(changed).toHaveBeenCalledWith('guarantors[1].phone'); + expect(changed).not.toHaveBeenCalledWith('guarantors[1].email'); + }); }); diff --git a/frontend/src/app/components/guarantor-form/guarantor-form.ts b/frontend/src/app/components/guarantor-form/guarantor-form.ts index 181487b..4e2a330 100644 --- a/frontend/src/app/components/guarantor-form/guarantor-form.ts +++ b/frontend/src/app/components/guarantor-form/guarantor-form.ts @@ -1,10 +1,12 @@ -import { Component, output, signal } from '@angular/core'; +import { Component, computed, effect, input, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { FormRoot, FormField, form, required, email } from '@angular/forms/signals'; +import { FormRoot, FormField, form, required, email, validate } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { Guarantor } from '../../services/lease.service'; +import { FieldValidationError } from '../../services/http-error-message'; +import { isValidPhoneNumber } from '../../services/phone-number'; @Component({ selector: 'app-guarantor-form', @@ -21,6 +23,12 @@ import { Guarantor } from '../../services/lease.service'; }) export class GuarantorForm { readonly submitted = output(); + readonly updated = output(); + readonly fieldChanged = output(); + readonly guarantor = input(); + readonly index = input(-1); + readonly saveFieldErrors = input([]); + readonly editing = computed(() => this.guarantor() !== undefined); readonly guarantorModel = signal({ firstName: '', @@ -35,8 +43,20 @@ export class GuarantorForm { required(f.lastName); required(f.email); email(f.email); + validate(f.phone, ({ value }) => + isValidPhoneNumber(value()) + ? null + : { kind: 'phone', message: 'Le numéro de téléphone est invalide.' }, + ); }); + constructor() { + effect(() => { + const guarantor = this.guarantor(); + if (guarantor) this.guarantorModel.set({ ...guarantor }); + }); + } + save(): void { this.guarantorModel.update((m) => ({ firstName: m.firstName.trim(), @@ -52,13 +72,18 @@ export class GuarantorForm { } const modelVal = this.guarantorModel(); - this.submitted.emit({ + const guarantor = { firstName: modelVal.firstName, lastName: modelVal.lastName, email: modelVal.email, phone: modelVal.phone, address: modelVal.address, - }); + }; + if (this.editing()) { + this.updated.emit(guarantor); + return; + } + this.submitted.emit(guarantor); this.guarantorModel.set({ firstName: '', @@ -69,4 +94,56 @@ export class GuarantorForm { }); this.guarantorForm().reset(); } + + validate(): boolean { + if (!this.editing()) return true; + this.guarantorForm().markAsTouched(); + return !this.guarantorForm().invalid(); + } + + fieldError(field: 'firstName' | 'lastName' | 'email' | 'phone' | 'address'): string { + const serverError = this.saveFieldErrors().some( + (error) => error.field === this.path(field), + ); + if (!serverError && !this.fieldInvalid(field)) return ''; + return { + firstName: 'Renseignez le prénom du garant.', + lastName: 'Renseignez le nom du garant.', + email: 'Renseignez une adresse e-mail valide.', + phone: 'Renseignez un numéro de téléphone valide.', + address: '', + }[field]; + } + + valueChanged(field: 'firstName' | 'lastName' | 'email' | 'phone' | 'address'): void { + this.fieldChanged.emit(this.path(field)); + if (this.editing()) queueMicrotask(() => this.updated.emit(this.guarantorModel())); + } + + focusInvalid(): boolean { + const field = (['firstName', 'lastName', 'email', 'phone'] as const).find((item) => + Boolean(this.fieldError(item)), + ); + document.getElementById(`guarantor-${this.index()}-${field ?? 'firstName'}`)?.focus(); + return Boolean(field); + } + + private fieldInvalid(field: 'firstName' | 'lastName' | 'email' | 'phone' | 'address'): boolean { + switch (field) { + case 'firstName': + return this.guarantorForm.firstName().touched() && this.guarantorForm.firstName().invalid(); + case 'lastName': + return this.guarantorForm.lastName().touched() && this.guarantorForm.lastName().invalid(); + case 'email': + return this.guarantorForm.email().touched() && this.guarantorForm.email().invalid(); + case 'phone': + return this.guarantorForm.phone().touched() && this.guarantorForm.phone().invalid(); + default: + return false; + } + } + + private path(field: string): string { + return this.editing() ? `guarantors[${this.index()}].${field}` : `guarantor.${field}`; + } } diff --git a/frontend/src/app/components/lease-clause-editor/lease-clause-editor.html b/frontend/src/app/components/lease-clause-editor/lease-clause-editor.html index a092214..89b857d 100644 --- a/frontend/src/app/components/lease-clause-editor/lease-clause-editor.html +++ b/frontend/src/app/components/lease-clause-editor/lease-clause-editor.html @@ -23,15 +23,26 @@ {{ definition.label }}

{{ definition.description }}

+ @if (catalogError(definition.id)) { +

+ {{ catalogError(definition.id) }} +

+ } @if (isSelected(definition.id) && definition.parameters.length) {
@for (parameter of definition.parameters; track parameter.name) { - + {{ parameter.label }} @switch (parameter.type) { @case ('CHOICE') { @@ -127,6 +138,11 @@ /> } } + @if (parameterErrorFor(definition.id, parameter)) { + {{ + parameterErrorFor(definition.id, parameter) + }} + } }
@@ -150,7 +166,10 @@
@for (item of customClauses(); track item.index) {
- + Clause personnalisée {{ $index + 1 }} + @if (customClauseError(item.index, item.clause)) { + {{ customClauseError(item.index, item.clause) }} + }
diff --git a/frontend/src/app/components/lease-clause-editor/lease-clause-editor.spec.ts b/frontend/src/app/components/lease-clause-editor/lease-clause-editor.spec.ts index 1adbd25..e4405eb 100644 --- a/frontend/src/app/components/lease-clause-editor/lease-clause-editor.spec.ts +++ b/frontend/src/app/components/lease-clause-editor/lease-clause-editor.spec.ts @@ -130,4 +130,22 @@ describe('LeaseClauseEditor', () => { expect(getLeaseClauses).toHaveBeenNthCalledWith(1, 'STANDARD'); expect(getLeaseClauses).toHaveBeenNthCalledWith(2, 'COLOCATION'); }); + + it('clears only the corrected indexed parameter error', () => { + const fixture = createEditor(); + const editor = fixture.componentInstance; + const changed = vi.fn(); + editor.fieldChanged.subscribe(changed); + editor.toggleCatalog('VISITS', true); + fixture.componentRef.setInput('saveFieldErrors', [ + { field: 'specialClauses[0].parameters.startTime', code: 'required' }, + { field: 'specialClauses[0].parameters.day', code: 'invalid' }, + ]); + + editor.setParameter('VISITS', 'startTime', '18:00'); + + expect(changed).toHaveBeenCalledWith('specialClauses[0].parameters.startTime'); + expect(changed).not.toHaveBeenCalledWith('specialClauses[0].parameters.day'); + expect(editor.parameterErrorFor('VISITS', definitions[0].parameters[3])).toContain('valide'); + }); }); diff --git a/frontend/src/app/components/lease-clause-editor/lease-clause-editor.ts b/frontend/src/app/components/lease-clause-editor/lease-clause-editor.ts index fa5585e..6f88d12 100644 --- a/frontend/src/app/components/lease-clause-editor/lease-clause-editor.ts +++ b/frontend/src/app/components/lease-clause-editor/lease-clause-editor.ts @@ -1,6 +1,7 @@ import { Component, DestroyRef, + ElementRef, computed, effect, inject, @@ -21,6 +22,7 @@ import { LeaseService, LeaseSpecialClause, } from '../../services/lease.service'; +import { FieldValidationError } from '../../services/http-error-message'; @Component({ selector: 'app-lease-clause-editor', @@ -38,11 +40,14 @@ import { export class LeaseClauseEditor { private readonly leaseService = inject(LeaseService); private readonly destroyRef = inject(DestroyRef); + private readonly element = inject>(ElementRef); private requestId = 0; readonly context = input.required(); readonly specialClauses = input([]); readonly specialClausesChange = output(); + readonly fieldChanged = output(); + readonly saveFieldErrors = input([]); readonly definitions = signal([]); readonly loading = signal(true); @@ -79,6 +84,10 @@ export class LeaseClauseEditor { const definition = this.definitions().find(({ id }) => id === clause.catalogId); if (!definition) return `La clause « ${clause.catalogId} » n'est plus disponible. Retirez-la.`; + const invalid = definition.parameters.find((parameter) => + Boolean(this.parameterError(clause, parameter, true)), + ); + if (invalid) return 'Corrigez « ' + invalid.label + ' ».'; const missing = definition.parameters.find( (parameter) => parameter.required && !clause.parameters[parameter.name]?.trim(), ); @@ -104,15 +113,25 @@ export class LeaseClauseEditor { return !this.validationMessage(); } + focusInvalid(): boolean { + const control = this.element.nativeElement.querySelector( + '[data-clause-invalid=true] input, [data-clause-invalid=true] textarea, [data-clause-invalid=true], .validation-error', + ); + control?.focus(); + return control !== null; + } + isSelected(id: string): boolean { return this.specialClauses().some(({ catalogId }) => catalogId === id); } toggleCatalog(id: string, selected: boolean): void { + const index = this.clauseIndex(id); const clauses = this.specialClauses().filter(({ catalogId }) => catalogId !== id); this.emit( selected ? [...clauses, { catalogId: id, parameters: {}, customText: '' }] : clauses, ); + this.fieldChanged.emit('specialClauses[' + index + '].catalogId'); } parameterValue(catalogId: string, parameterName: string): string { @@ -131,6 +150,9 @@ export class LeaseClauseEditor { : clause, ), ); + this.fieldChanged.emit( + 'specialClauses[' + this.clauseIndex(catalogId) + '].parameters.' + parameterName, + ); } setParameterFromEvent(catalogId: string, parameterName: string, event: Event): void { @@ -147,6 +169,7 @@ export class LeaseClauseEditor { clauseIndex === index ? { ...clause, customText } : clause, ), ); + this.fieldChanged.emit('specialClauses[' + index + '].customText'); } updateCustomClauseFromEvent(index: number, event: Event): void { @@ -180,10 +203,80 @@ export class LeaseClauseEditor { } private emit(clauses: LeaseSpecialClause[]): void { - this.showValidationError.set(false); this.specialClausesChange.emit(clauses); } + parameterError( + clause: LeaseSpecialClause, + parameter: LeaseClauseDefinition['parameters'][number], + show = this.showValidationError(), + ): string { + const path = + 'specialClauses[' + this.clauseIndex(clause.catalogId) + '].parameters.' + parameter.name; + if (this.saveFieldErrors().some((error) => error.field === path)) { + return this.parameterMessage(parameter); + } + if (!show) return ''; + const value = clause.parameters[parameter.name]?.trim() ?? ''; + if (parameter.required && !value) return this.parameterMessage(parameter, true); + return value && !this.parameterValid(parameter, value) ? this.parameterMessage(parameter) : ''; + } + + parameterErrorFor( + catalogId: string, + parameter: LeaseClauseDefinition['parameters'][number], + ): string { + const clause = this.specialClauses().find((item) => item.catalogId === catalogId); + return clause ? this.parameterError(clause, parameter) : ''; + } + + catalogError(catalogId: string): string { + const path = 'specialClauses[' + this.clauseIndex(catalogId) + '].catalogId'; + return this.saveFieldErrors().some((error) => error.field === path) + ? 'Cette clause n’est pas disponible pour ce bail.' + : ''; + } + + customClauseError(index: number, clause: LeaseSpecialClause): string { + const serverError = this.saveFieldErrors().some( + (error) => error.field === 'specialClauses[' + index + '].customText', + ); + if (serverError || (this.showValidationError() && !clause.customText.trim())) { + return 'Renseignez le texte de la clause personnalisée ou retirez-la.'; + } + return ''; + } + + private parameterMessage( + parameter: LeaseClauseDefinition['parameters'][number], + required = false, + ): string { + if (required) return 'Renseignez « ' + parameter.label + ' ».'; + return parameter.type === 'CHOICE' + ? 'Sélectionnez une valeur proposée pour « ' + parameter.label + ' ».' + : 'Renseignez une valeur valide pour « ' + parameter.label + ' ».'; + } + + private parameterValid( + parameter: LeaseClauseDefinition['parameters'][number], + value: string, + ): boolean { + if (parameter.type === 'TEXT' || parameter.type === 'TIME') return true; + if (parameter.type === 'CHOICE') return parameter.choices.some((choice) => choice.value === value); + if (parameter.type === 'NUMBER') return Number.isFinite(Number(value)); + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); + } + + private clauseIndex(catalogId: string | null): number { + return this.specialClauses().findIndex((clause) => clause.catalogId === catalogId); + } + private eventValue(event: Event): string { const target = event.target; return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement diff --git a/frontend/src/app/components/lease-form/lease-form.html b/frontend/src/app/components/lease-form/lease-form.html index 250f08f..3fd8f58 100644 --- a/frontend/src/app/components/lease-form/lease-form.html +++ b/frontend/src/app/components/lease-form/lease-form.html @@ -201,11 +201,15 @@ [legalTerms]="leaseModel().legalTerms" [tenantCount]="leaseModel().tenantIds.length" [energyRating]="selectedEnergyRating()" + [saveFieldErrors]="saveFieldErrors()" + (fieldChanged)="fieldWasChanged($event)" /> @@ -218,10 +222,16 @@

Ajoutez un ou plusieurs contacts de garantie.

- @for (guarantor of guarantors(); track $index) { + @for (guarantor of guarantors(); track guarantor.id ?? $index) {
- {{ guarantor.firstName }} {{ guarantor.lastName }}
diff --git a/frontend/src/app/components/lease-form/lease-form.ts b/frontend/src/app/components/lease-form/lease-form.ts index 29fb3f9..79c8789 100644 --- a/frontend/src/app/components/lease-form/lease-form.ts +++ b/frontend/src/app/components/lease-form/lease-form.ts @@ -8,6 +8,7 @@ import { output, signal, viewChild, + viewChildren, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormRoot, FormField, form, max, min, required, validate } from '@angular/forms/signals'; @@ -81,6 +82,7 @@ export class LeaseForm { readonly fieldChanged = output(); readonly clauseEditor = viewChild(LeaseClauseEditor); readonly legalTermsEditor = viewChild(LeaseLegalTermsEditor); + readonly guarantorForms = viewChildren(GuarantorForm); readonly leaseModel = signal({ apartmentId: '', @@ -202,11 +204,20 @@ export class LeaseForm { ); } + updateGuarantor(index: number, guarantor: Guarantor): void { + this.guarantors.update((guarantors) => + guarantors.map((current, currentIndex) => + currentIndex === index ? { ...guarantor, id: current.id } : current, + ), + ); + } + save(): void { if (this.saving()) return; this.leaseForm().markAsTouched(); if ( this.leaseForm().invalid() || + this.guarantorForms().some((guarantor) => !guarantor.validate()) || this.legalTermsEditor()?.validate() === false || this.clauseEditor()?.validate() === false ) { @@ -282,7 +293,13 @@ export class LeaseForm { private focusFirstInvalid(): void { const field = leaseCoreFields.find((item) => this.fieldError(item)); const control = field && this.element.nativeElement.querySelector(`#${field}`); - control?.focus(); + if (control) { + control.focus(); + return; + } + if (this.guarantorForms().some((guarantor) => guarantor.focusInvalid())) return; + if (this.legalTermsEditor()?.focusInvalid()) return; + this.clauseEditor()?.focusInvalid(); } private formatCivilDate(date: string): string { diff --git a/frontend/src/app/components/lease-legal-terms/lease-legal-terms.html b/frontend/src/app/components/lease-legal-terms/lease-legal-terms.html index 1a33cbf..9ef128d 100644 --- a/frontend/src/app/components/lease-legal-terms/lease-legal-terms.html +++ b/frontend/src/app/components/lease-legal-terms/lease-legal-terms.html @@ -17,39 +17,72 @@ @if (legalTerms().rentControl.referenceRentApplicable) {
- + Loyer de référence €/m² + @if (error('rentControl.baseReferenceRent')) { + {{ error('rentControl.baseReferenceRent') }} + } - + Loyer de référence majoré €/m² + @if (error('rentControl.increasedReferenceRent')) { + {{ error('rentControl.increasedReferenceRent') }} + } - + Complément de loyer € + @if (error('rentControl.supplement')) { + {{ error('rentControl.supplement') }} + }
@if ((legalTerms().rentControl.supplement ?? 0) > 0) { - + Justification du complément de loyer + @if (error('rentControl.supplementJustification')) { + {{ error('rentControl.supplementJustification') }} + } } } @@ -62,34 +95,65 @@ @if (legalTerms().previousRent.applicable) {
- + Montant du précédent loyer € + @if (error('previousRent.amount')) { + {{ error('previousRent.amount') }} + } - + Dernière révision du précédent loyer + @if (error('previousRent.lastRevisionDate')) { + {{ error('previousRent.lastRevisionDate') }} + } - + Date du dernier versement + @if (error('previousRent.lastPaymentDate')) { + {{ error('previousRent.lastPaymentDate') }} + }
- + Je confirme avoir communiqué ces informations au locataire. + @if (error('previousRent.tenantConfirmation')) { +

{{ error('previousRent.tenantConfirmation') }}

+ } } @@ -107,7 +171,10 @@ }
- + Jour de révision + @if (error('rentRevision.annualRevisionDay')) { + {{ error('rentRevision.annualRevisionDay') }} + } - + Mois de révision Non renseigné Janvier @@ -137,33 +212,60 @@ Novembre Décembre + @if (error('rentRevision.annualRevisionMonth')) { + {{ error('rentRevision.annualRevisionMonth') }} + } - + Trimestre IRL de référence - + Non renseigné 1er trimestre 2e trimestre 3e trimestre 4e trimestre + @if (error('rentRevision.referenceQuarter')) { + {{ error('rentRevision.referenceQuarter') }} + } - + Année IRL de référence + @if (error('rentRevision.referenceYear')) { + {{ error('rentRevision.referenceYear') }} + } - + Identifiant de la série INSEE + @if (error('rentRevision.seriesId')) { + {{ error('rentRevision.seriesId') }} + }
} @@ -172,17 +274,35 @@

Travaux et autres conditions structurées

- + Montant des travaux récents € + @if (error('recentWork.amount')) { + {{ error('recentWork.amount') }} + } - + Description des travaux récents - + + @if (error('recentWork.description')) { + {{ error('recentWork.description') }} + }
@@ -196,14 +316,35 @@ Une servitude de résidence principale est applicable. @if (tenantCount() > 1) { - + Prévoir la solidarité et l'indivisibilité entre les colocataires. } + @if (error('jointAndSeveralLiability')) { +

+ {{ error('jointAndSeveralLiability') }} +

+ }
- + Autres conditions - + + @if (error('otherTerms')) { + {{ error('otherTerms') }} + }
diff --git a/frontend/src/app/components/lease-legal-terms/lease-legal-terms.ts b/frontend/src/app/components/lease-legal-terms/lease-legal-terms.ts index 6065408..7a76f3e 100644 --- a/frontend/src/app/components/lease-legal-terms/lease-legal-terms.ts +++ b/frontend/src/app/components/lease-legal-terms/lease-legal-terms.ts @@ -1,4 +1,4 @@ -import { Component, computed, input, signal } from '@angular/core'; +import { Component, computed, ElementRef, inject, input, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FieldTree, FormField } from '@angular/forms/signals'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -7,6 +7,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { EnergyRating } from '../../services/apartment.service'; import { LeaseLegalTerms } from '../../services/lease.service'; +import { FieldValidationError } from '../../services/http-error-message'; @Component({ selector: 'app-lease-legal-terms', @@ -22,11 +23,15 @@ import { LeaseLegalTerms } from '../../services/lease.service'; styleUrl: './lease-legal-terms.css', }) export class LeaseLegalTermsEditor { + private readonly element = inject>(ElementRef); readonly legalTermsForm = input.required>(); readonly legalTerms = input.required(); readonly tenantCount = input(0); readonly energyRating = input(null); + readonly saveFieldErrors = input([]); + readonly fieldChanged = output(); readonly showValidationError = signal(false); + private readonly touchedFields = signal>(new Set()); readonly validationMessage = computed(() => this.issue()); readonly showIrlDpeWarning = computed(() => { const rating = this.energyRating(); @@ -38,6 +43,106 @@ export class LeaseLegalTermsEditor { return !this.validationMessage(); } + changed(field: string): void { + this.touchedFields.update((fields) => new Set(fields).add(field)); + this.fieldChanged.emit('legalTerms.' + field); + } + + error(field: string): string { + const error = this.saveFieldErrors().find((item) => item.field === 'legalTerms.' + field); + if (!error) { + return this.showValidationError() || this.touchedFields().has(field) + ? this.localError(field) + : ''; + } + return { + required: 'Renseignez ce champ.', + below_base: 'Le loyer majoré ne peut pas être inférieur au loyer de référence.', + invalid_date: 'La date de révision est invalide.', + requires_colocation: 'La solidarité nécessite au moins deux locataires.', + min: 'Renseignez une valeur valide.', + max: 'Renseignez une valeur valide.', + positive: 'Renseignez une valeur positive.', + positive_or_zero: 'Renseignez une valeur positive ou nulle.', + }[error.code] ?? 'Renseignez une valeur valide.'; + } + + focusInvalid(): boolean { + const control = this.element.nativeElement.querySelector( + '[data-legal-invalid=true] input, [data-legal-invalid=true] textarea, [data-legal-invalid=true]', + ); + control?.focus(); + return control !== null; + } + + private localError(field: string): string { + const terms = this.legalTerms(); + const rentControl = terms.rentControl; + if (rentControl.referenceRentApplicable) { + if (field === 'rentControl.baseReferenceRent' && rentControl.baseReferenceRent === null) { + return 'Renseignez le loyer de référence.'; + } + if ( + field === 'rentControl.increasedReferenceRent' && + (rentControl.increasedReferenceRent === null || + (rentControl.baseReferenceRent !== null && + rentControl.increasedReferenceRent < rentControl.baseReferenceRent)) + ) { + return rentControl.increasedReferenceRent === null + ? 'Renseignez le loyer de référence majoré.' + : 'Le loyer majoré ne peut pas être inférieur au loyer de référence.'; + } + if ( + field === 'rentControl.supplementJustification' && + (rentControl.supplement ?? 0) > 0 && + !rentControl.supplementJustification.trim() + ) { + return 'Justifiez le complément de loyer.'; + } + } + const previousRent = terms.previousRent; + if (previousRent.applicable) { + if (field === 'previousRent.amount' && previousRent.amount === null) { + return 'Renseignez le précédent loyer.'; + } + if (field === 'previousRent.lastRevisionDate' && !previousRent.lastRevisionDate) { + return 'Renseignez la date de révision.'; + } + if (field === 'previousRent.lastPaymentDate' && !previousRent.lastPaymentDate) { + return 'Renseignez la date du dernier versement.'; + } + if (field === 'previousRent.tenantConfirmation' && !previousRent.tenantConfirmation) { + return 'Confirmez la communication au locataire.'; + } + } + const revision = terms.rentRevision; + if (revision.enabled) { + if (field === 'rentRevision.annualRevisionDay' && revision.annualRevisionDay === null) { + return 'Renseignez le jour de révision.'; + } + if (field === 'rentRevision.annualRevisionMonth' && revision.annualRevisionMonth === null) { + return 'Renseignez le mois de révision.'; + } + if (field === 'rentRevision.referenceQuarter' && revision.referenceQuarter === null) { + return 'Renseignez le trimestre IRL.'; + } + if (field === 'rentRevision.referenceYear' && revision.referenceYear === null) { + return 'Renseignez l’année IRL.'; + } + if (field === 'rentRevision.seriesId' && !revision.seriesId.trim()) { + return 'Renseignez la série INSEE.'; + } + } + if ( + field === 'recentWork.description' && + (terms.recentWork.amount ?? 0) > 0 && + !terms.recentWork.description.trim() + ) { + return 'Décrivez les travaux récents.'; + } + return ''; + } + private issue(): string { const terms = this.legalTerms(); const amounts = [