Align lease finalization validation flows (#53)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 8b37c96671
commit d16239881a
17 files changed
+839 -163

No files matched your search

@@ -21,6 +21,8 @@
<p class="uploaded-deed">
<strong>{{ deed.fileName }}</strong>
<span>Acte téléversé le {{ deed.uploadedAt }}</span>
<span>Empreinte SHA-256 : {{ deed.sha256 }}</span>
<span>Déposé par : {{ deed.authorId }}</span>
</p>
} @else {
<label class="file-action">
@@ -36,11 +38,17 @@
requirement.tenantName
"
(change)="selectFile($event, requirement)"
[attr.aria-invalid]="fieldError(requirement, 'file') ? 'true' : null"
/>
</label>
@if (selectedFiles()[key(requirement)]; as file) {
<p class="selected-file">{{ file.name }}</p>
}
@if (fieldError(requirement, 'file')) {
<p class="operation-error" role="alert">
{{ fieldError(requirement, 'file') }}
</p>
}
<mat-checkbox
[checked]="attestedKeys().has(key(requirement))"
[disabled]="uploadingKeys().has(key(requirement))"
@@ -48,14 +56,15 @@
>
J'atteste transmettre l'acte signé correspondant à ce garant et ce locataire.
</mat-checkbox>
@if (fieldError(requirement, 'attestation')) {
<p class="operation-error" role="alert">
{{ fieldError(requirement, 'attestation') }}
</p>
}
<button
mat-stroked-button
type="button"
[disabled]="
!selectedFiles()[key(requirement)] ||
!attestedKeys().has(key(requirement)) ||
uploadingKeys().has(key(requirement))
"
[disabled]="uploadingKeys().has(key(requirement))"
(click)="upload(requirement)"
>
@if (uploadingKeys().has(key(requirement))) {
@@ -62,7 +62,12 @@ describe('GuaranteeDeedList', () => {
const uploaded = vi.fn();
component.deedUploaded.subscribe(uploaded);
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
expect(button.disabled).toBe(false);
button.click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Sélectionnez l’acte signé');
expect(fixture.nativeElement.textContent).toContain('Attestez transmettre');
expect(uploadGuaranteeDeed).not.toHaveBeenCalled();
component.selectedFiles.set({ 'guarantor-1:tenant-1': file });
component.setAttestation(requirement, true);
@@ -75,6 +80,7 @@ describe('GuaranteeDeedList', () => {
file,
'guarantor-1',
'tenant-1',
0,
);
expect(fixture.nativeElement.textContent).toContain('Téléversement…');
@@ -89,7 +95,7 @@ describe('GuaranteeDeedList', () => {
it('keeps an upload failure local to its pair', () => {
uploadGuaranteeDeed.mockReturnValue(
throwError(() => ({ error: { message: 'Acte refusé' } })),
throwError(() => ({ status: 409, error: { message: 'Acte refusé' } })),
);
const fixture = createList();
const component = fixture.componentInstance;
@@ -107,10 +113,59 @@ describe('GuaranteeDeedList', () => {
expect(fixture.nativeElement.querySelector('[role=alert]')).not.toBeNull();
});
it('clears an invalid replacement and explains a missing file or attestation on submit', async () => {
const fixture = createList();
const component = fixture.componentInstance;
component.selectedFiles.set({
'guarantor-1:tenant-1': new File(['%PDF-valid'], 'previous.pdf', {
type: 'application/pdf',
}),
});
const input = document.createElement('input');
const invalid = new File(['not a PDF'], 'notes.txt', { type: 'text/plain' });
Object.defineProperty(input, 'files', {
value: { item: () => invalid },
configurable: true,
});
await component.selectFile({ target: input } as unknown as Event, requirement);
expect(component.selectedFiles()['guarantor-1:tenant-1']).toBeUndefined();
expect(component.fieldError(requirement, 'file')).toContain('PDF valide');
component.upload(requirement);
expect(component.fieldError(requirement, 'attestation')).toContain('Attestez');
expect(uploadGuaranteeDeed).not.toHaveBeenCalled();
});
it('does not accept a deed response for another guarantee pair', () => {
const response = new Subject<GuaranteeDeed>();
uploadGuaranteeDeed.mockReturnValue(response);
const fixture = createList();
const component = fixture.componentInstance;
const uploaded = vi.fn();
component.deedUploaded.subscribe(uploaded);
component.selectedFiles.set({
'guarantor-1:tenant-1': new File(['%PDF-valid'], 'cautionnement.pdf', {
type: 'application/pdf',
}),
});
component.setAttestation(requirement, true);
component.upload(requirement);
response.next({ ...deed, tenantId: 'tenant-2' });
response.complete();
fixture.detectChanges();
expect(uploaded).not.toHaveBeenCalled();
expect(component.selectedFiles()['guarantor-1:tenant-1']?.name).toBe('cautionnement.pdf');
expect(fixture.nativeElement.textContent).toContain('ne correspond pas au couple');
});
it('renders an already uploaded deed without another upload form', () => {
const fixture = createList([{ ...requirement, deed }]);
expect(fixture.nativeElement.textContent).toContain('cautionnement.pdf');
expect(fixture.nativeElement.textContent).toContain('Empreinte SHA-256');
expect(fixture.nativeElement.querySelector('input[type=file]')).toBeNull();
});
});
@@ -8,7 +8,7 @@ import {
GuaranteeRequirement,
LeaseFinalizationService,
} from '../../services/lease-finalization.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { FieldValidationError, saveFailure } from '../../services/http-error-message';
import { withoutRecordKey, withoutSetValue } from '../../services/immutable-state';
export interface GuaranteeDeedUploaded {
@@ -35,22 +35,35 @@ export class GuaranteeDeedList {
readonly attestedKeys = signal<ReadonlySet<string>>(new Set());
readonly uploadingKeys = signal<ReadonlySet<string>>(new Set());
readonly errors = signal<Record<string, string>>({});
readonly fieldErrors = signal<Record<string, FieldValidationError[]>>({});
private readonly fileSelectionIds: Record<string, number> = {};
key(requirement: GuaranteeRequirement): string {
return `${requirement.guarantorId}:${requirement.tenantId}`;
}
selectFile(event: Event, requirement: GuaranteeRequirement): void {
async selectFile(event: Event, requirement: GuaranteeRequirement): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.item(0);
const file = input.files?.item(0) ?? null;
const key = this.key(requirement);
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
this.errors.update((errors) => ({ ...errors, [key]: 'Sélectionnez un fichier PDF.' }));
} else {
this.errors.update((errors) => withoutRecordKey(errors, key));
this.selectedFiles.update((files) => ({ ...files, [key]: file ?? undefined }));
}
const selectionId = (this.fileSelectionIds[key] ?? 0) + 1;
this.fileSelectionIds[key] = selectionId;
input.value = '';
this.clearFieldError(requirement, 'file');
if (!file) {
this.selectedFiles.update((files) => ({ ...files, [key]: undefined }));
return;
}
const isPdf =
file.type.toLowerCase() === 'application/pdf' &&
(await file.slice(0, 5).text()) === '%PDF-';
if (selectionId !== this.fileSelectionIds[key]) return;
this.selectedFiles.update((files) => ({ ...files, [key]: isPdf ? file : undefined }));
if (!isPdf) {
this.setFieldErrors(key, [
{ field: this.fieldPath(requirement, 'file'), code: 'invalid' },
]);
}
}
setAttestation(requirement: GuaranteeRequirement, checked: boolean): void {
@@ -61,37 +74,122 @@ export class GuaranteeDeedList {
else next.delete(key);
return next;
});
this.clearFieldError(requirement, 'attestation');
}
upload(requirement: GuaranteeRequirement): void {
const key = this.key(requirement);
const file = this.selectedFiles()[key];
if (!file || !this.attestedKeys().has(key) || this.uploadingKeys().has(key)) return;
if (this.uploadingKeys().has(key)) return;
const fieldErrors = this.validationErrors(requirement, file);
if (fieldErrors.length) {
this.setFieldErrors(key, fieldErrors);
return;
}
if (!file) return;
this.uploadingKeys.update((keys) => new Set(keys).add(key));
this.errors.update((errors) => withoutRecordKey(errors, key));
this.fieldErrors.update((errors) => withoutRecordKey(errors, key));
this.finalizationService
.uploadGuaranteeDeed(
this.leaseId(),
file,
requirement.guarantorId,
requirement.tenantId,
this.requirementIndex(requirement),
)
.pipe(finalize(() => this.uploadingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({
next: (deed) =>
next: (deed) => {
if (
deed.guarantorId !== requirement.guarantorId ||
deed.tenantId !== requirement.tenantId
) {
this.errors.update((errors) => ({
...errors,
[key]: 'La réponse ne correspond pas au couple garant–locataire sélectionné.',
}));
return;
}
this.deedUploaded.emit({
guarantorId: requirement.guarantorId,
tenantId: requirement.tenantId,
deed,
}),
error: (error) =>
this.errors.update((errors) => ({
});
},
error: (error) => {
const failure = saveFailure(
error,
'Impossible de téléverser cet acte de cautionnement.',
);
this.errors.update((errors) => ({ ...errors, [key]: failure.message }));
this.fieldErrors.update((errors) => ({
...errors,
[key]: httpErrorMessage(
error,
'Impossible de téléverser cet acte de cautionnement.',
),
})),
[key]: failure.fieldErrors,
}));
},
});
}
fieldError(requirement: GuaranteeRequirement, field: 'file' | 'attestation'): string {
const code = this.fieldErrors()[this.key(requirement)]?.find(
(error) => error.field === this.fieldPath(requirement, field),
)?.code;
if (!code) return '';
if (field === 'attestation') {
return 'Attestez transmettre l’acte signé correspondant à ce garant et ce locataire.';
}
return code === 'invalid'
? 'Sélectionnez un fichier dont le contenu est un PDF valide.'
: 'Sélectionnez l’acte signé au format PDF.';
}
private validationErrors(
requirement: GuaranteeRequirement,
file: File | undefined,
): FieldValidationError[] {
const errors: FieldValidationError[] = [];
if (!file) {
const field = this.fieldPath(requirement, 'file');
const code = this.fieldErrors()[this.key(requirement)]?.find(
(error) => error.field === field,
)?.code;
errors.push({ field, code: code ?? 'required' });
}
if (!this.attestedKeys().has(this.key(requirement))) {
errors.push({ field: this.fieldPath(requirement, 'attestation'), code: 'required' });
}
return errors;
}
private setFieldErrors(key: string, fieldErrors: FieldValidationError[]): void {
this.errors.update((errors) => withoutRecordKey(errors, key));
this.fieldErrors.update((errors) => ({ ...errors, [key]: fieldErrors }));
}
private clearFieldError(
requirement: GuaranteeRequirement,
field: 'file' | 'attestation',
): void {
const key = this.key(requirement);
const remaining = (this.fieldErrors()[key] ?? []).filter(
(error) => error.field !== this.fieldPath(requirement, field),
);
this.fieldErrors.update((errors) =>
remaining.length ? { ...errors, [key]: remaining } : withoutRecordKey(errors, key),
);
if (!remaining.length) this.errors.update((errors) => withoutRecordKey(errors, key));
}
private fieldPath(requirement: GuaranteeRequirement, field: 'file' | 'attestation'): string {
return `guaranteeRequirements[${this.requirementIndex(requirement)}].${field}`;
}
private requirementIndex(requirement: GuaranteeRequirement): number {
return this.requirements().findIndex(
(candidate) =>
candidate.guarantorId === requirement.guarantorId &&
candidate.tenantId === requirement.tenantId,
);
}
}
@@ -16,11 +16,15 @@
accept="application/pdf,.pdf"
aria-label="Bail signé au format PDF"
(change)="selectSignedLease($event)"
[attr.aria-invalid]="fieldError('signedLease') ? 'true' : null"
/>
</label>
@if (signedLease(); as file) {
<p class="selected-file">{{ file.name }}</p>
}
@if (fieldError('signedLease')) {
<p class="operation-error" role="alert">{{ fieldError('signedLease') }}</p>
}
<label class="date-field" for="lastSignatureDate">
<span>Date de la dernière signature</span>
@@ -30,22 +34,34 @@
required
[value]="lastSignatureDate()"
(input)="setLastSignatureDate($event)"
[attr.aria-invalid]="fieldError('lastSignatureDate') ? 'true' : null"
/>
@if (fieldError('lastSignatureDate')) {
<p class="operation-error" role="alert">
{{ fieldError('lastSignatureDate') }}
</p>
}
</label>
<mat-checkbox
[checked]="allPartiesSigned()"
(change)="allPartiesSigned.set($event.checked)"
(change)="setAllPartiesSigned($event.checked)"
>
J'atteste que toutes les parties ont signé le PDF transmis.
</mat-checkbox>
@if (fieldError('allPartiesSigned')) {
<p class="operation-error" role="alert">{{ fieldError('allPartiesSigned') }}</p>
}
<mat-checkbox
[checked]="revisionMatches()"
(change)="revisionMatches.set($event.checked)"
(change)="setRevisionMatches($event.checked)"
>
J'ai vérifié que le PDF signé correspond à la révision {{ revision().number }} ·
modèle {{ revision().templateVersion }}.
</mat-checkbox>
@if (fieldError('revisionMatches')) {
<p class="operation-error" role="alert">{{ fieldError('revisionMatches') }}</p>
}
@if (warnings().length) {
<div class="warnings" aria-labelledby="warnings-heading">
@@ -58,6 +74,11 @@
{{ warningLabel(warning.code) }}
<small>Version de règle : {{ warning.ruleVersion }}</small>
</mat-checkbox>
@if (fieldError('confirmedWarningCodes[' + $index + ']')) {
<p class="operation-error" role="alert">
{{ fieldError('confirmedWarningCodes[' + $index + ']') }}
</p>
}
}
</div>
}
@@ -66,19 +87,15 @@
L'application conserve le document et vos attestations ; elle ne vérifie pas les
signatures contenues dans le PDF.
</p>
@if (!guaranteesComplete()) {
@if (guaranteesError()) {
<p class="operation-error" role="alert">
Téléversez tous les actes de cautionnement requis avant de finaliser.
{{ guaranteesError() }}
</p>
}
@if (validationError() || submitError()) {
<p class="operation-error" role="alert">{{ validationError() || submitError() }}</p>
}
<button
mat-flat-button
type="submit"
[disabled]="submitting() || !guaranteesComplete()"
>
<button mat-flat-button type="submit" [disabled]="submitting()">
@if (submitting()) {
<mat-spinner diameter="18" aria-hidden="true" /> Finalisation…
} @else {
@@ -125,11 +142,19 @@
required
[value]="correction.privateAreaId"
(change)="setCorrection(index, 'privateAreaId', $event)"
[attr.aria-invalid]="
correctionError(index, 'privateAreaId') ? 'true' : null
"
>
@for (area of revision().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
@if (correctionError(index, 'privateAreaId')) {
<p class="operation-error" role="alert">
{{ correctionError(index, 'privateAreaId') }}
</p>
}
</label>
<label>
Désignation
@@ -138,7 +163,15 @@
required
[value]="correction.designation"
(input)="setCorrection(index, 'designation', $event)"
[attr.aria-invalid]="
correctionError(index, 'designation') ? 'true' : null
"
/>
@if (correctionError(index, 'designation')) {
<p class="operation-error" role="alert">
{{ correctionError(index, 'designation') }}
</p>
}
</label>
<label>
Quantité
@@ -149,7 +182,13 @@
required
[value]="correction.quantity"
(input)="setCorrection(index, 'quantity', $event)"
[attr.aria-invalid]="correctionError(index, 'quantity') ? 'true' : null"
/>
@if (correctionError(index, 'quantity')) {
<p class="operation-error" role="alert">
{{ correctionError(index, 'quantity') }}
</p>
}
</label>
<label>
Description de la correction
@@ -157,7 +196,15 @@
required
[value]="correction.description"
(input)="setCorrection(index, 'description', $event)"
[attr.aria-invalid]="
correctionError(index, 'description') ? 'true' : null
"
></textarea>
@if (correctionError(index, 'description')) {
<p class="operation-error" role="alert">
{{ correctionError(index, 'description') }}
</p>
}
</label>
<button type="button" (click)="removeCorrection(index)">
Supprimer cette correction
@@ -167,10 +214,15 @@
<button type="button" (click)="addCorrection()">Ajouter une correction</button>
<mat-checkbox
[checked]="allInventoryCorrectionsInitialed()"
(change)="allInventoryCorrectionsInitialed.set($event.checked)"
(change)="setAllInventoryCorrectionsInitialed($event.checked)"
>
Toutes les parties ont paraphé chaque correction manuscrite déclarée.
</mat-checkbox>
@if (fieldError('allInventoryCorrectionsInitialed')) {
<p class="operation-error" role="alert">
{{ fieldError('allInventoryCorrectionsInitialed') }}
</p>
}
@if (validationError()) {
<p class="operation-error" role="alert">{{ validationError() }}</p>
}
@@ -145,7 +145,9 @@ describe('LeaseFinalizationForm', () => {
fixture.componentRef.setInput('guaranteesComplete', false);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(true);
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(false);
fixture.componentInstance.submit();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Téléversez tous les actes');
});
@@ -209,7 +211,7 @@ describe('LeaseFinalizationForm', () => {
expect(fixture.nativeElement.textContent).not.toContain('ID d’espace privatif');
});
it('accepts only a PDF upload, captures the civil date, and toggles warning confirmations', () => {
it('accepts only a PDF upload, captures the civil date, and toggles warning confirmations', async () => {
const fixture = createForm(['DPE_MISSING']);
const component = fixture.componentInstance;
const input = document.createElement('input');
@@ -219,13 +221,13 @@ describe('LeaseFinalizationForm', () => {
configurable: true,
});
component.selectSignedLease({ target: input } as unknown as Event);
await component.selectSignedLease({ target: input } as unknown as Event);
expect(component.signedLease()).toBeNull();
expect(component.validationError()).toContain('format PDF');
expect(component.fieldError('signedLease')).toContain('PDF');
const pdf = new File(['pdf'], 'bail.pdf', { type: 'application/pdf' });
const pdf = new File(['%PDF-valid'], 'bail.pdf', { type: 'application/pdf' });
Object.defineProperty(input, 'files', { value: { item: () => pdf }, configurable: true });
component.selectSignedLease({ target: input } as unknown as Event);
await component.selectSignedLease({ target: input } as unknown as Event);
const date = document.createElement('input');
date.value = '2026-07-20';
component.setLastSignatureDate(eventFor(date));
@@ -238,6 +240,19 @@ describe('LeaseFinalizationForm', () => {
expect(component.warningLabel('DPE_MISSING')).toContain('DPE');
});
it('does not turn a blank correction quantity into zero', () => {
const fixture = createForm();
const component = fixture.componentInstance;
component.answerYes();
const quantity = document.createElement('input');
quantity.value = '';
component.setCorrection(0, 'quantity', eventFor(quantity));
expect(Number.isNaN(component.inventoryCorrections()[0].quantity)).toBe(true);
component.continueWithCorrections();
expect(component.correctionError(0, 'quantity')).toContain('quantité entière');
});
it('edits a handwritten inventory correction from the revision snapshot', () => {
const fixture = createForm();
const component = fixture.componentInstance;
@@ -7,6 +7,7 @@ import {
FinalizeLeaseRequest,
InventoryCorrection,
} from '../../services/lease-finalization.service';
import { FieldValidationError } from '../../services/http-error-message';
import { LeaseRevisionWarning } from '../../services/lease.service';
import { leaseWarningConfirmation } from '../../services/lease-warning-label';
@@ -24,7 +25,9 @@ export class LeaseFinalizationForm {
readonly guaranteesComplete = input(true);
readonly submitting = input(false);
readonly submitError = input('');
readonly fieldErrors = input<FieldValidationError[]>([]);
readonly finalizationRequested = output<FinalizeLeaseRequest>();
readonly fieldChanged = output<string>();
readonly signedLease = signal<File | null>(null);
readonly lastSignatureDate = signal('');
@@ -35,25 +38,55 @@ export class LeaseFinalizationForm {
readonly discrepancyAnswer = signal<boolean | null>(null);
readonly inventoryCorrections = signal<InventoryCorrection[]>([]);
readonly allInventoryCorrectionsInitialed = signal(false);
readonly clientFieldErrors = signal<FieldValidationError[]>([]);
readonly discrepancyDialog = viewChild<ElementRef<HTMLDialogElement>>('discrepancyDialog');
readonly firstCorrectionInput = viewChild<ElementRef<HTMLInputElement>>('firstCorrectionInput');
private signedFileSelectionId = 0;
selectSignedLease(event: Event): void {
async selectSignedLease(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.item(0) ?? null;
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
this.signedLease.set(null);
this.validationError.set('Sélectionnez le bail signé au format PDF.');
} else {
this.signedLease.set(file);
this.validationError.set('');
}
const selectionId = ++this.signedFileSelectionId;
input.value = '';
this.clearFieldError('signedLease');
if (!file) {
this.signedLease.set(null);
return;
}
const isPdf =
file.type.toLowerCase() === 'application/pdf' &&
(await file.slice(0, 5).text()) === '%PDF-';
if (selectionId !== this.signedFileSelectionId) return;
if (isPdf) {
this.signedLease.set(file);
} else {
this.signedLease.set(null);
this.clientFieldErrors.update((errors) => [
...errors,
{ field: 'signedLease', code: 'invalid' },
]);
}
}
setLastSignatureDate(event: Event): void {
const target = event.target;
this.lastSignatureDate.set(target instanceof HTMLInputElement ? target.value : '');
this.clearFieldError('lastSignatureDate');
}
setAllPartiesSigned(checked: boolean): void {
this.allPartiesSigned.set(checked);
this.clearFieldError('allPartiesSigned');
}
setRevisionMatches(checked: boolean): void {
this.revisionMatches.set(checked);
this.clearFieldError('revisionMatches');
}
setAllInventoryCorrectionsInitialed(checked: boolean): void {
this.allInventoryCorrectionsInitialed.set(checked);
this.clearFieldError('allInventoryCorrectionsInitialed');
}
setWarning(code: string, checked: boolean): void {
@@ -63,6 +96,7 @@ export class LeaseFinalizationForm {
else next.delete(code);
return next;
});
this.clearFieldError(this.warningField(code));
}
warningLabel(code: string): string {
@@ -72,18 +106,18 @@ export class LeaseFinalizationForm {
submit(): void {
if (this.submitting()) return;
if (this.discrepancyAnswer() === null) {
const error = this.errorMessage();
if (error) {
this.validationError.set(error);
const errors = this.validationErrors(false);
if (errors.length) {
this.showValidationErrors(errors);
return;
}
this.discrepancyDialog()?.nativeElement.showModal();
return;
}
const error = this.errorMessage();
this.validationError.set(error);
const errors = this.validationErrors(true);
this.showValidationErrors(errors);
const signedLease = this.signedLease();
if (error || !signedLease) return;
if (errors.length || !signedLease) return;
this.finalizationRequested.emit({
signedLease,
revisionNumber: this.revision().number,
@@ -126,7 +160,7 @@ export class LeaseFinalizationForm {
inventoryLineId: null,
privateAreaId: this.revision().privateAreas[0]?.id ?? '',
designation: '',
quantity: 0,
quantity: Number.NaN,
description: '',
},
]);
@@ -136,6 +170,7 @@ export class LeaseFinalizationForm {
this.inventoryCorrections.update((corrections) =>
corrections.filter((_, item) => item !== index),
);
this.clearAllCorrectionErrors();
}
setCorrection(index: number, field: keyof InventoryCorrection, event: Event): void {
@@ -152,7 +187,9 @@ export class LeaseFinalizationForm {
...correction,
[field]:
field === 'quantity'
? Number(value)
? value === ''
? Number.NaN
: Number(value)
: field === 'inventoryLineId'
? value || null
: value,
@@ -160,6 +197,7 @@ export class LeaseFinalizationForm {
: correction,
),
);
this.clearFieldError(this.correctionField(index, field));
}
selectInventoryLine(index: number, event: Event): void {
@@ -181,53 +219,161 @@ export class LeaseFinalizationForm {
: correction,
),
);
this.clearCorrectionErrors(index);
}
continueWithCorrections(): void {
const error = this.errorMessage();
if (error) {
this.validationError.set(error);
const errors = this.validationErrors(true);
if (errors.length) {
this.showValidationErrors(errors);
return;
}
this.discrepancyDialog()?.nativeElement.close();
this.submit();
}
private errorMessage(): string {
if (!this.guaranteesComplete()) {
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
fieldError(field: string): string {
const code = this.errorCode(field);
if (!code) return '';
if (field === 'signedLease') {
return code === 'invalid'
? 'Sélectionnez un bail signé dont le contenu est un PDF valide.'
: 'Sélectionnez le bail signé au format PDF.';
}
if (!this.signedLease()) return 'Sélectionnez le bail signé au format PDF.';
if (!this.lastSignatureDate()) return 'Renseignez la date de la dernière signature.';
if (!this.allPartiesSigned()) {
if (field === 'lastSignatureDate') return 'Renseignez la date de la dernière signature.';
if (field === 'allPartiesSigned') {
return 'Attestez que toutes les parties ont signé le document transmis.';
}
if (!this.revisionMatches()) {
if (field === 'revisionMatches') {
return `Confirmez que le document signé correspond à la révision ${this.revision().number}.`;
}
if (this.discrepancyAnswer() && !this.inventoryCorrections().length) {
return 'Déclarez au moins une correction d’inventaire.';
if (field === 'guaranteeRequirements') {
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
}
if (
this.discrepancyAnswer() &&
this.inventoryCorrections().some(
(correction) =>
!correction.id ||
!correction.privateAreaId.trim() ||
!correction.designation.trim() ||
!correction.description.trim() ||
!Number.isInteger(correction.quantity) ||
correction.quantity < 0,
)
) {
return 'Complétez chaque correction d’inventaire avec son espace, sa désignation, sa quantité et sa description.';
if (field === 'entryInventoryDiscrepancies') {
return 'Répondez à la question sur les incohérences d’inventaire à l’entrée.';
}
if (this.discrepancyAnswer() && !this.allInventoryCorrectionsInitialed()) {
if (field === 'allInventoryCorrectionsInitialed') {
return 'Attestez que chaque correction manuscrite d’inventaire a été paraphée.';
}
const missingWarning = this.warningCodes().find(
(code) => !this.confirmedWarningCodes().has(code),
if (field.startsWith('confirmedWarningCodes[')) {
return 'Confirmez cet avertissement légal avant de finaliser.';
}
if (field.endsWith('.privateAreaId')) {
return 'Choisissez un espace privatif de la révision imprimée.';
}
if (field.endsWith('.designation')) return 'Renseignez la désignation de la correction.';
if (field.endsWith('.quantity')) {
return 'Saisissez une quantité entière positive ou nulle.';
}
if (field.endsWith('.description')) return 'Décrivez la correction manuscrite.';
return 'Déclarez au moins une correction d’inventaire.';
}
correctionError(index: number, field: keyof InventoryCorrection): string {
return this.fieldError(this.correctionField(index, field));
}
guaranteesError(): string {
return this.fieldError('guaranteeRequirements');
}
private validationErrors(includeDiscrepancy: boolean): FieldValidationError[] {
const errors: FieldValidationError[] = [];
if (!this.guaranteesComplete()) {
errors.push({ field: 'guaranteeRequirements', code: 'incomplete' });
}
if (!this.signedLease()) {
errors.push({
field: 'signedLease',
code: this.errorCode('signedLease') ?? 'required',
});
}
if (!this.lastSignatureDate())
errors.push({ field: 'lastSignatureDate', code: 'required' });
if (!this.allPartiesSigned()) errors.push({ field: 'allPartiesSigned', code: 'required' });
if (!this.revisionMatches()) errors.push({ field: 'revisionMatches', code: 'required' });
for (const code of this.warningCodes()) {
if (!this.confirmedWarningCodes().has(code)) {
errors.push({ field: this.warningField(code), code: 'required' });
}
}
if (!includeDiscrepancy) return errors;
if (this.discrepancyAnswer() === null) {
errors.push({ field: 'entryInventoryDiscrepancies', code: 'required' });
return errors;
}
if (!this.discrepancyAnswer()) return errors;
if (!this.inventoryCorrections().length) {
errors.push({ field: 'inventoryCorrections', code: 'required' });
}
this.inventoryCorrections().forEach((correction, index) => {
if (!correction.privateAreaId.trim()) {
errors.push({
field: this.correctionField(index, 'privateAreaId'),
code: 'not_blank',
});
}
if (!correction.designation.trim()) {
errors.push({
field: this.correctionField(index, 'designation'),
code: 'not_blank',
});
}
if (!Number.isInteger(correction.quantity) || correction.quantity < 0) {
errors.push({ field: this.correctionField(index, 'quantity'), code: 'min' });
}
if (!correction.description.trim()) {
errors.push({
field: this.correctionField(index, 'description'),
code: 'not_blank',
});
}
});
if (!this.allInventoryCorrectionsInitialed()) {
errors.push({ field: 'allInventoryCorrectionsInitialed', code: 'required' });
}
return errors;
}
private showValidationErrors(errors: FieldValidationError[]): void {
this.clientFieldErrors.set(errors);
this.validationError.set(errors.length ? this.fieldError(errors[0].field) : '');
}
private errorCode(field: string): string | undefined {
return (
this.clientFieldErrors().find((error) => error.field === field)?.code ??
this.fieldErrors().find((error) => error.field === field)?.code
);
return missingWarning ? 'Confirmez chaque avertissement légal avant de finaliser.' : '';
}
private clearFieldError(field: string): void {
this.clientFieldErrors.update((errors) => errors.filter((error) => error.field !== field));
this.fieldChanged.emit(field);
if (!this.clientFieldErrors().length) this.validationError.set('');
}
private clearCorrectionErrors(index: number): void {
const prefix = `inventoryCorrections[${index}].`;
this.clientFieldErrors.update((errors) =>
errors.filter((error) => !error.field.startsWith(prefix)),
);
this.fieldChanged.emit(prefix);
}
private clearAllCorrectionErrors(): void {
this.clientFieldErrors.update((errors) =>
errors.filter((error) => !error.field.startsWith('inventoryCorrections[')),
);
this.fieldChanged.emit('inventoryCorrections[');
}
private correctionField(index: number, field: keyof InventoryCorrection): string {
return `inventoryCorrections[${index}].${field}`;
}
private warningField(code: string): string {
return `confirmedWarningCodes[${this.warningCodes().indexOf(code)}]`;
}
}
@@ -89,6 +89,8 @@
[guaranteesComplete]="guaranteesComplete()"
[submitting]="submitting() || confirming()"
[submitError]="submitError()"
[fieldErrors]="submitFieldErrors()"
(fieldChanged)="clearFinalizationFieldError($event)"
(finalizationRequested)="requestFinalization($event)"
/>
}
@@ -167,7 +167,9 @@ describe('LeaseFinalizationPage', () => {
});
it('keeps a finalization failure actionable without showing success', () => {
finalize.mockReturnValue(throwError(() => ({ error: { message: 'PDF signé refusé' } })));
finalize.mockReturnValue(
throwError(() => ({ status: 409, error: { message: 'PDF signé refusé' } })),
);
const fixture = TestBed.createComponent(LeaseFinalizationPage);
fixture.detectChanges();
const request: FinalizeLeaseRequest = {
@@ -13,7 +13,11 @@ import {
LeaseFinalizationState,
} from '../../services/lease-finalization.service';
import { WorkspaceService } from '../../services/workspace.service';
import { httpErrorMessage } from '../../services/http-error-message';
import {
FieldValidationError,
httpErrorMessage,
saveFailure,
} from '../../services/http-error-message';
import {
GuaranteeDeedList,
GuaranteeDeedUploaded,
@@ -39,6 +43,7 @@ export class LeaseFinalizationPage {
readonly submitting = signal(false);
readonly confirming = signal(false);
readonly submitError = signal('');
readonly submitFieldErrors = signal<FieldValidationError[]>([]);
readonly guaranteesComplete = computed(
() => this.state()?.guaranteeRequirements.every(({ deed }) => deed !== null) ?? false,
);
@@ -81,6 +86,14 @@ export class LeaseFinalizationPage {
);
}
clearFinalizationFieldError(field: string): void {
const remaining = this.submitFieldErrors().filter((error) =>
field.endsWith('[') ? !error.field.startsWith(field) : error.field !== field,
);
this.submitFieldErrors.set(remaining);
if (!remaining.length) this.submitError.set('');
}
requestFinalization(request: FinalizeLeaseRequest): void {
if (this.submitting() || this.confirming()) return;
this.confirming.set(true);
@@ -108,6 +121,7 @@ export class LeaseFinalizationPage {
if (this.submitting()) return;
this.submitting.set(true);
this.submitError.set('');
this.submitFieldErrors.set([]);
this.finalizationService
.finalize(this.leaseId, request)
.pipe(rxFinalize(() => this.submitting.set(false)))
@@ -118,10 +132,11 @@ export class LeaseFinalizationPage {
state ? { ...state, finalization: result.finalization } : state,
);
},
error: (error) =>
this.submitError.set(
httpErrorMessage(error, 'Impossible de finaliser ce bail.'),
),
error: (error) => {
const failure = saveFailure(error, 'Impossible de finaliser ce bail.');
this.submitError.set(failure.message);
this.submitFieldErrors.set(failure.fieldErrors);
},
});
}
}