feat: add comprehensive loading feedback (#18)

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 45de0cebcc
commit ec3319494b
47 files changed
+1753 -406

No files matched your search

@@ -27,6 +27,17 @@
background: var(--surface);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.primary-action mat-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -1,66 +1,91 @@
<div class="form-page">
<header class="page-heading form-heading">
<div>
<button mat-button type="button" class="back-action" (click)="closed.emit()">← Retour</button>
<button
mat-button
type="button"
class="back-action"
[disabled]="saving()"
(click)="closed.emit()"
>
← Retour
</button>
<p class="eyebrow">Portefeuille</p>
<h1>{{ apartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}</h1>
<p>Renseignez les informations principales du bien.</p>
</div>
</header>
<form (submit)="save(); $event.preventDefault()" class="apartment-form">
<form
(submit)="save(); $event.preventDefault()"
class="apartment-form"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
@if (error()) {
<div class="alert-error" role="alert">{{ error() }}</div>
}
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
name="name"
[ngModel]="name()"
(ngModelChange)="name.set($event)"
placeholder="T2 Paris 15"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
name="purchasePrice"
[ngModel]="purchasePrice()"
(ngModelChange)="purchasePrice.set($event)"
min="1"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Description ou notes</mat-label
><textarea
matInput
name="description"
[ngModel]="description()"
(ngModelChange)="description.set($event)"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closed.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">Enregistrer</button>
</div>
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
name="name"
[ngModel]="name()"
(ngModelChange)="name.set($event)"
placeholder="T2 Paris 15"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
name="purchasePrice"
[ngModel]="purchasePrice()"
(ngModelChange)="purchasePrice.set($event)"
min="1"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Description ou notes</mat-label
><textarea
matInput
name="description"
[ngModel]="description()"
(ngModelChange)="description.set($event)"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closed.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">
<span aria-live="polite">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer
}
</span>
</button>
</div>
</fieldset>
</form>
</div>
@@ -50,4 +50,17 @@ describe('ApartmentForm', () => {
description: 'Cinquième étage',
});
});
it('disables the whole form and preserves a save error while pending', () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('saving', true);
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Enregistrement…');
expect(fixture.nativeElement.textContent).toContain(
'Impossible d’enregistrer cet appartement.',
);
});
});
@@ -3,16 +3,25 @@ import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
@Component({
selector: 'app-apartment-form',
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
imports: [
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
templateUrl: './apartment-form.html',
styleUrl: './apartment-form.css',
})
export class ApartmentForm {
readonly apartment = input<Apartment | null>(null);
readonly saving = input(false);
readonly saveError = input('');
readonly closed = output<void>();
readonly saved = output<Apartment>();
@@ -27,6 +36,7 @@ export class ApartmentForm {
}
save(): void {
if (this.saving()) return;
if (!this.name().trim() || !this.address().trim() || this.purchasePrice() <= 0) {
this.error.set('Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).');
return;
@@ -4,6 +4,12 @@
.page-heading {
margin-bottom: 30px;
}
.content-region > mat-progress-bar {
margin-bottom: 16px;
}
.refresh-error {
margin-bottom: 16px;
}
.page-heading h1 {
margin-bottom: 8px;
font-size: clamp(2rem, 7vw, 3.2rem);
@@ -127,6 +133,16 @@
.apt-actions .danger-action.mat-mdc-button {
--mdc-text-button-label-text-color: var(--danger);
}
.apt-actions mat-spinner {
display: inline-block;
margin-right: 6px;
vertical-align: middle;
}
.card-error {
margin: 14px 0 0;
color: var(--danger);
font-size: 0.84rem;
}
@media (min-width: 700px) {
.page-heading {
display: flex;
@@ -9,62 +9,109 @@
</button>
</header>
@if (apartments().length === 0) {
<article class="empty-state">
<div class="empty-illustration" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="m4 10 8-6 8 6v10H4z" />
<path d="M9 20v-6h6v6" />
</svg>
<div class="content-region" [attr.aria-busy]="loadState().loading">
@if (loadState().loaded && loadState().loading) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des appartements" />
}
@if (loadState().loaded && loadState().error) {
<div class="refresh-error" role="alert">
<p>{{ loadState().error }}</p>
<button mat-button type="button" (click)="retryRequested.emit()">Réessayer</button>
</div>
<p class="eyebrow">Commencez ici</p>
<h2>Aucun appartement enregistré</h2>
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
<button mat-stroked-button type="button" (click)="addRequested.emit()">
Ajouter un appartement
</button>
</article>
} @else {
<div class="apartments-grid">
@for (apt of apartments(); track apt.id) {
<article class="apartment-card">
<div class="apartment-card-topline">
<span class="property-label">Bien immobilier</span>
<span class="property-value">{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span>
</div>
<div class="apt-info">
<h2>{{ apt.name }}</h2>
<p class="apt-address">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M20 10c0 5-8 11-8 11S4 15 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
{{ apt.address }}
</p>
@if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p>
}
@if (!loadState().loaded && loadState().loading) {
<div
class="apartments-grid loading-region"
aria-busy="true"
aria-label="Chargement des appartements"
>
@for (placeholder of [1, 2, 3]; track placeholder) {
<article class="apartment-card skeleton-card" aria-hidden="true">
<span class="skeleton-line skeleton-line-short"></span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line"></span>
<span class="skeleton-line skeleton-line-medium"></span>
</article>
}
</div>
} @else if (!loadState().loaded && loadState().error) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ loadState().error }}</p>
<button mat-stroked-button type="button" (click)="retryRequested.emit()">Réessayer</button>
</article>
} @else if (apartments().length === 0) {
<article class="empty-state">
<div class="empty-illustration" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="m4 10 8-6 8 6v10H4z" />
<path d="M9 20v-6h6v6" />
</svg>
</div>
<p class="eyebrow">Commencez ici</p>
<h2>Aucun appartement enregistré</h2>
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
<button mat-stroked-button type="button" (click)="addRequested.emit()">
Ajouter un appartement
</button>
</article>
} @else {
<div class="apartments-grid">
@for (apt of apartments(); track apt.id) {
<article class="apartment-card" [attr.aria-busy]="!!apt.id && deletingIds().has(apt.id)">
<div class="apartment-card-topline">
<span class="property-label">Bien immobilier</span>
<span class="property-value">{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span>
</div>
<div class="apt-info">
<h2>{{ apt.name }}</h2>
<p class="apt-address">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M20 10c0 5-8 11-8 11S4 15 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
{{ apt.address }}
</p>
@if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p>
}
</div>
@if (apt.id && deleteErrors()[apt.id]) {
<p class="card-error" role="alert">{{ deleteErrors()[apt.id] }}</p>
}
</div>
<div class="apt-actions">
<button mat-button type="button" (click)="editRequested.emit(apt)">Modifier</button>
@if (apt.id) {
<div class="apt-actions" aria-live="polite">
<button
mat-button
type="button"
class="danger-action"
(click)="deleteRequested.emit(apt.id)"
[disabled]="!!apt.id && deletingIds().has(apt.id)"
(click)="editRequested.emit(apt)"
>
Supprimer
Modifier
</button>
}
</div>
</article>
}
</div>
}
@if (apt.id) {
<button
mat-button
type="button"
class="danger-action"
[disabled]="deletingIds().has(apt.id)"
(click)="deleteRequested.emit(apt.id)"
>
@if (deletingIds().has(apt.id)) {
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
} @else {
Supprimer
}
</button>
}
</div>
</article>
}
</div>
}
</div>
@@ -1,16 +1,23 @@
import { Component, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import { LoadState } from '../../services/workspace.service';
@Component({
selector: 'app-apartment-list',
imports: [MatButtonModule],
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
templateUrl: './apartment-list.html',
styleUrl: './apartment-list.css',
})
export class ApartmentList {
readonly apartments = input.required<Apartment[]>();
readonly loadState = input.required<LoadState>();
readonly deletingIds = input<ReadonlySet<string>>(new Set());
readonly deleteErrors = input<Record<string, string>>({});
readonly addRequested = output<void>();
readonly editRequested = output<Apartment>();
readonly deleteRequested = output<string>();
readonly retryRequested = output<void>();
}
@@ -2,13 +2,19 @@
@if (!showForm()) {
<app-apartment-list
[apartments]="workspace.apartments()"
[loadState]="workspace.apartmentLoadState()"
[deletingIds]="workspace.deletingApartmentIds()"
[deleteErrors]="workspace.apartmentDeleteErrors()"
(addRequested)="openAddForm()"
(editRequested)="openEditForm($event)"
(deleteRequested)="workspace.deleteApartment($event)"
(retryRequested)="workspace.loadApartments()"
/>
} @else {
<app-apartment-form
[apartment]="editingApartment()"
[saving]="workspace.apartmentSaving()"
[saveError]="workspace.apartmentSaveError()"
(closed)="closeForm()"
(saved)="saveApartment($event)"
/>
@@ -3,16 +3,96 @@ import { signal } from '@angular/core';
import { vi } from 'vitest';
import { ApartmentsPage } from './apartments-page';
import { WorkspaceService } from '../../services/workspace.service';
import { Apartment } from '../../services/apartment.service';
describe('ApartmentsPage', () => {
const workspace = {
apartments: signal([]),
apartments: signal<Apartment[]>([]),
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
apartmentSaving: signal(false),
apartmentSaveError: signal(''),
deletingApartmentIds: signal<ReadonlySet<string>>(new Set()),
apartmentDeleteErrors: signal<Record<string, string>>({}),
loadApartments: () => undefined,
saveApartment: () => undefined,
saveApartment: vi.fn(),
deleteApartment: () => undefined,
};
beforeEach(() => TestBed.configureTestingModule({ providers: [{ provide: WorkspaceService, useValue: workspace }] }));
beforeEach(() => {
vi.clearAllMocks();
workspace.apartments.set([]);
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
workspace.deletingApartmentIds.set(new Set());
TestBed.configureTestingModule({
providers: [{ provide: WorkspaceService, useValue: workspace }],
});
});
it('shows apartment-shaped placeholders instead of a false empty state on first load', () => {
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.skeleton-card').length).toBeGreaterThan(0);
expect(fixture.nativeElement.textContent).not.toContain('Aucun appartement enregistré');
});
it('shows a retryable error instead of an empty state when the first load fails', () => {
workspace.apartmentLoadState.set({
loaded: false,
loading: false,
error: 'Impossible de charger les appartements.',
});
const loadApartments = vi.spyOn(workspace, 'loadApartments');
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Impossible de charger les appartements.');
fixture.nativeElement.querySelector('.load-error button').click();
expect(loadApartments).toHaveBeenCalled();
expect(fixture.nativeElement.textContent).not.toContain('Aucun appartement enregistré');
});
it('keeps established apartments visible during a background refresh', () => {
workspace.apartments.set([
{
id: 'apt-1',
name: 'T2 Paris 15',
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
},
]);
workspace.apartmentLoadState.set({ loaded: true, loading: true, error: '' });
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('T2 Paris 15');
expect(fixture.nativeElement.querySelector('.content-region').getAttribute('aria-busy')).toBe(
'true',
);
expect(fixture.nativeElement.querySelector('mat-progress-bar')).not.toBeNull();
});
it('disables only the apartment being deleted', () => {
workspace.apartments.set([
{
id: 'apt-1',
name: 'T2 Paris 15',
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
},
]);
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
workspace.deletingApartmentIds.set(new Set(['apt-1']));
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
const deleteButton = fixture.nativeElement.querySelector('.danger-action');
expect(deleteButton.disabled).toBe(true);
expect(deleteButton.textContent).toContain('Suppression…');
expect(fixture.nativeElement.querySelector('.primary-action').disabled).toBe(false);
});
it('opens the form for the apartment selected for editing', () => {
const apartment = {
@@ -32,7 +112,7 @@ describe('ApartmentsPage', () => {
expect(page.editingApartment()).toEqual(apartment);
});
it('saves the edited apartment together with its id', () => {
it('keeps the form open until the edited apartment is confirmed', () => {
const apartment = {
id: 'apt-1',
name: 'T2 Paris 15',
@@ -43,12 +123,21 @@ describe('ApartmentsPage', () => {
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
const page = fixture.componentInstance;
const saveApartment = vi.spyOn(workspace, 'saveApartment');
let confirmSave: (() => void) | undefined;
workspace.saveApartment.mockImplementation((_value, _id, onSuccess) => {
confirmSave = onSuccess;
});
page.openEditForm(apartment);
page.saveApartment({ ...apartment, name: 'T2 rénové' });
expect(saveApartment).toHaveBeenCalledWith({ ...apartment, name: 'T2 rénové' }, 'apt-1');
expect(workspace.saveApartment).toHaveBeenCalledWith(
{ ...apartment, name: 'T2 rénové' },
'apt-1',
expect.any(Function),
);
expect(page.showForm()).toBe(true);
confirmSave?.();
expect(page.showForm()).toBe(false);
});
});
@@ -15,14 +15,18 @@ export class ApartmentsPage {
readonly showForm = signal(false);
readonly editingApartment = signal<Apartment | null>(null);
constructor() { this.workspace.loadApartments(); }
constructor() {
this.workspace.loadApartments();
}
openAddForm(): void {
this.workspace.apartmentSaveError.set('');
this.editingApartment.set(null);
this.showForm.set(true);
}
openEditForm(apartment: Apartment): void {
this.workspace.apartmentSaveError.set('');
this.editingApartment.set(apartment);
this.showForm.set(true);
}
@@ -33,7 +37,6 @@ export class ApartmentsPage {
}
saveApartment(apartment: Apartment): void {
this.workspace.saveApartment(apartment, this.editingApartment()?.id);
this.closeForm();
this.workspace.saveApartment(apartment, this.editingApartment()?.id, () => this.closeForm());
}
}
@@ -84,6 +84,17 @@
padding-inline: 0;
font-weight: 750;
}
.count-skeleton {
width: min(260px, 80%);
margin: 0 0 12px;
}
.count-error p {
margin-bottom: 4px;
color: var(--danger);
}
.feature-card-primary mat-progress-bar {
margin-bottom: 12px;
}
.feature-icon {
display: grid;
width: 48px;
@@ -8,7 +8,10 @@
<span class="status-pill"><span></span>Espace sécurisé</span>
</header>
<div class="dashboard-grid">
<article class="feature-card feature-card-primary">
<article
class="feature-card feature-card-primary"
[attr.aria-busy]="workspace.apartmentLoadState().loading"
>
<div class="feature-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16M2 21h20" />
@@ -17,12 +20,39 @@
</div>
<div>
<p class="eyebrow">Votre portefeuille</p>
<h2>{{ workspace.apartments().length }} appartement{{ workspace.apartments().length > 1 ? 's' : '' }}</h2>
@if (!workspace.apartmentLoadState().loaded && workspace.apartmentLoadState().loading) {
<span
class="skeleton-line skeleton-line-title count-skeleton"
aria-label="Chargement du nombre d’appartements"
></span>
} @else if (
!workspace.apartmentLoadState().loaded && workspace.apartmentLoadState().error
) {
<div class="count-error" role="alert">
<p>{{ workspace.apartmentLoadState().error }}</p>
<button mat-button type="button" (click)="workspace.loadApartments()">Réessayer</button>
</div>
} @else {
<h2>
{{ workspace.apartments().length }} appartement{{
workspace.apartments().length > 1 ? 's' : ''
}}
</h2>
@if (workspace.apartmentLoadState().loading) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des appartements" />
}
@if (workspace.apartmentLoadState().error) {
<div class="count-error" role="alert">
<p>{{ workspace.apartmentLoadState().error }}</p>
<button mat-button type="button" (click)="workspace.loadApartments()">
Réessayer
</button>
</div>
}
}
<p>Centralisez les informations essentielles de vos biens.</p>
</div>
<button mat-button type="button" (click)="showApartments()">
Gérer mes appartements
</button>
<button mat-button type="button" (click)="showApartments()">Gérer mes appartements</button>
</article>
<article class="feature-card user-profile">
<div class="large-avatar">{{ userInitial() }}</div>
@@ -1,4 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
@@ -11,10 +12,15 @@ describe('DashboardPage', () => {
const http = { get: vi.fn() };
const router = { navigateByUrl: vi.fn() };
const session = { username: vi.fn(() => 'camille'), email: vi.fn(() => 'camille@example.com') };
const workspace = { apartments: vi.fn(() => []), loadApartments: vi.fn() };
const workspace = {
apartments: signal([]),
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
loadApartments: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
TestBed.configureTestingModule({
providers: [
{ provide: HttpClient, useValue: http },
@@ -25,6 +31,14 @@ describe('DashboardPage', () => {
});
});
it('skeletonizes only the apartment count while it loads', () => {
const fixture = TestBed.createComponent(DashboardPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.count-skeleton')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Tableau de bord');
});
it('loads the workspace and displays the user initial', () => {
const fixture = TestBed.createComponent(DashboardPage);
@@ -2,12 +2,13 @@ import { Component, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { SessionService } from '../../services/session.service';
import { WorkspaceService } from '../../services/workspace.service';
@Component({
selector: 'app-dashboard-page',
imports: [MatButtonModule],
imports: [MatButtonModule, MatProgressBarModule],
templateUrl: './dashboard-page.html',
styleUrl: './dashboard-page.css',
})
@@ -19,7 +20,9 @@ export class DashboardPage {
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
readonly helloMessage = signal('');
constructor() { this.workspace.loadApartments(); }
constructor() {
this.workspace.loadApartments();
}
checkConnection(): void {
this.http.get('/api/hello', { responseType: 'text' }).subscribe({
@@ -28,5 +31,7 @@ export class DashboardPage {
});
}
showApartments(): void { this.router.navigateByUrl('/apartments'); }
showApartments(): void {
this.router.navigateByUrl('/apartments');
}
}
@@ -79,3 +79,13 @@
.card-actions .danger-action.mat-mdc-button {
--mdc-text-button-label-text-color: var(--danger);
}
.card-actions mat-spinner {
display: inline-block;
margin-right: 6px;
vertical-align: middle;
}
.card-error {
margin: 12px 0 0;
color: var(--danger);
font-size: 0.84rem;
}
@@ -1,4 +1,4 @@
<article class="lease-card">
<article class="lease-card" [attr.aria-busy]="deleting() || downloading()">
<div class="lease-topline">
<span class="status-dot"></span><span>{{ statusLabel() }}</span
><strong>{{ lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
@@ -27,18 +27,35 @@
}
</div>
}
<div class="card-actions">
<button mat-button type="button" (click)="pdfRequested.emit(lease())">
Télécharger le bail
@if (deleteError() || downloadError()) {
<p class="card-error" role="alert">{{ deleteError() || downloadError() }}</p>
}
<div class="card-actions" aria-live="polite">
<button
mat-button
type="button"
[disabled]="deleting() || downloading()"
(click)="pdfRequested.emit(lease())"
>
@if (downloading()) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
} @else {
Télécharger le bail
}
</button>
@if (lease().id) {
<button
mat-button
type="button"
class="danger-action"
[disabled]="deleting()"
(click)="deleteRequested.emit(lease().id!)"
>
Supprimer
@if (deleting()) {
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
} @else {
Supprimer
}
</button>
}
</div>
@@ -1,12 +1,13 @@
import { Component, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import { Lease } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
@Component({
selector: 'app-lease-card',
imports: [MatButtonModule],
imports: [MatButtonModule, MatProgressSpinnerModule],
templateUrl: './lease-card.html',
styleUrl: './lease-card.css',
})
@@ -14,6 +15,10 @@ export class LeaseCard {
readonly lease = input.required<Lease>();
readonly apartments = input.required<Apartment[]>();
readonly tenants = input.required<Tenant[]>();
readonly deleting = input(false);
readonly deleteError = input('');
readonly downloading = input(false);
readonly downloadError = input('');
readonly deleteRequested = output<string>();
readonly pdfRequested = output<Lease>();
@@ -28,6 +28,17 @@
border-radius: 22px;
background: var(--surface);
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.primary-action mat-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.guarantor-section {
margin-top: 18px;
}
@@ -1,7 +1,13 @@
<section class="form-page">
<header class="page-heading">
<div>
<button mat-button type="button" class="back-action" (click)="cancelled.emit()">
<button
mat-button
type="button"
class="back-action"
[disabled]="saving()"
(click)="cancelled.emit()"
>
← Retour
</button>
<p class="eyebrow">Nouveau bail</p>
@@ -15,141 +21,163 @@
#leaseForm="ngForm"
ngNativeValidate
(ngSubmit)="save(leaseForm)"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
@if (error()) {
<div class="alert-error" role="alert">{{ error() }}</div>
}
<section class="form-section">
<div class="section-heading">
<span>01</span>
<div>
<h2>Parties du bail</h2>
<p>Choisissez le logement et les locataires.</p>
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<section class="form-section">
<div class="section-heading">
<span>01</span>
<div>
<h2>Parties du bail</h2>
<p>Choisissez le logement et les locataires.</p>
</div>
</div>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Appartement</mat-label
><mat-select
name="apartmentId"
[ngModel]="apartmentId()"
(ngModelChange)="apartmentId.set($event)"
required
><mat-option value="">Sélectionnez un appartement</mat-option>
@for (apartment of apartments(); track apartment.id) {
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
}
</mat-select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Locataires</mat-label
><mat-select
name="tenantIds"
[ngModel]="tenantIds()"
(ngModelChange)="tenantIds.set($event)"
multiple
required
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Appartement</mat-label
><mat-select
name="apartmentId"
[ngModel]="apartmentId()"
(ngModelChange)="apartmentId.set($event)"
required
><mat-option value="">Sélectionnez un appartement</mat-option>
@for (apartment of apartments(); track apartment.id) {
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
}
</mat-select></mat-form-field
>
@for (tenant of tenants(); track tenant.id) {
<mat-option [value]="tenant.id"
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
>
}
</mat-select></mat-form-field
>
</div>
</section>
<section class="form-section">
<mat-form-field appearance="outline"
><mat-label>Locataires</mat-label
><mat-select
name="tenantIds"
[ngModel]="tenantIds()"
(ngModelChange)="tenantIds.set($event)"
multiple
required
>
@for (tenant of tenants(); track tenant.id) {
<mat-option [value]="tenant.id"
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
>
}
</mat-select></mat-form-field
>
</div>
</section>
<section class="form-section">
<div class="section-heading">
<span>02</span>
<div>
<h2>Conditions</h2>
<p>Définissez la période et les montants mensuels.</p>
</div>
</div>
<div class="form-grid three-columns">
<mat-form-field appearance="outline"
><mat-label>Date de début</mat-label
><input
matInput
[matDatepicker]="startDatePicker"
name="startDate"
[ngModel]="startDate()"
(ngModelChange)="startDate.set($event)"
required /><mat-datepicker-toggle
matIconSuffix
[for]="startDatePicker" /><mat-datepicker #startDatePicker
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Date de fin</mat-label
><input
matInput
[matDatepicker]="endDatePicker"
name="endDate"
[ngModel]="endDate()"
(ngModelChange)="endDate.set($event)"
required /><mat-datepicker-toggle
matIconSuffix
[for]="endDatePicker" /><mat-datepicker #endDatePicker
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Loyer mensuel</mat-label
><input
matInput
type="number"
name="monthlyRent"
[ngModel]="monthlyRent()"
(ngModelChange)="monthlyRent.set($event)"
min="1"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Charges mensuelles</mat-label
><input
matInput
type="number"
name="monthlyCharges"
[ngModel]="monthlyCharges()"
(ngModelChange)="monthlyCharges.set($event)"
min="0"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Dépôt de garantie</mat-label
><input
matInput
type="number"
name="securityDeposit"
[ngModel]="securityDeposit()"
(ngModelChange)="securityDeposit.set($event)"
min="0"
required
/><span matTextSuffix>€</span></mat-form-field
>
</div>
</section>
</fieldset>
</form>
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<section class="guarantor-section">
<div class="section-heading">
<span>02</span>
<span>03</span>
<div>
<h2>Conditions</h2>
<p>Définissez la période et les montants mensuels.</p>
<h2>Garants</h2>
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
</div>
</div>
<div class="form-grid three-columns">
<mat-form-field appearance="outline"
><mat-label>Date de début</mat-label
><input
matInput
[matDatepicker]="startDatePicker"
name="startDate"
[ngModel]="startDate()"
(ngModelChange)="startDate.set($event)"
required /><mat-datepicker-toggle
matIconSuffix
[for]="startDatePicker" /><mat-datepicker #startDatePicker
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Date de fin</mat-label
><input
matInput
[matDatepicker]="endDatePicker"
name="endDate"
[ngModel]="endDate()"
(ngModelChange)="endDate.set($event)"
required /><mat-datepicker-toggle matIconSuffix [for]="endDatePicker" /><mat-datepicker
#endDatePicker
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Loyer mensuel</mat-label
><input
matInput
type="number"
name="monthlyRent"
[ngModel]="monthlyRent()"
(ngModelChange)="monthlyRent.set($event)"
min="1"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Charges mensuelles</mat-label
><input
matInput
type="number"
name="monthlyCharges"
[ngModel]="monthlyCharges()"
(ngModelChange)="monthlyCharges.set($event)"
min="0"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Dépôt de garantie</mat-label
><input
matInput
type="number"
name="securityDeposit"
[ngModel]="securityDeposit()"
(ngModelChange)="securityDeposit.set($event)"
min="0"
required
/><span matTextSuffix>€</span></mat-form-field
>
</div>
@for (guarantor of guarantors(); track $index) {
<div class="guarantor-chip">
<span>{{ guarantor.firstName }} {{ guarantor.lastName }}</span
><button mat-button type="button" (click)="removeGuarantor($index)">Retirer</button>
</div>
}
<app-guarantor-form (submitted)="addGuarantor($event)" />
</section>
</form>
<section class="guarantor-section">
<div class="section-heading">
<span>03</span>
<div>
<h2>Garants</h2>
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
</div>
</div>
@for (guarantor of guarantors(); track $index) {
<div class="guarantor-chip">
<span>{{ guarantor.firstName }} {{ guarantor.lastName }}</span
><button mat-button type="button" (click)="removeGuarantor($index)">Retirer</button>
</div>
}
<app-guarantor-form (submitted)="addGuarantor($event)" />
</section>
</fieldset>
<div class="form-actions">
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button
><button mat-flat-button type="submit" form="lease-details" class="primary-action">
Enregistrer le bail
<button mat-button type="button" [disabled]="saving()" (click)="cancelled.emit()">
Annuler</button
><button
mat-flat-button
type="submit"
form="lease-details"
class="primary-action"
[disabled]="saving()"
>
<span aria-live="polite">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer le bail
}
</span>
</button>
</div>
</section>
@@ -5,6 +5,7 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import { Guarantor, Lease } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
@@ -19,6 +20,7 @@ import { GuarantorForm } from '../guarantor-form/guarantor-form';
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatProgressSpinnerModule,
GuarantorForm,
],
templateUrl: './lease-form.html',
@@ -27,6 +29,8 @@ import { GuarantorForm } from '../guarantor-form/guarantor-form';
export class LeaseForm {
readonly apartments = input.required<Apartment[]>();
readonly tenants = input.required<Tenant[]>();
readonly saving = input(false);
readonly saveError = input('');
readonly cancelled = output<void>();
readonly submitted = output<Lease>();
readonly apartmentId = signal('');
@@ -50,6 +54,7 @@ export class LeaseForm {
}
save(form: NgForm): void {
if (this.saving()) return;
if (form.invalid) {
form.control.markAllAsTouched();
return;
@@ -34,6 +34,16 @@
display: grid;
gap: 16px;
}
.content-region > mat-progress-bar,
.refresh-error {
margin-bottom: 16px;
}
.skeleton-card {
padding: 20px;
border: 1px solid var(--border);
border-radius: 22px;
background: var(--surface);
}
@media (min-width: 700px) {
.page-heading {
display: flex;
@@ -6,36 +6,78 @@
<h1>Mes baux</h1>
<p>Les baux actifs, leurs parties et leurs conditions financières.</p>
</div>
<button mat-flat-button type="button" class="primary-action" (click)="openForm()">
<button
mat-flat-button
type="button"
class="primary-action"
[disabled]="!contentReady()"
(click)="openForm()"
>
<span aria-hidden="true">+</span> Créer un bail
</button>
</header>
@if (workspace.leases().length === 0) {
<article class="empty-state">
<p class="eyebrow">Mise en location</p>
<h2>Aucun bail enregistré</h2>
<p>Créez un bail dès qu’un appartement et un locataire sont prêts.</p>
<button mat-stroked-button type="button" (click)="openForm()">Créer un bail</button>
</article>
} @else {
<div class="lease-grid">
@for (lease of workspace.leases(); track lease.id) {
<app-lease-card
[lease]="lease"
[apartments]="workspace.apartments()"
[tenants]="workspace.tenants()"
(pdfRequested)="workspace.downloadLeasePdf($event)"
(deleteRequested)="workspace.deleteLease($event)"
/>
<div class="content-region" [attr.aria-busy]="contentLoading()">
@if (!contentReady() && loadError()) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ loadError() }}</p>
<button mat-stroked-button type="button" (click)="retryLoad()">Réessayer</button>
</article>
} @else if (!contentReady() && contentLoading()) {
<div class="lease-grid" aria-label="Chargement des baux">
@for (placeholder of [1, 2, 3]; track placeholder) {
<article class="skeleton-card" aria-hidden="true">
<span class="skeleton-line skeleton-line-short"></span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-medium"></span>
<span class="skeleton-line"></span>
</article>
}
</div>
} @else if (contentReady()) {
@if (contentLoading()) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des baux" />
}
</div>
}
@if (loadError()) {
<div class="refresh-error" role="alert">
<p>{{ loadError() }}</p>
<button mat-button type="button" (click)="retryLoad()">Réessayer</button>
</div>
}
@if (workspace.leases().length === 0) {
<article class="empty-state">
<p class="eyebrow">Mise en location</p>
<h2>Aucun bail enregistré</h2>
<p>Créez un bail dès qu’un appartement et un locataire sont prêts.</p>
<button mat-stroked-button type="button" (click)="openForm()">Créer un bail</button>
</article>
} @else {
<div class="lease-grid">
@for (lease of workspace.leases(); track lease.id) {
<app-lease-card
[lease]="lease"
[apartments]="workspace.apartments()"
[tenants]="workspace.tenants()"
[deleting]="!!lease.id && workspace.deletingLeaseIds().has(lease.id)"
[deleteError]="lease.id ? (workspace.leaseDeleteErrors()[lease.id] ?? '') : ''"
[downloading]="!!lease.id && workspace.downloadingLeaseIds().has(lease.id)"
[downloadError]="lease.id ? (workspace.leaseDownloadErrors()[lease.id] ?? '') : ''"
(pdfRequested)="workspace.downloadLeasePdf($event)"
(deleteRequested)="workspace.deleteLease($event)"
/>
}
</div>
}
}
</div>
</section>
} @else {
<app-lease-form
[apartments]="workspace.apartments()"
[tenants]="workspace.tenants()"
[saving]="workspace.leaseSaving()"
[saveError]="workspace.leaseSaveError()"
(cancelled)="closeForm()"
(submitted)="workspace.saveLease($event); closeForm()"
(submitted)="saveLease($event)"
/>
}
@@ -1,9 +1,45 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { LeasesPage } from './leases-page';
import { WorkspaceService } from '../../services/workspace.service';
describe('LeasesPage', () => {
beforeEach(() => TestBed.configureTestingModule({ providers: [{ provide: WorkspaceService, useValue: { loadApartments: () => undefined, loadTenants: () => undefined, loadLeases: () => undefined } }] }));
const workspace = {
apartments: signal([]),
tenants: signal([]),
leases: signal([]),
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
tenantLoadState: signal({ loaded: false, loading: true, error: '' }),
leaseLoadState: signal({ loaded: false, loading: true, error: '' }),
loadApartments: vi.fn(),
loadTenants: vi.fn(),
loadLeases: vi.fn(),
saveLease: vi.fn(),
deleteLease: vi.fn(),
downloadLeasePdf: vi.fn(),
leaseSaving: signal(false),
leaseSaveError: signal(''),
};
beforeEach(() => {
vi.clearAllMocks();
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
workspace.tenantLoadState.set({ loaded: false, loading: true, error: '' });
workspace.leaseLoadState.set({ loaded: false, loading: true, error: '' });
TestBed.configureTestingModule({
providers: [{ provide: WorkspaceService, useValue: workspace }],
});
});
it('waits for baux, appartements, and locataires before revealing content', () => {
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
const fixture = TestBed.createComponent(LeasesPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.skeleton-card').length).toBeGreaterThan(0);
expect(fixture.nativeElement.textContent).not.toContain('Aucun bail enregistré');
expect(fixture.nativeElement.querySelector('.primary-action').disabled).toBe(true);
});
it('opens and closes the focused lease form module', () => {
const fixture = TestBed.createComponent(LeasesPage);
@@ -1,18 +1,38 @@
import { Component, inject, signal } from '@angular/core';
import { Component, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { WorkspaceService } from '../../services/workspace.service';
import { Lease } from '../../services/lease.service';
import { LeaseCard } from '../lease-card/lease-card';
import { LeaseForm } from '../lease-form/lease-form';
@Component({
selector: 'app-leases-page',
imports: [MatButtonModule, LeaseCard, LeaseForm],
imports: [MatButtonModule, MatProgressBarModule, LeaseCard, LeaseForm],
templateUrl: './leases-page.html',
styleUrl: './leases-page.css',
})
export class LeasesPage {
readonly workspace = inject(WorkspaceService);
readonly showForm = signal(false);
readonly contentReady = computed(
() =>
this.workspace.apartmentLoadState().loaded &&
this.workspace.tenantLoadState().loaded &&
this.workspace.leaseLoadState().loaded,
);
readonly contentLoading = computed(
() =>
this.workspace.apartmentLoadState().loading ||
this.workspace.tenantLoadState().loading ||
this.workspace.leaseLoadState().loading,
);
readonly loadError = computed(
() =>
this.workspace.leaseLoadState().error ||
this.workspace.apartmentLoadState().error ||
this.workspace.tenantLoadState().error,
);
constructor() {
this.workspace.loadApartments();
@@ -21,9 +41,22 @@ export class LeasesPage {
}
openForm(): void {
this.workspace.leaseSaveError.set('');
this.showForm.set(true);
}
closeForm(): void {
this.showForm.set(false);
}
saveLease(lease: Lease): void {
this.workspace.saveLease(lease, () => this.closeForm());
}
retryLoad(): void {
if (!this.workspace.apartmentLoadState().loaded || this.workspace.apartmentLoadState().error)
this.workspace.loadApartments();
if (!this.workspace.tenantLoadState().loaded || this.workspace.tenantLoadState().error)
this.workspace.loadTenants();
if (!this.workspace.leaseLoadState().loaded || this.workspace.leaseLoadState().error)
this.workspace.loadLeases();
}
}
@@ -97,6 +97,19 @@
color: var(--text-muted);
font-weight: 500;
}
.upload-progress {
display: grid;
gap: 8px;
margin-top: 12px;
color: var(--text-muted);
font-size: 0.82rem;
}
.attachment-row mat-spinner,
.card-actions mat-spinner {
display: inline-block;
margin-right: 6px;
vertical-align: middle;
}
.attachment-error {
margin: 8px 0 0;
color: var(--danger);
@@ -1,4 +1,4 @@
<article class="tenant-card">
<article class="tenant-card" [attr.aria-busy]="deleting() || uploading()">
<div class="tenant-identity">
<span class="tenant-avatar"
>{{ tenant().firstName.charAt(0) }}{{ tenant().lastName.charAt(0) }}</span
@@ -31,11 +31,20 @@
mat-button
type="button"
class="attachment-row"
[attr.aria-busy]="downloadingAttachmentIds().has(attachment.id)"
[disabled]="deleting() || downloadingAttachmentIds().has(attachment.id)"
[attr.aria-label]="'Télécharger la pièce jointe ' + attachment.fileName"
(click)="attachmentDownloadRequested.emit({ tenantId: tenant().id!, attachment })"
>
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
@if (downloadingAttachmentIds().has(attachment.id)) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
} @else {
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
}
</button>
@if (attachmentDownloadErrors()[attachment.id]) {
<p class="attachment-error" role="alert">{{ attachmentDownloadErrors()[attachment.id] }}</p>
}
}
@if (tenant().id) {
@if (uploadError() || attachmentError()) {
@@ -44,24 +53,44 @@
<label class="upload-action"
><input
type="file"
[disabled]="uploading() || deleting()"
[attr.aria-label]="
'Ajouter une pièce jointe pour ' + tenant().firstName + ' ' + tenant().lastName
"
(change)="selectFile($event)"
/>+ Ajouter une pièce jointe <small>· 250 Mo maximum</small></label
>
@if (uploading()) {
<div class="upload-progress" role="status" aria-live="polite">
<span>Envoi{{ uploadProgress() === null ? '…' : ' : ' + uploadProgress() + ' %' }}</span>
<mat-progress-bar
[mode]="uploadProgress() === null ? 'indeterminate' : 'determinate'"
[value]="uploadProgress() ?? 0"
/>
</div>
}
}
</div>
<div class="card-actions">
<button mat-button type="button" (click)="editRequested.emit(tenant())">Modifier</button>
@if (deleteError()) {
<p class="attachment-error" role="alert">{{ deleteError() }}</p>
}
<div class="card-actions" aria-live="polite">
<button mat-button type="button" [disabled]="deleting()" (click)="editRequested.emit(tenant())">
Modifier
</button>
@if (tenant().id) {
<button
mat-button
type="button"
class="danger-action"
[disabled]="deleting()"
(click)="deleteRequested.emit(tenant().id!)"
>
Supprimer
@if (deleting()) {
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
} @else {
Supprimer
}
</button>
}
</div>
@@ -21,4 +21,44 @@ describe('TenantCard', () => {
expect(fixture.componentInstance.uploadError()).toContain('250 Mo');
});
it('shows measurable progress and disables only its upload control', () => {
const fixture = TestBed.createComponent(TenantCard);
fixture.componentRef.setInput('tenant', {
id: 'tenant-1',
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
});
fixture.componentRef.setInput('uploading', true);
fixture.componentRef.setInput('uploadProgress', 50);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('input[type=file]').disabled).toBe(true);
expect(fixture.nativeElement.querySelector('mat-progress-bar')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Envoi : 50 %');
});
it('shows progress only on the selected attachment download', () => {
const fixture = TestBed.createComponent(TenantCard);
fixture.componentRef.setInput('tenant', {
id: 'tenant-1',
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
attachments: [
{ id: 'attachment-1', fileName: 'id.pdf', contentType: 'application/pdf', size: 42 },
],
});
fixture.componentRef.setInput('downloadingAttachmentIds', new Set(['attachment-1']));
fixture.detectChanges();
const download = fixture.nativeElement.querySelector('.attachment-row');
expect(download.disabled).toBe(true);
expect(download.textContent).toContain('Téléchargement…');
});
});
@@ -1,5 +1,7 @@
import { Component, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Tenant, TenantAttachment } from '../../services/tenant.service';
export interface TenantAttachmentRequest {
@@ -9,13 +11,19 @@ export interface TenantAttachmentRequest {
@Component({
selector: 'app-tenant-card',
imports: [MatButtonModule],
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
templateUrl: './tenant-card.html',
styleUrl: './tenant-card.css',
})
export class TenantCard {
readonly tenant = input.required<Tenant>();
readonly attachmentError = input('');
readonly uploading = input(false);
readonly uploadProgress = input<number | null>(null);
readonly deleting = input(false);
readonly deleteError = input('');
readonly downloadingAttachmentIds = input<ReadonlySet<string>>(new Set());
readonly attachmentDownloadErrors = input<Record<string, string>>({});
readonly editRequested = output<Tenant>();
readonly deleteRequested = output<string>();
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
@@ -24,7 +32,7 @@ export class TenantCard {
selectFile(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.item(0);
const file = this.uploading() || this.deleting() ? null : input.files?.item(0);
const tenantId = this.tenant().id;
if (file && file.size > 250 * 1024 * 1024) {
this.uploadError.set('Une pièce jointe ne peut pas dépasser 250 Mo.');
@@ -26,6 +26,25 @@
border-radius: 22px;
background: var(--surface);
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.alert-error {
margin-bottom: 18px;
padding: 13px 15px;
border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent);
border-radius: 12px;
background: var(--danger-soft);
color: var(--danger);
}
.primary-action mat-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.form-grid {
display: grid;
gap: 2px;
@@ -1,7 +1,13 @@
<section class="form-page">
<header class="page-heading">
<div>
<button mat-button type="button" class="back-action" (click)="cancelled.emit()">
<button
mat-button
type="button"
class="back-action"
[disabled]="saving()"
(click)="cancelled.emit()"
>
← Retour
</button>
<p class="eyebrow">Dossier locataire</p>
@@ -9,56 +15,79 @@
<p>Renseignez les coordonnées nécessaires à la gestion du bail.</p>
</div>
</header>
<form class="entity-form" #tenantForm="ngForm" ngNativeValidate (ngSubmit)="save(tenantForm)">
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Prénom</mat-label
><input
matInput
name="firstName"
[ngModel]="firstName()"
(ngModelChange)="firstName.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
matInput
name="lastName"
[ngModel]="lastName()"
(ngModelChange)="lastName.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
matInput
type="email"
name="email"
[ngModel]="email()"
(ngModelChange)="email.set($event)"
required
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
matInput
type="tel"
name="phone"
[ngModel]="phone()"
(ngModelChange)="phone.set($event)"
pattern="^(?:(?:0\d(?:[ .-]?\d){8})|(?:(?:\+|00)\d(?:[ .()-]?\d){7,14}))$"
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input matInput name="address" [ngModel]="address()" (ngModelChange)="address.set($event)"
/></mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">Enregistrer</button>
</div>
<form
class="entity-form"
#tenantForm="ngForm"
ngNativeValidate
(ngSubmit)="save(tenantForm)"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Prénom</mat-label
><input
matInput
name="firstName"
[ngModel]="firstName()"
(ngModelChange)="firstName.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
matInput
name="lastName"
[ngModel]="lastName()"
(ngModelChange)="lastName.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
matInput
type="email"
name="email"
[ngModel]="email()"
(ngModelChange)="email.set($event)"
required
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
matInput
type="tel"
name="phone"
[ngModel]="phone()"
(ngModelChange)="phone.set($event)"
pattern="^(?:(?:0\d(?:[ .-]?\d){8})|(?:(?:\+|00)\d(?:[ .()-]?\d){7,14}))$"
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
/></mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">
<span aria-live="polite">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer
}
</span>
</button>
</div>
</fieldset>
</form>
</section>
@@ -3,6 +3,7 @@ import { FormsModule, NgForm } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Tenant } from '../../services/tenant.service';
export interface TenantSaveRequest {
@@ -12,12 +13,20 @@ export interface TenantSaveRequest {
@Component({
selector: 'app-tenant-form',
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
imports: [
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
templateUrl: './tenant-form.html',
styleUrl: './tenant-form.css',
})
export class TenantForm {
readonly tenant = input<Tenant | null>(null);
readonly saving = input(false);
readonly saveError = input('');
readonly cancelled = output<void>();
readonly submitted = output<TenantSaveRequest>();
@@ -32,6 +41,7 @@ export class TenantForm {
}
save(form: NgForm): void {
if (this.saving()) return;
if (form.invalid) {
form.control.markAllAsTouched();
return;
@@ -34,6 +34,16 @@
display: grid;
gap: 16px;
}
.content-region > mat-progress-bar,
.refresh-error {
margin-bottom: 16px;
}
.skeleton-card {
padding: 20px;
border: 1px solid var(--border);
border-radius: 22px;
background: var(--surface);
}
@media (min-width: 700px) {
.page-heading {
display: flex;
@@ -10,34 +10,82 @@
<span aria-hidden="true">+</span> Ajouter un locataire
</button>
</header>
@if (workspace.tenants().length === 0) {
<article class="empty-state">
<p class="eyebrow">Premier dossier</p>
<h2>Aucun locataire enregistré</h2>
<p>Centralisez ici les coordonnées et pièces jointes utiles de vos locataires.</p>
<button mat-stroked-button type="button" (click)="openAddForm()">
Ajouter un locataire
</button>
</article>
} @else {
<div class="tenant-grid">
@for (tenant of workspace.tenants(); track tenant.id) {
<app-tenant-card
[tenant]="tenant"
[attachmentError]="workspace.tenantAttachmentError()"
(editRequested)="openEditForm($event)"
(deleteRequested)="workspace.deleteTenant($event)"
(attachmentUploadRequested)="workspace.uploadTenantAttachment($event.tenantId, $event.file)"
(attachmentDownloadRequested)="workspace.downloadTenantAttachment($event.tenantId, $event.attachment)"
/>
<div class="content-region" [attr.aria-busy]="workspace.tenantLoadState().loading">
@if (!workspace.tenantLoadState().loaded && workspace.tenantLoadState().loading) {
<div class="tenant-grid" aria-label="Chargement des locataires">
@for (placeholder of [1, 2, 3]; track placeholder) {
<article class="skeleton-card" aria-hidden="true">
<span class="skeleton-line skeleton-line-short"></span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line"></span>
<span class="skeleton-line skeleton-line-medium"></span>
</article>
}
</div>
} @else if (!workspace.tenantLoadState().loaded && workspace.tenantLoadState().error) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ workspace.tenantLoadState().error }}</p>
<button mat-stroked-button type="button" (click)="workspace.loadTenants()">
Réessayer
</button>
</article>
} @else {
@if (workspace.tenantLoadState().loading) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des locataires" />
}
</div>
}
@if (workspace.tenantLoadState().error) {
<div class="refresh-error" role="alert">
<p>{{ workspace.tenantLoadState().error }}</p>
<button mat-button type="button" (click)="workspace.loadTenants()">Réessayer</button>
</div>
}
@if (workspace.tenants().length === 0) {
<article class="empty-state">
<p class="eyebrow">Premier dossier</p>
<h2>Aucun locataire enregistré</h2>
<p>Centralisez ici les coordonnées et pièces jointes utiles de vos locataires.</p>
<button mat-stroked-button type="button" (click)="openAddForm()">
Ajouter un locataire
</button>
</article>
} @else {
<div class="tenant-grid">
@for (tenant of workspace.tenants(); track tenant.id) {
<app-tenant-card
[tenant]="tenant"
[attachmentError]="
tenant.id ? (workspace.tenantAttachmentErrors()[tenant.id] ?? '') : ''
"
[uploading]="workspace.uploadingTenantIds().has(tenant.id!)"
[uploadProgress]="
tenant.id ? (workspace.tenantUploadProgress()[tenant.id] ?? null) : null
"
[deleting]="!!tenant.id && workspace.deletingTenantIds().has(tenant.id)"
[deleteError]="tenant.id ? (workspace.tenantDeleteErrors()[tenant.id] ?? '') : ''"
[downloadingAttachmentIds]="workspace.downloadingAttachmentIds()"
[attachmentDownloadErrors]="workspace.attachmentDownloadErrors()"
(editRequested)="openEditForm($event)"
(deleteRequested)="workspace.deleteTenant($event)"
(attachmentUploadRequested)="
workspace.uploadTenantAttachment($event.tenantId, $event.file)
"
(attachmentDownloadRequested)="
workspace.downloadTenantAttachment($event.tenantId, $event.attachment)
"
/>
}
</div>
}
}
</div>
</section>
} @else {
<app-tenant-form
[tenant]="editingTenant()"
[saving]="workspace.tenantSaving()"
[saveError]="workspace.tenantSaveError()"
(cancelled)="closeForm()"
(submitted)="workspace.saveTenant($event.tenant, editingTenant()?.id); closeForm()"
(submitted)="saveTenant($event)"
/>
}
@@ -1,9 +1,19 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TenantsPage } from './tenants-page';
import { WorkspaceService } from '../../services/workspace.service';
describe('TenantsPage', () => {
beforeEach(() => TestBed.configureTestingModule({ providers: [{ provide: WorkspaceService, useValue: { loadTenants: () => undefined } }] }));
beforeEach(() =>
TestBed.configureTestingModule({
providers: [
{
provide: WorkspaceService,
useValue: { loadTenants: () => undefined, tenantSaveError: signal('') },
},
],
}),
);
it('opens and closes the focused tenant form module', () => {
const fixture = TestBed.createComponent(TenantsPage);
@@ -1,13 +1,14 @@
import { Component, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { Tenant } from '../../services/tenant.service';
import { WorkspaceService } from '../../services/workspace.service';
import { TenantCard } from '../tenant-card/tenant-card';
import { TenantForm } from '../tenant-form/tenant-form';
import { TenantForm, TenantSaveRequest } from '../tenant-form/tenant-form';
@Component({
selector: 'app-tenants-page',
imports: [MatButtonModule, TenantCard, TenantForm],
imports: [MatButtonModule, MatProgressBarModule, TenantCard, TenantForm],
templateUrl: './tenants-page.html',
styleUrl: './tenants-page.css',
})
@@ -17,13 +18,17 @@ export class TenantsPage {
readonly showForm = signal(false);
readonly editingTenant = signal<Tenant | null>(null);
constructor() { this.workspace.loadTenants(); }
constructor() {
this.workspace.loadTenants();
}
openAddForm(): void {
this.workspace.tenantSaveError.set('');
this.editingTenant.set(null);
this.showForm.set(true);
}
openEditForm(tenant: Tenant): void {
this.workspace.tenantSaveError.set('');
this.editingTenant.set(tenant);
this.showForm.set(true);
}
@@ -31,4 +36,7 @@ export class TenantsPage {
this.showForm.set(false);
this.editingTenant.set(null);
}
saveTenant(request: TenantSaveRequest): void {
this.workspace.saveTenant(request.tenant, request.tenantId, () => this.closeForm());
}
}