Align nested lease validation flows (#50)
This commit is contained in:
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 = [
|
||||
|
||||
Reference in new issue
Block a user