Align lease ending validation flows (#54)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent ca3e28d3a2
commit a0f86bb511
25 files changed
+847 -130

No files matched your search

@@ -159,17 +159,45 @@
>
<fieldset [disabled]="withdrawalPending()">
<legend>Accepter un retrait</legend>
<label
><span>Date d’acceptation</span
><input type="date" [formField]="withdrawalForm.acceptedOn"
/></label>
<label>
<span>Date d’acceptation</span>
<input
id="withdrawal-date"
type="date"
[formField]="withdrawalForm.acceptedOn"
(input)="withdrawalFieldChanged.emit('acceptedOn')"
/>
</label>
@if (withdrawalDateError()) {
<p class="operation-error" role="alert">{{ withdrawalDateError() }}</p>
}
@if (serverError(withdrawalFieldErrors(), 'acceptedOn')) {
<p class="operation-error" role="alert">
{{ serverError(withdrawalFieldErrors(), 'acceptedOn') }}
</p>
}
<label
><span>Raison</span
><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
><textarea
id="withdrawal-reason"
rows="2"
[formField]="withdrawalForm.reason"
(input)="withdrawalFieldChanged.emit('reason')"
></textarea>
</label>
@if (withdrawalError() || withdrawalOperationError()) {
@if (withdrawalReasonError()) {
<p class="operation-error" role="alert">
{{ withdrawalError() || withdrawalOperationError() }}
{{ withdrawalReasonError() }}
</p>
}
@if (serverError(withdrawalFieldErrors(), 'reason')) {
<p class="operation-error" role="alert">
{{ serverError(withdrawalFieldErrors(), 'reason') }}
</p>
}
@if (withdrawalOperationError()) {
<p class="operation-error" role="alert">
{{ withdrawalOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
@@ -184,17 +212,47 @@
>
<fieldset [disabled]="invalidationPending()">
<legend>Invalider administrativement</legend>
<label
><span>Date d’invalidation</span
><input type="date" [formField]="invalidationForm.invalidatedOn"
/></label>
<label>
<span>Date d’invalidation</span>
<input
id="invalidation-date"
type="date"
[formField]="invalidationForm.invalidatedOn"
(input)="invalidationFieldChanged.emit('invalidatedOn')"
/>
</label>
@if (invalidationDateError()) {
<p class="operation-error" role="alert">
{{ invalidationDateError() }}
</p>
}
@if (serverError(invalidationFieldErrors(), 'invalidatedOn')) {
<p class="operation-error" role="alert">
{{ serverError(invalidationFieldErrors(), 'invalidatedOn') }}
</p>
}
<label
><span>Raison</span
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
><textarea
id="invalidation-reason"
rows="2"
[formField]="invalidationForm.reason"
(input)="invalidationFieldChanged.emit('reason')"
></textarea>
</label>
@if (invalidationError() || invalidationOperationError()) {
@if (invalidationReasonError()) {
<p class="operation-error" role="alert">
{{ invalidationError() || invalidationOperationError() }}
{{ invalidationReasonError() }}
</p>
}
@if (serverError(invalidationFieldErrors(), 'reason')) {
<p class="operation-error" role="alert">
{{ serverError(invalidationFieldErrors(), 'reason') }}
</p>
}
@if (invalidationOperationError()) {
<p class="operation-error" role="alert">
{{ invalidationOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
@@ -50,6 +50,8 @@ describe('LeaseEndNoticeCard', () => {
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('notice', notice);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-20');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
fixture.componentRef.setInput('evidenceUploadEnabled', true);
fixture.componentRef.setInput('withdrawalPending', false);
@@ -1,4 +1,4 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@@ -11,6 +11,7 @@ import {
LeaseEndService,
NoticeDeliveryMode,
} from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
export interface NoticeAction<T> {
noticeId: string;
@@ -24,10 +25,13 @@ export interface NoticeAction<T> {
styleUrl: './lease-end-notice-card.css',
})
export class LeaseEndNoticeCard {
private readonly element = inject(ElementRef<HTMLElement>);
readonly endService = inject(LeaseEndService);
readonly leaseId = input.required<string>();
readonly notice = input.required<LeaseEndNotice>();
readonly timeZone = input.required<string>();
readonly today = input.required<string>();
readonly maxEvidenceBytes = input.required<number>();
readonly lifecycleMutationsEnabled = input.required<boolean>();
readonly evidenceUploadEnabled = input.required<boolean>();
readonly withdrawalPending = input(false);
@@ -36,9 +40,13 @@ export class LeaseEndNoticeCard {
readonly withdrawalOperationError = input('');
readonly invalidationOperationError = input('');
readonly evidenceOperationError = input('');
readonly withdrawalFieldErrors = input<FieldValidationError[]>([]);
readonly invalidationFieldErrors = input<FieldValidationError[]>([]);
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
readonly withdrawalFieldChanged = output<string>();
readonly invalidationFieldChanged = output<string>();
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
@@ -49,12 +57,41 @@ export class LeaseEndNoticeCard {
});
readonly evidence = signal<File | null>(null);
readonly evidenceReason = signal('');
readonly withdrawalError = signal('');
readonly invalidationError = signal('');
readonly evidenceError = signal('');
readonly withdrawalSubmitted = signal(false);
readonly invalidationSubmitted = signal(false);
readonly civilDate = formatFrenchCivilDate;
constructor() {
effect(() => {
const withdrawalField = this.withdrawalFieldErrors()[0]?.field;
if (withdrawalField) {
queueMicrotask(() =>
(this.element.nativeElement as HTMLElement)
.querySelector<HTMLElement>(
withdrawalField === 'reason'
? '#withdrawal-reason'
: '#withdrawal-date',
)
?.focus(),
);
}
const invalidationField = this.invalidationFieldErrors()[0]?.field;
if (invalidationField) {
queueMicrotask(() =>
(this.element.nativeElement as HTMLElement)
.querySelector<HTMLElement>(
invalidationField === 'reason'
? '#invalidation-reason'
: '#invalidation-date',
)
?.focus(),
);
}
});
}
statusLabel(): string {
return {
ACTIVE: 'Congé en cours',
@@ -87,12 +124,12 @@ export class LeaseEndNoticeCard {
submitWithdrawal(): void {
if (this.withdrawalPending()) return;
this.withdrawalSubmitted.set(true);
this.withdrawalForm().markAsTouched();
const model = this.withdrawalModel();
if (!model.acceptedOn || !model.reason.trim()) {
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
if (this.withdrawalDateError() || this.withdrawalReasonError()) {
return;
}
this.withdrawalError.set('');
this.withdrawalRequested.emit({
noticeId: this.notice().id,
request: {
@@ -105,12 +142,12 @@ export class LeaseEndNoticeCard {
submitInvalidation(): void {
if (this.invalidationPending()) return;
this.invalidationSubmitted.set(true);
this.invalidationForm().markAsTouched();
const model = this.invalidationModel();
if (!model.invalidatedOn || !model.reason.trim()) {
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
if (this.invalidationDateError() || this.invalidationReasonError()) {
return;
}
this.invalidationError.set('');
this.invalidationRequested.emit({
noticeId: this.notice().id,
request: {
@@ -129,6 +166,18 @@ export class LeaseEndNoticeCard {
this.evidenceError.set('Sélectionnez une preuve à conserver.');
return;
}
if (evidence.size === 0) {
this.evidenceError.set('La preuve sélectionnée est vide.');
return;
}
if (evidence.size > this.maxEvidenceBytes()) {
this.evidenceError.set('La preuve dépasse la taille maximale autorisée.');
return;
}
if (reason.length > 500) {
this.evidenceError.set('La raison ne peut pas dépasser 500 caractères.');
return;
}
if (this.notice().evidenceVersions.length > 0 && !reason) {
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
return;
@@ -139,4 +188,60 @@ export class LeaseEndNoticeCard {
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
});
}
withdrawalDateError(): string {
const date = this.withdrawalModel().acceptedOn;
if (!this.show(this.withdrawalSubmitted(), this.withdrawalForm.acceptedOn())) return '';
if (!date) return 'Renseignez la date d’acceptation.';
return date < this.notice().receivedOn || date > this.today()
? 'La date doit être comprise entre la réception du congé et aujourd’hui.'
: '';
}
withdrawalReasonError(): string {
return this.reasonError(
this.withdrawalSubmitted(),
this.withdrawalForm.reason(),
this.withdrawalModel().reason,
);
}
invalidationDateError(): string {
const date = this.invalidationModel().invalidatedOn;
if (!this.show(this.invalidationSubmitted(), this.invalidationForm.invalidatedOn()))
return '';
if (!date) return 'Renseignez la date d’invalidation.';
return date < this.notice().receivedOn || date > this.today()
? 'La date doit être comprise entre la réception du congé et aujourd’hui.'
: '';
}
invalidationReasonError(): string {
return this.reasonError(
this.invalidationSubmitted(),
this.invalidationForm.reason(),
this.invalidationModel().reason,
);
}
serverError(errors: FieldValidationError[], field: string): string {
const code = errors.find((error) => error.field === field)?.code;
if (field === 'reason' && code === 'max_length')
return 'La raison ne peut pas dépasser 500 caractères.';
if (field === 'reason' && code === 'required') return 'Renseignez la raison.';
if ((field === 'acceptedOn' || field === 'invalidatedOn') && code) {
return 'La date doit être comprise entre la réception du congé et aujourd’hui.';
}
return '';
}
private reasonError(submitted: boolean, field: { touched(): boolean }, value: string): string {
if (!this.show(submitted, field)) return '';
if (!value.trim()) return 'Renseignez la raison.';
return value.trim().length > 500 ? 'La raison ne peut pas dépasser 500 caractères.' : '';
}
private show(submitted: boolean, field: { touched(): boolean }): boolean {
return submitted || field.touched();
}
}
@@ -1,6 +1,6 @@
<section class="notice-form" aria-labelledby="notice-form-title">
<h2 id="notice-form-title">Enregistrer un congé</h2>
<p>Les dates sont enregistrées comme des dates civiles dans le fuseau {{ timeZone() }}.</p>
<p>Les dates restent des dates civiles dans le fuseau {{ timeZone() }}.</p>
<form
[formRoot]="noticeForm"
[attr.aria-busy]="submitting()"
@@ -10,39 +10,109 @@
<div class="field-grid">
<label>
<span>Date de réception</span>
<input type="date" [formField]="noticeForm.receivedOn" />
<input
id="notice-received-on"
type="date"
[formField]="noticeForm.receivedOn"
(input)="fieldWasChanged('receivedOn')"
/>
@if (receivedOnError()) {
<p class="operation-error" role="alert">{{ receivedOnError() }}</p>
}
@if (serverError('receivedOn')) {
<p class="operation-error" role="alert">{{ serverError('receivedOn') }}</p>
}
</label>
<label>
<span>Départ demandé</span>
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
<input
id="notice-requested-departure-on"
type="date"
[formField]="noticeForm.requestedDepartureOn"
(input)="fieldWasChanged('requestedDepartureOn')"
/>
@if (requestedDepartureOnError()) {
<p class="operation-error" role="alert">
{{ requestedDepartureOnError() }}
</p>
}
@if (serverError('requestedDepartureOn')) {
<p class="operation-error" role="alert">
{{ serverError('requestedDepartureOn') }}
</p>
}
</label>
</div>
<label>
<span>Mode de remise</span>
<select [formField]="noticeForm.deliveryMode">
<select
id="notice-delivery-mode"
[formField]="noticeForm.deliveryMode"
(change)="fieldWasChanged('deliveryMode')"
>
<option value="">Choisissez un mode</option>
@for (mode of deliveryModes; track mode.value) {
<option [value]="mode.value">{{ mode.label }}</option>
}
</select>
@if (deliveryModeError()) {
<p class="operation-error" role="alert">{{ deliveryModeError() }}</p>
}
</label>
@if (model().deliveryMode === 'OTHER') {
<label>
<span>Mode de remise utilisé</span>
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
<input
id="notice-other-delivery-mode"
type="text"
[formField]="noticeForm.otherDeliveryMode"
(input)="fieldWasChanged('otherDeliveryMode')"
/>
@if (otherDeliveryModeError()) {
<p class="operation-error" role="alert">{{ otherDeliveryModeError() }}</p>
}
@if (serverError('otherDeliveryMode')) {
<p class="operation-error" role="alert">
{{ serverError('otherDeliveryMode') }}
</p>
}
</label>
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
<input
type="checkbox"
[formField]="noticeForm.nonStandardDeliveryConfirmed"
(change)="fieldWasChanged('nonStandardDeliveryConfirmed')"
/>
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
</label>
@if (nonStandardDeliveryError()) {
<p class="operation-error" role="alert">{{ nonStandardDeliveryError() }}</p>
}
@if (serverError('nonStandardDeliveryConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('nonStandardDeliveryConfirmed') }}
</p>
}
}
@if (lateRegistration()) {
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
<input
type="checkbox"
[formField]="noticeForm.lateRegistrationConfirmed"
(change)="fieldWasChanged('lateRegistrationConfirmed')"
/>
<span
>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span
>
</label>
@if (lateRegistrationError()) {
<p class="operation-error" role="alert">{{ lateRegistrationError() }}</p>
}
@if (serverError('lateRegistrationConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('lateRegistrationConfirmed') }}
</p>
}
}
<label>
<span>Preuve (facultative, tout format)</span>
@@ -53,14 +123,23 @@
<div class="missing-evidence-warning">
<p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p>
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.missingEvidenceConfirmed" />
<input
type="checkbox"
[formField]="noticeForm.missingEvidenceConfirmed"
(change)="fieldWasChanged('missingEvidenceConfirmed')"
/>
<span>Je confirme enregistrer ce congé sans justificatif.</span>
</label>
@if (serverError('missingEvidenceConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('missingEvidenceConfirmed') }}
</p>
}
</div>
}
@if (validationError() || operationError()) {
@if (evidenceError() || operationError()) {
<p class="operation-error" role="alert">
{{ validationError() || operationError() }}
{{ evidenceError() || operationError() }}
</p>
}
<button mat-flat-button type="submit" [disabled]="submitting()">
@@ -9,6 +9,7 @@ describe('LeaseEndNoticeForm', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
fixture.detectChanges();
fixture.componentInstance.model.set({
@@ -25,22 +26,48 @@ describe('LeaseEndNoticeForm', () => {
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
expect(fixture.componentInstance.lateRegistrationError()).toContain(
'enregistrement tardif',
);
fixture.componentInstance.model.update((model) => ({
...model,
lateRegistrationConfirmed: true,
}));
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain(
expect(fixture.componentInstance.nonStandardDeliveryError()).toContain(
'mode de remise non standard',
);
});
it('shows the requested departure guidance after submit', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
fixture.componentInstance.model.set({
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-07-18',
deliveryMode: 'HAND_DELIVERY',
otherDeliveryMode: '',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
});
fixture.componentInstance.submit();
expect(fixture.componentInstance.requestedDepartureOnError()).toContain(
'ne peut pas précéder',
);
});
it('emits civil dates, timezone and arbitrary evidence without converting the day', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
@@ -86,6 +113,7 @@ describe('LeaseEndNoticeForm', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
@@ -101,7 +129,7 @@ describe('LeaseEndNoticeForm', () => {
});
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
expect(fixture.componentInstance.evidenceError()).toContain('sans justificatif');
fixture.componentInstance.model.update((model) => ({
...model,
@@ -1,8 +1,37 @@
import { Component, computed, input, output, signal } from '@angular/core';
import {
Component,
computed,
effect,
ElementRef,
inject,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { FormField, FormRoot, form, maxLength, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
const serverMessages: Record<string, Record<string, string>> = {
receivedOn: { not_future: 'La date de réception ne peut pas être future.' },
requestedDepartureOn: {
after_or_equal_received_on:
'La date de départ demandée ne peut pas précéder la réception du congé.',
},
otherDeliveryMode: {
required: 'Décrivez le mode de remise utilisé.',
max_length: 'Le mode de remise ne peut pas dépasser 200 caractères.',
},
lateRegistrationConfirmed: {
required: 'Confirmez cet enregistrement tardif avant de continuer.',
},
nonStandardDeliveryConfirmed: {
required: 'Confirmez le mode de remise non standard avant de continuer.',
},
missingEvidenceConfirmed: { required: 'Confirmez l’enregistrement sans justificatif.' },
};
@Component({
selector: 'app-lease-end-notice-form',
@@ -11,11 +40,15 @@ import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-en
styleUrl: './lease-end-notice-form.css',
})
export class LeaseEndNoticeForm {
private readonly element = inject(ElementRef<HTMLElement>);
readonly timeZone = input.required<string>();
readonly today = input.required<string>();
readonly maxEvidenceBytes = input.required<number>();
readonly submitting = input.required<boolean>();
readonly operationError = input('');
readonly fieldErrors = input<FieldValidationError[]>([]);
readonly noticeRequested = output<RecordNoticeRequest>();
readonly fieldChanged = output<string>();
readonly model = signal({
receivedOn: '',
requestedDepartureOn: '',
@@ -26,10 +59,13 @@ export class LeaseEndNoticeForm {
missingEvidenceConfirmed: false,
});
readonly noticeForm = form(this.model, (fields) => {
required(fields.receivedOn);
required(fields.requestedDepartureOn);
required(fields.deliveryMode);
maxLength(fields.otherDeliveryMode, 200);
});
readonly evidence = signal<File | null>(null);
readonly validationError = signal('');
readonly submitted = signal(false);
readonly lateRegistration = computed(
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
);
@@ -40,6 +76,12 @@ export class LeaseEndNoticeForm {
{ value: 'OTHER', label: 'Autre mode' },
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
constructor() {
effect(() => {
if (this.fieldErrors().length) queueMicrotask(() => this.focusFirstInvalid());
});
}
selectEvidence(event: Event): void {
const input = event.target;
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
@@ -50,47 +92,127 @@ export class LeaseEndNoticeForm {
if (input instanceof HTMLInputElement) input.value = '';
}
receivedOnError(): string {
if (!this.show(this.noticeForm.receivedOn())) return '';
if (!this.model().receivedOn) return 'Renseignez la date de réception du congé.';
return this.model().receivedOn > this.today()
? 'La date de réception ne peut pas être future.'
: '';
}
requestedDepartureOnError(): string {
if (!this.show(this.noticeForm.requestedDepartureOn())) return '';
if (!this.model().requestedDepartureOn) return 'Renseignez la date de départ demandée.';
return this.model().receivedOn &&
this.model().requestedDepartureOn < this.model().receivedOn
? 'La date de départ demandée ne peut pas précéder la réception du congé.'
: '';
}
deliveryModeError(): string {
return this.show(this.noticeForm.deliveryMode()) && !this.model().deliveryMode
? 'Choisissez le mode de remise du congé.'
: '';
}
otherDeliveryModeError(): string {
if (
this.model().deliveryMode !== 'OTHER' ||
!this.show(this.noticeForm.otherDeliveryMode())
) {
return '';
}
return !this.model().otherDeliveryMode.trim()
? 'Décrivez le mode de remise utilisé.'
: this.model().otherDeliveryMode.trim().length > 200
? 'Le mode de remise ne peut pas dépasser 200 caractères.'
: '';
}
lateRegistrationError(): string {
return this.lateRegistration() &&
this.show(this.noticeForm.lateRegistrationConfirmed()) &&
!this.model().lateRegistrationConfirmed
? 'Confirmez cet enregistrement tardif avant de continuer.'
: '';
}
nonStandardDeliveryError(): string {
return this.model().deliveryMode === 'OTHER' &&
this.show(this.noticeForm.nonStandardDeliveryConfirmed()) &&
!this.model().nonStandardDeliveryConfirmed
? 'Confirmez le mode de remise non standard avant de continuer.'
: '';
}
evidenceError(): string {
const evidence = this.evidence();
if (evidence?.size === 0) return 'La preuve sélectionnée est vide.';
if (evidence && evidence.size > this.maxEvidenceBytes()) {
return 'La preuve dépasse la taille maximale autorisée.';
}
return !evidence && this.submitted() && !this.model().missingEvidenceConfirmed
? 'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.'
: '';
}
serverError(field: string): string {
const code = this.fieldErrors().find((error) => error.field === field)?.code;
return serverMessages[field]?.[code ?? ''] ?? '';
}
fieldWasChanged(field: string): void {
this.fieldChanged.emit(field);
}
submit(): void {
if (this.submitting()) return;
this.submitted.set(true);
this.noticeForm().markAsTouched();
const model = this.model();
if (!model.receivedOn || !model.requestedDepartureOn || !model.deliveryMode) {
this.validationError.set('Renseignez les deux dates et le mode de remise du congé.');
if (
this.receivedOnError() ||
this.requestedDepartureOnError() ||
this.deliveryModeError() ||
this.otherDeliveryModeError() ||
this.lateRegistrationError() ||
this.nonStandardDeliveryError() ||
this.evidenceError()
) {
return;
}
if (this.lateRegistration() && !model.lateRegistrationConfirmed) {
this.validationError.set('Confirmez cet enregistrement tardif avant de continuer.');
return;
}
if (model.deliveryMode === 'OTHER' && !model.otherDeliveryMode.trim()) {
this.validationError.set('Décrivez le mode de remise utilisé.');
return;
}
if (model.deliveryMode === 'OTHER' && !model.nonStandardDeliveryConfirmed) {
this.validationError.set(
'Confirmez le mode de remise non standard avant de continuer.',
);
return;
}
if (!this.evidence() && !model.missingEvidenceConfirmed) {
this.validationError.set(
'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.',
);
return;
}
this.validationError.set('');
const deliveryMode = model.deliveryMode;
if (!deliveryMode) return;
const evidence = this.evidence();
this.noticeRequested.emit({
timeZone: this.timeZone(),
receivedOn: model.receivedOn,
requestedDepartureOn: model.requestedDepartureOn,
deliveryMode: model.deliveryMode,
...(model.deliveryMode === 'OTHER'
deliveryMode,
...(deliveryMode === 'OTHER'
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
: {}),
lateRegistrationConfirmed: this.lateRegistration(),
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
nonStandardDeliveryConfirmed: deliveryMode === 'OTHER',
missingEvidenceConfirmed: evidence === null,
...(evidence ? { evidence } : {}),
});
}
private show(field: { touched(): boolean }): boolean {
return this.submitted() || field.touched();
}
private focusFirstInvalid(): void {
const field = this.fieldErrors()[0]?.field;
const selector =
field === 'receivedOn'
? '#notice-received-on'
: field === 'requestedDepartureOn'
? '#notice-requested-departure-on'
: field === 'otherDeliveryMode'
? '#notice-other-delivery-mode'
: '#notice-delivery-mode';
(this.element.nativeElement as HTMLElement).querySelector<HTMLElement>(selector)?.focus();
}
}
@@ -116,6 +116,8 @@
[leaseId]="leaseId"
[notice]="notice"
[timeZone]="timeZone"
[today]="today"
[maxEvidenceBytes]="endState.maxEvidenceBytes"
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
[evidenceUploadEnabled]="true"
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
@@ -124,9 +126,17 @@
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
[evidenceOperationError]="operationError('evidence:' + notice.id)"
[withdrawalFieldErrors]="operationFieldError('withdrawal:' + notice.id)"
[invalidationFieldErrors]="operationFieldError('invalidation:' + notice.id)"
(withdrawalRequested)="requestWithdrawal($event)"
(invalidationRequested)="requestInvalidation($event)"
(evidenceRequested)="appendEvidence($event)"
(withdrawalFieldChanged)="
clearOperationFieldError('withdrawal:' + notice.id, $event)
"
(invalidationFieldChanged)="
clearOperationFieldError('invalidation:' + notice.id, $event)
"
/>
}
</section>
@@ -136,14 +146,19 @@
<app-lease-end-notice-form
[timeZone]="timeZone"
[today]="today"
[maxEvidenceBytes]="endState.maxEvidenceBytes"
[submitting]="operationPending('notice')"
[operationError]="operationError('notice')"
[fieldErrors]="operationFieldError('notice')"
(noticeRequested)="recordNotice($event)"
(fieldChanged)="clearOperationFieldError('notice', $event)"
/>
}
@if (canRecordAgreement() || canClose()) {
<app-lease-end-resolution-forms
[timeZone]="timeZone"
[leaseStartOn]="endState.leaseStartOn"
[currentEndOn]="endState.effectiveEndOn"
[showAgreement]="canRecordAgreement()"
[showClosure]="canClose()"
[keysReturnedOn]="exitReportDate()"
@@ -152,8 +167,12 @@
[closurePending]="operationPending('closure')"
[agreementOperationError]="operationError('agreement')"
[closureOperationError]="operationError('closure')"
[agreementFieldErrors]="operationFieldError('agreement')"
[closureFieldErrors]="operationFieldError('closure')"
(agreedEndRequested)="recordAgreement($event)"
(closureRequested)="closeLease($event)"
(agreementFieldChanged)="clearOperationFieldError('agreement', $event)"
(closureFieldChanged)="clearOperationFieldError('closure', $event)"
/>
}
}
@@ -17,8 +17,10 @@ describe('LeaseEndPage', () => {
unsupportedReason: null,
phase: 'NOTICE_PERIOD',
leaseType: 'STUDENT',
leaseStartOn: '2026-01-01',
fixedEndOn: '2026-07-31',
effectiveEndOn: '2026-07-31',
maxEvidenceBytes: 1024,
correctionRequired: false,
agreedEarlyEnds: [],
agreedEarlyEnd: null,
@@ -230,6 +232,36 @@ describe('LeaseEndPage', () => {
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
});
it('clears only the edited structured field error and its generic message', () => {
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
recordNotice.mockReturnValue(
throwError(() => ({
status: 400,
error: {
code: 'validation_failed',
errors: [{ field: 'receivedOn', code: 'not_future' }],
},
})),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
fixture.componentInstance.recordNotice({
timeZone: 'Europe/Paris',
receivedOn: '2026-07-20',
requestedDepartureOn: '2026-08-20',
deliveryMode: 'HAND_DELIVERY',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
});
expect(fixture.componentInstance.operationFieldError('notice')).toHaveLength(1);
fixture.componentInstance.clearOperationFieldError('notice', 'receivedOn');
expect(fixture.componentInstance.operationFieldError('notice')).toEqual([]);
expect(fixture.componentInstance.operationError('notice')).toBe('');
});
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
const fixture = TestBed.createComponent(LeaseEndPage);
@@ -10,7 +10,7 @@ import {
formatFrenchCivilMonth,
todayInTimeZone,
} from '../../services/civil-date';
import { httpErrorMessage } from '../../services/http-error-message';
import { FieldValidationError, saveFailure } from '../../services/http-error-message';
import { withoutRecordKey } from '../../services/immutable-state';
import {
AgreedEarlyEndRequest,
@@ -58,6 +58,7 @@ export class LeaseEndPage {
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
readonly operationErrors = signal<Record<string, string>>({});
readonly operationFieldErrors = signal<Record<string, FieldValidationError[]>>({});
readonly lifecycleMutationsEnabled = computed(() => {
const state = this.state();
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
@@ -110,7 +111,7 @@ export class LeaseEndPage {
next: (state) => this.state.set(state),
error: (error: unknown) =>
this.loadError.set(
httpErrorMessage(error, 'Impossible de charger la fin de ce bail.'),
saveFailure(error, 'Impossible de charger la fin de ce bail.').message,
),
});
}
@@ -201,6 +202,22 @@ export class LeaseEndPage {
return this.operationErrors()[key] ?? '';
}
operationFieldError(key: string): FieldValidationError[] {
return this.operationFieldErrors()[key] ?? [];
}
clearOperationFieldError(key: string, field: string): void {
const errors = this.operationFieldError(key);
if (!errors.some((error) => error.field === field)) return;
const remaining = errors.filter((error) => error.field !== field);
this.operationFieldErrors.update((all) =>
remaining.length ? { ...all, [key]: remaining } : withoutRecordKey(all, key),
);
if (!remaining.length) {
this.operationErrors.update((all) => withoutRecordKey(all, key));
}
}
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
if (this.operationPending(key)) return;
this.confirmingOperations.update((keys) => new Set(keys).add(key));
@@ -223,6 +240,7 @@ export class LeaseEndPage {
if (this.pendingOperations().has(key)) return;
this.pendingOperations.update((keys) => new Set(keys).add(key));
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
this.operationFieldErrors.update((errors) => withoutRecordKey(errors, key));
request
.pipe(
takeUntilDestroyed(this.destroyRef),
@@ -230,11 +248,17 @@ export class LeaseEndPage {
)
.subscribe({
next: (state) => this.state.set(state),
error: (error: unknown) =>
error: (error: unknown) => {
const failure = saveFailure(error, fallback);
this.operationErrors.update((errors) => ({
...errors,
[key]: httpErrorMessage(error, fallback),
})),
[key]: failure.message,
}));
this.operationFieldErrors.update((errors) => ({
...errors,
[key]: failure.fieldErrors,
}));
},
});
}
}
@@ -10,15 +10,41 @@
<fieldset [disabled]="agreementPending()">
<label>
<span>Date de fin convenue</span>
<input type="date" [formField]="agreementForm.endOn" />
<input
id="agreement-end-on"
type="date"
[formField]="agreementForm.endOn"
(input)="agreementFieldChanged.emit('endOn')"
/>
</label>
@if (agreementDateError()) {
<p class="operation-error" role="alert">{{ agreementDateError() }}</p>
}
@if (serverError(agreementFieldErrors(), 'endOn')) {
<p class="operation-error" role="alert">
{{ serverError(agreementFieldErrors(), 'endOn') }}
</p>
}
<label>
<span>Raison de l’accord</span>
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
<textarea
id="agreement-reason"
rows="3"
[formField]="agreementForm.reason"
(input)="agreementFieldChanged.emit('reason')"
></textarea>
</label>
@if (agreementError() || agreementOperationError()) {
@if (agreementReasonError()) {
<p class="operation-error" role="alert">{{ agreementReasonError() }}</p>
}
@if (serverError(agreementFieldErrors(), 'reason')) {
<p class="operation-error" role="alert">
{{ agreementError() || agreementOperationError() }}
{{ serverError(agreementFieldErrors(), 'reason') }}
</p>
}
@if (agreementOperationError()) {
<p class="operation-error" role="alert">
{{ agreementOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
@@ -47,12 +73,22 @@
}}</strong>
</p>
<label class="confirmation-field">
<input type="checkbox" [formField]="closureForm.confirmed" />
<input
id="closure-confirmed"
type="checkbox"
[formField]="closureForm.confirmed"
(change)="closureFieldChanged.emit('confirmed')"
/>
<span
>Je confirme que les clés ont réellement été restituées à cette
date.</span
>
</label>
@if (serverError(closureFieldErrors(), 'confirmed')) {
<p class="operation-error" role="alert">
{{ serverError(closureFieldErrors(), 'confirmed') }}
</p>
}
@if (closureError() || closureOperationError()) {
<p class="operation-error" role="alert">
{{ closureError() || closureOperationError() }}
@@ -6,6 +6,7 @@ describe('LeaseEndResolutionForms', () => {
function createFixture() {
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
fixture.componentRef.setInput('leaseStartOn', '2026-01-01');
fixture.componentRef.setInput('agreementPending', false);
fixture.componentRef.setInput('closurePending', false);
fixture.detectChanges();
@@ -1,8 +1,9 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
@Component({
selector: 'app-lease-end-resolution-forms',
@@ -11,34 +12,64 @@ import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-e
styleUrl: './lease-end-resolution-forms.css',
})
export class LeaseEndResolutionForms {
private readonly element = inject(ElementRef<HTMLElement>);
readonly timeZone = input.required<string>();
readonly leaseStartOn = input.required<string>();
readonly currentEndOn = input<string | null>(null);
readonly agreementPending = input(false);
readonly closurePending = input(false);
readonly agreementOperationError = input('');
readonly closureOperationError = input('');
readonly agreementFieldErrors = input<FieldValidationError[]>([]);
readonly closureFieldErrors = input<FieldValidationError[]>([]);
readonly showAgreement = input(true);
readonly showClosure = input(true);
readonly keysReturnedOn = input<string | null>(null);
readonly closureEnabled = input(false);
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
readonly closureRequested = output<CloseLeaseRequest>();
readonly agreementFieldChanged = output<string>();
readonly closureFieldChanged = output<string>();
readonly agreementModel = signal({ endOn: '', reason: '' });
readonly closureModel = signal({ confirmed: false });
readonly agreementForm = form(this.agreementModel, (fields) => {
maxLength(fields.reason, 500);
});
readonly closureForm = form(this.closureModel);
readonly agreementError = signal('');
readonly closureError = signal('');
readonly agreementSubmitted = signal(false);
constructor() {
effect(() => {
const agreementField = this.agreementFieldErrors()[0]?.field;
if (agreementField) {
queueMicrotask(() =>
(this.element.nativeElement as HTMLElement)
.querySelector<HTMLElement>(
agreementField === 'reason' ? '#agreement-reason' : '#agreement-end-on',
)
?.focus(),
);
}
const closureField = this.closureFieldErrors()[0]?.field;
if (closureField) {
queueMicrotask(() =>
(this.element.nativeElement as HTMLElement)
.querySelector<HTMLElement>('#closure-confirmed')
?.focus(),
);
}
});
}
submitAgreement(): void {
if (this.agreementPending()) return;
this.agreementSubmitted.set(true);
this.agreementForm().markAsTouched();
const model = this.agreementModel();
if (!model.endOn || !model.reason.trim()) {
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
if (this.agreementDateError() || this.agreementReasonError()) {
return;
}
this.agreementError.set('');
this.agreedEndRequested.emit({
timeZone: this.timeZone(),
endOn: model.endOn,
@@ -46,6 +77,40 @@ export class LeaseEndResolutionForms {
});
}
agreementDateError(): string {
if (!this.show(this.agreementForm.endOn())) return '';
const endOn = this.agreementModel().endOn;
if (!endOn) return 'Renseignez la date de fin convenue.';
if (endOn < this.leaseStartOn()) {
return 'La fin convenue ne peut pas précéder le début du bail.';
}
const currentEndOn = this.currentEndOn();
return currentEndOn && endOn >= currentEndOn
? 'La fin convenue doit être antérieure à la fin actuelle du bail.'
: '';
}
agreementReasonError(): string {
if (!this.show(this.agreementForm.reason())) return '';
const reason = this.agreementModel().reason.trim();
if (!reason) return 'Renseignez la raison de la fin anticipée.';
return reason.length > 500 ? 'La raison ne peut pas dépasser 500 caractères.' : '';
}
serverError(errors: FieldValidationError[], field: string): string {
const code = errors.find((error) => error.field === field)?.code;
if (field === 'endOn' && code === 'after_or_equal_lease_start') {
return 'La fin convenue ne peut pas précéder le début du bail.';
}
if (field === 'endOn' && code === 'before_current_end') {
return 'La fin convenue doit être antérieure à la fin actuelle du bail.';
}
if (field === 'reason' && code === 'max_length')
return 'La raison ne peut pas dépasser 500 caractères.';
if (field === 'confirmed' && code) return 'Confirmez la restitution réelle des clés.';
return '';
}
submitClosure(): void {
if (this.closurePending()) return;
const model = this.closureModel();
@@ -65,4 +130,8 @@ export class LeaseEndResolutionForms {
confirmed: true,
});
}
private show(field: { touched(): boolean }): boolean {
return this.agreementSubmitted() || field.touched();
}
}
@@ -48,8 +48,10 @@ export interface LeaseEndState {
unsupportedReason: string | null;
phase: LeaseEndPhase;
leaseType: 'STANDARD' | 'STUDENT';
leaseStartOn: string;
fixedEndOn: string | null;
effectiveEndOn: string | null;
maxEvidenceBytes: number;
correctionRequired: boolean;
exitConditionReport: {
occurredOn: string;