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 -11
View File
@@ -1,15 +1,9 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideAppInitializer,
inject,
} from '@angular/core';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { MAT_NATIVE_DATE_FORMATS, MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/core';
import { routes } from './app.routes';
import { ConfigService } from './services/config.service';
const frenchDateFormats = {
...MAT_NATIVE_DATE_FORMATS,
@@ -26,9 +20,5 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(),
provideNativeDateAdapter(frenchDateFormats),
{ provide: MAT_DATE_LOCALE, useValue: 'fr-FR' },
provideAppInitializer(() => {
const configService = inject(ConfigService);
return configService.loadConfig();
}),
],
};
+2 -52
View File
@@ -1,13 +1,9 @@
@if (!isLoggedIn) {
@if (!session.isLoggedIn()) {
<app-sign-in-page (loginRequested)="login()" />
} @else {
<div class="workspace-shell">
<app-workspace-navigation
[activeTab]="activeTab()"
[username]="username"
[email]="email"
[userInitial]="userInitial()"
(tabSelected)="activeTab.set($event)"
(logoutRequested)="logout()"
/>
@@ -21,53 +17,7 @@
</header>
<div class="page-container">
@if (activeTab() === 'dashboard') {
<app-dashboard-page
[username]="username"
[email]="email"
[userInitial]="userInitial()"
[apartmentCount]="apartments().length"
[helloMessage]="helloMessage()"
(apartmentsRequested)="activeTab.set('apartments')"
(connectionCheckRequested)="callHelloApi()"
/>
}
@if (activeTab() === 'apartments') {
<app-apartments-page
[apartments]="apartments()"
(deleteRequested)="deleteApartment($event)"
(apartmentSaved)="saveApartment($event)"
/>
}
@if (activeTab() === 'tenants') {
<app-tenants-page
[tenants]="tenants()"
[attachmentError]="tenantAttachmentError()"
(tenantSaved)="saveTenant($event)"
(deleteRequested)="deleteTenant($event)"
(attachmentUploadRequested)="uploadTenantAttachment($event)"
(attachmentDownloadRequested)="downloadTenantAttachment($event)"
/>
}
@if (activeTab() === 'leases') {
<app-leases-page
[leases]="leases()"
[apartments]="apartments()"
[tenants]="tenants()"
(leaseSaved)="saveLease($event)"
(deleteRequested)="deleteLease($event)"
(pdfRequested)="downloadLeasePdf($event)"
/>
}
@if (activeTab() === 'settings') {
<app-settings-page
[username]="username"
[email]="email"
[themePreference]="themePreference()"
(themePreferenceChanged)="themeService.setPreference($event)"
(logoutRequested)="logout()"
/>
}
<router-outlet />
</div>
</main>
</div>
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { routes } from './app.routes';
describe('routes', () => {
it('maps every workspace page to a URL', () => {
expect(routes.map((route) => route.path)).toEqual([
'dashboard',
'apartments',
'tenants',
'leases',
'settings',
'',
'**',
]);
});
});
+14 -1
View File
@@ -1,3 +1,16 @@
import { Routes } from '@angular/router';
import { ApartmentsPage } from './components/apartments-page/apartments-page';
import { DashboardPage } from './components/dashboard-page/dashboard-page';
import { LeasesPage } from './components/leases-page/leases-page';
import { SettingsPage } from './components/settings-page/settings-page';
import { TenantsPage } from './components/tenants-page/tenants-page';
export const routes: Routes = [];
export const routes: Routes = [
{ path: 'dashboard', component: DashboardPage },
{ path: 'apartments', component: ApartmentsPage },
{ path: 'tenants', component: TenantsPage },
{ path: 'leases', component: LeasesPage },
{ path: 'settings', component: SettingsPage },
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: '**', redirectTo: 'dashboard' },
];
-389
View File
@@ -1,389 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { HttpClient } from '@angular/common/http';
import { ApartmentService } from './services/apartment.service';
import { TenantService } from './services/tenant.service';
import { LeaseService } from './services/lease.service';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
describe('App', () => {
let mockHttpClient: any;
let mockApartmentService: any;
let mockTenantService: any;
let mockLeaseService: any;
let mockUserResponse: { authenticated: boolean; username: string; email: string };
let originalLocation: any;
let locationMock: { href: string; origin: string };
beforeEach(async () => {
mockUserResponse = { authenticated: false, username: '', email: '' };
mockHttpClient = {
get: (url: string) => {
if (url === '/api/user') {
return of(mockUserResponse);
}
return of('Hello from mocked API!');
},
};
mockApartmentService = {
getApartments: () =>
of([
{
id: '1',
name: 'Apt 1',
address: 'Addr 1',
purchasePrice: 100000,
description: 'Desc 1',
},
]),
getApartment: (id: string) =>
of({ id, name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }),
createApartment: (apt: any) => of({ id: '2', ...apt }),
updateApartment: (id: string, apt: any) => of({ id, ...apt }),
deleteApartment: () => of(undefined),
};
mockTenantService = {
getTenants: vi.fn().mockReturnValue(of([])),
createTenant: vi.fn().mockImplementation((tenant) => of({ id: 'tenant-2', ...tenant })),
updateTenant: vi.fn().mockImplementation((id, tenant) => of({ id, ...tenant })),
deleteTenant: vi.fn().mockReturnValue(of(undefined)),
uploadAttachment: vi.fn().mockReturnValue(of({ id: 'attachment-1' })),
downloadAttachment: vi.fn().mockReturnValue(of(new Blob(['attachment']))),
};
mockLeaseService = {
getLeases: vi.fn().mockReturnValue(of([])),
createLease: vi.fn().mockImplementation((lease) => of({ id: 'lease-1', ...lease })),
updateLease: vi.fn().mockImplementation((id, lease) => of({ id, ...lease })),
deleteLease: vi.fn().mockReturnValue(of(undefined)),
downloadPdf: vi.fn().mockReturnValue(of(new Blob(['pdf']))),
};
originalLocation = window.location;
delete (window as any).location;
locationMock = {
href: '',
origin: 'http://localhost',
};
(window as any).location = locationMock;
await TestBed.configureTestingModule({
imports: [App],
providers: [
{ provide: HttpClient, useValue: mockHttpClient },
{ provide: ApartmentService, useValue: mockApartmentService },
{ provide: TenantService, useValue: mockTenantService },
{ provide: LeaseService, useValue: mockLeaseService },
],
}).compileComponents();
});
afterEach(() => {
(window as any).location = originalLocation;
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('LMNP Organizer');
});
it('should return login status based on user endpoint response', async () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
// Initially unauthenticated
fixture.detectChanges();
await fixture.whenStable();
expect(app.isLoggedIn).toBe(false);
// Activating authenticated response and calling ngOnInit manually
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
app.ngOnInit();
expect(app.isLoggedIn).toBe(true);
expect(app.username).toBe('Alice');
expect(app.email).toBe('alice@example.com');
});
it('should invoke OIDC login and logout flows by redirecting the browser window', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.login();
expect(locationMock.href).toBe('/oauth2/authorization/oidc');
app.logout();
expect(locationMock.href).toBe('/logout');
});
it('should call hello API and set message on success', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.callHelloApi();
expect(app.helloMessage()).toBe('Hello from mocked API!');
});
it('should call hello API and handle error', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
mockHttpClient.get = (url: string) => {
if (url === '/api/user') {
return of(mockUserResponse);
}
return throwError(() => ({ statusText: 'Unauthorized' }));
};
app.callHelloApi();
expect(app.helloMessage()).toBe('Failed to connect to backend: Unauthorized');
});
it('should render logged in state when user is authenticated', async () => {
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.user-details h3')?.textContent).toContain('Alice');
expect(compiled.querySelector('.user-email')?.textContent).toContain('alice@example.com');
});
it('should render hello API response when API button is clicked', async () => {
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
// Click button
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('.api-actions button') as HTMLButtonElement;
button.click();
fixture.detectChanges();
await fixture.whenStable();
// Verify response message is shown
expect(app.helloMessage()).toBe('Hello from mocked API!');
fixture.detectChanges();
expect(compiled.querySelector('.api-response-code')?.textContent).toContain(
'Hello from mocked API!',
);
});
// Apartment CRUD tests
it('should load apartments on init if authenticated', async () => {
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
const loadSpy = vi.spyOn(mockApartmentService, 'getApartments');
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
app.ngOnInit();
expect(loadSpy).toHaveBeenCalled();
expect(app.apartments().length).toBe(1);
expect(app.apartments()[0].name).toBe('Apt 1');
});
it('should navigate between tabs', async () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
expect(app.activeTab()).toBe('dashboard');
app.activeTab.set('apartments');
fixture.detectChanges();
await fixture.whenStable();
expect(app.activeTab()).toBe('apartments');
});
it('should save a new apartment successfully', async () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const createSpy = vi.spyOn(mockApartmentService, 'createApartment');
fixture.detectChanges();
await fixture.whenStable();
app.saveApartment({
apartment: {
name: 'New Apt',
address: 'New Addr',
purchasePrice: 200000,
description: 'New Desc',
},
});
fixture.detectChanges();
await fixture.whenStable();
expect(createSpy).toHaveBeenCalledWith({
name: 'New Apt',
address: 'New Addr',
purchasePrice: 200000,
description: 'New Desc',
});
});
it('should update an existing apartment successfully', async () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const updateSpy = vi.spyOn(mockApartmentService, 'updateApartment');
const apt = {
id: '1',
name: 'Old Apt',
address: 'Old Addr',
purchasePrice: 100000,
description: 'Old Desc',
};
fixture.detectChanges();
await fixture.whenStable();
app.saveApartment({
apartmentId: apt.id,
apartment: {
name: 'Updated Apt',
address: 'Updated Addr',
purchasePrice: 110000,
description: 'Updated Desc',
},
});
fixture.detectChanges();
await fixture.whenStable();
expect(updateSpy).toHaveBeenCalledWith('1', {
name: 'Updated Apt',
address: 'Updated Addr',
purchasePrice: 110000,
description: 'Updated Desc',
});
});
it('should delete an apartment when confirmed', async () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const deleteSpy = vi.spyOn(mockApartmentService, 'deleteApartment');
vi.spyOn(window, 'confirm').mockImplementation(() => true);
fixture.detectChanges();
await fixture.whenStable();
app.deleteApartment('1');
fixture.detectChanges();
await fixture.whenStable();
expect(deleteSpy).toHaveBeenCalledWith('1');
});
it('should not delete an apartment when not confirmed', async () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const deleteSpy = vi.spyOn(mockApartmentService, 'deleteApartment');
vi.spyOn(window, 'confirm').mockImplementation(() => false);
fixture.detectChanges();
await fixture.whenStable();
app.deleteApartment('1');
fixture.detectChanges();
await fixture.whenStable();
expect(deleteSpy).not.toHaveBeenCalled();
});
it('should orchestrate tenant mutations and attachment upload', () => {
const app = TestBed.createComponent(App).componentInstance;
const tenant = {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
};
const file = new File(['identity'], 'identite.pdf');
app.saveTenant({ tenant });
app.saveTenant({ tenant, tenantId: 'tenant-1' });
app.uploadTenantAttachment({ tenantId: 'tenant-1', file });
expect(mockTenantService.createTenant).toHaveBeenCalledWith(tenant);
expect(mockTenantService.updateTenant).toHaveBeenCalledWith('tenant-1', tenant);
expect(mockTenantService.uploadAttachment).toHaveBeenCalledWith('tenant-1', file);
expect(mockTenantService.getTenants).toHaveBeenCalledTimes(3);
});
it('should orchestrate lease creation and confirmed deletions', () => {
const app = TestBed.createComponent(App).componentInstance;
const lease = {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'ACTIVE' as const,
};
vi.spyOn(window, 'confirm').mockReturnValue(true);
app.saveLease(lease);
app.deleteLease('lease-1');
app.deleteTenant('tenant-1');
expect(mockLeaseService.createLease).toHaveBeenCalledWith(lease);
expect(mockLeaseService.deleteLease).toHaveBeenCalledWith('lease-1');
expect(mockTenantService.deleteTenant).toHaveBeenCalledWith('tenant-1');
});
it('should download tenant attachments and lease PDFs', () => {
const app = TestBed.createComponent(App).componentInstance;
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
app.downloadTenantAttachment({
tenantId: 'tenant-1',
attachment: { id: 'attachment-1', fileName: 'identite.pdf' },
});
app.downloadLeasePdf({
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'ACTIVE',
});
expect(mockTenantService.downloadAttachment).toHaveBeenCalled();
expect(mockLeaseService.downloadPdf).toHaveBeenCalledWith('lease-1');
expect(createObjectUrl).toHaveBeenCalledTimes(2);
expect(click).toHaveBeenCalledTimes(2);
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:test');
});
});
+8 -226
View File
@@ -1,21 +1,9 @@
import { Component, computed, signal, inject, OnInit, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApartmentService, Apartment } from './services/apartment.service';
import { ThemeService } from './services/theme.service';
import { Component, computed, inject, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { BrandMark } from './components/brand-mark';
import { SignInPage } from './components/sign-in-page/sign-in-page';
import {
WorkspaceNavigation,
WorkspaceTab,
} from './components/workspace-navigation/workspace-navigation';
import { DashboardPage } from './components/dashboard-page/dashboard-page';
import { ApartmentsPage } from './components/apartments-page/apartments-page';
import { ApartmentSaveRequest } from './components/apartments-page/apartments-page';
import { SettingsPage } from './components/settings-page/settings-page';
import { Tenant, TenantService } from './services/tenant.service';
import { Lease, LeaseService } from './services/lease.service';
import { TenantsPage, TenantSaveRequest } from './components/tenants-page/tenants-page';
import { LeasesPage } from './components/leases-page/leases-page';
import { WorkspaceNavigation } from './components/workspace-navigation/workspace-navigation';
import { SessionService } from './services/session.service';
@Component({
selector: 'app-root',
@@ -23,81 +11,17 @@ import { LeasesPage } from './components/leases-page/leases-page';
BrandMark,
SignInPage,
WorkspaceNavigation,
DashboardPage,
ApartmentsPage,
TenantsPage,
LeasesPage,
SettingsPage,
RouterOutlet,
],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App implements OnInit {
protected readonly title = signal('LMNP Organizer');
readonly helloMessage = signal<string>('');
// Tab & CRUD States
readonly activeTab = signal<WorkspaceTab>('dashboard');
readonly apartments = signal<Apartment[]>([]);
readonly tenants = signal<Tenant[]>([]);
readonly leases = signal<Lease[]>([]);
readonly tenantAttachmentError = signal('');
private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>('');
private readonly _email = signal<string>('');
readonly userInitial = computed(() => this._username().charAt(0).toUpperCase());
private readonly http = inject(HttpClient);
private readonly apartmentService = inject(ApartmentService);
private readonly tenantService = inject(TenantService);
private readonly leaseService = inject(LeaseService);
protected readonly themeService = inject(ThemeService);
readonly themePreference = this.themeService.preference;
constructor() {
effect(() => {
if (this.isLoggedIn) {
this.loadApartments();
this.loadTenants();
this.loadLeases();
}
});
}
readonly session = inject(SessionService);
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
ngOnInit() {
this.http
.get<{ authenticated: boolean; username?: string; email?: string }>('/api/user')
.subscribe({
next: (user) => {
if (user.authenticated) {
this._isLoggedIn.set(true);
this._username.set(user.username || 'User');
this._email.set(user.email || '');
} else {
this._isLoggedIn.set(false);
this._username.set('');
this._email.set('');
}
},
error: (err) => {
console.error('Error fetching user authentication status', err);
this._isLoggedIn.set(false);
this._username.set('');
this._email.set('');
},
});
}
get isLoggedIn(): boolean {
return this._isLoggedIn();
}
get username(): string {
return this._username();
}
get email(): string {
return this._email();
this.session.refresh();
}
login() {
@@ -108,146 +32,4 @@ export class App implements OnInit {
window.location.href = '/logout';
}
callHelloApi() {
this.http.get('/api/hello', { responseType: 'text' }).subscribe({
next: (msg) => this.helloMessage.set(msg),
error: (err) => {
console.error(err);
this.helloMessage.set('Failed to connect to backend: ' + err.statusText);
},
});
}
// Apartment CRUD operations
loadApartments() {
this.apartmentService.getApartments().subscribe({
next: (data) => this.apartments.set(data),
error: (err) => console.error('Error loading apartments', err),
});
}
saveApartment({ apartment, apartmentId }: ApartmentSaveRequest): void {
if (apartmentId) {
this.apartmentService.updateApartment(apartmentId, apartment).subscribe({
next: () => {
this.loadApartments();
},
error: (err) => console.error('Error updating apartment', err),
});
} else {
this.apartmentService.createApartment(apartment).subscribe({
next: () => {
this.loadApartments();
},
error: (err) => console.error('Error creating apartment', err),
});
}
}
deleteApartment(id: string) {
if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) {
this.apartmentService.deleteApartment(id).subscribe({
next: () => this.loadApartments(),
error: (err) => console.error('Error deleting apartment', err),
});
}
}
loadTenants(): void {
this.tenantService.getTenants().subscribe({
next: (data) => this.tenants.set(data),
error: (err) => console.error('Error loading tenants', err),
});
}
saveTenant({ tenant, tenantId }: TenantSaveRequest): void {
const request = tenantId
? this.tenantService.updateTenant(tenantId, tenant)
: this.tenantService.createTenant(tenant);
request.subscribe({
next: () => this.loadTenants(),
error: (err) => console.error('Error saving tenant', err),
});
}
deleteTenant(id: string): void {
if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?')) {
this.tenantService.deleteTenant(id).subscribe({
next: () => this.loadTenants(),
error: (err) => console.error('Error deleting tenant', err),
});
}
}
uploadTenantAttachment(request: { tenantId: string; file: File }): void {
this.tenantAttachmentError.set('');
this.tenantService.uploadAttachment(request.tenantId, request.file).subscribe({
next: () => this.loadTenants(),
error: (error: unknown) => {
console.error('Error uploading tenant attachment', error);
this.tenantAttachmentError.set(this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.'));
},
});
}
downloadTenantAttachment(request: {
tenantId: string;
attachment: { id: string; fileName: string };
}): void {
this.tenantService.downloadAttachment(request.tenantId, request.attachment.id).subscribe({
next: (blob) => this.saveBlob(blob, request.attachment.fileName),
error: (err) => console.error('Error downloading tenant attachment', err),
});
}
loadLeases(): void {
this.leaseService.getLeases().subscribe({
next: (data) => this.leases.set(data),
error: (err) => console.error('Error loading leases', err),
});
}
saveLease(lease: Lease): void {
this.leaseService.createLease(lease).subscribe({
next: () => this.loadLeases(),
error: (err) => console.error('Error saving lease', err),
});
}
deleteLease(id: string): void {
if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?')) {
this.leaseService.deleteLease(id).subscribe({
next: () => this.loadLeases(),
error: (err) => console.error('Error deleting lease', err),
});
}
}
downloadLeasePdf(lease: Lease): void {
if (!lease.id) return;
this.leaseService.downloadPdf(lease.id).subscribe({
next: (blob) => this.saveBlob(blob, `bail-${lease.id}.pdf`),
error: (err) => console.error('Error downloading lease PDF', err),
});
}
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 {
if (typeof error !== 'object' || error === null || !('error' in error)) {
return fallback;
}
const response = error.error;
if (typeof response !== 'object' || response === null || !('message' in response)) {
return fallback;
}
return typeof response.message === 'string' ? response.message : fallback;
}
}
@@ -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);
}
@@ -1,22 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ConfigService } from './config.service';
describe('ConfigService', () => {
let service: ConfigService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ConfigService],
});
service = TestBed.inject(ConfigService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should resolve loadConfig successfully', async () => {
await expect(service.loadConfig()).resolves.toBeUndefined();
});
});
@@ -1,10 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ConfigService {
async loadConfig(): Promise<void> {
return Promise.resolve();
}
}
@@ -0,0 +1,25 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class SessionService {
private readonly http = inject(HttpClient);
readonly isLoggedIn = signal(false);
readonly username = signal('');
readonly email = signal('');
refresh(): void {
this.http.get<{ authenticated: boolean; username?: string; email?: string }>('/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 || '') : '');
},
error: () => {
this.isLoggedIn.set(false);
this.username.set('');
this.email.set('');
},
});
}
}
@@ -0,0 +1,30 @@
import { Injectable, inject, signal } from '@angular/core';
import { Apartment, ApartmentService } from './apartment.service';
import { Lease, LeaseService } from './lease.service';
import { Tenant, TenantService } from './tenant.service';
@Injectable({ providedIn: 'root' })
export class WorkspaceService {
private readonly apartmentService = inject(ApartmentService);
private readonly tenantService = inject(TenantService);
private readonly leaseService = inject(LeaseService);
readonly apartments = signal<Apartment[]>([]);
readonly tenants = signal<Tenant[]>([]);
readonly leases = signal<Lease[]>([]);
readonly tenantAttachmentError = signal('');
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; }
}