feat(private-area-editor): enhance UI with Material components and improve area management

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent ef04cbe45b
commit 983a080cc7
7 files changed
+131 -45

No files matched your search

+1 -4
View File
@@ -5,15 +5,12 @@
"type": "java",
"name": "Backend: Spring Boot",
"request": "launch",
"presentation": {
"hidden": true
},
"mainClass": "fr.nessar.lmnporganizer.LmnpOrganizerApplication",
"projectName": "backend",
"cwd": "${workspaceFolder}/backend",
"envFile": "${workspaceFolder}/.env",
"env": {
"SPRING_DATA_MONGODB_URI": "mongodb://admin:adminpassword@localhost:27017/lmnp?authSource=admin"
"SPRING_DATA_MONGODB_URI": "mongodb://${MONGO_ROOT_USERNAME:lmnp}:${MONGO_ROOT_PASSWORD}@localhost:${MONGO_PORT:27017}/lmnp?authSource=admin&replicaSet=rs0&directConnection=true"
},
"preLaunchTask": "Start MongoDB",
"postDebugTask": "Stop MongoDB"
+2 -2
View File
@@ -4,7 +4,7 @@
{
"label": "Start MongoDB",
"type": "shell",
"command": "docker compose up --detach --wait mongodb",
"command": "docker compose --file=compose.dev.yml up --detach --wait mongodb",
"options": {
"cwd": "${workspaceFolder}"
},
@@ -13,7 +13,7 @@
{
"label": "Stop MongoDB",
"type": "shell",
"command": "docker compose stop mongodb",
"command": "docker compose --file=compose.dev.yml stop mongodb",
"options": {
"cwd": "${workspaceFolder}"
},
+2
View File
@@ -4,6 +4,8 @@ services:
mongodb:
image: mongo:8.0
restart: unless-stopped
ports:
- "${MONGO_PORT:-27017}:27017"
entrypoint:
- /bin/bash
- -c
@@ -1,17 +1,57 @@
.private-areas {
display: grid;
gap: 12px;
gap: var(--space-3, 0.75rem);
}
.private-areas fieldset {
.private-area {
overflow: hidden;
border: 1px solid var(--border, #dce5df);
background: var(--surface-raised, #ffffff);
box-shadow: none;
}
.private-area-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2, 0.5rem);
margin-bottom: var(--space-4, 1rem);
}
.private-area-heading h3 {
min-width: 0;
margin: 0;
overflow-wrap: break-word;
font-size: 1rem;
}
.danger-action.mat-mdc-button {
--mdc-text-button-label-text-color: var(--danger, #b42318);
}
.main-room-count {
color: var(--accent, #0f766e);
font-size: 0.875rem;
font-weight: 700;
}
.empty-private-areas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
place-items: center;
gap: var(--space-3, 0.75rem);
padding: var(--space-6, 1.5rem);
border: 1px dashed var(--border-strong, #c7d3cd);
border-radius: var(--radius-md, 0.8125rem);
background: var(--surface-soft, #e9efeb);
color: var(--text-muted, #687670);
text-align: center;
}
.private-areas label {
display: grid;
gap: 4px;
.empty-private-areas p {
margin: 0;
}
.private-areas input,
.private-areas select {
min-height: 40px;
.add-private-area {
margin-top: var(--space-4, 1rem);
}
@@ -1,38 +1,62 @@
<section class="form-section" aria-labelledby="private-areas-heading">
<div class="section-heading">
<h2 id="private-areas-heading">Espaces privatifs</h2>
<p>
Le nombre réglementaire de pièces principales est calculé depuis ces espaces :
{{ mainRoomCount() }}.
<p>Le nombre réglementaire de pièces principales est calculé depuis ces espaces.</p>
<p class="main-room-count" aria-live="polite">
{{ mainRoomCount() }} pièce{{ mainRoomCount() === 1 ? '' : 's' }} principale{{
mainRoomCount() === 1 ? '' : 's'
}}
</p>
</div>
@if (areas().length === 0) {
<div class="empty-private-areas">
<p>Aucun espace privatif renseigné.</p>
<button mat-stroked-button type="button" (click)="addArea()">Ajouter un espace</button>
</div>
} @else {
<div class="private-areas">
@for (area of areas(); track area.id) {
<fieldset>
<legend>{{ area.label || 'Espace privatif' }}</legend>
<label>
<span>Famille</span>
<select [value]="area.family" (change)="updateArea(area.id, 'family', $event)">
<mat-card class="private-area">
<mat-card-content>
<div class="private-area-heading">
<h3>{{ area.label || 'Nouvel espace privatif' }}</h3>
<button mat-button type="button" class="danger-action" (click)="removeArea(area.id)">
Supprimer
</button>
</div>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Famille</mat-label>
<mat-select [value]="area.family"
(selectionChange)="updateArea(area.id, 'family', $event.value)">
@for (family of families; track family.value) {
<option [value]="family.value">{{ family.label }}</option>
<mat-option [value]="family.value">{{ family.label }}</mat-option>
}
</select>
</label>
<label>
<span>Sous-type</span>
<select [value]="area.subtype" (change)="updateArea(area.id, 'subtype', $event)">
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sous-type</mat-label>
<mat-select [value]="area.subtype"
(selectionChange)="updateArea(area.id, 'subtype', $event.value)">
@for (subtype of subtypesFor(area.family); track subtype.value) {
<option [value]="subtype.value">{{ subtype.label }}</option>
<mat-option [value]="subtype.value">{{ subtype.label }}</mat-option>
}
</select>
</label>
<label>
<span>Libellé</span>
<input [value]="area.label" (input)="updateArea(area.id, 'label', $event)" />
</label>
<button type="button" (click)="removeArea(area.id)">Supprimer</button>
</fieldset>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Libellé</mat-label>
<input matInput [value]="area.label" (input)="updateArea(area.id, 'label', $event)"
placeholder="Ex. Chambre 1" />
</mat-form-field>
</div>
</mat-card-content>
</mat-card>
}
</div>
<button type="button" (click)="addArea()">Ajouter un espace privatif</button>
}
@if (areas().length !== 0) {
<button mat-stroked-button type="button" class="add-private-area" (click)="addArea()">
Ajouter un espace privatif
</button>
}
</section>
@@ -9,6 +9,21 @@ function eventFor(target: EventTarget): Event {
}
describe('PrivateAreaEditor', () => {
it('renders each private area with Material controls', () => {
const fixture = TestBed.createComponent(PrivateAreaEditor);
fixture.componentRef.setInput('areas', [
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'BEDROOM', label: 'Chambre' },
]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('mat-card')).not.toBeNull();
expect(element.querySelectorAll('mat-form-field')).toHaveLength(3);
expect(element.querySelectorAll('mat-select')).toHaveLength(2);
expect(element.textContent).toContain('1 pièce principale');
});
it('adds a stable private area and derives the main-room count from its family', () => {
const fixture = TestBed.createComponent(PrivateAreaEditor);
fixture.componentRef.setInput('areas', []);
@@ -1,4 +1,9 @@
import { Component, computed, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import {
PrivateArea,
PrivateAreaFamily,
@@ -35,6 +40,7 @@ const subtypeFamilies: Record<PrivateAreaFamily, readonly PrivateAreaSubtype[]>
@Component({
selector: 'app-private-area-editor',
imports: [MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './private-area-editor.html',
styleUrl: './private-area-editor.css',
})
@@ -58,17 +64,19 @@ export class PrivateAreaEditor {
]);
}
updateArea(id: string, field: keyof Omit<PrivateArea, 'id'>, event: Event): void {
const value =
event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement
? event.target.value
updateArea(id: string, field: keyof Omit<PrivateArea, 'id'>, value: string | Event): void {
const nextValue =
typeof value === 'string'
? value
: value.target instanceof HTMLInputElement || value.target instanceof HTMLSelectElement
? value.target.value
: '';
this.areasChanged.emit(
this.areas().map((area) =>
area.id === id
? {
...area,
[field]: value,
[field]: nextValue,
...(field === 'family' ? { subtype: 'OTHER' as const } : {}),
}
: area,