feat(private-area-editor): enhance UI with Material components and improve area management
This commit is contained in:
1 parent
ef04cbe45b
commit
983a080cc7
7 files changed
+191
-105
No files matched your search
Vendored
+1
-4
@@ -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"
|
||||
|
||||
Vendored
+46
-46
@@ -1,47 +1,47 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Start MongoDB",
|
||||
"type": "shell",
|
||||
"command": "docker compose up --detach --wait mongodb",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Stop MongoDB",
|
||||
"type": "shell",
|
||||
"command": "docker compose stop mongodb",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Start Angular",
|
||||
"type": "shell",
|
||||
"command": "bun run start -- --proxy-config proxy.conf.json",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "angular",
|
||||
"pattern": {
|
||||
"regexp": "^$"
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".*",
|
||||
"endsPattern": "^\\s*➜\\s+Local:\\s+http://localhost:4200/\\s*$"
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Start MongoDB",
|
||||
"type": "shell",
|
||||
"command": "docker compose --file=compose.dev.yml up --detach --wait mongodb",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Stop MongoDB",
|
||||
"type": "shell",
|
||||
"command": "docker compose --file=compose.dev.yml stop mongodb",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Start Angular",
|
||||
"type": "shell",
|
||||
"command": "bun run start -- --proxy-config proxy.conf.json",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "angular",
|
||||
"pattern": {
|
||||
"regexp": "^$"
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".*",
|
||||
"endsPattern": "^\\s*➜\\s+Local:\\s+http://localhost:4200/\\s*$"
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
}
|
||||
.private-areas fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
.private-area {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
background: var(--surface-raised, #ffffff);
|
||||
box-shadow: none;
|
||||
}
|
||||
.private-areas label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
.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-areas input,
|
||||
.private-areas select {
|
||||
min-height: 40px;
|
||||
|
||||
.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;
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<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)">
|
||||
@for (family of families; track family.value) {
|
||||
<option [value]="family.value">{{ family.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Sous-type</span>
|
||||
<select [value]="area.subtype" (change)="updateArea(area.id, 'subtype', $event)">
|
||||
@for (subtype of subtypesFor(area.family); track subtype.value) {
|
||||
<option [value]="subtype.value">{{ subtype.label }}</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>
|
||||
}
|
||||
</div>
|
||||
<button type="button" (click)="addArea()">Ajouter un espace privatif</button>
|
||||
</section>
|
||||
<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.</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) {
|
||||
<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) {
|
||||
<mat-option [value]="family.value">{{ family.label }}</mat-option>
|
||||
}
|
||||
</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) {
|
||||
<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', () => {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user