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
+191 -105

No files matched your search

+1 -4
View File
@@ -5,15 +5,12 @@
"type": "java", "type": "java",
"name": "Backend: Spring Boot", "name": "Backend: Spring Boot",
"request": "launch", "request": "launch",
"presentation": {
"hidden": true
},
"mainClass": "fr.nessar.lmnporganizer.LmnpOrganizerApplication", "mainClass": "fr.nessar.lmnporganizer.LmnpOrganizerApplication",
"projectName": "backend", "projectName": "backend",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
"envFile": "${workspaceFolder}/.env", "envFile": "${workspaceFolder}/.env",
"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", "preLaunchTask": "Start MongoDB",
"postDebugTask": "Stop MongoDB" "postDebugTask": "Stop MongoDB"
+46 -46
View File
@@ -1,47 +1,47 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "Start MongoDB", "label": "Start MongoDB",
"type": "shell", "type": "shell",
"command": "docker compose up --detach --wait mongodb", "command": "docker compose --file=compose.dev.yml up --detach --wait mongodb",
"options": { "options": {
"cwd": "${workspaceFolder}" "cwd": "${workspaceFolder}"
}, },
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Stop MongoDB", "label": "Stop MongoDB",
"type": "shell", "type": "shell",
"command": "docker compose stop mongodb", "command": "docker compose --file=compose.dev.yml stop mongodb",
"options": { "options": {
"cwd": "${workspaceFolder}" "cwd": "${workspaceFolder}"
}, },
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Start Angular", "label": "Start Angular",
"type": "shell", "type": "shell",
"command": "bun run start -- --proxy-config proxy.conf.json", "command": "bun run start -- --proxy-config proxy.conf.json",
"options": { "options": {
"cwd": "${workspaceFolder}/frontend" "cwd": "${workspaceFolder}/frontend"
}, },
"isBackground": true, "isBackground": true,
"problemMatcher": { "problemMatcher": {
"owner": "angular", "owner": "angular",
"pattern": { "pattern": {
"regexp": "^$" "regexp": "^$"
}, },
"background": { "background": {
"activeOnStart": true, "activeOnStart": true,
"beginsPattern": ".*", "beginsPattern": ".*",
"endsPattern": "^\\s*➜\\s+Local:\\s+http://localhost:4200/\\s*$" "endsPattern": "^\\s*➜\\s+Local:\\s+http://localhost:4200/\\s*$"
} }
}, },
"presentation": { "presentation": {
"reveal": "always", "reveal": "always",
"panel": "dedicated" "panel": "dedicated"
} }
} }
] ]
} }
+2
View File
@@ -4,6 +4,8 @@ services:
mongodb: mongodb:
image: mongo:8.0 image: mongo:8.0
restart: unless-stopped restart: unless-stopped
ports:
- "${MONGO_PORT:-27017}:27017"
entrypoint: entrypoint:
- /bin/bash - /bin/bash
- -c - -c
@@ -1,17 +1,57 @@
.private-areas { .private-areas {
display: grid; display: grid;
gap: 12px; gap: var(--space-3, 0.75rem);
} }
.private-areas fieldset {
display: grid; .private-area {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); overflow: hidden;
gap: 12px; border: 1px solid var(--border, #dce5df);
background: var(--surface-raised, #ffffff);
box-shadow: none;
} }
.private-areas label {
display: grid; .private-area-heading {
gap: 4px; 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-areas input,
.private-areas select { .private-area-heading h3 {
min-height: 40px; 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;
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;
}
.empty-private-areas p {
margin: 0;
}
.add-private-area {
margin-top: var(--space-4, 1rem);
}
@@ -1,38 +1,62 @@
<section class="form-section" aria-labelledby="private-areas-heading"> <section class="form-section" aria-labelledby="private-areas-heading">
<div class="section-heading"> <div class="section-heading">
<h2 id="private-areas-heading">Espaces privatifs</h2> <h2 id="private-areas-heading">Espaces privatifs</h2>
<p> <p>Le nombre réglementaire de pièces principales est calculé depuis ces espaces.</p>
Le nombre réglementaire de pièces principales est calculé depuis ces espaces : <p class="main-room-count" aria-live="polite">
{{ mainRoomCount() }}. {{ mainRoomCount() }} pièce{{ mainRoomCount() === 1 ? '' : 's' }} principale{{
</p> mainRoomCount() === 1 ? '' : 's'
</div> }}
<div class="private-areas"> </p>
@for (area of areas(); track area.id) { </div>
<fieldset> @if (areas().length === 0) {
<legend>{{ area.label || 'Espace privatif' }}</legend> <div class="empty-private-areas">
<label> <p>Aucun espace privatif renseigné.</p>
<span>Famille</span> <button mat-stroked-button type="button" (click)="addArea()">Ajouter un espace</button>
<select [value]="area.family" (change)="updateArea(area.id, 'family', $event)"> </div>
@for (family of families; track family.value) { } @else {
<option [value]="family.value">{{ family.label }}</option> <div class="private-areas">
} @for (area of areas(); track area.id) {
</select> <mat-card class="private-area">
</label> <mat-card-content>
<label> <div class="private-area-heading">
<span>Sous-type</span> <h3>{{ area.label || 'Nouvel espace privatif' }}</h3>
<select [value]="area.subtype" (change)="updateArea(area.id, 'subtype', $event)"> <button mat-button type="button" class="danger-action" (click)="removeArea(area.id)">
@for (subtype of subtypesFor(area.family); track subtype.value) { Supprimer
<option [value]="subtype.value">{{ subtype.label }}</option> </button>
} </div>
</select> <div class="form-grid">
</label> <mat-form-field appearance="outline">
<label> <mat-label>Famille</mat-label>
<span>Libellé</span> <mat-select [value]="area.family"
<input [value]="area.label" (input)="updateArea(area.id, 'label', $event)" /> (selectionChange)="updateArea(area.id, 'family', $event.value)">
</label> @for (family of families; track family.value) {
<button type="button" (click)="removeArea(area.id)">Supprimer</button> <mat-option [value]="family.value">{{ family.label }}</mat-option>
</fieldset> }
} </mat-select>
</div> </mat-form-field>
<button type="button" (click)="addArea()">Ajouter un espace privatif</button> <mat-form-field appearance="outline">
</section> <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) {
<mat-option [value]="subtype.value">{{ subtype.label }}</mat-option>
}
</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>
}
@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', () => { 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', () => { it('adds a stable private area and derives the main-room count from its family', () => {
const fixture = TestBed.createComponent(PrivateAreaEditor); const fixture = TestBed.createComponent(PrivateAreaEditor);
fixture.componentRef.setInput('areas', []); fixture.componentRef.setInput('areas', []);
@@ -1,4 +1,9 @@
import { Component, computed, input, output } from '@angular/core'; 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 { import {
PrivateArea, PrivateArea,
PrivateAreaFamily, PrivateAreaFamily,
@@ -35,6 +40,7 @@ const subtypeFamilies: Record<PrivateAreaFamily, readonly PrivateAreaSubtype[]>
@Component({ @Component({
selector: 'app-private-area-editor', selector: 'app-private-area-editor',
imports: [MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './private-area-editor.html', templateUrl: './private-area-editor.html',
styleUrl: './private-area-editor.css', styleUrl: './private-area-editor.css',
}) })
@@ -58,17 +64,19 @@ export class PrivateAreaEditor {
]); ]);
} }
updateArea(id: string, field: keyof Omit<PrivateArea, 'id'>, event: Event): void { updateArea(id: string, field: keyof Omit<PrivateArea, 'id'>, value: string | Event): void {
const value = const nextValue =
event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement typeof value === 'string'
? event.target.value ? value
: ''; : value.target instanceof HTMLInputElement || value.target instanceof HTMLSelectElement
? value.target.value
: '';
this.areasChanged.emit( this.areasChanged.emit(
this.areas().map((area) => this.areas().map((area) =>
area.id === id area.id === id
? { ? {
...area, ...area,
[field]: value, [field]: nextValue,
...(field === 'family' ? { subtype: 'OTHER' as const } : {}), ...(field === 'family' ? { subtype: 'OTHER' as const } : {}),
} }
: area, : area,