Align furniture inventory validation (#52)
This commit is contained in:
1 parent
66fa95dabd
commit
82293a322d
8 files changed
+712
-227
No files matched your search
+45
-20
@@ -10,13 +10,11 @@ import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryWarning
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@@ -47,34 +45,62 @@ Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String l
|
||||
Response replace(
|
||||
@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@Valid @RequestBody Request request) {
|
||||
@RequestBody Request request) {
|
||||
var landlordId = landlord.id(principal);
|
||||
var id = new LeaseId(leaseId);
|
||||
var privateAreas = inventories.privateAreas(landlordId, id);
|
||||
List<FieldError> errors = FurnitureInventoryRequestValidator.validate(request, privateAreas);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new InventoryValidationException(errors);
|
||||
}
|
||||
return Response.from(
|
||||
inventories.replace(
|
||||
landlordId,
|
||||
id,
|
||||
request.version(),
|
||||
request.lines().stream().map(LineRequest::toDomain).toList()),
|
||||
inventories.privateAreas(landlordId, id));
|
||||
privateAreas);
|
||||
}
|
||||
|
||||
record Request(@NotNull @Min(0) Long version, @NotNull @Valid List<LineRequest> lines) {}
|
||||
@ExceptionHandler(InventoryValidationException.class)
|
||||
ResponseEntity<ValidationError> invalidInventory(InventoryValidationException exception) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ValidationError("validation_failed", exception.errors()));
|
||||
}
|
||||
}
|
||||
|
||||
record Request(Long version, List<LineRequest> lines) {}
|
||||
|
||||
record LineRequest(
|
||||
@NotBlank String id,
|
||||
String id,
|
||||
FurnitureCategory category,
|
||||
FurniturePresence presence,
|
||||
@NotNull FurnitureEquipmentKind equipmentKind,
|
||||
@NotBlank String privateAreaId,
|
||||
@NotBlank String designation,
|
||||
@Min(0) int quantity,
|
||||
FurnitureEquipmentKind equipmentKind,
|
||||
String privateAreaId,
|
||||
String designation,
|
||||
Integer quantity,
|
||||
String description) {
|
||||
FurnitureLine toDomain() {
|
||||
FurnitureLine toDomain() {
|
||||
return new FurnitureLine(
|
||||
id, category, presence, equipmentKind, privateAreaId, designation, quantity, description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class InventoryValidationException extends RuntimeException {
|
||||
private final List<FieldError> errors;
|
||||
|
||||
InventoryValidationException(List<FieldError> errors) {
|
||||
this.errors = List.copyOf(errors);
|
||||
}
|
||||
|
||||
List<FieldError> errors() {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
record ValidationError(String code, List<FieldError> errors) {}
|
||||
|
||||
record FieldError(String field, String code) {}
|
||||
|
||||
record LineResponse(
|
||||
String id,
|
||||
@@ -85,7 +111,7 @@ record LineResponse(
|
||||
String designation,
|
||||
int quantity,
|
||||
String description) {
|
||||
static LineResponse from(FurnitureLine line) {
|
||||
static LineResponse from(FurnitureLine line) {
|
||||
return new LineResponse(
|
||||
line.id(),
|
||||
line.category(),
|
||||
@@ -95,13 +121,13 @@ record LineResponse(
|
||||
line.designation(),
|
||||
line.quantity(),
|
||||
line.description());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record PrivateAreaResponse(String id, String label) {
|
||||
static PrivateAreaResponse from(PrivateArea area) {
|
||||
static PrivateAreaResponse from(PrivateArea area) {
|
||||
return new PrivateAreaResponse(area.id(), area.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record Response(
|
||||
@@ -115,7 +141,7 @@ record Response(
|
||||
boolean readyForLeaseDocument,
|
||||
boolean frozen,
|
||||
long version) {
|
||||
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
|
||||
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
|
||||
return new Response(
|
||||
inventory.id().value(),
|
||||
inventory.templateVersion(),
|
||||
@@ -127,6 +153,5 @@ record Response(
|
||||
inventory.isReadyForLeaseDocument(),
|
||||
inventory.frozen(),
|
||||
inventory.version());
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
final class FurnitureInventoryRequestValidator {
|
||||
private FurnitureInventoryRequestValidator() {}
|
||||
|
||||
static List<FieldError> validate(Request request, List<PrivateArea> privateAreas) {
|
||||
List<FieldError> errors = new ArrayList<>();
|
||||
if (request == null) {
|
||||
errors.add(new FieldError("request", "not_null"));
|
||||
return errors;
|
||||
}
|
||||
if (request.version() == null) {
|
||||
errors.add(new FieldError("version", "not_null"));
|
||||
} else if (request.version() < 0) {
|
||||
errors.add(new FieldError("version", "min"));
|
||||
}
|
||||
if (request.lines() == null) {
|
||||
errors.add(new FieldError("lines", "not_null"));
|
||||
return errors;
|
||||
}
|
||||
|
||||
Set<String> privateAreaIds = new HashSet<>();
|
||||
for (PrivateArea privateArea : privateAreas) {
|
||||
privateAreaIds.add(privateArea.id());
|
||||
}
|
||||
Map<String, Integer> lineIds = new HashMap<>();
|
||||
Map<FurnitureCategory, Integer> categories = new HashMap<>();
|
||||
for (int index = 0; index < request.lines().size(); index++) {
|
||||
LineRequest line = request.lines().get(index);
|
||||
if (line == null) {
|
||||
errors.add(new FieldError(field(index, "line"), "not_null"));
|
||||
continue;
|
||||
}
|
||||
validate(errors, privateAreaIds, line, index);
|
||||
duplicate(errors, lineIds, trimmed(line.id()), index, "id");
|
||||
if (line.category() != null) {
|
||||
duplicate(errors, categories, line.category(), index, "category");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void validate(
|
||||
List<FieldError> errors, Set<String> privateAreaIds, LineRequest line, int index) {
|
||||
if (blank(line.id())) errors.add(new FieldError(field(index, "id"), "not_blank"));
|
||||
if (line.equipmentKind() == null) {
|
||||
errors.add(new FieldError(field(index, "equipmentKind"), "not_null"));
|
||||
}
|
||||
if (blank(line.privateAreaId())) {
|
||||
errors.add(new FieldError(field(index, "privateAreaId"), "not_blank"));
|
||||
} else if (!privateAreaIds.contains(line.privateAreaId().trim())) {
|
||||
errors.add(new FieldError(field(index, "privateAreaId"), "unknown_private_area"));
|
||||
}
|
||||
if (blank(line.designation()))
|
||||
errors.add(new FieldError(field(index, "designation"), "not_blank"));
|
||||
if (line.quantity() == null) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "not_null"));
|
||||
} else if (line.quantity() < 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "min"));
|
||||
}
|
||||
if (line.category() == null && line.presence() != null) {
|
||||
errors.add(new FieldError(field(index, "presence"), "invalid_presence"));
|
||||
}
|
||||
if (line.category() != null && line.equipmentKind() != null) {
|
||||
if (line.equipmentKind() != line.category().equipmentKind()) {
|
||||
errors.add(new FieldError(field(index, "equipmentKind"), "invalid_equipment_kind"));
|
||||
}
|
||||
if (line.presence() == null && line.quantity() != null && line.quantity() != 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "undeclared_quantity"));
|
||||
}
|
||||
}
|
||||
if (line.presence() == FurniturePresence.PRESENT
|
||||
&& line.quantity() != null
|
||||
&& line.quantity() == 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "present_quantity"));
|
||||
}
|
||||
if (line.presence() == FurniturePresence.ABSENT
|
||||
&& line.quantity() != null
|
||||
&& line.quantity() != 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "absent_quantity"));
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void duplicate(
|
||||
List<FieldError> errors, Map<T, Integer> values, T value, int index, String name) {
|
||||
if (value == null) return;
|
||||
Integer first = values.putIfAbsent(value, index);
|
||||
if (first != null) {
|
||||
errors.add(new FieldError(field(first, name), "duplicate"));
|
||||
errors.add(new FieldError(field(index, name), "duplicate"));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String trimmed(String value) {
|
||||
return blank(value) ? null : value.trim();
|
||||
}
|
||||
|
||||
private static String field(int index, String name) {
|
||||
return "lines[" + index + "]." + name;
|
||||
}
|
||||
}
|
||||
+53
-1
@@ -1,5 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import static org.hamcrest.Matchers.hasItems;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -84,13 +85,21 @@ void getInitializesAndExposesUndeclaredLegalCategories() throws Exception {
|
||||
@Test
|
||||
void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
|
||||
when(inventories.replace(eq(LANDLORD_ID), eq(LEASE_ID), eq(0L), any())).thenReturn(inventory());
|
||||
when(inventories.privateAreas(LANDLORD_ID, LEASE_ID))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new PrivateArea(
|
||||
"area-1",
|
||||
PrivateArea.Family.PIECE_PRINCIPALE,
|
||||
PrivateArea.Subtype.LIVING_ROOM,
|
||||
"Séjour")));
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/leases/lease-1/inventory")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"equipmentKind\":\"NONE\",\"privateAreaId\":\"area-1\",\"designation\":\"Lampe\",\"quantity\":1}]}")
|
||||
"{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"category\":\"BEDDING\",\"equipmentKind\":\"NONE\",\"privateAreaId\":\"area-1\",\"designation\":\"Literie\",\"quantity\":0}]}")
|
||||
.with(landlord())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk());
|
||||
@@ -106,6 +115,49 @@ void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEveryIndexedInventoryLineErrorTogether() throws Exception {
|
||||
when(inventories.privateAreas(LANDLORD_ID, LEASE_ID))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new PrivateArea(
|
||||
"area-1",
|
||||
PrivateArea.Family.PIECE_PRINCIPALE,
|
||||
PrivateArea.Subtype.LIVING_ROOM,
|
||||
"Séjour")));
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/leases/lease-1/inventory")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{"version":-1,"lines":[
|
||||
{"id":"","category":"BEDDING","presence":"PRESENT","equipmentKind":"OTHER","privateAreaId":"","designation":"","quantity":0},
|
||||
{"id":"","category":"BEDDING","presence":"ABSENT","equipmentKind":"NONE","privateAreaId":"unknown","designation":"Lit","quantity":1},
|
||||
{"id":"free","presence":"PRESENT","equipmentKind":"NONE","privateAreaId":"area-1","designation":"Lampe","quantity":1}
|
||||
]}
|
||||
""")
|
||||
.with(landlord())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors.length()").value(12))
|
||||
.andExpect(
|
||||
jsonPath("$.errors[*].field")
|
||||
.value(
|
||||
hasItems(
|
||||
"version",
|
||||
"lines[0].privateAreaId",
|
||||
"lines[0].designation",
|
||||
"lines[0].quantity",
|
||||
"lines[1].privateAreaId",
|
||||
"lines[1].quantity",
|
||||
"lines[2].presence",
|
||||
"lines[0].category",
|
||||
"lines[1].category")));
|
||||
}
|
||||
|
||||
private static FurnitureInventory inventory() {
|
||||
return FurnitureInventory.prefilled(
|
||||
new fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId("inventory-1"),
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user