Align furniture inventory validation (#52)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 66fa95dabd
commit 82293a322d
8 files changed
+712 -227

No files matched your search

@@ -0,0 +1,67 @@
import { FurnitureInventoryLine } from '../../services/furniture-inventory.service';
import { FieldValidationError } from '../../services/http-error-message';
export function inventoryLineErrors(
lines: FurnitureInventoryLine[],
privateAreaIds: Set<string>,
): FieldValidationError[] {
return lines.flatMap((line, index) => {
const errors: FieldValidationError[] = [];
if (!privateAreaIds.has(line.privateAreaId)) {
errors.push(
fieldError(
index,
'privateAreaId',
line.privateAreaId ? 'unknown_private_area' : 'not_blank',
),
);
}
if (!line.designation.trim()) errors.push(fieldError(index, 'designation', 'not_blank'));
if (!Number.isInteger(line.quantity) || line.quantity < 0) {
errors.push(fieldError(index, 'quantity', 'min'));
}
if (line.category === null && line.presence !== null) {
errors.push(fieldError(index, 'presence', 'invalid_presence'));
}
if (line.category && line.presence === null && line.quantity !== 0) {
errors.push(fieldError(index, 'quantity', 'undeclared_quantity'));
}
if (line.category && line.presence === 'PRESENT' && line.quantity === 0) {
errors.push(fieldError(index, 'quantity', 'present_quantity'));
}
if (line.category && line.presence === 'ABSENT' && line.quantity !== 0) {
errors.push(fieldError(index, 'quantity', 'absent_quantity'));
}
return errors;
});
}
export function inventoryLineGuidance(code: string, field: string): string {
if (field === 'privateAreaId') {
return code === 'unknown_private_area'
? 'Choisissez un espace privatif du logement.'
: 'Renseignez l’espace privatif de cette ligne.';
}
if (field === 'designation') return 'Renseignez la désignation de cette ligne.';
if (field === 'presence') {
return 'Une ligne libre ne peut pas déclarer une présence réglementaire.';
}
if (field === 'category') return 'Chaque catégorie réglementaire ne peut figurer qu’une fois.';
if (field === 'equipmentKind') {
return 'La classification de cette catégorie réglementaire ne peut pas être modifiée.';
}
if (code === 'present_quantity') {
return 'Un élément réglementaire présent doit avoir une quantité positive.';
}
if (code === 'absent_quantity') {
return 'Un élément réglementaire absent doit avoir une quantité nulle.';
}
if (code === 'undeclared_quantity') {
return 'La quantité doit être nulle tant que l’élément n’est pas déclaré.';
}
return 'Saisissez une quantité entière positive ou nulle.';
}
function fieldError(index: number, field: string, code: string): FieldValidationError {
return { field: `lines[${index}].${field}`, code };
}
@@ -14,3 +14,6 @@
max-width: 32rem;
width: 100%;
}
.field-error {
margin: 0.25rem 0 0;
}
@@ -11,180 +11,296 @@
</div>
</header>
@if (loading()) {
@if (loading() && !inventory().id) {
<p role="status">Chargement de l’inventaire…</p>
} @else {
@if (error()) {
<p role="alert">{{ error() }}</p>
@if (loading()) {
<p role="status">Actualisation de l’inventaire…</p>
}
@if (success()) {
<p role="status">{{ success() }}</p>
@if (loadError()) {
<p role="alert">{{ loadError() }}</p>
<button type="button" (click)="load()">Réessayer</button>
}
@if (inventory().frozen) {
<p role="status">Cet inventaire est figé.</p>
}
<p>
@if (inventory().readyForLeaseDocument) {
L’inventaire est prêt pour la génération du bail.
} @else {
Des éléments réglementaires doivent encore être déclarés.
@if (inventory().id) {
@if (saveError()) {
<p role="alert">{{ saveError() }}</p>
}
</p>
@if (inventory().legalWarnings.length) {
<section aria-labelledby="inventory-warnings-heading">
<h2 id="inventory-warnings-heading">Avertissements juridiques</h2>
<ul>
@for (warning of inventory().legalWarnings; track warning.code) {
<li>
{{ warningLabel(warning.category) }} est déclaré absent (règle
{{ warning.ruleVersion }}).
</li>
}
</ul>
</section>
}
<form (submit)="$event.preventDefault(); save()">
<fieldset [disabled]="inventory().frozen || saving()">
<section aria-labelledby="mandatory-furniture-heading">
<h2 id="mandatory-furniture-heading">Catalogue réglementaire</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category) {
<fieldset class="inventory-line">
<legend>{{ line.designation || line.category }}</legend>
<p>{{ line.category }}</p>
<label>
<span>Présence</span>
<select
required
[value]="line.presence ?? ''"
(change)="updatePresence(line.id, $event)"
>
<option value="">À déclarer</option>
<option value="PRESENT">Présent</option>
<option value="ABSENT">Absent</option>
</select>
</label>
<label>
<span>Zone</span>
<select
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
</label>
<label>
<span>Désignation</span>
<input
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
/>
</label>
<label>
<span>Quantité</span>
<input
required
type="number"
[min]="line.presence === 'PRESENT' ? 1 : 0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
/>
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
@if (line.equipmentKind !== 'NONE') {
<p>Une zone dédiée sera créée dans l’état des lieux.</p>
}
</fieldset>
}
}
</section>
<section aria-labelledby="free-furniture-heading">
<h2 id="free-furniture-heading">Éléments complémentaires</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category === null) {
<fieldset class="inventory-line">
<legend>{{ line.designation || 'Ligne libre' }}</legend>
<label>
<span>Zone</span>
<select
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
</label>
<label>
<span>Désignation</span>
<input
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
/>
</label>
<label>
<span>Quantité</span>
<input
required
type="number"
min="0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
/>
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
<label>
<span>Suivi dans l’état des lieux</span>
<select
[value]="line.equipmentKind"
(change)="updateEquipmentKind(line.id, $event)"
>
<option value="NONE">Aucune zone dédiée</option>
<option value="CLIMATISATION">Climatisation</option>
<option value="OTHER">Autre équipement</option>
</select>
</label>
<button type="button" (click)="removeLine(line.id)">
Supprimer cette ligne
</button>
</fieldset>
}
}
<button type="button" (click)="addLine()">Ajouter une ligne libre</button>
</section>
</fieldset>
@if (!inventory().frozen) {
<button type="submit" [disabled]="saving()">
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
</button>
@if (success()) {
<p role="status">{{ success() }}</p>
}
</form>
@if (inventory().frozen) {
<p role="status">Cet inventaire est figé.</p>
}
<p>
@if (inventory().readyForLeaseDocument) {
L’inventaire est prêt pour la génération du bail.
} @else {
Des éléments réglementaires doivent encore être déclarés.
}
</p>
@if (inventory().legalWarnings.length) {
<section aria-labelledby="inventory-warnings-heading">
<h2 id="inventory-warnings-heading">Avertissements juridiques</h2>
<ul>
@for (warning of inventory().legalWarnings; track warning.code) {
<li>
{{ warningLabel(warning.category) }} est déclaré absent (règle
{{ warning.ruleVersion }}).
</li>
}
</ul>
</section>
}
<form novalidate (submit)="$event.preventDefault(); save()">
<fieldset [disabled]="inventory().frozen || saving()">
<section aria-labelledby="mandatory-furniture-heading">
<h2 id="mandatory-furniture-heading">Catalogue réglementaire</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category) {
<fieldset class="inventory-line">
<legend>{{ line.designation || line.category }}</legend>
<p>{{ line.category }}</p>
@if (lineError(line, 'category')) {
<p class="field-error">{{ lineError(line, 'category') }}</p>
}
<label>
<span>Présence</span>
<select
[id]="controlId(line, 'presence')"
required
[value]="line.presence ?? ''"
(change)="updatePresence(line.id, $event)"
[attr.aria-describedby]="
lineError(line, 'presence')
? controlId(line, 'presence') + '-error'
: null
"
>
<option value="">À déclarer</option>
<option value="PRESENT">Présent</option>
<option value="ABSENT">Absent</option>
</select>
@if (lineError(line, 'presence')) {
<p
[id]="controlId(line, 'presence') + '-error'"
class="field-error"
>
{{ lineError(line, 'presence') }}
</p>
}
</label>
<label>
<span>Espace privatif</span>
<select
[id]="controlId(line, 'privateAreaId')"
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
[attr.aria-describedby]="
lineError(line, 'privateAreaId')
? controlId(line, 'privateAreaId') + '-error'
: null
"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
@if (lineError(line, 'privateAreaId')) {
<p
[id]="controlId(line, 'privateAreaId') + '-error'"
class="field-error"
>
{{ lineError(line, 'privateAreaId') }}
</p>
}
</label>
<label>
<span>Désignation</span>
<input
[id]="controlId(line, 'designation')"
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
[attr.aria-describedby]="
lineError(line, 'designation')
? controlId(line, 'designation') + '-error'
: null
"
/>
@if (lineError(line, 'designation')) {
<p
[id]="controlId(line, 'designation') + '-error'"
class="field-error"
>
{{ lineError(line, 'designation') }}
</p>
}
</label>
<label>
<span>Quantité</span>
<input
[id]="controlId(line, 'quantity')"
required
type="number"
[min]="line.presence === 'PRESENT' ? 1 : 0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
[attr.aria-describedby]="
lineError(line, 'quantity')
? controlId(line, 'quantity') + '-error'
: null
"
/>
@if (lineError(line, 'quantity')) {
<p
[id]="controlId(line, 'quantity') + '-error'"
class="field-error"
>
{{ lineError(line, 'quantity') }}
</p>
}
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
@if (line.equipmentKind !== 'NONE') {
<p>Une zone dédiée sera créée dans l’état des lieux.</p>
}
</fieldset>
}
}
</section>
<section aria-labelledby="free-furniture-heading">
<h2 id="free-furniture-heading">Éléments complémentaires</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category === null) {
<fieldset class="inventory-line">
<legend>{{ line.designation || 'Ligne libre' }}</legend>
@if (lineError(line, 'category')) {
<p class="field-error">{{ lineError(line, 'category') }}</p>
}
@if (lineError(line, 'presence')) {
<p class="field-error">{{ lineError(line, 'presence') }}</p>
}
<label>
<span>Espace privatif</span>
<select
[id]="controlId(line, 'privateAreaId')"
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
[attr.aria-describedby]="
lineError(line, 'privateAreaId')
? controlId(line, 'privateAreaId') + '-error'
: null
"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
@if (lineError(line, 'privateAreaId')) {
<p
[id]="controlId(line, 'privateAreaId') + '-error'"
class="field-error"
>
{{ lineError(line, 'privateAreaId') }}
</p>
}
</label>
<label>
<span>Désignation</span>
<input
[id]="controlId(line, 'designation')"
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
[attr.aria-describedby]="
lineError(line, 'designation')
? controlId(line, 'designation') + '-error'
: null
"
/>
@if (lineError(line, 'designation')) {
<p
[id]="controlId(line, 'designation') + '-error'"
class="field-error"
>
{{ lineError(line, 'designation') }}
</p>
}
</label>
<label>
<span>Quantité</span>
<input
[id]="controlId(line, 'quantity')"
required
type="number"
min="0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
[attr.aria-describedby]="
lineError(line, 'quantity')
? controlId(line, 'quantity') + '-error'
: null
"
/>
@if (lineError(line, 'quantity')) {
<p
[id]="controlId(line, 'quantity') + '-error'"
class="field-error"
>
{{ lineError(line, 'quantity') }}
</p>
}
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
<label>
<span>Suivi dans l’état des lieux</span>
<select
[value]="line.equipmentKind"
(change)="updateEquipmentKind(line.id, $event)"
>
<option value="NONE">Aucune zone dédiée</option>
<option value="CLIMATISATION">Climatisation</option>
<option value="OTHER">Autre équipement</option>
</select>
</label>
<button type="button" (click)="removeLine(line.id)">
Supprimer cette ligne
</button>
</fieldset>
}
}
<button type="button" (click)="addLine()">Ajouter une ligne libre</button>
</section>
</fieldset>
@if (!inventory().frozen) {
<button type="submit" [disabled]="saving()">
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
</button>
}
</form>
}
}
</section>
@@ -70,7 +70,7 @@ describe('InventoryPage', () => {
});
});
it('blocks an undeclared legal category with an actionable error', () => {
it('saves an undeclared legal category with its zero quantity', () => {
const { fixture, service } = createPage({
...inventory,
lines: [{ ...inventory.lines[0], presence: null, quantity: 0 }],
@@ -78,11 +78,50 @@ describe('InventoryPage', () => {
readyForLeaseDocument: false,
});
fixture.componentInstance.save();
expect(service.saveInventory).toHaveBeenCalledWith('lease-1', {
version: 3,
lines: [{ ...inventory.lines[0], presence: null, quantity: 0 }],
});
});
it('reveals every local line error before saving', async () => {
const { fixture, service } = createPage({
...inventory,
lines: [
{
...inventory.lines[0],
privateAreaId: 'unknown',
designation: '',
presence: 'PRESENT',
quantity: 0,
},
{
id: 'free-1',
category: null,
presence: 'PRESENT',
equipmentKind: 'NONE',
privateAreaId: 'bedroom-1',
designation: 'Lampe',
quantity: 1,
description: '',
},
],
});
fixture.componentInstance.save();
fixture.detectChanges();
await Promise.resolve();
expect(service.saveInventory).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Déclarez la présence ou l’absence');
expect(fixture.componentInstance.fieldErrors()).toEqual([
{ field: 'lines[0].privateAreaId', code: 'unknown_private_area' },
{ field: 'lines[0].designation', code: 'not_blank' },
{ field: 'lines[0].quantity', code: 'present_quantity' },
{ field: 'lines[1].presence', code: 'invalid_presence' },
]);
expect(document.activeElement?.id).toBe('inventory-bed-1-privateAreaId');
});
it('updates editable facts, normalizes legal presence, and protects frozen inventory', () => {
@@ -127,7 +166,7 @@ describe('InventoryPage', () => {
page.removeLine('bed-1');
page.save();
expect(page.inventory().lines).toHaveLength(1);
expect(page.error()).toContain('figé');
expect(page.saveError()).toContain('figé');
});
it('shows load and save failures without replacing the current inventory', () => {
@@ -153,9 +192,40 @@ describe('InventoryPage', () => {
const page = fixture.componentInstance;
page.save();
expect(page.error()).toContain('Impossible d’enregistrer');
expect(page.saveError()).toContain('Impossible d’enregistrer');
page.load();
expect(page.error()).toContain('Impossible de charger');
expect(page.loadError()).toContain('Impossible de charger');
expect(page.inventory()).toEqual(inventory);
});
it('reveals all server line errors, focuses the first, and clears corrections independently', async () => {
const { fixture, service } = createPage();
service.saveInventory.mockReturnValue(
throwError(() => ({
status: 400,
error: {
code: 'validation_failed',
errors: [
{ field: 'lines[0].privateAreaId', code: 'not_blank' },
{ field: 'lines[0].designation', code: 'not_blank' },
],
},
})),
);
fixture.componentInstance.save();
fixture.detectChanges();
await Promise.resolve();
expect(fixture.nativeElement.textContent).toContain('Renseignez l’espace privatif');
expect(fixture.nativeElement.textContent).toContain('Renseignez la désignation');
expect(document.activeElement?.id).toBe('inventory-bed-1-privateAreaId');
const designation = document.createElement('input');
designation.value = 'Lit double';
fixture.componentInstance.updateText('bed-1', 'designation', eventFor(designation));
expect(fixture.componentInstance.fieldErrors()).toEqual([
{ field: 'lines[0].privateAreaId', code: 'not_blank' },
]);
});
});
@@ -7,7 +7,12 @@ import {
FurnitureInventoryService,
FurniturePresence,
} from '../../services/furniture-inventory.service';
import { httpErrorMessage } from '../../services/http-error-message';
import {
FieldValidationError,
httpErrorMessage,
saveFailure,
} from '../../services/http-error-message';
import { inventoryLineErrors, inventoryLineGuidance } from './inventory-line-validation';
const emptyInventory: FurnitureInventory = {
id: '',
@@ -35,7 +40,9 @@ export class InventoryPage {
readonly inventory = signal<FurnitureInventory>(emptyInventory);
readonly loading = signal(true);
readonly saving = signal(false);
readonly error = signal('');
readonly loadError = signal('');
readonly saveError = signal('');
readonly fieldErrors = signal<FieldValidationError[]>([]);
readonly success = signal('');
constructor() {
@@ -44,15 +51,14 @@ export class InventoryPage {
load(): void {
this.loading.set(true);
this.error.set('');
this.success.set('');
this.loadError.set('');
this.inventories
.getInventory(this.leaseId)
.pipe(rxFinalize(() => this.loading.set(false)))
.subscribe({
next: (inventory) => this.inventory.set(inventory),
error: (error) =>
this.error.set(
this.loadError.set(
httpErrorMessage(error, 'Impossible de charger l’inventaire du mobilier.'),
),
});
@@ -70,6 +76,7 @@ export class InventoryPage {
? event.target.value
: '';
this.updateLine(id, { [field]: value });
this.clearFieldError(id, field);
}
updateQuantity(id: string, event: Event): void {
@@ -79,6 +86,7 @@ export class InventoryPage {
? -1
: input.valueAsNumber;
this.updateLine(id, { quantity });
this.clearFieldError(id, 'quantity');
}
updatePresence(id: string, event: Event): void {
@@ -96,6 +104,7 @@ export class InventoryPage {
? 1
: line.quantity,
});
this.clearFieldError(id, 'presence', 'quantity');
}
addLine(): void {
@@ -130,6 +139,7 @@ export class InventoryPage {
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
if (value === 'NONE' || value === 'CLIMATISATION' || value === 'OTHER') {
this.updateLine(id, { equipmentKind: value });
this.clearFieldError(id, 'equipmentKind');
}
}
@@ -141,18 +151,25 @@ export class InventoryPage {
}
save(): void {
if (this.saving()) return;
const inventory = this.inventory();
if (inventory.frozen) {
this.error.set('Cet inventaire est figé et ne peut plus être modifié.');
this.saveError.set('Cet inventaire est figé et ne peut plus être modifié.');
return;
}
const validationError = this.validationError(inventory.lines);
if (validationError) {
this.error.set(validationError);
const errors = inventoryLineErrors(
inventory.lines,
new Set(inventory.privateAreas.map((privateArea) => privateArea.id)),
);
if (errors.length) {
this.saveError.set('Veuillez corriger les champs signalés.');
this.fieldErrors.set(errors);
this.focusFirstError(errors);
return;
}
this.saving.set(true);
this.error.set('');
this.saveError.set('');
this.fieldErrors.set([]);
this.success.set('');
this.inventories
.saveInventory(this.leaseId, { version: inventory.version, lines: inventory.lines })
@@ -162,13 +179,49 @@ export class InventoryPage {
this.inventory.set(saved);
this.success.set('Inventaire enregistré.');
},
error: (error) =>
this.error.set(
httpErrorMessage(error, 'Impossible d’enregistrer l’inventaire.'),
),
error: (error) => {
const failure = saveFailure(error, 'Impossible d’enregistrer l’inventaire.');
this.saveError.set(failure.message);
this.fieldErrors.set(failure.fieldErrors);
this.focusFirstError(failure.fieldErrors);
},
});
}
lineError(line: FurnitureInventoryLine, field: string): string | null {
const index = this.inventory().lines.indexOf(line);
const error = this.fieldErrors().find(
(candidate) => candidate.field === this.fieldName(index, field),
);
return error ? inventoryLineGuidance(error.code, field) : null;
}
controlId(line: FurnitureInventoryLine, field: string): string {
return `inventory-${line.id}-${field === 'category' ? 'presence' : field}`;
}
private clearFieldError(id: string, ...fields: string[]): void {
const index = this.inventory().lines.findIndex((line) => line.id === id);
this.fieldErrors.update((errors) =>
errors.filter(
(error) => !fields.some((field) => error.field === this.fieldName(index, field)),
),
);
if (!this.fieldErrors().length) this.saveError.set('');
}
private focusFirstError(errors: FieldValidationError[]): void {
queueMicrotask(() => {
const error = errors.find((candidate) => candidate.field.startsWith('lines['));
if (!error) return;
const close = error.field.indexOf('].');
const index = Number(error.field.slice('lines['.length, close));
const field = error.field.slice(close + 2);
const line = this.inventory().lines[index];
document.getElementById(line ? this.controlId(line, field) : '')?.focus();
});
}
private updateLine(id: string, changes: Partial<FurnitureInventoryLine>): void {
if (this.inventory().frozen || this.saving()) return;
this.inventory.update((inventory) => ({
@@ -177,23 +230,7 @@ export class InventoryPage {
}));
}
private validationError(lines: FurnitureInventoryLine[]): string | null {
for (const line of lines) {
if (!line.privateAreaId.trim()) return 'Renseignez la zone de chaque ligne.';
if (!line.designation.trim()) return 'Renseignez la désignation de chaque ligne.';
if (!Number.isInteger(line.quantity) || line.quantity < 0) {
return 'Saisissez une quantité entière positive ou nulle.';
}
if (line.category && line.presence === null) {
return 'Déclarez la présence ou l’absence de chaque élément réglementaire.';
}
if (line.category && line.presence === 'PRESENT' && line.quantity === 0) {
return 'Un élément réglementaire présent doit avoir une quantité positive.';
}
if (line.category && line.presence === 'ABSENT' && line.quantity !== 0) {
return 'Un élément réglementaire absent doit avoir une quantité nulle.';
}
}
return null;
private fieldName(index: number, field: string): string {
return `lines[${index}].${field}`;
}
}