feat: add comprehensive loading feedback (#18)
This commit is contained in:
1 parent
45de0cebcc
commit
ec3319494b
47 files changed
+1753
-406
No files matched your search
@@ -4,6 +4,22 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-state {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--canvas);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-state strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--canvas);
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
@if (!session.isLoggedIn()) {
|
||||
@if (session.status() === 'checking') {
|
||||
<section class="session-state" role="status" aria-live="polite">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<mat-spinner diameter="34" />
|
||||
<strong>Vérification de votre session…</strong>
|
||||
</section>
|
||||
} @else if (session.status() === 'error') {
|
||||
<section class="session-state" role="alert">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<strong>Impossible de vérifier votre session</strong>
|
||||
<p>Vérifiez votre connexion puis réessayez.</p>
|
||||
<button mat-flat-button type="button" class="primary-action" (click)="session.refresh()">
|
||||
Réessayer
|
||||
</button>
|
||||
</section>
|
||||
} @else if (!session.isLoggedIn()) {
|
||||
<app-sign-in-page (loginRequested)="login()" />
|
||||
} @else {
|
||||
<div class="workspace-shell">
|
||||
<app-workspace-navigation
|
||||
[userInitial]="userInitial()"
|
||||
(logoutRequested)="logout()"
|
||||
/>
|
||||
<app-workspace-navigation [userInitial]="userInitial()" (logoutRequested)="logout()" />
|
||||
|
||||
<main class="workspace-main">
|
||||
<header class="mobile-header">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { SessionService, SessionStatus } from './services/session.service';
|
||||
|
||||
describe('App', () => {
|
||||
const status = signal<SessionStatus>('checking');
|
||||
const session = {
|
||||
status,
|
||||
isLoggedIn: signal(false),
|
||||
username: signal(''),
|
||||
email: signal(''),
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
status.set('checking');
|
||||
session.isLoggedIn.set(false);
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: SessionService, useValue: session }],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a neutral session loader before authentication resolves', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Vérification de votre session');
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a retry when the session cannot be checked', () => {
|
||||
status.set('error');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de vérifier votre session');
|
||||
fixture.nativeElement.querySelector('button').click();
|
||||
expect(session.refresh).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component, computed, inject, OnInit } from '@angular/core';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { BrandMark } from './components/brand-mark';
|
||||
import { SignInPage } from './components/sign-in-page/sign-in-page';
|
||||
@@ -12,6 +14,8 @@ import { SessionService } from './services/session.service';
|
||||
SignInPage,
|
||||
WorkspaceNavigation,
|
||||
RouterOutlet,
|
||||
MatProgressSpinnerModule,
|
||||
MatButtonModule,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
@@ -31,5 +35,4 @@ export class App implements OnInit {
|
||||
logout() {
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@@ -9,12 +9,29 @@ describe('SessionService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({ providers: [SessionService, { provide: HttpClient, useValue: http }] });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [SessionService, { provide: HttpClient, useValue: http }],
|
||||
});
|
||||
});
|
||||
|
||||
it('distinguishes a pending session check from its final outcome', () => {
|
||||
const response = new Subject<{ authenticated: boolean }>();
|
||||
http.get.mockReturnValueOnce(response);
|
||||
const service = TestBed.inject(SessionService);
|
||||
|
||||
service.refresh();
|
||||
expect(service.status()).toBe('checking');
|
||||
|
||||
response.next({ authenticated: false });
|
||||
response.complete();
|
||||
expect(service.status()).toBe('anonymous');
|
||||
});
|
||||
|
||||
it('stores an authenticated user and applies its defaults', () => {
|
||||
const service = TestBed.inject(SessionService);
|
||||
http.get.mockReturnValueOnce(of({ authenticated: true, username: 'Camille', email: 'camille@example.com' }));
|
||||
http.get.mockReturnValueOnce(
|
||||
of({ authenticated: true, username: 'Camille', email: 'camille@example.com' }),
|
||||
);
|
||||
service.refresh();
|
||||
expect(service.username()).toBe('Camille');
|
||||
expect(service.email()).toBe('camille@example.com');
|
||||
|
||||
@@ -7,22 +7,28 @@ interface UserResponse {
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export type SessionStatus = 'checking' | 'authenticated' | 'anonymous' | 'error';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly status = signal<SessionStatus>('checking');
|
||||
readonly isLoggedIn = signal(false);
|
||||
readonly username = signal('');
|
||||
readonly email = signal('');
|
||||
|
||||
refresh(): void {
|
||||
this.status.set('checking');
|
||||
this.http.get<UserResponse>('/api/user').subscribe({
|
||||
next: (user) => {
|
||||
this.isLoggedIn.set(user.authenticated);
|
||||
this.username.set(user.authenticated ? (user.username || 'User') : '');
|
||||
this.email.set(user.authenticated ? (user.email || '') : '');
|
||||
this.status.set(user.authenticated ? 'authenticated' : 'anonymous');
|
||||
this.username.set(user.authenticated ? user.username || 'User' : '');
|
||||
this.email.set(user.authenticated ? user.email || '' : '');
|
||||
},
|
||||
error: () => {
|
||||
this.isLoggedIn.set(false);
|
||||
this.status.set('error');
|
||||
this.username.set('');
|
||||
this.email.set('');
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('TenantService', () => {
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/api/tenants/tenant-1/attachments',
|
||||
expect.any(FormData),
|
||||
{ observe: 'events', reportProgress: true },
|
||||
);
|
||||
expect(http.get).toHaveBeenCalledWith('/api/tenants/tenant-1/attachments/attachment-1', {
|
||||
responseType: 'blob',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpEvent } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@@ -39,10 +39,13 @@ export class TenantService {
|
||||
return this.http.delete<void>(`/api/tenants/${id}`);
|
||||
}
|
||||
|
||||
uploadAttachment(tenantId: string, file: File): Observable<TenantAttachment> {
|
||||
uploadAttachment(tenantId: string, file: File): Observable<HttpEvent<TenantAttachment>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<TenantAttachment>(`/api/tenants/${tenantId}/attachments`, formData);
|
||||
return this.http.post<TenantAttachment>(`/api/tenants/${tenantId}/attachments`, formData, {
|
||||
observe: 'events',
|
||||
reportProgress: true,
|
||||
});
|
||||
}
|
||||
|
||||
downloadAttachment(tenantId: string, attachmentId: string): Observable<Blob> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpEvent, HttpEventType, HttpResponse } from '@angular/common/http';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ApartmentService } from './apartment.service';
|
||||
import { LeaseService } from './lease.service';
|
||||
@@ -7,12 +8,53 @@ import { TenantService } from './tenant.service';
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
const apartment = { id: 'apartment-1', name: 'T2', address: '1 rue de Paris', purchasePrice: 250000, description: '' };
|
||||
const tenant = { id: 'tenant-1', firstName: 'Camille', lastName: 'Martin', email: 'camille@example.com', phone: '', address: '' };
|
||||
const lease = { id: 'lease-1', apartmentId: 'apartment-1', tenantIds: ['tenant-1'], guarantors: [], startDate: '2026-01-01', endDate: '2027-01-01', monthlyRent: 800, monthlyCharges: 50, securityDeposit: 800, status: 'ACTIVE' as const };
|
||||
const apartments = { getApartments: vi.fn(), createApartment: vi.fn(), updateApartment: vi.fn(), deleteApartment: vi.fn() };
|
||||
const tenants = { getTenants: vi.fn(), createTenant: vi.fn(), updateTenant: vi.fn(), deleteTenant: vi.fn(), uploadAttachment: vi.fn(), downloadAttachment: vi.fn() };
|
||||
const leases = { getLeases: vi.fn(), createLease: vi.fn(), deleteLease: vi.fn(), downloadPdf: vi.fn() };
|
||||
const apartment = {
|
||||
id: 'apartment-1',
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
};
|
||||
const tenant = {
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
};
|
||||
const lease = {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2027-01-01',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
status: 'ACTIVE' as const,
|
||||
};
|
||||
const apartments = {
|
||||
getApartments: vi.fn(),
|
||||
createApartment: vi.fn(),
|
||||
updateApartment: vi.fn(),
|
||||
deleteApartment: vi.fn(),
|
||||
};
|
||||
const tenants = {
|
||||
getTenants: vi.fn(),
|
||||
createTenant: vi.fn(),
|
||||
updateTenant: vi.fn(),
|
||||
deleteTenant: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
downloadAttachment: vi.fn(),
|
||||
};
|
||||
const leases = {
|
||||
getLeases: vi.fn(),
|
||||
createLease: vi.fn(),
|
||||
deleteLease: vi.fn(),
|
||||
downloadPdf: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -30,7 +72,10 @@ describe('WorkspaceService', () => {
|
||||
leases.createLease.mockReturnValue(of(lease));
|
||||
leases.deleteLease.mockReturnValue(of(undefined));
|
||||
leases.downloadPdf.mockReturnValue(of(new Blob(['pdf'])));
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
);
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() });
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
TestBed.configureTestingModule({
|
||||
@@ -45,6 +90,126 @@ describe('WorkspaceService', () => {
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('distinguishes the first apartment load from a refresh with established content', () => {
|
||||
const firstResponse = new Subject<(typeof apartment)[]>();
|
||||
apartments.getApartments.mockReturnValueOnce(firstResponse);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
|
||||
service.loadApartments();
|
||||
expect(service.apartmentLoadState()).toEqual({ loaded: false, loading: true, error: '' });
|
||||
|
||||
firstResponse.next([apartment]);
|
||||
firstResponse.complete();
|
||||
expect(service.apartmentLoadState()).toEqual({ loaded: true, loading: false, error: '' });
|
||||
|
||||
const refreshResponse = new Subject<(typeof apartment)[]>();
|
||||
apartments.getApartments.mockReturnValueOnce(refreshResponse);
|
||||
service.loadApartments();
|
||||
expect(service.apartments()).toEqual([apartment]);
|
||||
expect(service.apartmentLoadState()).toEqual({ loaded: true, loading: true, error: '' });
|
||||
});
|
||||
|
||||
it('keeps a confirmed apartment save visible while reconciliation is pending', () => {
|
||||
const updatedApartment = { ...apartment, name: 'T2 rénové' };
|
||||
const saveResponse = new Subject<typeof apartment>();
|
||||
const refreshResponse = new Subject<(typeof apartment)[]>();
|
||||
apartments.updateApartment.mockReturnValueOnce(saveResponse);
|
||||
apartments.getApartments.mockReturnValueOnce(refreshResponse);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.apartments.set([apartment]);
|
||||
const saved = vi.fn();
|
||||
|
||||
service.saveApartment(updatedApartment, apartment.id, saved);
|
||||
expect(service.apartmentSaving()).toBe(true);
|
||||
|
||||
saveResponse.next(updatedApartment);
|
||||
saveResponse.complete();
|
||||
expect(saved).toHaveBeenCalled();
|
||||
expect(service.apartments()).toEqual([updatedApartment]);
|
||||
expect(service.apartmentSaving()).toBe(false);
|
||||
expect(service.apartmentLoadState().loading).toBe(true);
|
||||
});
|
||||
|
||||
it('does not let an older refresh overwrite a confirmed apartment save', () => {
|
||||
const updatedApartment = { ...apartment, name: 'T2 rénové' };
|
||||
const staleRefresh = new Subject<(typeof apartment)[]>();
|
||||
const reconciledRefresh = new Subject<(typeof apartment)[]>();
|
||||
apartments.getApartments
|
||||
.mockReturnValueOnce(staleRefresh)
|
||||
.mockReturnValueOnce(reconciledRefresh);
|
||||
apartments.updateApartment.mockReturnValueOnce(of(updatedApartment));
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.apartments.set([apartment]);
|
||||
|
||||
service.loadApartments();
|
||||
service.saveApartment(updatedApartment, apartment.id);
|
||||
expect(service.apartments()).toEqual([updatedApartment]);
|
||||
|
||||
staleRefresh.next([apartment]);
|
||||
staleRefresh.complete();
|
||||
expect(service.apartments()).toEqual([updatedApartment]);
|
||||
expect(apartments.getApartments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('removes a confirmed deletion locally before reconciliation finishes', () => {
|
||||
const deleteResponse = new Subject<void>();
|
||||
const refreshResponse = new Subject<(typeof apartment)[]>();
|
||||
apartments.deleteApartment.mockReturnValueOnce(deleteResponse);
|
||||
apartments.getApartments.mockReturnValueOnce(refreshResponse);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.apartments.set([apartment]);
|
||||
|
||||
service.deleteApartment(apartment.id);
|
||||
expect(service.deletingApartmentIds().has(apartment.id)).toBe(true);
|
||||
expect(service.apartments()).toEqual([apartment]);
|
||||
|
||||
deleteResponse.next();
|
||||
deleteResponse.complete();
|
||||
expect(service.apartments()).toEqual([]);
|
||||
expect(service.deletingApartmentIds().has(apartment.id)).toBe(false);
|
||||
expect(service.apartmentLoadState().loading).toBe(true);
|
||||
});
|
||||
|
||||
it('reports attachment upload progress and keeps the confirmed attachment', () => {
|
||||
const attachment = { id: 'attachment-1', fileName: 'id.pdf' };
|
||||
const uploadEvents = new Subject<HttpEvent<typeof attachment>>();
|
||||
const refreshResponse = new Subject<(typeof tenant)[]>();
|
||||
tenants.uploadAttachment.mockReturnValueOnce(uploadEvents);
|
||||
tenants.getTenants.mockReturnValueOnce(refreshResponse);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.tenants.set([tenant]);
|
||||
|
||||
service.uploadTenantAttachment(tenant.id, new File(['id'], 'id.pdf'));
|
||||
expect(service.uploadingTenantIds().has(tenant.id)).toBe(true);
|
||||
expect(service.tenantUploadProgress()[tenant.id]).toBeNull();
|
||||
|
||||
uploadEvents.next({ type: HttpEventType.UploadProgress, loaded: 1, total: 2 });
|
||||
expect(service.tenantUploadProgress()[tenant.id]).toBe(50);
|
||||
|
||||
uploadEvents.next(new HttpResponse({ body: attachment }));
|
||||
uploadEvents.complete();
|
||||
expect(service.uploadingTenantIds().has(tenant.id)).toBe(false);
|
||||
expect(service.tenants()[0].attachments).toEqual([attachment]);
|
||||
});
|
||||
|
||||
it('scopes attachment download progress and errors to the selected attachment', () => {
|
||||
const download = new Subject<Blob>();
|
||||
tenants.downloadAttachment.mockReturnValueOnce(download);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
|
||||
service.downloadTenantAttachment(tenant.id, {
|
||||
id: 'attachment-1',
|
||||
fileName: 'id.pdf',
|
||||
});
|
||||
expect(service.downloadingAttachmentIds().has('attachment-1')).toBe(true);
|
||||
|
||||
download.error(new Error('offline'));
|
||||
expect(service.downloadingAttachmentIds().has('attachment-1')).toBe(false);
|
||||
expect(service.attachmentDownloadErrors()['attachment-1']).toBe(
|
||||
'Impossible de télécharger cette pièce jointe.',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads, creates, updates, and deletes every workspace resource', () => {
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.loadApartments();
|
||||
@@ -88,12 +253,16 @@ describe('WorkspaceService', () => {
|
||||
|
||||
it('displays a server attachment error or its fallback', () => {
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
tenants.uploadAttachment.mockReturnValueOnce(throwError(() => ({ error: { message: 'Format refusé' } })));
|
||||
tenants.uploadAttachment.mockReturnValueOnce(
|
||||
throwError(() => ({ error: { message: 'Format refusé' } })),
|
||||
);
|
||||
service.uploadTenantAttachment('tenant-1', new File(['id'], 'id.pdf'));
|
||||
expect(service.tenantAttachmentError()).toBe('Format refusé');
|
||||
expect(service.tenantAttachmentErrors()['tenant-1']).toBe('Format refusé');
|
||||
|
||||
tenants.uploadAttachment.mockReturnValueOnce(throwError(() => new Error('offline')));
|
||||
service.uploadTenantAttachment('tenant-1', new File(['id'], 'id.pdf'));
|
||||
expect(service.tenantAttachmentError()).toBe('Impossible d’envoyer cette pièce jointe.');
|
||||
expect(service.tenantAttachmentErrors()['tenant-1']).toBe(
|
||||
'Impossible d’envoyer cette pièce jointe.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,332 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpEventType } from '@angular/common/http';
|
||||
import { finalize } from 'rxjs';
|
||||
import { Apartment, ApartmentService } from './apartment.service';
|
||||
import { Lease, LeaseService } from './lease.service';
|
||||
import { Tenant, TenantService } from './tenant.service';
|
||||
|
||||
export interface LoadState {
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const initialLoadState: LoadState = { loaded: false, loading: false, error: '' };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WorkspaceService {
|
||||
private readonly apartmentService = inject(ApartmentService);
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly leaseService = inject(LeaseService);
|
||||
private apartmentRevision = 0;
|
||||
private tenantRevision = 0;
|
||||
private leaseRevision = 0;
|
||||
readonly apartments = signal<Apartment[]>([]);
|
||||
readonly apartmentLoadState = signal<LoadState>(initialLoadState);
|
||||
readonly apartmentSaving = signal(false);
|
||||
readonly apartmentSaveError = signal('');
|
||||
readonly deletingApartmentIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly apartmentDeleteErrors = signal<Record<string, string>>({});
|
||||
readonly tenants = signal<Tenant[]>([]);
|
||||
readonly tenantLoadState = signal<LoadState>(initialLoadState);
|
||||
readonly tenantSaving = signal(false);
|
||||
readonly tenantSaveError = signal('');
|
||||
readonly deletingTenantIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly tenantDeleteErrors = signal<Record<string, string>>({});
|
||||
readonly leases = signal<Lease[]>([]);
|
||||
readonly tenantAttachmentError = signal('');
|
||||
readonly leaseLoadState = signal<LoadState>(initialLoadState);
|
||||
readonly leaseSaving = signal(false);
|
||||
readonly leaseSaveError = signal('');
|
||||
readonly deletingLeaseIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly leaseDeleteErrors = signal<Record<string, string>>({});
|
||||
readonly tenantAttachmentErrors = signal<Record<string, string>>({});
|
||||
readonly uploadingTenantIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly tenantUploadProgress = signal<Record<string, number | null>>({});
|
||||
readonly downloadingAttachmentIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly attachmentDownloadErrors = signal<Record<string, string>>({});
|
||||
readonly downloadingLeaseIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly leaseDownloadErrors = signal<Record<string, string>>({});
|
||||
|
||||
loadApartments(): void { this.apartmentService.getApartments().subscribe({ next: (data) => this.apartments.set(data), error: (error) => console.error('Error loading apartments', error) }); }
|
||||
saveApartment(apartment: Apartment, id?: string): void { (id ? this.apartmentService.updateApartment(id, apartment) : this.apartmentService.createApartment(apartment)).subscribe({ next: () => this.loadApartments(), error: (error) => console.error('Error saving apartment', error) }); }
|
||||
deleteApartment(id: string): void { if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) this.apartmentService.deleteApartment(id).subscribe({ next: () => this.loadApartments(), error: (error) => console.error('Error deleting apartment', error) }); }
|
||||
loadTenants(): void { this.tenantService.getTenants().subscribe({ next: (data) => this.tenants.set(data), error: (error) => console.error('Error loading tenants', error) }); }
|
||||
saveTenant(tenant: Tenant, id?: string): void { (id ? this.tenantService.updateTenant(id, tenant) : this.tenantService.createTenant(tenant)).subscribe({ next: () => this.loadTenants(), error: (error) => console.error('Error saving tenant', error) }); }
|
||||
deleteTenant(id: string): void { if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?')) this.tenantService.deleteTenant(id).subscribe({ next: () => this.loadTenants(), error: (error) => console.error('Error deleting tenant', error) }); }
|
||||
uploadTenantAttachment(tenantId: string, file: File): void { this.tenantAttachmentError.set(''); this.tenantService.uploadAttachment(tenantId, file).subscribe({ next: () => this.loadTenants(), error: (error: unknown) => this.tenantAttachmentError.set(this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.')) }); }
|
||||
downloadTenantAttachment(tenantId: string, attachment: { id: string; fileName: string }): void { this.tenantService.downloadAttachment(tenantId, attachment.id).subscribe({ next: (blob) => this.saveBlob(blob, attachment.fileName), error: (error) => console.error('Error downloading tenant attachment', error) }); }
|
||||
loadLeases(): void { this.leaseService.getLeases().subscribe({ next: (data) => this.leases.set(data), error: (error) => console.error('Error loading leases', error) }); }
|
||||
saveLease(lease: Lease): void { this.leaseService.createLease(lease).subscribe({ next: () => this.loadLeases(), error: (error) => console.error('Error saving lease', error) }); }
|
||||
deleteLease(id: string): void { if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?')) this.leaseService.deleteLease(id).subscribe({ next: () => this.loadLeases(), error: (error) => console.error('Error deleting lease', error) }); }
|
||||
downloadLeasePdf(lease: Lease): void { if (lease.id) this.leaseService.downloadPdf(lease.id).subscribe({ next: (blob) => this.saveBlob(blob, `bail-${lease.id}.pdf`), error: (error) => console.error('Error downloading lease PDF', error) }); }
|
||||
private saveBlob(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url); }
|
||||
private errorMessage(error: unknown, fallback: string): string { return typeof error === 'object' && error !== null && 'error' in error && typeof error.error === 'object' && error.error !== null && 'message' in error.error && typeof error.error.message === 'string' ? error.error.message : fallback; }
|
||||
loadApartments(): void {
|
||||
if (this.apartmentLoadState().loading) return;
|
||||
const revision = this.apartmentRevision;
|
||||
this.apartmentLoadState.update((state) => ({ ...state, loading: true, error: '' }));
|
||||
this.apartmentService.getApartments().subscribe({
|
||||
next: (data) => {
|
||||
if (revision === this.apartmentRevision) this.apartments.set(data);
|
||||
this.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
if (revision !== this.apartmentRevision) this.loadApartments();
|
||||
},
|
||||
error: () =>
|
||||
this.apartmentLoadState.update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: 'Impossible de charger les appartements.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
saveApartment(apartment: Apartment, id?: string, onSuccess?: () => void): void {
|
||||
if (this.apartmentSaving()) return;
|
||||
this.apartmentSaving.set(true);
|
||||
this.apartmentSaveError.set('');
|
||||
(id
|
||||
? this.apartmentService.updateApartment(id, apartment)
|
||||
: this.apartmentService.createApartment(apartment)
|
||||
)
|
||||
.pipe(finalize(() => this.apartmentSaving.set(false)))
|
||||
.subscribe({
|
||||
next: (savedApartment) => {
|
||||
this.apartmentRevision += 1;
|
||||
this.apartments.update((items) => this.mergeById(items, savedApartment));
|
||||
onSuccess?.();
|
||||
this.loadApartments();
|
||||
},
|
||||
error: () => this.apartmentSaveError.set('Impossible d’enregistrer cet appartement.'),
|
||||
});
|
||||
}
|
||||
deleteApartment(id: string): void {
|
||||
if (
|
||||
this.deletingApartmentIds().has(id) ||
|
||||
!confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')
|
||||
)
|
||||
return;
|
||||
this.deletingApartmentIds.update((ids) => new Set(ids).add(id));
|
||||
this.apartmentDeleteErrors.update((errors) => this.withoutKey(errors, id));
|
||||
this.apartmentService
|
||||
.deleteApartment(id)
|
||||
.pipe(finalize(() => this.deletingApartmentIds.update((ids) => this.without(ids, id))))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.apartmentRevision += 1;
|
||||
this.apartments.update((items) => items.filter((item) => item.id !== id));
|
||||
this.loadApartments();
|
||||
},
|
||||
error: () =>
|
||||
this.apartmentDeleteErrors.update((errors) => ({
|
||||
...errors,
|
||||
[id]: 'Impossible de supprimer cet appartement.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
loadTenants(): void {
|
||||
if (this.tenantLoadState().loading) return;
|
||||
const revision = this.tenantRevision;
|
||||
this.tenantLoadState.update((state) => ({ ...state, loading: true, error: '' }));
|
||||
this.tenantService.getTenants().subscribe({
|
||||
next: (data) => {
|
||||
if (revision === this.tenantRevision) this.tenants.set(data);
|
||||
this.tenantLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
if (revision !== this.tenantRevision) this.loadTenants();
|
||||
},
|
||||
error: () =>
|
||||
this.tenantLoadState.update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: 'Impossible de charger les locataires.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
saveTenant(tenant: Tenant, id?: string, onSuccess?: () => void): void {
|
||||
if (this.tenantSaving()) return;
|
||||
this.tenantSaving.set(true);
|
||||
this.tenantSaveError.set('');
|
||||
(id ? this.tenantService.updateTenant(id, tenant) : this.tenantService.createTenant(tenant))
|
||||
.pipe(finalize(() => this.tenantSaving.set(false)))
|
||||
.subscribe({
|
||||
next: (savedTenant) => {
|
||||
this.tenantRevision += 1;
|
||||
this.tenants.update((items) => this.mergeById(items, savedTenant));
|
||||
onSuccess?.();
|
||||
this.loadTenants();
|
||||
},
|
||||
error: () => this.tenantSaveError.set('Impossible d’enregistrer ce locataire.'),
|
||||
});
|
||||
}
|
||||
deleteTenant(id: string): void {
|
||||
if (
|
||||
this.deletingTenantIds().has(id) ||
|
||||
!confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?')
|
||||
)
|
||||
return;
|
||||
this.deletingTenantIds.update((ids) => new Set(ids).add(id));
|
||||
this.tenantDeleteErrors.update((errors) => this.withoutKey(errors, id));
|
||||
this.tenantService
|
||||
.deleteTenant(id)
|
||||
.pipe(finalize(() => this.deletingTenantIds.update((ids) => this.without(ids, id))))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.tenantRevision += 1;
|
||||
this.tenants.update((items) => items.filter((item) => item.id !== id));
|
||||
this.loadTenants();
|
||||
},
|
||||
error: () =>
|
||||
this.tenantDeleteErrors.update((errors) => ({
|
||||
...errors,
|
||||
[id]: 'Impossible de supprimer ce locataire.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
uploadTenantAttachment(tenantId: string, file: File): void {
|
||||
if (this.uploadingTenantIds().has(tenantId)) return;
|
||||
this.tenantAttachmentErrors.update((errors) => this.withoutKey(errors, tenantId));
|
||||
this.uploadingTenantIds.update((ids) => new Set(ids).add(tenantId));
|
||||
this.tenantUploadProgress.update((progress) => ({ ...progress, [tenantId]: null }));
|
||||
this.tenantService
|
||||
.uploadAttachment(tenantId, file)
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.uploadingTenantIds.update((ids) => this.without(ids, tenantId));
|
||||
this.tenantUploadProgress.update((progress) => this.withoutKey(progress, tenantId));
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === HttpEventType.UploadProgress) {
|
||||
const percent = event.total ? Math.round((event.loaded / event.total) * 100) : null;
|
||||
this.tenantUploadProgress.update((progress) => ({ ...progress, [tenantId]: percent }));
|
||||
} else if (event.type === HttpEventType.Response && event.body) {
|
||||
this.tenantRevision += 1;
|
||||
this.tenants.update((items) =>
|
||||
items.map((item) =>
|
||||
item.id === tenantId
|
||||
? { ...item, attachments: this.mergeById(item.attachments ?? [], event.body!) }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
this.loadTenants();
|
||||
}
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
const message = this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.');
|
||||
this.tenantAttachmentErrors.update((errors) => ({ ...errors, [tenantId]: message }));
|
||||
},
|
||||
});
|
||||
}
|
||||
downloadTenantAttachment(tenantId: string, attachment: { id: string; fileName: string }): void {
|
||||
if (this.downloadingAttachmentIds().has(attachment.id)) return;
|
||||
this.downloadingAttachmentIds.update((ids) => new Set(ids).add(attachment.id));
|
||||
this.attachmentDownloadErrors.update((errors) => this.withoutKey(errors, attachment.id));
|
||||
this.tenantService
|
||||
.downloadAttachment(tenantId, attachment.id)
|
||||
.pipe(
|
||||
finalize(() =>
|
||||
this.downloadingAttachmentIds.update((ids) => this.without(ids, attachment.id)),
|
||||
),
|
||||
)
|
||||
.subscribe({
|
||||
next: (blob) => this.saveBlob(blob, attachment.fileName),
|
||||
error: () =>
|
||||
this.attachmentDownloadErrors.update((errors) => ({
|
||||
...errors,
|
||||
[attachment.id]: 'Impossible de télécharger cette pièce jointe.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
loadLeases(): void {
|
||||
if (this.leaseLoadState().loading) return;
|
||||
const revision = this.leaseRevision;
|
||||
this.leaseLoadState.update((state) => ({ ...state, loading: true, error: '' }));
|
||||
this.leaseService.getLeases().subscribe({
|
||||
next: (data) => {
|
||||
if (revision === this.leaseRevision) this.leases.set(data);
|
||||
this.leaseLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
if (revision !== this.leaseRevision) this.loadLeases();
|
||||
},
|
||||
error: () =>
|
||||
this.leaseLoadState.update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: 'Impossible de charger les baux.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
saveLease(lease: Lease, onSuccess?: () => void): void {
|
||||
if (this.leaseSaving()) return;
|
||||
this.leaseSaving.set(true);
|
||||
this.leaseSaveError.set('');
|
||||
this.leaseService
|
||||
.createLease(lease)
|
||||
.pipe(finalize(() => this.leaseSaving.set(false)))
|
||||
.subscribe({
|
||||
next: (savedLease) => {
|
||||
this.leaseRevision += 1;
|
||||
this.leases.update((items) => this.mergeById(items, savedLease));
|
||||
onSuccess?.();
|
||||
this.loadLeases();
|
||||
},
|
||||
error: () => this.leaseSaveError.set('Impossible d’enregistrer ce bail.'),
|
||||
});
|
||||
}
|
||||
deleteLease(id: string): void {
|
||||
if (this.deletingLeaseIds().has(id) || !confirm('Êtes-vous sûr de vouloir supprimer ce bail ?'))
|
||||
return;
|
||||
this.deletingLeaseIds.update((ids) => new Set(ids).add(id));
|
||||
this.leaseDeleteErrors.update((errors) => this.withoutKey(errors, id));
|
||||
this.leaseService
|
||||
.deleteLease(id)
|
||||
.pipe(finalize(() => this.deletingLeaseIds.update((ids) => this.without(ids, id))))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.leaseRevision += 1;
|
||||
this.leases.update((items) => items.filter((item) => item.id !== id));
|
||||
this.loadLeases();
|
||||
},
|
||||
error: () =>
|
||||
this.leaseDeleteErrors.update((errors) => ({
|
||||
...errors,
|
||||
[id]: 'Impossible de supprimer ce bail.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
downloadLeasePdf(lease: Lease): void {
|
||||
if (!lease.id || this.downloadingLeaseIds().has(lease.id)) return;
|
||||
const id = lease.id;
|
||||
this.downloadingLeaseIds.update((ids) => new Set(ids).add(id));
|
||||
this.leaseDownloadErrors.update((errors) => this.withoutKey(errors, id));
|
||||
this.leaseService
|
||||
.downloadPdf(id)
|
||||
.pipe(finalize(() => this.downloadingLeaseIds.update((ids) => this.without(ids, id))))
|
||||
.subscribe({
|
||||
next: (blob) => this.saveBlob(blob, `bail-${id}.pdf`),
|
||||
error: () =>
|
||||
this.leaseDownloadErrors.update((errors) => ({
|
||||
...errors,
|
||||
[id]: 'Impossible de télécharger ce bail.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
private saveBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
private mergeById<T extends { id?: string }>(items: T[], saved: T): T[] {
|
||||
return saved.id && items.some((item) => item.id === saved.id)
|
||||
? items.map((item) => (item.id === saved.id ? saved : item))
|
||||
: [...items, saved];
|
||||
}
|
||||
private without(ids: ReadonlySet<string>, id: string): ReadonlySet<string> {
|
||||
const next = new Set(ids);
|
||||
next.delete(id);
|
||||
return next;
|
||||
}
|
||||
private withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
|
||||
const next = { ...record };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
private errorMessage(error: unknown, fallback: string): string {
|
||||
return typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'error' in error &&
|
||||
typeof error.error === 'object' &&
|
||||
error.error !== null &&
|
||||
'message' in error.error &&
|
||||
typeof error.error.message === 'string'
|
||||
? error.error.message
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,67 @@ p {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.load-error,
|
||||
.refresh-error {
|
||||
padding: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent);
|
||||
border-radius: 16px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.load-error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-error h2,
|
||||
.load-error p,
|
||||
.refresh-error p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--surface-soft) 25%,
|
||||
var(--border) 50%,
|
||||
var(--surface-soft) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line-short {
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.skeleton-line-medium {
|
||||
width: 68%;
|
||||
}
|
||||
|
||||
.skeleton-line-title {
|
||||
width: 55%;
|
||||
height: 24px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
to {
|
||||
background-position-x: -200%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -145,4 +206,8 @@ p {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user