([]);
readonly deliveryMethods = [
{ value: 'REGISTERED_LETTER', label: 'Lettre recommandée' },
{ value: 'POSTAL_LETTER', label: 'Lettre postale' },
@@ -65,6 +67,7 @@ export class RentRevisionApplicationComponent {
this.pending.set(true);
this.validationError.set('');
this.error.set('');
+ this.fieldErrors.set([]);
const proof = this.proof();
this.leases
.applyRentRevision(this.leaseId(), this.revision().id, {
@@ -80,14 +83,45 @@ export class RentRevisionApplicationComponent {
)
.subscribe({
next: (response) => this.applied.emit(response),
- error: (error: unknown) =>
+ error: (error: unknown) => {
+ const failure = saveFailure(
+ error,
+ 'Impossible d’appliquer cette révision de loyer.',
+ );
+ const visibleErrors = failure.fieldErrors.filter((fieldError) =>
+ this.isKnownField(fieldError.field),
+ );
+ this.fieldErrors.set(visibleErrors);
this.error.set(
- httpErrorMessage(error, 'Impossible d’appliquer cette révision de loyer.'),
- ),
+ visibleErrors.length === failure.fieldErrors.length
+ ? failure.message
+ : this.operationError(failure.fieldErrors),
+ );
+ this.focusFirstFieldError();
+ },
});
}
+ clearFieldError(field: string): void {
+ if (!this.fieldErrors().some((error) => error.field === field)) return;
+ const remaining = this.fieldErrors().filter((error) => error.field !== field);
+ this.fieldErrors.set(remaining);
+ if (remaining.length === 0) this.error.set('');
+ }
+
+ fieldError(field: string): string {
+ if (!this.fieldErrors().some((error) => error.field === field)) return '';
+ return (
+ {
+ deliveryMethod: 'Choisissez un mode d’envoi valide.',
+ deliveryConfirmed: 'Confirmez l’envoi de la lettre.',
+ proof: 'Choisissez une preuve PDF lisible.',
+ }[field] ?? 'Vérifiez cette valeur.'
+ );
+ }
+
selectProof(event: Event): void {
+ this.clearFieldError('proof');
const input = event.target;
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
if (file && file.name.toLowerCase().endsWith('.pdf') && file.type === 'application/pdf') {
@@ -104,4 +138,33 @@ export class RentRevisionApplicationComponent {
const [year, month, day] = date.split('-').map(Number);
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
}
+
+ private isKnownField(field: string): boolean {
+ return ['deliveryMethod', 'deliveryConfirmed', 'proof'].includes(field);
+ }
+
+ private operationError(errors: FieldValidationError[]): string {
+ return errors.some((error) => error.field === 'sentOn')
+ ? 'La date d’envoi de cette proposition est invalide.'
+ : errors.some((error) => error.field === 'timeZone')
+ ? 'Le fuseau horaire du navigateur est invalide.'
+ : 'Impossible d’appliquer cette révision de loyer.';
+ }
+
+ private focusFirstFieldError(): void {
+ const selector = this.fieldErrors()
+ .map(
+ (error) =>
+ ({
+ deliveryMethod: '.delivery-method',
+ deliveryConfirmed: '.delivery-confirmed',
+ proof: 'input[type=file]',
+ })[error.field],
+ )
+ .find((value): value is string => typeof value === 'string');
+ if (selector)
+ queueMicrotask(() =>
+ (this.host.nativeElement.querySelector(selector) as HTMLElement | null)?.focus(),
+ );
+ }
}
diff --git a/frontend/src/app/components/rent-revision-preview/rent-revision-preview.css b/frontend/src/app/components/rent-revision-preview/rent-revision-preview.css
index d042db4..a17a81a 100644
--- a/frontend/src/app/components/rent-revision-preview/rent-revision-preview.css
+++ b/frontend/src/app/components/rent-revision-preview/rent-revision-preview.css
@@ -108,6 +108,12 @@ input[type='number'] {
color: var(--danger, #b42318);
}
+.field-error,
+.stale-preview {
+ color: var(--danger, #b42318);
+ font-size: 0.75rem;
+}
+
.preview-warnings ul {
display: grid;
gap: var(--space-2, 0.5rem);
diff --git a/frontend/src/app/components/rent-revision-preview/rent-revision-preview.html b/frontend/src/app/components/rent-revision-preview/rent-revision-preview.html
index 51224a0..02148f8 100644
--- a/frontend/src/app/components/rent-revision-preview/rent-revision-preview.html
+++ b/frontend/src/app/components/rent-revision-preview/rent-revision-preview.html
@@ -17,7 +17,11 @@
Changer la série INSEE
@if (requestModel().seriesIdOverride.trim()) {
@if (currentPreview.chargeCeiling !== null) {
}
@@ -71,10 +89,14 @@
}
+ @if (fieldError('confirmedWarningCodes')) {
+ {{ fieldError('confirmedWarningCodes') }}
+ }
}
- Le serveur recalculera les indices et plafonds avant de figer la lettre.
+ Le serveur recalculera la série, la période de référence, les indices et les
+ plafonds avant de figer la lettre.
@if (validationError() || createError()) {
diff --git a/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.spec.ts b/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.spec.ts
index 4ad4a5d..8f38569 100644
--- a/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.spec.ts
+++ b/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.spec.ts
@@ -106,6 +106,7 @@ describe('RentRevisionProposalsComponent', () => {
if (withPreview) {
fixture.componentRef.setInput('preview', preview);
fixture.componentRef.setInput('previewRequest', previewRequest);
+ fixture.componentRef.setInput('previewActionable', true);
}
fixture.detectChanges();
return fixture;
diff --git a/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.ts b/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.ts
index 23e3310..eabdd5e 100644
--- a/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.ts
+++ b/frontend/src/app/components/rent-revision-proposals/rent-revision-proposals.ts
@@ -1,10 +1,19 @@
-import { Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core';
+import {
+ Component,
+ computed,
+ DestroyRef,
+ ElementRef,
+ effect,
+ inject,
+ input,
+ signal,
+} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
-import { FormField, FormRoot, form, min } from '@angular/forms/signals';
+import { FormField, FormRoot, form, max, min } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { finalize } from 'rxjs';
-import { httpErrorMessage } from '../../services/http-error-message';
+import { FieldValidationError, saveFailure } from '../../services/http-error-message';
import {
LeaseService,
RentRevisionDeliveryMethod,
@@ -40,13 +49,16 @@ const euro = new Intl.NumberFormat('fr-FR', {
export class RentRevisionProposalsComponent {
private readonly leases = inject(LeaseService);
private readonly destroyRef = inject(DestroyRef);
+ private readonly host = inject(ElementRef);
readonly history = inject(RentRevisionHistoryState);
readonly leaseId = input.required();
readonly preview = input(null);
readonly previewRequest = input(null);
+ readonly previewActionable = input(false);
readonly creating = signal(false);
readonly createError = signal('');
+ readonly fieldErrors = signal([]);
readonly validationError = signal('');
readonly confirmedWarningCodes = signal>(new Set());
readonly proposalModel = signal({
@@ -55,8 +67,10 @@ export class RentRevisionProposalsComponent {
chosenCharges: null as number | null,
});
readonly proposalForm = form(this.proposalModel, (fields) => {
- min(fields.chosenRent, 0.01);
- min(fields.chosenCharges, 0.01);
+ min(fields.chosenRent, () => this.preview()?.previousRent);
+ max(fields.chosenRent, () => this.preview()?.rentCeiling);
+ min(fields.chosenCharges, () => this.preview()?.previousCharges);
+ max(fields.chosenCharges, () => this.preview()?.chargeCeiling ?? undefined);
});
readonly previewRevisionApplied = computed(() => {
const preview = this.preview();
@@ -90,6 +104,7 @@ export class RentRevisionProposalsComponent {
this.confirmedWarningCodes.set(new Set());
this.validationError.set('');
this.createError.set('');
+ this.fieldErrors.set([]);
});
}
@@ -102,6 +117,7 @@ export class RentRevisionProposalsComponent {
!previewRequest ||
this.history.loading() ||
this.history.error() ||
+ !this.previewActionable() ||
this.previewRevisionApplied() ||
this.proposalWindowIssue()
)
@@ -127,6 +143,7 @@ export class RentRevisionProposalsComponent {
this.creating.set(true);
this.validationError.set('');
this.createError.set('');
+ this.fieldErrors.set([]);
this.leases
.createRentRevision(this.leaseId(), request)
.pipe(
@@ -135,17 +152,46 @@ export class RentRevisionProposalsComponent {
)
.subscribe({
next: (revision) => this.history.prepend(revision),
- error: (error: unknown) =>
+ error: (error: unknown) => {
+ const failure = saveFailure(
+ error,
+ 'Impossible de créer cette proposition de révision.',
+ );
+ const visibleErrors = failure.fieldErrors.filter((fieldError) =>
+ this.isKnownField(fieldError.field),
+ );
+ this.fieldErrors.set(visibleErrors);
this.createError.set(
- httpErrorMessage(
- error,
- 'Impossible de créer cette proposition de révision.',
- ),
- ),
+ visibleErrors.length === failure.fieldErrors.length
+ ? failure.message
+ : 'Impossible de créer cette proposition de révision.',
+ );
+ this.focusFirstFieldError();
+ },
});
}
+ clearFieldError(field: string): void {
+ if (!this.fieldErrors().some((error) => error.field === field)) return;
+ const remaining = this.fieldErrors().filter((error) => error.field !== field);
+ this.fieldErrors.set(remaining);
+ if (remaining.length === 0) this.createError.set('');
+ }
+
+ fieldError(field: string): string {
+ if (!this.fieldErrors().some((error) => error.field === field)) return '';
+ return (
+ {
+ requestedOn: 'Renseignez une date de demande valide.',
+ chosenRent: 'Renseignez un nouveau loyer strictement positif.',
+ chosenCharges: 'Renseignez des charges positives ou nulles.',
+ confirmedWarningCodes: 'Confirmez chaque avertissement affiché.',
+ }[field] ?? 'Vérifiez cette valeur.'
+ );
+ }
+
setWarning(code: string, checked: boolean): void {
+ this.clearFieldError('confirmedWarningCodes');
this.confirmedWarningCodes.update((codes) => {
const next = new Set(codes);
if (checked) next.add(code);
@@ -241,4 +287,28 @@ export class RentRevisionProposalsComponent {
parts.find((part) => part.type === type)?.value ?? '';
return `${value('year')}-${value('month')}-${value('day')}`;
}
+
+ private isKnownField(field: string): boolean {
+ return ['requestedOn', 'chosenRent', 'chosenCharges', 'confirmedWarningCodes'].includes(
+ field,
+ );
+ }
+
+ private focusFirstFieldError(): void {
+ const selector = this.fieldErrors()
+ .map(
+ (error) =>
+ ({
+ requestedOn: '.requested-on',
+ chosenRent: '.chosen-rent',
+ chosenCharges: '.chosen-charges',
+ confirmedWarningCodes: '.proposal-warning',
+ })[error.field],
+ )
+ .find((value): value is string => typeof value === 'string');
+ if (selector)
+ queueMicrotask(() =>
+ (this.host.nativeElement.querySelector(selector) as HTMLElement | null)?.focus(),
+ );
+ }
}