Refactor application structure to use WorkspaceService for state management

- Removed direct state management from components and centralized it in WorkspaceService.
- Updated ApartmentsPage, TenantsPage, LeasesPage, and DashboardPage to utilize WorkspaceService for data handling.
- Introduced SessionService to manage user session state and authentication.
- Simplified component templates to reflect new service-based architecture.
- Removed unused ConfigService and its tests.
- Added lease.html template for lease document generation.
- Created app.routes.spec.ts for route mapping tests.
This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:15 +02:00
1 parent be1f680a62
commit 82f339e209
33 files changed
+292 -929

No files matched your search

@@ -1,10 +1,10 @@
<section class="page-section apartments-page">
@if (!showForm()) {
<app-apartment-list
[apartments]="apartments()"
[apartments]="workspace.apartments()"
(addRequested)="openAddForm()"
(editRequested)="openEditForm($event)"
(deleteRequested)="deleteRequested.emit($event)"
(deleteRequested)="workspace.deleteApartment($event)"
/>
} @else {
<app-apartment-form
@@ -1,8 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { vi } from 'vitest';
import { ApartmentsPage } from './apartments-page';
import { WorkspaceService } from '../../services/workspace.service';
describe('ApartmentsPage', () => {
const workspace = {
apartments: signal([]),
loadApartments: () => undefined,
saveApartment: () => undefined,
deleteApartment: () => undefined,
};
beforeEach(() => TestBed.configureTestingModule({ providers: [{ provide: WorkspaceService, useValue: workspace }] }));
it('opens the form for the apartment selected for editing', () => {
const apartment = {
id: 'apt-1',
@@ -12,7 +23,6 @@ describe('ApartmentsPage', () => {
description: '',
};
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.componentRef.setInput('apartments', [apartment]);
fixture.detectChanges();
const page = fixture.componentInstance;
@@ -22,7 +32,7 @@ describe('ApartmentsPage', () => {
expect(page.editingApartment()).toEqual(apartment);
});
it('emits the edited apartment together with its id', () => {
it('saves the edited apartment together with its id', () => {
const apartment = {
id: 'apt-1',
name: 'T2 Paris 15',
@@ -31,19 +41,14 @@ describe('ApartmentsPage', () => {
description: '',
};
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.componentRef.setInput('apartments', [apartment]);
fixture.detectChanges();
const page = fixture.componentInstance;
const apartmentSaved = vi.fn();
page.apartmentSaved.subscribe(apartmentSaved);
const saveApartment = vi.spyOn(workspace, 'saveApartment');
page.openEditForm(apartment);
page.saveApartment({ ...apartment, name: 'T2 rénové' });
expect(apartmentSaved).toHaveBeenCalledWith({
apartment: { ...apartment, name: 'T2 rénové' },
apartmentId: 'apt-1',
});
expect(saveApartment).toHaveBeenCalledWith({ ...apartment, name: 'T2 rénové' }, 'apt-1');
expect(page.showForm()).toBe(false);
});
});
@@ -1,26 +1,22 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { Apartment } from '../../services/apartment.service';
import { WorkspaceService } from '../../services/workspace.service';
import { ApartmentForm } from '../apartment-form/apartment-form';
import { ApartmentList } from '../apartment-list/apartment-list';
export interface ApartmentSaveRequest {
apartment: Apartment;
apartmentId?: string;
}
@Component({
selector: 'app-apartments-page',
imports: [ApartmentForm, ApartmentList],
templateUrl: './apartments-page.html',
})
export class ApartmentsPage {
readonly apartments = input.required<Apartment[]>();
readonly apartmentSaved = output<ApartmentSaveRequest>();
readonly deleteRequested = output<string>();
readonly workspace = inject(WorkspaceService);
readonly showForm = signal(false);
readonly editingApartment = signal<Apartment | null>(null);
constructor() { this.workspace.loadApartments(); }
openAddForm(): void {
this.editingApartment.set(null);
this.showForm.set(true);
@@ -37,7 +33,7 @@ export class ApartmentsPage {
}
saveApartment(apartment: Apartment): void {
this.apartmentSaved.emit({ apartment, apartmentId: this.editingApartment()?.id });
this.workspace.saveApartment(apartment, this.editingApartment()?.id);
this.closeForm();
}
}
@@ -1,7 +1,7 @@
<section class="page-section dashboard-page">
<header class="page-heading">
<div>
<p class="eyebrow">Bonjour {{ username() }}</p>
<p class="eyebrow">Bonjour {{ session.username() }}</p>
<h1>Tableau de bord</h1>
<p>Retrouvez rapidement les actions utiles à votre gestion locative.</p>
</div>
@@ -17,10 +17,10 @@
</div>
<div>
<p class="eyebrow">Votre portefeuille</p>
<h2>{{ apartmentCount() }} appartement{{ apartmentCount() > 1 ? 's' : '' }}</h2>
<h2>{{ workspace.apartments().length }} appartement{{ workspace.apartments().length > 1 ? 's' : '' }}</h2>
<p>Centralisez les informations essentielles de vos biens.</p>
</div>
<button mat-button type="button" (click)="apartmentsRequested.emit()">
<button mat-button type="button" (click)="showApartments()">
Gérer mes appartements
</button>
</article>
@@ -28,8 +28,8 @@
<div class="large-avatar">{{ userInitial() }}</div>
<div class="user-details">
<p class="eyebrow">Compte actif</p>
<h3>{{ username() }}</h3>
<p class="user-email">{{ email() }}</p>
<h3>{{ session.username() }}</h3>
<p class="user-email">{{ session.email() }}</p>
<span class="role-chip">Bailleur connecté</span>
</div>
</article>
@@ -40,7 +40,7 @@
<p>Vérifiez que votre espace communique correctement avec le service sécurisé.</p>
</div>
<div class="api-actions">
<button mat-stroked-button type="button" (click)="connectionCheckRequested.emit()">
<button mat-stroked-button type="button" (click)="checkConnection()">
Tester la connexion
</button>
</div>
@@ -1,5 +1,9 @@
import { Component, input, output } from '@angular/core';
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 { SessionService } from '../../services/session.service';
import { WorkspaceService } from '../../services/workspace.service';
@Component({
selector: 'app-dashboard-page',
@@ -8,11 +12,21 @@ import { MatButtonModule } from '@angular/material/button';
styleUrl: './dashboard-page.css',
})
export class DashboardPage {
readonly username = input.required<string>();
readonly email = input.required<string>();
readonly userInitial = input.required<string>();
readonly apartmentCount = input.required<number>();
readonly helloMessage = input.required<string>();
readonly apartmentsRequested = output<void>();
readonly connectionCheckRequested = output<void>();
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
readonly session = inject(SessionService);
readonly workspace = inject(WorkspaceService);
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
readonly helloMessage = signal('');
constructor() { this.workspace.loadApartments(); }
checkConnection(): void {
this.http.get('/api/hello', { responseType: 'text' }).subscribe({
next: (message) => this.helloMessage.set(message),
error: (error) => this.helloMessage.set(`Failed to connect to backend: ${error.statusText}`),
});
}
showApartments(): void { this.router.navigateByUrl('/apartments'); }
}
@@ -10,7 +10,7 @@
<span aria-hidden="true">+</span> Créer un bail
</button>
</header>
@if (leases().length === 0) {
@if (workspace.leases().length === 0) {
<article class="empty-state">
<p class="eyebrow">Mise en location</p>
<h2>Aucun bail enregistré</h2>
@@ -19,13 +19,13 @@
</article>
} @else {
<div class="lease-grid">
@for (lease of leases(); track lease.id) {
@for (lease of workspace.leases(); track lease.id) {
<app-lease-card
[lease]="lease"
[apartments]="apartments()"
[tenants]="tenants()"
(pdfRequested)="pdfRequested.emit($event)"
(deleteRequested)="deleteRequested.emit($event)"
[apartments]="workspace.apartments()"
[tenants]="workspace.tenants()"
(pdfRequested)="workspace.downloadLeasePdf($event)"
(deleteRequested)="workspace.deleteLease($event)"
/>
}
</div>
@@ -33,9 +33,9 @@
</section>
} @else {
<app-lease-form
[apartments]="apartments()"
[tenants]="tenants()"
[apartments]="workspace.apartments()"
[tenants]="workspace.tenants()"
(cancelled)="closeForm()"
(submitted)="leaseSaved.emit($event); closeForm()"
(submitted)="workspace.saveLease($event); closeForm()"
/>
}
@@ -1,12 +1,12 @@
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 } }] }));
it('opens and closes the focused lease form module', () => {
const fixture = TestBed.createComponent(LeasesPage);
fixture.componentRef.setInput('leases', []);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
const component = fixture.componentInstance;
component.openForm();
@@ -1,8 +1,6 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { Apartment } from '../../services/apartment.service';
import { Lease } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
import { WorkspaceService } from '../../services/workspace.service';
import { LeaseCard } from '../lease-card/lease-card';
import { LeaseForm } from '../lease-form/lease-form';
@@ -13,14 +11,15 @@ import { LeaseForm } from '../lease-form/lease-form';
styleUrl: './leases-page.css',
})
export class LeasesPage {
readonly leases = input.required<Lease[]>();
readonly apartments = input.required<Apartment[]>();
readonly tenants = input.required<Tenant[]>();
readonly leaseSaved = output<Lease>();
readonly deleteRequested = output<string>();
readonly pdfRequested = output<Lease>();
readonly workspace = inject(WorkspaceService);
readonly showForm = signal(false);
constructor() {
this.workspace.loadApartments();
this.workspace.loadTenants();
this.workspace.loadLeases();
}
openForm(): void {
this.showForm.set(true);
}
@@ -21,8 +21,8 @@
<mat-radio-group
class="theme-options"
aria-label="Thème de l'application"
[value]="themePreference()"
(change)="themePreferenceChanged.emit($event.value)"
[value]="theme.preference()"
(change)="theme.setPreference($event.value)"
>
<mat-radio-button value="light"
><span class="theme-option-copy"
@@ -60,10 +60,10 @@
</div>
<div class="session-action">
<div>
<strong>{{ username() }}</strong
><span>{{ email() }}</span>
<strong>{{ session.username() }}</strong
><span>{{ session.email() }}</span>
</div>
<button mat-stroked-button type="button" (click)="logoutRequested.emit()">Déconnexion</button>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section>
@@ -1,7 +1,8 @@
import { Component, input, output } from '@angular/core';
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { ThemePreference } from '../../services/theme.service';
import { SessionService } from '../../services/session.service';
import { ThemeService } from '../../services/theme.service';
@Component({
selector: 'app-settings-page',
@@ -10,9 +11,8 @@ import { ThemePreference } from '../../services/theme.service';
styleUrl: './settings-page.css',
})
export class SettingsPage {
readonly username = input.required<string>();
readonly email = input.required<string>();
readonly themePreference = input.required<ThemePreference>();
readonly themePreferenceChanged = output<ThemePreference>();
readonly logoutRequested = output<void>();
readonly session = inject(SessionService);
readonly theme = inject(ThemeService);
logout(): void { window.location.href = '/logout'; }
}
@@ -10,7 +10,7 @@
<span aria-hidden="true">+</span> Ajouter un locataire
</button>
</header>
@if (tenants().length === 0) {
@if (workspace.tenants().length === 0) {
<article class="empty-state">
<p class="eyebrow">Premier dossier</p>
<h2>Aucun locataire enregistré</h2>
@@ -21,14 +21,14 @@
</article>
} @else {
<div class="tenant-grid">
@for (tenant of tenants(); track tenant.id) {
@for (tenant of workspace.tenants(); track tenant.id) {
<app-tenant-card
[tenant]="tenant"
[attachmentError]="attachmentError()"
[attachmentError]="workspace.tenantAttachmentError()"
(editRequested)="openEditForm($event)"
(deleteRequested)="deleteRequested.emit($event)"
(attachmentUploadRequested)="attachmentUploadRequested.emit($event)"
(attachmentDownloadRequested)="attachmentDownloadRequested.emit($event)"
(deleteRequested)="workspace.deleteTenant($event)"
(attachmentUploadRequested)="workspace.uploadTenantAttachment($event.tenantId, $event.file)"
(attachmentDownloadRequested)="workspace.downloadTenantAttachment($event.tenantId, $event.attachment)"
/>
}
</div>
@@ -38,6 +38,6 @@
<app-tenant-form
[tenant]="editingTenant()"
(cancelled)="closeForm()"
(submitted)="tenantSaved.emit($event); closeForm()"
(submitted)="workspace.saveTenant($event.tenant, editingTenant()?.id); closeForm()"
/>
}
@@ -1,10 +1,12 @@
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 } }] }));
it('opens and closes the focused tenant form module', () => {
const fixture = TestBed.createComponent(TenantsPage);
fixture.componentRef.setInput('tenants', []);
const component = fixture.componentInstance;
component.openAddForm();
@@ -1,10 +1,9 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { Tenant } from '../../services/tenant.service';
import { TenantCard, TenantAttachmentRequest } from '../tenant-card/tenant-card';
import { TenantForm, TenantSaveRequest } from '../tenant-form/tenant-form';
export type { TenantAttachmentRequest, TenantSaveRequest };
import { WorkspaceService } from '../../services/workspace.service';
import { TenantCard } from '../tenant-card/tenant-card';
import { TenantForm } from '../tenant-form/tenant-form';
@Component({
selector: 'app-tenants-page',
@@ -13,16 +12,13 @@ export type { TenantAttachmentRequest, TenantSaveRequest };
styleUrl: './tenants-page.css',
})
export class TenantsPage {
readonly tenants = input.required<Tenant[]>();
readonly attachmentError = input('');
readonly tenantSaved = output<TenantSaveRequest>();
readonly deleteRequested = output<string>();
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
readonly attachmentDownloadRequested = output<TenantAttachmentRequest>();
readonly workspace = inject(WorkspaceService);
readonly showForm = signal(false);
readonly editingTenant = signal<Tenant | null>(null);
constructor() { this.workspace.loadTenants(); }
openAddForm(): void {
this.editingTenant.set(null);
this.showForm.set(true);
@@ -9,9 +9,8 @@
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'dashboard'"
[attr.aria-current]="activeTab() === 'dashboard' ? 'page' : null"
(click)="tabSelected.emit('dashboard')"
routerLink="/dashboard"
routerLinkActive="active"
>
<svg
viewBox="0 0 24 24"
@@ -31,9 +30,8 @@
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'apartments'"
[attr.aria-current]="activeTab() === 'apartments' ? 'page' : null"
(click)="tabSelected.emit('apartments')"
routerLink="/apartments"
routerLinkActive="active"
>
<svg
viewBox="0 0 24 24"
@@ -51,9 +49,8 @@
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'tenants'"
[attr.aria-current]="activeTab() === 'tenants' ? 'page' : null"
(click)="tabSelected.emit('tenants')"
routerLink="/tenants"
routerLinkActive="active"
>
<svg
viewBox="0 0 24 24"
@@ -73,9 +70,8 @@
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'leases'"
[attr.aria-current]="activeTab() === 'leases' ? 'page' : null"
(click)="tabSelected.emit('leases')"
routerLink="/leases"
routerLinkActive="active"
>
<svg
viewBox="0 0 24 24"
@@ -93,9 +89,8 @@
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'settings'"
[attr.aria-current]="activeTab() === 'settings' ? 'page' : null"
(click)="tabSelected.emit('settings')"
routerLink="/settings"
routerLinkActive="active"
>
<svg
viewBox="0 0 24 24"
@@ -116,8 +111,8 @@
<div class="sidebar-user">
<div class="compact-avatar">{{ userInitial() }}</div>
<div>
<strong>{{ username() }}</strong
><span>{{ email() }}</span>
<strong>{{ session.username() }}</strong
><span>{{ session.email() }}</span>
</div>
<button mat-button type="button" class="logout-action" (click)="logoutRequested.emit()">
Déconnexion
@@ -1,20 +1,18 @@
import { Component, input, output } from '@angular/core';
import { Component, inject, input, output } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { BrandMark } from '../brand-mark';
export type WorkspaceTab = 'dashboard' | 'apartments' | 'tenants' | 'leases' | 'settings';
import { SessionService } from '../../services/session.service';
@Component({
selector: 'app-workspace-navigation',
imports: [BrandMark, MatButtonModule],
imports: [BrandMark, MatButtonModule, RouterLink, RouterLinkActive],
templateUrl: './workspace-navigation.html',
styleUrl: './workspace-navigation.css',
})
export class WorkspaceNavigation {
readonly activeTab = input.required<WorkspaceTab>();
readonly username = input.required<string>();
readonly email = input.required<string>();
readonly userInitial = input.required<string>();
readonly tabSelected = output<WorkspaceTab>();
readonly logoutRequested = output<void>();
readonly session = inject(SessionService);
}