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",
|
"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"
|
||||||
|
|||||||
Vendored
+46
-46
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user