fix: rendre l’inventaire actionnable (#77)
This commit is contained in:
1 parent
68fad6e6e5
commit
c4c38805c0
14 files changed
+242
-41
No files matched your search
+1
-1
@@ -241,7 +241,7 @@ Dépense de valeur significative (seuil fiscal de 500 € HT en France) pour des
|
||||
_Avoid_: Investissement, charge à amortir
|
||||
|
||||
**Inventaire du mobilier** (Code: `FurnitureInventory`):
|
||||
Annexe contractuelle du bail recensant de façon exhaustive et détaillée les meubles, appareils et équipements mis à la disposition du locataire, dont les mêmes éléments sont ensuite décrits dans les états des lieux d'entrée et de sortie. Chaque ligne regroupe un type d'objet homogène dans un espace privatif avec sa désignation, sa quantité et une description facultative, sans décrire son état. Dans l'état des lieux, cette ligne porte l'unique constat du meuble ou de l'appareil correspondant et n'est pas dupliquée comme équipement de l'espace ; les équipements absents de l'inventaire sont constatés séparément. L'inventaire est prérempli depuis le catalogue de la version de modèle de bail applicable, complété librement par le bailleur, puis utilisé comme référence du tunnel d'entrée ; chaque écart de quantité ou de description constaté doit lui être appliqué explicitement avant la génération du bail, tandis que l'état du mobilier reste propre à l'état des lieux. L'inventaire réconcilié est figé avec le snapshot des espaces privatifs dans chaque révision générée, chaque élément réglementaire devant être explicitement renseigné sans être présumé présent et son absence produisant une alerte juridique contournable et auditée plutôt qu'un blocage permanent.
|
||||
Annexe contractuelle du bail recensant de façon exhaustive et détaillée les meubles, appareils et équipements mis à la disposition du locataire, dont les mêmes éléments sont ensuite décrits dans les états des lieux d'entrée et de sortie. Sa création exige que l'Appartement possède au moins un Espace privatif, et chaque ligne référence exclusivement un Espace privatif réel de cet Appartement avec sa désignation, sa quantité et une description facultative, sans décrire son état. Dans l'état des lieux, cette ligne porte l'unique constat du meuble ou de l'appareil correspondant et n'est pas dupliquée comme équipement de l'espace ; les équipements absents de l'inventaire sont constatés séparément. L'inventaire est prérempli depuis le catalogue de la version de modèle de bail applicable, complété librement par le bailleur, puis utilisé comme référence du tunnel d'entrée ; chaque écart de quantité ou de description constaté doit lui être appliqué explicitement avant la génération du bail, tandis que l'état du mobilier reste propre à l'état des lieux. L'inventaire réconcilié est figé avec le snapshot des espaces privatifs dans chaque révision générée, chaque élément réglementaire devant être explicitement renseigné sans être présumé présent et son absence produisant une alerte juridique contournable et auditée plutôt qu'un blocage permanent.
|
||||
_Avoid_: Inventaire des lieux, liste des meubles
|
||||
|
||||
**Écart d'inventaire à l'entrée** (Code: `EntryInventoryDiscrepancy`):
|
||||
|
||||
+14
@@ -12,6 +12,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.IncompleteApartmentLegalP
|
||||
import fr.nessar.lmnporganizer.rental.domain.condition.EntryConditionReportPrerequisite;
|
||||
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivateAreasMissing;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
@@ -60,6 +61,17 @@ ResponseEntity<CodedApiError> incompleteApartmentLegalProfile(
|
||||
.body(new CodedApiError("apartment_legal_profile_incomplete", exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(FurnitureInventoryPrivateAreasMissing.class)
|
||||
ResponseEntity<FurnitureInventoryPrerequisiteError> furnitureInventoryPrivateAreasMissing(
|
||||
FurnitureInventoryPrivateAreasMissing exception) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(
|
||||
new FurnitureInventoryPrerequisiteError(
|
||||
"furniture_inventory_private_areas_missing",
|
||||
"Ajoutez au moins un espace privatif avant de créer l’inventaire du mobilier.",
|
||||
exception.apartmentId()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
ResponseEntity<CodedApiError> invalidArgument() {
|
||||
return error(HttpStatus.BAD_REQUEST, "invalid_request", "La requête est invalide.");
|
||||
@@ -268,6 +280,8 @@ private static ResponseEntity<ValidationApiError> validation(String field, Strin
|
||||
|
||||
record CodedApiError(String code, String message) {}
|
||||
|
||||
record FurnitureInventoryPrerequisiteError(String code, String message, String apartmentId) {}
|
||||
|
||||
record ValidationApiError(String code, java.util.List<FieldValidationError> errors) {}
|
||||
|
||||
record FieldValidationError(String field, String code) {}
|
||||
|
||||
+8
-9
@@ -8,6 +8,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivateAreasMissing;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
@@ -70,17 +71,15 @@ public FurnitureInventory freeze(LandlordId landlordId, LeaseId leaseId) {
|
||||
}
|
||||
|
||||
public List<PrivateArea> privateAreas(LandlordId landlordId, LeaseId leaseId) {
|
||||
return apartment(landlordId, lease(landlordId, leaseId)).privateAreas();
|
||||
Apartment apartment = apartment(landlordId, lease(landlordId, leaseId));
|
||||
if (apartment.privateAreas().isEmpty()) {
|
||||
throw new FurnitureInventoryPrivateAreasMissing(apartment.id().value());
|
||||
}
|
||||
return apartment.privateAreas();
|
||||
}
|
||||
|
||||
private FurnitureInventory initialize(LandlordId landlordId, Lease lease) {
|
||||
Apartment apartment = apartment(landlordId, lease);
|
||||
String areaId =
|
||||
apartment.privateAreas().stream()
|
||||
.findFirst()
|
||||
.map(area -> area.id())
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("Le logement doit définir un espace privatif."));
|
||||
String areaId = privateAreas(landlordId, lease.id()).getFirst().id();
|
||||
return inventories.save(
|
||||
FurnitureInventory.prefilled(
|
||||
new FurnitureInventoryId(identifiers.next()),
|
||||
@@ -97,7 +96,7 @@ private String currentTemplateVersion() {
|
||||
|
||||
private void validatePrivateAreas(LandlordId landlordId, Lease lease, List<FurnitureLine> lines) {
|
||||
Set<String> privateAreaIds =
|
||||
apartment(landlordId, lease).privateAreas().stream()
|
||||
privateAreas(landlordId, lease.id()).stream()
|
||||
.map(area -> area.id())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (lines == null
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.furniture;
|
||||
|
||||
public final class FurnitureInventoryPrivateAreasMissing extends IllegalStateException {
|
||||
private final String apartmentId;
|
||||
|
||||
public FurnitureInventoryPrivateAreasMissing(String apartmentId) {
|
||||
this.apartmentId = apartmentId;
|
||||
}
|
||||
|
||||
public String apartmentId() {
|
||||
return apartmentId;
|
||||
}
|
||||
}
|
||||
+17
@@ -14,6 +14,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.IncompleteApartmentLegalP
|
||||
import fr.nessar.lmnporganizer.rental.domain.condition.EntryConditionReportPrerequisite;
|
||||
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivateAreasMissing;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
@@ -142,6 +143,22 @@ void mapsAnIncompleteApartmentLegalProfileToAnActionableConflict() {
|
||||
response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsMissingInventoryPrivateAreasToAnActionableConflict() {
|
||||
var response =
|
||||
new RentalApiExceptionHandler()
|
||||
.furnitureInventoryPrivateAreasMissing(
|
||||
new FurnitureInventoryPrivateAreasMissing("apartment-1"));
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
|
||||
assertEquals(
|
||||
new RentalApiExceptionHandler.FurnitureInventoryPrerequisiteError(
|
||||
"furniture_inventory_private_areas_missing",
|
||||
"Ajoutez au moins un espace privatif avant de créer l’inventaire du mobilier.",
|
||||
"apartment-1"),
|
||||
response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAnInvalidPhotoToStructuredValidation() {
|
||||
var response =
|
||||
|
||||
+13
@@ -20,6 +20,7 @@ import fr.nessar.lmnporganizer.rental.application.furniture.FurnitureInventorySe
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivateAreasMissing;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
@@ -82,6 +83,18 @@ void getInitializesAndExposesUndeclaredLegalCategories() throws Exception {
|
||||
.andExpect(jsonPath("$.readyForLeaseDocument").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheApartmentThatNeedsPrivateAreasBeforeInitializing() throws Exception {
|
||||
when(inventories.get(LANDLORD_ID, LEASE_ID))
|
||||
.thenThrow(new FurnitureInventoryPrivateAreasMissing("apartment-1"));
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/leases/lease-1/inventory").with(landlord()))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("furniture_inventory_private_areas_missing"))
|
||||
.andExpect(jsonPath("$.apartmentId").value("apartment-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
|
||||
when(inventories.replace(eq(LANDLORD_ID), eq(LEASE_ID), eq(0L), any())).thenReturn(inventory());
|
||||
|
||||
+26
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivateAreasMissing;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
@@ -69,6 +70,31 @@ void initializesOneUndeclaredLegalLinePerCategoryInTheLeaseApartmentFirstArea()
|
||||
assertEquals("id-1", captured.getValue().id().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesToInitializeWithoutPrivateAreasAndDoesNotCreateAnInventory() {
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease()));
|
||||
Apartment apartment =
|
||||
new Apartment(
|
||||
APARTMENT_ID,
|
||||
LANDLORD_ID,
|
||||
"Studio",
|
||||
"1 rue A",
|
||||
new BigDecimal("1"),
|
||||
"",
|
||||
ApartmentLegalProfile.empty(),
|
||||
List.of());
|
||||
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(apartment));
|
||||
when(inventories.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.empty());
|
||||
|
||||
FurnitureInventoryPrivateAreasMissing failure =
|
||||
assertThrows(
|
||||
FurnitureInventoryPrivateAreasMissing.class,
|
||||
() -> service(() -> "unused").get(LANDLORD_ID, LEASE_ID));
|
||||
|
||||
assertEquals("apartment-1", failure.apartmentId());
|
||||
verify(inventories, org.mockito.Mockito.never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsALineOutsideTheLeaseApartmentAndAStaleVersion() {
|
||||
FurnitureInventory inventory =
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ApartmentsPage } from './apartments-page';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { Apartment, emptyApartmentLegalProfile } from '../../services/apartment.service';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ActivatedRoute, ParamMap, convertToParamMap } from '@angular/router';
|
||||
|
||||
describe('ApartmentsPage', () => {
|
||||
let http: HttpTestingController;
|
||||
@@ -23,15 +25,24 @@ describe('ApartmentsPage', () => {
|
||||
saveApartment: vi.fn(),
|
||||
deleteApartment: () => undefined,
|
||||
};
|
||||
let queryParameters: BehaviorSubject<ParamMap>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workspace.apartments.set([]);
|
||||
queryParameters = new BehaviorSubject(convertToParamMap({}));
|
||||
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
workspace.deletingApartmentIds.set(new Set());
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: queryParameters.asObservable(),
|
||||
snapshot: { queryParamMap: queryParameters.value },
|
||||
},
|
||||
},
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
@@ -143,6 +154,32 @@ describe('ApartmentsPage', () => {
|
||||
expect(page.editingApartment()).toEqual(apartment);
|
||||
});
|
||||
|
||||
it('follows apartment edit deep links while the page remains active', () => {
|
||||
const firstApartment = {
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const secondApartment = { ...firstApartment, id: 'apt-2', name: 'Studio Lyon' };
|
||||
workspace.apartments.set([firstApartment, secondApartment]);
|
||||
queryParameters.next(convertToParamMap({ edit: 'apt-1' }));
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.editingApartment()).toEqual(firstApartment);
|
||||
http.expectOne('/api/apartments/apt-1/documents').flush([]);
|
||||
|
||||
queryParameters.next(convertToParamMap({ edit: 'apt-2' }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.editingApartment()).toEqual(secondApartment);
|
||||
http.expectOne('/api/apartments/apt-2/documents').flush([]);
|
||||
});
|
||||
|
||||
it('manages documents only while editing an existing apartment', () => {
|
||||
const apartment = {
|
||||
id: 'apt-1',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, effect, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import { Apartment } from '../../services/apartment.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { ApartmentForm } from '../apartment-form/apartment-form';
|
||||
@@ -12,12 +15,30 @@ import { ApartmentDocumentManager } from '../apartment-document-manager/apartmen
|
||||
})
|
||||
export class ApartmentsPage {
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly requestedApartmentId = toSignal(
|
||||
this.route.queryParamMap.pipe(map((parameters) => parameters.get('edit'))),
|
||||
{ initialValue: this.route.snapshot.queryParamMap.get('edit') },
|
||||
);
|
||||
private lastOpenedApartmentId: string | null = null;
|
||||
|
||||
readonly showForm = signal(false);
|
||||
readonly editingApartment = signal<Apartment | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.workspace.loadApartments();
|
||||
effect(() => {
|
||||
const apartmentId = this.requestedApartmentId();
|
||||
if (!apartmentId) {
|
||||
this.lastOpenedApartmentId = null;
|
||||
return;
|
||||
}
|
||||
const apartment = this.workspace.apartments().find(({ id }) => id === apartmentId);
|
||||
if (apartment && apartmentId !== this.lastOpenedApartmentId) {
|
||||
this.lastOpenedApartmentId = apartmentId;
|
||||
this.openEditForm(apartment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openAddForm(): void {
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
textChanged.emit({ field: 'privateAreaId', value: $event.value })
|
||||
"
|
||||
>
|
||||
<mat-option value="">Tout l’appartement</mat-option>
|
||||
@for (area of privateAreas(); track area.id) {
|
||||
<mat-option [value]="area.id">{{ area.label }}</mat-option>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
.inventory-cards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.inventory-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.inventory-card-grid .full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.inventory-line-card {
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
@@ -63,16 +73,6 @@
|
||||
margin-top: var(--space-5, 1.25rem);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
margin-bottom: 1.125rem;
|
||||
padding: 0.8125rem 0.9375rem;
|
||||
font-size: 0.87rem;
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
color: var(--accent, #0f766e);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
margin-bottom: 1.125rem;
|
||||
padding: 0.8125rem 0.9375rem;
|
||||
|
||||
@@ -30,17 +30,29 @@
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
@if (inventory().id) {
|
||||
@if (missingPrivateAreasApartmentId(); as apartmentId) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Ajoutez un espace privatif</h2>
|
||||
<p>
|
||||
L’inventaire du mobilier nécessite au moins un espace privatif de cet
|
||||
appartement.
|
||||
</p>
|
||||
<a
|
||||
mat-flat-button
|
||||
class="primary-action"
|
||||
[routerLink]="['/apartments']"
|
||||
[queryParams]="{ edit: apartmentId }"
|
||||
>
|
||||
Modifier l’appartement
|
||||
</a>
|
||||
</article>
|
||||
}
|
||||
@if (inventory().id && !missingPrivateAreasApartmentId()) {
|
||||
@if (saveError()) {
|
||||
<div class="alert-error" role="alert">
|
||||
<p>{{ saveError() }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (success()) {
|
||||
<div class="alert-success" role="status">
|
||||
<p>{{ success() }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (inventory().frozen) {
|
||||
<div class="alert-info" role="status">
|
||||
<p>Cet inventaire est figé.</p>
|
||||
@@ -148,7 +160,7 @@
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18"></mat-spinner>
|
||||
}
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer et revenir aux baux' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
@@ -46,7 +46,7 @@ describe('InventoryPage', () => {
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideRouter([{ path: 'leases', component: InventoryPage }]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
@@ -61,6 +61,7 @@ describe('InventoryPage', () => {
|
||||
|
||||
it('saves the editable lines with the aggregate version', () => {
|
||||
const { fixture, service } = createPage();
|
||||
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
|
||||
fixture.componentInstance.save();
|
||||
|
||||
@@ -68,6 +69,7 @@ describe('InventoryPage', () => {
|
||||
version: 3,
|
||||
lines: inventory.lines,
|
||||
});
|
||||
expect(navigate).toHaveBeenCalledWith(['/leases']);
|
||||
});
|
||||
|
||||
it('saves an undeclared legal category with its zero quantity', () => {
|
||||
@@ -198,6 +200,29 @@ describe('InventoryPage', () => {
|
||||
expect(page.inventory()).toEqual(inventory);
|
||||
});
|
||||
|
||||
it('explains the private-area prerequisite and links to the associated apartment', () => {
|
||||
const { fixture } = createPage();
|
||||
const service = TestBed.inject(FurnitureInventoryService) as unknown as {
|
||||
getInventory: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
service.getInventory.mockReturnValue(
|
||||
throwError(() => ({
|
||||
status: 409,
|
||||
error: {
|
||||
code: 'furniture_inventory_private_areas_missing',
|
||||
apartmentId: 'apartment-1',
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
fixture.componentInstance.load();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajoutez un espace privatif');
|
||||
expect(fixture.componentInstance.missingPrivateAreasApartmentId()).toBe('apartment-1');
|
||||
expect(fixture.nativeElement.querySelector('form')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals all server line errors, focuses the first, and clears corrections independently', async () => {
|
||||
const { fixture, service } = createPage();
|
||||
service.saveInventory.mockReturnValue(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@@ -7,6 +7,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
FurnitureInventory,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from '../../services/furniture-inventory.service';
|
||||
import {
|
||||
FieldValidationError,
|
||||
errorCode,
|
||||
httpErrorMessage,
|
||||
saveFailure,
|
||||
} from '../../services/http-error-message';
|
||||
@@ -53,15 +55,17 @@ const emptyInventory: FurnitureInventory = {
|
||||
})
|
||||
export class InventoryPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly inventories = inject(FurnitureInventoryService);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly inventory = signal<FurnitureInventory>(emptyInventory);
|
||||
readonly loading = signal(true);
|
||||
readonly saving = signal(false);
|
||||
readonly loadError = signal('');
|
||||
readonly missingPrivateAreasApartmentId = signal<string | null>(null);
|
||||
readonly saveError = signal('');
|
||||
readonly fieldErrors = signal<FieldValidationError[]>([]);
|
||||
readonly success = signal('');
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
@@ -70,15 +74,23 @@ export class InventoryPage {
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.missingPrivateAreasApartmentId.set(null);
|
||||
this.inventories
|
||||
.getInventory(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (inventory) => this.inventory.set(inventory),
|
||||
error: (error) =>
|
||||
this.loadError.set(
|
||||
httpErrorMessage(error, 'Impossible de charger l’inventaire du mobilier.'),
|
||||
),
|
||||
error: (error) => {
|
||||
const apartmentId = missingPrivateAreasApartmentId(error);
|
||||
if (apartmentId) this.missingPrivateAreasApartmentId.set(apartmentId);
|
||||
else
|
||||
this.loadError.set(
|
||||
httpErrorMessage(
|
||||
error,
|
||||
'Impossible de charger l’inventaire du mobilier.',
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,14 +206,14 @@ export class InventoryPage {
|
||||
this.saving.set(true);
|
||||
this.saveError.set('');
|
||||
this.fieldErrors.set([]);
|
||||
this.success.set('');
|
||||
this.inventories
|
||||
.saveInventory(this.leaseId, { version: inventory.version, lines: inventory.lines })
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.inventory.set(saved);
|
||||
this.success.set('Inventaire enregistré.');
|
||||
this.snackBar.open('Inventaire enregistré.', 'Fermer', { duration: 5000 });
|
||||
void this.router.navigate(['/leases']);
|
||||
},
|
||||
error: (error) => {
|
||||
const failure = saveFailure(error, 'Impossible d’enregistrer l’inventaire.');
|
||||
@@ -259,6 +271,19 @@ export class InventoryPage {
|
||||
}
|
||||
}
|
||||
|
||||
function missingPrivateAreasApartmentId(error: unknown): string | null {
|
||||
if (errorCode(error) !== 'furniture_inventory_private_areas_missing') return null;
|
||||
return typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'error' in error &&
|
||||
typeof error.error === 'object' &&
|
||||
error.error !== null &&
|
||||
'apartmentId' in error.error &&
|
||||
typeof error.error.apartmentId === 'string'
|
||||
? error.error.apartmentId
|
||||
: null;
|
||||
}
|
||||
|
||||
function targetValue(target: EventTarget | null): string {
|
||||
if (!target) return '';
|
||||
if (
|
||||
|
||||
Reference in new issue
Block a user