Align nested lease validation flows (#50)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 88d3f3c2a5
commit 41590a6a78
14 files changed
+886 -59

No files matched your search

@@ -1,35 +1,58 @@
<form class="guarantor-form" [formRoot]="guarantorForm" (ngSubmit)="save()">
<div class="form-grid guarantor-fields">
<mat-form-field appearance="outline"
><mat-label>Prénom du garant</mat-label
><input matInput id="guarantorFirstName" [formField]="guarantorForm.firstName"
/></mat-form-field>
><mat-label>Prénom du garant</mat-label
><input
matInput
[id]="'guarantor-' + index() + '-firstName'"
[formField]="guarantorForm.firstName"
(input)="valueChanged('firstName')"
/>
@if (fieldError('firstName')) { <mat-error>{{ fieldError('firstName') }}</mat-error> }
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom du garant</mat-label
><input matInput id="guarantorLastName" [formField]="guarantorForm.lastName"
/></mat-form-field>
><input
matInput
[id]="'guarantor-' + index() + '-lastName'"
[formField]="guarantorForm.lastName"
(input)="valueChanged('lastName')"
/>
@if (fieldError('lastName')) { <mat-error>{{ fieldError('lastName') }}</mat-error> }
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail du garant</mat-label
><input
matInput
type="email"
id="guarantorEmail"
[id]="'guarantor-' + index() + '-email'"
[formField]="guarantorForm.email"
autocomplete="email"
/></mat-form-field>
(input)="valueChanged('email')"
/>
@if (fieldError('email')) { <mat-error>{{ fieldError('email') }}</mat-error> }
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone du garant</mat-label
><input
matInput
type="tel"
id="guarantorPhone"
[id]="'guarantor-' + index() + '-phone'"
[formField]="guarantorForm.phone"
autocomplete="tel"
/></mat-form-field>
(input)="valueChanged('phone')"
/>
@if (fieldError('phone')) { <mat-error>{{ fieldError('phone') }}</mat-error> }
</mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse du garant</mat-label
><input matInput id="guarantorAddress" [formField]="guarantorForm.address"
/></mat-form-field>
><input
matInput
[id]="'guarantor-' + index() + '-address'"
[formField]="guarantorForm.address"
(input)="valueChanged('address')"
/>
</mat-form-field>
</div>
<button mat-stroked-button type="submit" class="add-guarantor">Ajouter le garant</button>
</form>
@@ -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');
});
});
@@ -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<Guarantor>();
readonly updated = output<Guarantor>();
readonly fieldChanged = output<string>();
readonly guarantor = input<Guarantor>();
readonly index = input(-1);
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly editing = computed(() => this.guarantor() !== undefined);
readonly guarantorModel = signal<Guarantor>({
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}`;
}
}
@@ -23,15 +23,26 @@
<mat-checkbox
[checked]="isSelected(definition.id)"
(change)="toggleCatalog(definition.id, $event.checked)"
[attr.data-clause-invalid]="catalogError(definition.id) ? 'true' : null"
>
{{ definition.label }}
</mat-checkbox>
<p>{{ definition.description }}</p>
@if (catalogError(definition.id)) {
<p class="alert-error" data-clause-invalid="true" tabindex="-1">
{{ catalogError(definition.id) }}
</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-form-field
appearance="outline"
[attr.data-clause-invalid]="
parameterErrorFor(definition.id, parameter) ? 'true' : null
"
>
<mat-label>{{ parameter.label }}</mat-label>
@switch (parameter.type) {
@case ('CHOICE') {
@@ -127,6 +138,11 @@
/>
}
}
@if (parameterErrorFor(definition.id, parameter)) {
<mat-error>{{
parameterErrorFor(definition.id, parameter)
}}</mat-error>
}
</mat-form-field>
}
</div>
@@ -150,7 +166,10 @@
<div class="custom-clauses">
@for (item of customClauses(); track item.index) {
<div class="custom-clause">
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-clause-invalid]="customClauseError(item.index, item.clause) ? 'true' : null"
>
<mat-label>Clause personnalisée {{ $index + 1 }}</mat-label>
<textarea
matInput
@@ -159,6 +178,9 @@
[value]="item.clause.customText"
(input)="updateCustomClauseFromEvent(item.index, $event)"
></textarea>
@if (customClauseError(item.index, item.clause)) {
<mat-error>{{ customClauseError(item.index, item.clause) }}</mat-error>
}
</mat-form-field>
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
</div>
@@ -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');
});
});
@@ -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<HTMLElement>>(ElementRef);
private requestId = 0;
readonly context = input.required<LeaseClauseContext>();
readonly specialClauses = input<LeaseSpecialClause[]>([]);
readonly specialClausesChange = output<LeaseSpecialClause[]>();
readonly fieldChanged = output<string>();
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly definitions = signal<LeaseClauseDefinition[]>([]);
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<HTMLElement>(
'[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
@@ -201,11 +201,15 @@
[legalTerms]="leaseModel().legalTerms"
[tenantCount]="leaseModel().tenantIds.length"
[energyRating]="selectedEnergyRating()"
[saveFieldErrors]="saveFieldErrors()"
(fieldChanged)="fieldWasChanged($event)"
/>
<app-lease-clause-editor
[context]="clauseContext()"
[specialClauses]="specialClauses()"
[saveFieldErrors]="saveFieldErrors()"
(specialClausesChange)="specialClauses.set($event)"
(fieldChanged)="fieldWasChanged($event)"
/>
</fieldset>
</form>
@@ -218,10 +222,16 @@
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
</div>
</div>
@for (guarantor of guarantors(); track $index) {
@for (guarantor of guarantors(); track guarantor.id ?? $index) {
<div class="guarantor-chip">
<span>{{ guarantor.firstName }} {{ guarantor.lastName }}</span
><button mat-button type="button" (click)="removeGuarantor($index)">
<app-guarantor-form
[guarantor]="guarantor"
[index]="$index"
[saveFieldErrors]="saveFieldErrors()"
(updated)="updateGuarantor($index, $event)"
(fieldChanged)="fieldWasChanged($event)"
/>
<button mat-button type="button" (click)="removeGuarantor($index)">
Retirer
</button>
</div>
@@ -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<string>();
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<HTMLElement>(`#${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 {
@@ -17,39 +17,72 @@
</mat-checkbox>
@if (legalTerms().rentControl.referenceRentApplicable) {
<div class="form-grid three-columns">
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('rentControl.baseReferenceRent') ? 'true' : null"
>
<mat-label>Loyer de référence</mat-label>
<input
matInput
type="number"
id="rentControlBaseReferenceRent"
[formField]="legalTermsForm().rentControl.baseReferenceRent"
(change)="changed('rentControl.baseReferenceRent')"
/><span matTextSuffix>€/m²</span>
@if (error('rentControl.baseReferenceRent')) {
<mat-error>{{ error('rentControl.baseReferenceRent') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('rentControl.increasedReferenceRent') ? 'true' : null"
>
<mat-label>Loyer de référence majoré</mat-label>
<input
matInput
type="number"
id="rentControlIncreasedReferenceRent"
[formField]="legalTermsForm().rentControl.increasedReferenceRent"
(change)="changed('rentControl.increasedReferenceRent')"
/><span matTextSuffix>€/m²</span>
@if (error('rentControl.increasedReferenceRent')) {
<mat-error>{{ error('rentControl.increasedReferenceRent') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('rentControl.supplement') ? 'true' : null"
>
<mat-label>Complément de loyer</mat-label>
<input
matInput
type="number"
id="rentControlSupplement"
[formField]="legalTermsForm().rentControl.supplement"
(change)="changed('rentControl.supplement')"
/><span matTextSuffix>€</span>
@if (error('rentControl.supplement')) {
<mat-error>{{ error('rentControl.supplement') }}</mat-error>
}
</mat-form-field>
</div>
@if ((legalTerms().rentControl.supplement ?? 0) > 0) {
<mat-form-field appearance="outline" class="full-width">
<mat-form-field
appearance="outline"
class="full-width"
[attr.data-legal-invalid]="error('rentControl.supplementJustification') ? 'true' : null"
>
<mat-label>Justification du complément de loyer</mat-label>
<textarea
matInput
rows="2"
id="rentControlSupplementJustification"
[formField]="legalTermsForm().rentControl.supplementJustification"
(change)="changed('rentControl.supplementJustification')"
></textarea>
@if (error('rentControl.supplementJustification')) {
<mat-error>{{ error('rentControl.supplementJustification') }}</mat-error>
}
</mat-form-field>
}
}
@@ -62,34 +95,65 @@
</mat-checkbox>
@if (legalTerms().previousRent.applicable) {
<div class="form-grid three-columns">
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('previousRent.amount') ? 'true' : null"
>
<mat-label>Montant du précédent loyer</mat-label>
<input
matInput
type="number"
id="previousRentAmount"
[formField]="legalTermsForm().previousRent.amount"
(change)="changed('previousRent.amount')"
/><span matTextSuffix>€</span>
@if (error('previousRent.amount')) {
<mat-error>{{ error('previousRent.amount') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('previousRent.lastRevisionDate') ? 'true' : null"
>
<mat-label>Dernière révision du précédent loyer</mat-label>
<input
matInput
type="date"
id="previousRentLastRevisionDate"
[formField]="legalTermsForm().previousRent.lastRevisionDate"
(change)="changed('previousRent.lastRevisionDate')"
/>
@if (error('previousRent.lastRevisionDate')) {
<mat-error>{{ error('previousRent.lastRevisionDate') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('previousRent.lastPaymentDate') ? 'true' : null"
>
<mat-label>Date du dernier versement</mat-label>
<input
matInput
type="date"
id="previousRentLastPaymentDate"
[formField]="legalTermsForm().previousRent.lastPaymentDate"
(change)="changed('previousRent.lastPaymentDate')"
/>
@if (error('previousRent.lastPaymentDate')) {
<mat-error>{{ error('previousRent.lastPaymentDate') }}</mat-error>
}
</mat-form-field>
</div>
<mat-checkbox [formField]="legalTermsForm().previousRent.tenantConfirmation">
<mat-checkbox
[formField]="legalTermsForm().previousRent.tenantConfirmation"
(change)="changed('previousRent.tenantConfirmation')"
[attr.data-legal-invalid]="error('previousRent.tenantConfirmation') ? 'true' : null"
>
Je confirme avoir communiqué ces informations au locataire.
</mat-checkbox>
@if (error('previousRent.tenantConfirmation')) {
<p class="alert-error">{{ error('previousRent.tenantConfirmation') }}</p>
}
}
</div>
@@ -107,7 +171,10 @@
</aside>
}
<div class="form-grid three-columns">
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('rentRevision.annualRevisionDay') ? 'true' : null"
>
<mat-label>Jour de révision</mat-label>
<input
matInput
@@ -115,13 +182,21 @@
type="number"
step="1"
[formField]="legalTermsForm().rentRevision.annualRevisionDay"
(change)="changed('rentRevision.annualRevisionDay')"
/>
@if (error('rentRevision.annualRevisionDay')) {
<mat-error>{{ error('rentRevision.annualRevisionDay') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('rentRevision.annualRevisionMonth') ? 'true' : null"
>
<mat-label>Mois de révision</mat-label>
<mat-select
id="annualRevisionMonth"
[formField]="legalTermsForm().rentRevision.annualRevisionMonth"
(selectionChange)="changed('rentRevision.annualRevisionMonth')"
>
<mat-option [value]="null">Non renseigné</mat-option>
<mat-option [value]="1">Janvier</mat-option>
@@ -137,33 +212,60 @@
<mat-option [value]="11">Novembre</mat-option>
<mat-option [value]="12">Décembre</mat-option>
</mat-select>
@if (error('rentRevision.annualRevisionMonth')) {
<mat-error>{{ error('rentRevision.annualRevisionMonth') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('rentRevision.referenceQuarter') ? 'true' : null"
>
<mat-label>Trimestre IRL de référence</mat-label>
<mat-select [formField]="legalTermsForm().rentRevision.referenceQuarter">
<mat-select
[formField]="legalTermsForm().rentRevision.referenceQuarter"
(selectionChange)="changed('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>
@if (error('rentRevision.referenceQuarter')) {
<mat-error>{{ error('rentRevision.referenceQuarter') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('rentRevision.referenceYear') ? 'true' : null"
>
<mat-label>Année IRL de référence</mat-label>
<input
matInput
type="number"
[formField]="legalTermsForm().rentRevision.referenceYear"
(change)="changed('rentRevision.referenceYear')"
/>
@if (error('rentRevision.referenceYear')) {
<mat-error>{{ error('rentRevision.referenceYear') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-form-field
appearance="outline"
class="full-field"
[attr.data-legal-invalid]="error('rentRevision.seriesId') ? 'true' : null"
>
<mat-label>Identifiant de la série INSEE</mat-label>
<input
matInput
type="text"
[formField]="legalTermsForm().rentRevision.seriesId"
placeholder="001515333"
(change)="changed('rentRevision.seriesId')"
/>
@if (error('rentRevision.seriesId')) {
<mat-error>{{ error('rentRevision.seriesId') }}</mat-error>
}
</mat-form-field>
</div>
}
@@ -172,17 +274,35 @@
<div class="terms-block">
<h3>Travaux et autres conditions structurées</h3>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('recentWork.amount') ? 'true' : null"
>
<mat-label>Montant des travaux récents</mat-label>
<input
matInput
type="number"
[formField]="legalTermsForm().recentWork.amount"
(change)="changed('recentWork.amount')"
/><span matTextSuffix>€</span>
@if (error('recentWork.amount')) {
<mat-error>{{ error('recentWork.amount') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-legal-invalid]="error('recentWork.description') ? 'true' : null"
>
<mat-label>Description des travaux récents</mat-label>
<input matInput type="text" [formField]="legalTermsForm().recentWork.description" />
<input
matInput
type="text"
[formField]="legalTermsForm().recentWork.description"
(change)="changed('recentWork.description')"
/>
@if (error('recentWork.description')) {
<mat-error>{{ error('recentWork.description') }}</mat-error>
}
</mat-form-field>
</div>
<div class="structured-options">
@@ -196,14 +316,35 @@
Une servitude de résidence principale est applicable.
</mat-checkbox>
@if (tenantCount() > 1) {
<mat-checkbox [formField]="legalTermsForm().jointAndSeveralLiability">
<mat-checkbox
[formField]="legalTermsForm().jointAndSeveralLiability"
(change)="changed('jointAndSeveralLiability')"
[attr.data-legal-invalid]="error('jointAndSeveralLiability') ? 'true' : null"
>
Prévoir la solidarité et l'indivisibilité entre les colocataires.
</mat-checkbox>
}
@if (error('jointAndSeveralLiability')) {
<p class="alert-error" data-legal-invalid="true" tabindex="-1">
{{ error('jointAndSeveralLiability') }}
</p>
}
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-form-field
appearance="outline"
class="full-width"
[attr.data-legal-invalid]="error('otherTerms') ? 'true' : null"
>
<mat-label>Autres conditions</mat-label>
<textarea matInput rows="3" [formField]="legalTermsForm().otherTerms"></textarea>
<textarea
matInput
rows="3"
[formField]="legalTermsForm().otherTerms"
(change)="changed('otherTerms')"
></textarea>
@if (error('otherTerms')) {
<mat-error>{{ error('otherTerms') }}</mat-error>
}
</mat-form-field>
</div>
@@ -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<HTMLElement>>(ElementRef);
readonly legalTermsForm = input.required<FieldTree<LeaseLegalTerms>>();
readonly legalTerms = input.required<LeaseLegalTerms>();
readonly tenantCount = input(0);
readonly energyRating = input<EnergyRating | null>(null);
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly fieldChanged = output<string>();
readonly showValidationError = signal(false);
private readonly touchedFields = signal<ReadonlySet<string>>(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<HTMLElement>(
'[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 = [