diff --git a/CONTEXT.md b/CONTEXT.md index 246013c..b3021e6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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`): diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java index a0b0e30..03b3ac9 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java @@ -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 incompleteApartmentLegalProfile( .body(new CodedApiError("apartment_legal_profile_incomplete", exception.getMessage())); } +@ExceptionHandler(FurnitureInventoryPrivateAreasMissing.class) +ResponseEntity 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 invalidArgument() { return error(HttpStatus.BAD_REQUEST, "invalid_request", "La requête est invalide."); @@ -268,6 +280,8 @@ private static ResponseEntity 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 errors) {} record FieldValidationError(String field, String code) {} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java index d7f73ff..4eed2f6 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java @@ -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 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 lines) { Set privateAreaIds = - apartment(landlordId, lease).privateAreas().stream() + privateAreas(landlordId, lease.id()).stream() .map(area -> area.id()) .collect(java.util.stream.Collectors.toSet()); if (lines == null diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventoryPrivateAreasMissing.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventoryPrivateAreasMissing.java new file mode 100644 index 0000000..cadc636 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventoryPrivateAreasMissing.java @@ -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; +} +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandlerContractTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandlerContractTest.java index fd4d8b0..24fa445 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandlerContractTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandlerContractTest.java @@ -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 = diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java index 9fb9c28..3289a80 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java @@ -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()); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java index fd8ef96..b89d174 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java @@ -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 = diff --git a/frontend/src/app/components/apartments-page/apartments-page.spec.ts b/frontend/src/app/components/apartments-page/apartments-page.spec.ts index 4a271fb..eda2309 100644 --- a/frontend/src/app/components/apartments-page/apartments-page.spec.ts +++ b/frontend/src/app/components/apartments-page/apartments-page.spec.ts @@ -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; 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', diff --git a/frontend/src/app/components/apartments-page/apartments-page.ts b/frontend/src/app/components/apartments-page/apartments-page.ts index 2e9ecec..8ec1086 100644 --- a/frontend/src/app/components/apartments-page/apartments-page.ts +++ b/frontend/src/app/components/apartments-page/apartments-page.ts @@ -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(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 { diff --git a/frontend/src/app/components/inventory-page/inventory-line-card.html b/frontend/src/app/components/inventory-page/inventory-line-card.html index e2a8419..29c7c02 100644 --- a/frontend/src/app/components/inventory-page/inventory-line-card.html +++ b/frontend/src/app/components/inventory-page/inventory-line-card.html @@ -20,7 +20,6 @@ textChanged.emit({ field: 'privateAreaId', value: $event.value }) " > - Tout l’appartement @for (area of privateAreas(); track area.id) { {{ area.label }} } diff --git a/frontend/src/app/components/inventory-page/inventory-page.css b/frontend/src/app/components/inventory-page/inventory-page.css index 8924170..775074e 100644 --- a/frontend/src/app/components/inventory-page/inventory-page.css +++ b/frontend/src/app/components/inventory-page/inventory-page.css @@ -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; diff --git a/frontend/src/app/components/inventory-page/inventory-page.html b/frontend/src/app/components/inventory-page/inventory-page.html index 4f838d7..02bcc04 100644 --- a/frontend/src/app/components/inventory-page/inventory-page.html +++ b/frontend/src/app/components/inventory-page/inventory-page.html @@ -30,17 +30,29 @@ } - @if (inventory().id) { + @if (missingPrivateAreasApartmentId(); as apartmentId) { + + } + @if (inventory().id && !missingPrivateAreasApartmentId()) { @if (saveError()) { } - @if (success()) { -
-

{{ success() }}

-
- } @if (inventory().frozen) {

Cet inventaire est figé.

@@ -148,7 +160,7 @@ @if (saving()) { } - {{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }} + {{ saving() ? 'Enregistrement…' : 'Enregistrer et revenir aux baux' }}
} diff --git a/frontend/src/app/components/inventory-page/inventory-page.spec.ts b/frontend/src/app/components/inventory-page/inventory-page.spec.ts index bbb79d6..e7205ca 100644 --- a/frontend/src/app/components/inventory-page/inventory-page.spec.ts +++ b/frontend/src/app/components/inventory-page/inventory-page.spec.ts @@ -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; + }; + 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( diff --git a/frontend/src/app/components/inventory-page/inventory-page.ts b/frontend/src/app/components/inventory-page/inventory-page.ts index d78586e..409c07c 100644 --- a/frontend/src/app/components/inventory-page/inventory-page.ts +++ b/frontend/src/app/components/inventory-page/inventory-page.ts @@ -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(emptyInventory); readonly loading = signal(true); readonly saving = signal(false); readonly loadError = signal(''); + readonly missingPrivateAreasApartmentId = signal(null); readonly saveError = signal(''); readonly fieldErrors = signal([]); - 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 (