Align rent payment dialog validation (#55)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent cd32d05466
commit 96bd02e3a6
23 files changed
+723 -87

No files matched your search

@@ -8,7 +8,11 @@
<fieldset class="dialog-fieldset" [disabled]="saving()">
<mat-form-field appearance="outline">
<mat-label>Échéance cible</mat-label>
<mat-select formControlName="rentDueId" required>
<mat-select
formControlName="rentDueId"
required
(selectionChange)="clearFieldError('rentDueId')"
>
@for (item of data.eligibleRentDues; track item.rentDue.id) {
<mat-option [value]="item.rentDue.id">
{{ item.label }} —
@@ -16,19 +20,27 @@
</mat-option>
}
</mat-select>
@if (form.controls.rentDueId.invalid) {
<mat-error>Choisissez une autre échéance de ce bail.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Montant à affecter</mat-label>
<input
matInput
type="number"
step="0.01"
min="0.01"
step="any"
min="0"
formControlName="amount"
required
(input)="clearFieldError('amount')"
/>
<span matTextSuffix>€</span>
@if (form.controls.amount.hasError('unavailableAmount')) {
@if (
form.controls.amount.hasError('unavailableAmount') ||
form.controls.amount.hasError('exceeds_payment_balance') ||
form.controls.amount.hasError('exceeds_rent_due_balance')
) {
<mat-error>Le montant dépasse le surplus ou le solde de l’échéance.</mat-error>
} @else if (form.controls.amount.invalid) {
<mat-error>Le montant doit être strictement positif.</mat-error>
@@ -47,7 +59,7 @@
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-spinner diameter="18" aria-label="Affectation en cours" />
}
@@ -1,4 +1,4 @@
import { Component, computed, DestroyRef, inject, signal } from '@angular/core';
import { Component, computed, DestroyRef, ElementRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import {
AbstractControl,
@@ -16,7 +16,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { finalize } from 'rxjs';
import { RentDue } from '../../services/lease.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { saveFailure } from '../../services/http-error-message';
import { formatEuro } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
@@ -49,9 +49,11 @@ export class RentPaymentAllocationDialog {
readonly data = inject<RentPaymentAllocationDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<RentPaymentAllocationDialog>);
private readonly payments = inject(RentPaymentService);
private readonly host = inject(ElementRef<HTMLElement>);
private readonly operationId = crypto.randomUUID();
readonly saving = signal(false);
readonly error = signal('');
readonly fieldErrorFields = signal<ReadonlySet<string>>(new Set());
readonly formatEuro = formatEuro;
readonly form = new FormGroup({
@@ -61,7 +63,7 @@ export class RentPaymentAllocationDialog {
}),
amount: new FormControl(0, {
nonNullable: true,
validators: [Validators.required, Validators.min(0.01)],
validators: [Validators.required, Validators.min(Number.MIN_VALUE)],
}),
});
@@ -101,7 +103,11 @@ export class RentPaymentAllocationDialog {
submit(): void {
this.form.markAllAsTouched();
const due = this.selectedRentDue()?.rentDue;
if (!due || this.form.invalid || this.saving()) return;
if (!due || this.form.invalid) {
this.focusFirstInvalid();
return;
}
if (this.saving()) return;
this.saving.set(true);
this.error.set('');
this.payments
@@ -120,11 +126,52 @@ export class RentPaymentAllocationDialog {
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
return;
}
this.error.set(httpErrorMessage(error, 'Impossible d’affecter ce règlement.'));
this.showFailure(error, 'Impossible d’affecter ce règlement.');
},
});
}
private showFailure(error: unknown, fallback: string): void {
const failure = saveFailure(error, fallback);
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);
if (control) control.setErrors({ ...control.errors, [fieldError.code]: true });
}
this.focusFirstInvalid();
}
clearFieldError(field: string): void {
if (!this.fieldErrorFields().has(field)) return;
const remaining = new Set(this.fieldErrorFields());
remaining.delete(field);
this.fieldErrorFields.set(remaining);
if (remaining.size === 0) this.error.set('');
}
private isKnownFieldError(field: string, code: string): boolean {
return (
(field === 'rentDueId' &&
['not_blank', 'different_lease', 'already_allocated'].includes(code)) ||
(field === 'amount' &&
[
'not_null',
'positive',
'exceeds_payment_balance',
'exceeds_rent_due_balance',
].includes(code))
);
}
private focusFirstInvalid(): void {
const field = ['rentDueId', 'amount'].find((name) => this.form.get(name)?.invalid);
if (field) this.host.nativeElement.querySelector(`[formControlName="${field}"]`)?.focus();
}
private withinAvailableAmount(control: AbstractControl<number>): ValidationErrors | null {
return control.value > this.maximumAmount() ? { unavailableAmount: true } : null;
}
@@ -1,9 +1,20 @@
import { RentPayment } from '../services/rent-payment.service';
export const RENT_PAYMENT_DATA_STALE = 'RENT_PAYMENT_DATA_STALE';
const CONCURRENT_CHANGE = 'concurrent_change';
export type RentPaymentDialogResult = RentPayment | typeof RENT_PAYMENT_DATA_STALE;
export function isRentPaymentDataStale(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'status' in error && error.status === 409;
return (
typeof error === 'object' &&
error !== null &&
'status' in error &&
error.status === 409 &&
'error' in error &&
typeof error.error === 'object' &&
error.error !== null &&
'code' in error.error &&
error.error.code === CONCURRENT_CHANGE
);
}
@@ -53,8 +53,10 @@ describe('rent payment dialogs', () => {
expect(dialogRef.close).toHaveBeenCalledWith(result);
});
it('closes stale data so the page can refresh instead of retrying an obsolete version', () => {
payments.record.mockReturnValue(throwError(() => ({ status: 409 })));
it('closes only a coded concurrent change so the page can refresh obsolete data', () => {
payments.record.mockReturnValue(
throwError(() => ({ status: 409, error: { code: 'concurrent_change' } })),
);
const component = create(RentPaymentValidationDialog, {
rentDue: due(),
apartmentName: 'T2',
@@ -65,6 +67,63 @@ describe('rent payment dialogs', () => {
expect(dialogRef.close).toHaveBeenCalledWith(RENT_PAYMENT_DATA_STALE);
});
it('keeps a business conflict visible with the entered values', () => {
payments.record.mockReturnValue(
throwError(() => ({ status: 409, error: { message: 'Échéance déjà réglée' } })),
);
const component = create(RentPaymentValidationDialog, {
rentDue: due(),
apartmentName: 'T2',
});
component.form.controls.amount.setValue(0.001);
component.submit();
expect(component.form.controls.amount.value).toBe(0.001);
expect(component.error()).toBe('Échéance déjà réglée');
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('maps a known server field error and clears it when that field changes', () => {
payments.refund.mockReturnValue(
throwError(() => ({
status: 400,
error: {
code: 'validation_failed',
errors: [{ field: 'amount', code: 'exceeds_unallocated_balance' }],
},
})),
);
const component = create(RentPaymentRefundDialog, payment({ unallocatedBalance: 100 }));
component.submit();
expect(component.error()).toBe('');
expect(component.form.controls.amount.hasError('exceeds_unallocated_balance')).toBe(true);
component.form.controls.amount.setValue(50);
component.clearFieldError('amount');
expect(component.form.controls.amount.hasError('exceeds_unallocated_balance')).toBe(false);
expect(component.fieldErrorFields().has('amount')).toBe(false);
});
it('keeps an unrecognized structured error at form level', () => {
payments.refund.mockReturnValue(
throwError(() => ({
status: 400,
error: {
code: 'validation_failed',
errors: [{ field: 'request', code: 'invalid' }],
},
})),
);
const component = create(RentPaymentRefundDialog, payment({ unallocatedBalance: 100 }));
component.submit();
expect(component.error()).toBe('Veuillez corriger les champs signalés.');
expect(component.form.controls.amount.errors).toBeNull();
});
it('defaults a surplus allocation to the largest safe amount', () => {
const result = payment({ unallocatedBalance: 20, version: 4 });
payments.allocate.mockReturnValue(of(result));
@@ -128,6 +187,17 @@ describe('rent payment dialogs', () => {
expect(dialogRef.close).toHaveBeenCalledWith(result);
});
it('reveals a whitespace-only reversal reason without sending it', () => {
const component = create(RentPaymentReversalDialog, payment({ version: 3 }));
component.form.controls.reason.setValue(' ');
component.submit();
expect(component.form.controls.reason.touched).toBe(true);
expect(component.form.controls.reason.hasError('required')).toBe(true);
expect(payments.reverse).not.toHaveBeenCalled();
});
function create<T>(component: Type<T>, data: unknown): T {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
@@ -8,9 +8,21 @@
<fieldset class="dialog-fieldset" [disabled]="saving()">
<mat-form-field appearance="outline">
<mat-label>Date du remboursement</mat-label>
<input matInput type="date" formControlName="refundedAt" [max]="today" required />
@if (form.controls.refundedAt.hasError('futureDate')) {
<input
matInput
type="date"
formControlName="refundedAt"
[max]="today"
required
(input)="clearFieldError('refundedAt')"
/>
@if (
form.controls.refundedAt.hasError('futureDate') ||
form.controls.refundedAt.hasError('future_date')
) {
<mat-error>La date du remboursement ne peut pas être dans le futur.</mat-error>
} @else if (form.controls.refundedAt.invalid) {
<mat-error>La date du remboursement est obligatoire.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
@@ -18,13 +30,17 @@
<input
matInput
type="number"
step="0.01"
min="0.01"
step="any"
min="0"
formControlName="amount"
required
(input)="clearFieldError('amount')"
/>
<span matTextSuffix>€</span>
@if (form.controls.amount.hasError('max')) {
@if (
form.controls.amount.hasError('max') ||
form.controls.amount.hasError('exceeds_unallocated_balance')
) {
<mat-error>Le remboursement dépasse le solde non affecté.</mat-error>
} @else if (form.controls.amount.invalid) {
<mat-error>Le montant doit être strictement positif.</mat-error>
@@ -37,7 +53,7 @@
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-spinner diameter="18" aria-label="Remboursement en cours" />
}
@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core';
import { Component, ElementRef, inject, signal } from '@angular/core';
import {
AbstractControl,
FormControl,
@@ -13,7 +13,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { finalize } from 'rxjs';
import { httpErrorMessage } from '../../services/http-error-message';
import { saveFailure } from '../../services/http-error-message';
import { browserTimeZone, formatEuro, localIsoDate } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
@@ -35,10 +35,12 @@ export class RentPaymentRefundDialog {
readonly payment = inject<RentPayment>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<RentPaymentRefundDialog>);
private readonly payments = inject(RentPaymentService);
private readonly host = inject(ElementRef<HTMLElement>);
private readonly operationId = crypto.randomUUID();
readonly today = localIsoDate();
readonly saving = signal(false);
readonly error = signal('');
readonly fieldErrorFields = signal<ReadonlySet<string>>(new Set());
readonly formatEuro = formatEuro;
readonly form = new FormGroup({
@@ -50,7 +52,7 @@ export class RentPaymentRefundDialog {
nonNullable: true,
validators: [
Validators.required,
Validators.min(0.01),
Validators.min(Number.MIN_VALUE),
Validators.max(this.payment.unallocatedBalance),
],
}),
@@ -58,7 +60,11 @@ export class RentPaymentRefundDialog {
submit(): void {
this.form.markAllAsTouched();
if (this.form.invalid || this.saving()) return;
if (this.form.invalid) {
this.focusFirstInvalid();
return;
}
if (this.saving()) return;
this.saving.set(true);
this.error.set('');
this.payments
@@ -76,13 +82,46 @@ export class RentPaymentRefundDialog {
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
return;
}
this.error.set(
httpErrorMessage(error, 'Impossible d’enregistrer ce remboursement.'),
);
this.showFailure(error, 'Impossible d’enregistrer ce remboursement.');
},
});
}
private showFailure(error: unknown, fallback: string): void {
const failure = saveFailure(error, fallback);
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);
if (control) control.setErrors({ ...control.errors, [fieldError.code]: true });
}
this.focusFirstInvalid();
}
clearFieldError(field: string): void {
if (!this.fieldErrorFields().has(field)) return;
const remaining = new Set(this.fieldErrorFields());
remaining.delete(field);
this.fieldErrorFields.set(remaining);
if (remaining.size === 0) this.error.set('');
}
private isKnownFieldError(field: string, code: string): boolean {
return (
(field === 'refundedAt' && ['not_null', 'future_date'].includes(code)) ||
(field === 'amount' &&
['not_null', 'positive', 'exceeds_unallocated_balance'].includes(code))
);
}
private focusFirstInvalid(): void {
const field = ['refundedAt', 'amount'].find((name) => this.form.get(name)?.invalid);
if (field) this.host.nativeElement.querySelector(`[formControlName="${field}"]`)?.focus();
}
private notFuture(control: AbstractControl<string>): ValidationErrors | null {
return control.value > this.today ? { futureDate: true } : null;
}
@@ -18,12 +18,20 @@
<textarea
matInput
rows="4"
maxlength="500"
formControlName="reason"
required
(input)="clearFieldError('reason')"
></textarea>
@if (form.controls.reason.invalid) {
@if (
form.controls.reason.hasError('required') ||
form.controls.reason.hasError('not_blank')
) {
<mat-error>Le motif est obligatoire.</mat-error>
} @else if (
form.controls.reason.hasError('maxlength') ||
form.controls.reason.hasError('size')
) {
<mat-error>Le motif ne peut pas dépasser 500 caractères.</mat-error>
}
</mat-form-field>
</fieldset>
@@ -33,12 +41,7 @@
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
<button
mat-flat-button
type="submit"
class="danger-action"
[disabled]="form.invalid || saving()"
>
<button mat-flat-button type="submit" class="danger-action" [disabled]="saving()">
@if (saving()) {
<mat-spinner diameter="18" aria-label="Contrepassation en cours" />
}
@@ -1,12 +1,18 @@
import { Component, inject, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Component, ElementRef, inject, signal } from '@angular/core';
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { finalize } from 'rxjs';
import { httpErrorMessage } from '../../services/http-error-message';
import { saveFailure } from '../../services/http-error-message';
import { browserTimeZone, formatEuro } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
@@ -28,20 +34,26 @@ export class RentPaymentReversalDialog {
readonly payment = inject<RentPayment>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<RentPaymentReversalDialog>);
private readonly payments = inject(RentPaymentService);
private readonly host = inject(ElementRef<HTMLElement>);
readonly saving = signal(false);
readonly error = signal('');
readonly fieldErrorFields = signal<ReadonlySet<string>>(new Set());
readonly formatEuro = formatEuro;
readonly form = new FormGroup({
reason: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(500)],
validators: this.validReason.bind(this),
}),
});
submit(): void {
this.form.markAllAsTouched();
if (this.form.invalid || this.saving()) return;
if (this.form.invalid) {
this.focusFirstInvalid();
return;
}
if (this.saving()) return;
this.saving.set(true);
this.error.set('');
this.payments
@@ -59,10 +71,45 @@ export class RentPaymentReversalDialog {
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
return;
}
this.error.set(
httpErrorMessage(error, 'Impossible de contrepasser ce règlement.'),
);
this.showFailure(error, 'Impossible de contrepasser ce règlement.');
},
});
}
private validReason(control: AbstractControl<string>): ValidationErrors | null {
const reason = control.value.trim();
if (!reason) return { required: true };
return reason.length > 500 ? { maxlength: true } : null;
}
private showFailure(error: unknown, fallback: string): void {
const failure = saveFailure(error, fallback);
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);
if (control) control.setErrors({ ...control.errors, [fieldError.code]: true });
}
this.focusFirstInvalid();
}
clearFieldError(field: string): void {
if (!this.fieldErrorFields().has(field)) return;
const remaining = new Set(this.fieldErrorFields());
remaining.delete(field);
this.fieldErrorFields.set(remaining);
if (remaining.size === 0) this.error.set('');
}
private isKnownFieldError(field: string, code: string): boolean {
return field === 'reason' && ['not_blank', 'size'].includes(code);
}
private focusFirstInvalid(): void {
if (this.form.controls.reason.invalid)
this.host.nativeElement.querySelector('[formControlName="reason"]')?.focus();
}
}
@@ -28,9 +28,21 @@
<fieldset class="dialog-fieldset" [disabled]="saving()">
<mat-form-field appearance="outline">
<mat-label>Date de réception</mat-label>
<input matInput type="date" formControlName="receivedAt" [max]="today" required />
@if (form.controls.receivedAt.hasError('futureDate')) {
<input
matInput
type="date"
formControlName="receivedAt"
[max]="today"
required
(input)="clearFieldError('receivedAt')"
/>
@if (
form.controls.receivedAt.hasError('futureDate') ||
form.controls.receivedAt.hasError('future_date')
) {
<mat-error>La date de réception ne peut pas être dans le futur.</mat-error>
} @else if (form.controls.receivedAt.invalid) {
<mat-error>La date de réception est obligatoire.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
@@ -38,10 +50,11 @@
<input
matInput
type="number"
step="0.01"
min="0.01"
step="any"
min="0"
formControlName="amount"
required
(input)="clearFieldError('amount')"
/>
<span matTextSuffix>€</span>
@if (form.controls.amount.invalid) {
@@ -68,7 +81,7 @@
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-spinner diameter="18" aria-label="Enregistrement en cours" />
}
@@ -1,4 +1,4 @@
import { Component, computed, inject, signal } from '@angular/core';
import { Component, computed, ElementRef, inject, signal } from '@angular/core';
import {
AbstractControl,
FormControl,
@@ -15,7 +15,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { toSignal } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs';
import { RentDue } from '../../services/lease.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { saveFailure } from '../../services/http-error-message';
import { browserTimeZone, formatEuro, localIsoDate } from '../../services/rent-payment-format';
import { RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
@@ -42,10 +42,12 @@ export class RentPaymentValidationDialog {
readonly data = inject<RentPaymentValidationDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<RentPaymentValidationDialog>);
private readonly payments = inject(RentPaymentService);
private readonly host = inject(ElementRef<HTMLElement>);
private readonly operationId = crypto.randomUUID();
readonly today = localIsoDate();
readonly saving = signal(false);
readonly error = signal('');
readonly fieldErrorFields = signal<ReadonlySet<string>>(new Set());
readonly formatEuro = formatEuro;
readonly form = new FormGroup({
@@ -55,7 +57,7 @@ export class RentPaymentValidationDialog {
}),
amount: new FormControl(this.data.rentDue.remainingAmount, {
nonNullable: true,
validators: [Validators.required, Validators.min(0.01)],
validators: [Validators.required, Validators.min(Number.MIN_VALUE)],
}),
});
@@ -74,7 +76,11 @@ export class RentPaymentValidationDialog {
submit(): void {
this.form.markAllAsTouched();
if (this.form.invalid || this.saving()) return;
if (this.form.invalid) {
this.focusFirstInvalid();
return;
}
if (this.saving()) return;
this.saving.set(true);
this.error.set('');
this.payments
@@ -93,13 +99,45 @@ export class RentPaymentValidationDialog {
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
return;
}
this.error.set(
httpErrorMessage(error, 'Impossible d’enregistrer ce règlement.'),
);
this.showFailure(error, 'Impossible d’enregistrer ce règlement.');
},
});
}
private showFailure(error: unknown, fallback: string): void {
const failure = saveFailure(error, fallback);
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);
if (control) control.setErrors({ ...control.errors, [fieldError.code]: true });
}
this.focusFirstInvalid();
}
clearFieldError(field: string): void {
if (!this.fieldErrorFields().has(field)) return;
const remaining = new Set(this.fieldErrorFields());
remaining.delete(field);
this.fieldErrorFields.set(remaining);
if (remaining.size === 0) this.error.set('');
}
private isKnownFieldError(field: string, code: string): boolean {
return (
(field === 'receivedAt' && ['not_null', 'future_date'].includes(code)) ||
(field === 'amount' && ['not_null', 'positive'].includes(code))
);
}
private focusFirstInvalid(): void {
const field = ['receivedAt', 'amount'].find((name) => this.form.get(name)?.invalid);
if (field) this.host.nativeElement.querySelector(`[formControlName="${field}"]`)?.focus();
}
private notFuture(control: AbstractControl<string>): ValidationErrors | null {
return control.value > this.today ? { futureDate: true } : null;
}