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:
1 parent
be1f680a62
commit
82f339e209
33 files changed
+292
-929
No files matched your search
@@ -3,7 +3,10 @@
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "bun",
|
||||
"schematicCollections": ["angular-eslint"]
|
||||
"schematicCollections": [
|
||||
"angular-eslint"
|
||||
],
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
|
||||
+1
-22
@@ -23,7 +23,6 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"angular-eslint": "22.1.0",
|
||||
"eslint": "^10.6.0",
|
||||
"jsdom": "^28.0.0",
|
||||
@@ -153,8 +152,6 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="],
|
||||
@@ -561,8 +558,6 @@
|
||||
|
||||
"@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
|
||||
@@ -607,8 +602,6 @@
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
@@ -835,8 +828,6 @@
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
@@ -881,15 +872,9 @@
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||
|
||||
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="],
|
||||
|
||||
@@ -953,10 +938,6 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -1305,8 +1286,6 @@
|
||||
|
||||
"@angular/forms/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"angular-eslint": "22.1.0",
|
||||
"eslint": "^10.6.0",
|
||||
"jsdom": "^28.0.0",
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
'**',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user