feat(frontend): add mobile-first Material redesign (#17)

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent fd3fc0123a
commit 1a70bcd126
15 files changed
+1723 -663

No files matched your search

+2
View File
@@ -38,6 +38,8 @@ yarn-error.log
testem.log testem.log
/typings /typings
__screenshots__/ __screenshots__/
/playwright-report
/test-results
# System files # System files
.DS_Store .DS_Store
+6 -4
View File
@@ -7,7 +7,7 @@ This project was generated using [Angular CLI](https://github.com/angular/angula
To start a local development server, run: To start a local development server, run:
```bash ```bash
ng serve bun run start
``` ```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
@@ -41,7 +41,7 @@ This will compile your project and store the build artifacts in the `dist/` dire
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash ```bash
ng test bun run test -- --watch=false
``` ```
## Running end-to-end tests ## Running end-to-end tests
@@ -49,10 +49,12 @@ ng test
For end-to-end (e2e) testing, run: For end-to-end (e2e) testing, run:
```bash ```bash
ng e2e bunx playwright install chromium
bun run e2e
``` ```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. The Playwright suite starts the Angular development server automatically and runs against the
360 px mobile, 768 px tablet, and 1280 px desktop projects defined in `playwright.config.ts`.
## Additional Resources ## Additional Resources
+3 -3
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm", "packageManager": "bun",
"schematicCollections": ["angular-eslint"] "schematicCollections": ["angular-eslint"]
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
@@ -25,7 +25,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["src/styles.css"] "styles": ["src/styles.scss"]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -37,7 +37,7 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "10kB", "maximumWarning": "15kB",
"maximumError": "20kB" "maximumError": "20kB"
} }
], ],
+20
View File
@@ -5,10 +5,12 @@
"": { "": {
"name": "lmnp-frontend", "name": "lmnp-frontend",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0", "@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0", "@angular/router": "^22.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -19,6 +21,8 @@
"@angular/cli": "^22.0.6", "@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"angular-eslint": "22.1.0", "angular-eslint": "22.1.0",
"eslint": "^10.6.0", "eslint": "^10.6.0",
@@ -85,6 +89,8 @@
"@angular/build": ["@angular/build@22.0.6", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2200.6", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "6.0.12", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.4.2", "browserslist": "^4.26.0", "esbuild": "0.28.1", "https-proxy-agent": "9.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.4", "piscina": "5.2.0", "rollup": "4.60.2", "sass": "1.99.0", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.16", "vite": "7.3.5", "watchpack": "2.5.1" }, "optionalDependencies": { "lmdb": "3.5.4" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.0.6", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "tailwindcss", "vitest"] }, "sha512-mIp1snH1haw4pKiJwUmKyp9f31NRUTeUwqrl9W59X3Iv54egOnD1EdCc0jsW7WJVr/JHRZYVt/ooFelUB8uV8g=="], "@angular/build": ["@angular/build@22.0.6", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2200.6", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "6.0.12", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.4.2", "browserslist": "^4.26.0", "esbuild": "0.28.1", "https-proxy-agent": "9.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.4", "piscina": "5.2.0", "rollup": "4.60.2", "sass": "1.99.0", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.16", "vite": "7.3.5", "watchpack": "2.5.1" }, "optionalDependencies": { "lmdb": "3.5.4" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.0.6", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "tailwindcss", "vitest"] }, "sha512-mIp1snH1haw4pKiJwUmKyp9f31NRUTeUwqrl9W59X3Iv54egOnD1EdCc0jsW7WJVr/JHRZYVt/ooFelUB8uV8g=="],
"@angular/cdk": ["@angular/cdk@22.0.4", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-XaH1ff9tOn4acPh0zWbrmJznfZqZ0KZkw5+DM5BIeM9URmAOI8xdZWmVNyaUpVSmriANFjeGZS+Sfat9S6hLOg=="],
"@angular/cli": ["@angular/cli@22.0.6", "", { "dependencies": { "@angular-devkit/architect": "0.2200.6", "@angular-devkit/core": "22.0.6", "@angular-devkit/schematics": "22.0.6", "@inquirer/prompts": "8.4.2", "@listr2/prompt-adapter-inquirer": "4.2.3", "@modelcontextprotocol/sdk": "1.29.0", "@schematics/angular": "22.0.6", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.52.0", "ini": "6.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "npm-package-arg": "13.0.2", "pacote": "21.5.1", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.7.4", "yargs": "18.0.0", "zod": "4.4.2" }, "bin": { "ng": "bin/ng.js" } }, "sha512-/sEN1hVcywSML+yWrr/T7gbuGfIrZwKC+WhiOv5GksSQVUrSujWnMX4meRJ5ocG/Ie8UN48LJ7LVDsm32NBt1A=="], "@angular/cli": ["@angular/cli@22.0.6", "", { "dependencies": { "@angular-devkit/architect": "0.2200.6", "@angular-devkit/core": "22.0.6", "@angular-devkit/schematics": "22.0.6", "@inquirer/prompts": "8.4.2", "@listr2/prompt-adapter-inquirer": "4.2.3", "@modelcontextprotocol/sdk": "1.29.0", "@schematics/angular": "22.0.6", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.52.0", "ini": "6.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "npm-package-arg": "13.0.2", "pacote": "21.5.1", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.7.4", "yargs": "18.0.0", "zod": "4.4.2" }, "bin": { "ng": "bin/ng.js" } }, "sha512-/sEN1hVcywSML+yWrr/T7gbuGfIrZwKC+WhiOv5GksSQVUrSujWnMX4meRJ5ocG/Ie8UN48LJ7LVDsm32NBt1A=="],
"@angular/common": ["@angular/common@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-+sC0W0FWGeZ/HKUhueCBX8kXdFQDlStbUSK6WF/32igfNYR/PdUrp2L5bU7q654yjxnMzFWDAB+Uvzv6YI/cQg=="], "@angular/common": ["@angular/common@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-+sC0W0FWGeZ/HKUhueCBX8kXdFQDlStbUSK6WF/32igfNYR/PdUrp2L5bU7q654yjxnMzFWDAB+Uvzv6YI/cQg=="],
@@ -97,6 +103,8 @@
"@angular/forms": ["@angular/forms@22.0.6", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0", "zod": "^4.0.10" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-pZGMk1TvT+m8z2ZGm90W0sBlo6fhtMAA2baZbMt3HbGRmY23vZv1Orv9npX3Z6a9CCNDY3XBMBvkuj8g7vde9Q=="], "@angular/forms": ["@angular/forms@22.0.6", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0", "zod": "^4.0.10" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-pZGMk1TvT+m8z2ZGm90W0sBlo6fhtMAA2baZbMt3HbGRmY23vZv1Orv9npX3Z6a9CCNDY3XBMBvkuj8g7vde9Q=="],
"@angular/material": ["@angular/material@22.0.4", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/cdk": "22.0.4", "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-htOFlTwMtcB9J4q6yPD9jJIQVJ5LfRub+KAiD4D7UU1iaCknpNuQKRfLrZh/mEdOVACI52jHAOXS3+eTkloXcQ=="],
"@angular/platform-browser": ["@angular/platform-browser@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.0.6", "@angular/common": "22.0.6", "@angular/core": "22.0.6" }, "optionalPeers": ["@angular/animations"] }, "sha512-DyyYRx4jXlfaenf0mwsp9jcIak/c17QCFgJKA7OauSkQnk3dL+voMYEwdigfnTIUdw+2Y9wYDywLeuM6NychNQ=="], "@angular/platform-browser": ["@angular/platform-browser@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.0.6", "@angular/common": "22.0.6", "@angular/core": "22.0.6" }, "optionalPeers": ["@angular/animations"] }, "sha512-DyyYRx4jXlfaenf0mwsp9jcIak/c17QCFgJKA7OauSkQnk3dL+voMYEwdigfnTIUdw+2Y9wYDywLeuM6NychNQ=="],
"@angular/router": ["@angular/router@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-s0BErBBadCXlvilob8aWke4H1FRlXDbnDfyYkjW6RkUaGQ4jzeEAzH56KsG2hf8NtBtYwkRlsLfOX9s5sPwm6w=="], "@angular/router": ["@angular/router@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-s0BErBBadCXlvilob8aWke4H1FRlXDbnDfyYkjW6RkUaGQ4jzeEAzH56KsG2hf8NtBtYwkRlsLfOX9s5sPwm6w=="],
@@ -413,6 +421,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
@@ -527,6 +537,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="],
@@ -1071,6 +1083,10 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="], "postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="],
@@ -1219,6 +1235,8 @@
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@@ -1377,6 +1395,8 @@
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+197
View File
@@ -0,0 +1,197 @@
import { expect, Page, test } from '@playwright/test';
async function mockUser(page: Page, authenticated: boolean): Promise<void> {
await page.unroute('**/api/user');
await page.route('**/api/user', async (route) => {
await route.fulfill({
contentType: 'application/json',
json: authenticated
? { authenticated: true, username: 'Alice', email: 'alice@example.com' }
: { authenticated: false, username: '', email: '' },
});
});
}
async function mockEmptyApartmentList(page: Page): Promise<void> {
await page.route('**/api/apartments', async (route) => {
await route.fulfill({ contentType: 'application/json', json: [] });
});
}
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
const hasHorizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(hasHorizontalOverflow).toBe(false);
}
test.beforeEach(async ({ page }) => {
await mockUser(page, false);
});
test('renders the sign-in experience without horizontal overflow', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'LMNP Organizer' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Se connecter avec OIDC' })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('starts the OIDC login flow from the sign-in screen', async ({ page }) => {
await page.route('**/oauth2/authorization/oidc', async (route) => {
await route.fulfill({ contentType: 'text/html', body: '<h1>OIDC provider</h1>' });
});
await page.goto('/');
await page.getByRole('button', { name: 'Se connecter avec OIDC' }).click();
await expect(page).toHaveURL(/\/oauth2\/authorization\/oidc$/);
});
test('persists a dark appearance preference from Settings', async ({ page }) => {
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.goto('/');
await page.getByRole('button', { name: 'Réglages' }).click();
await page.getByRole('radio', { name: 'Sombre' }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
expect(await page.evaluate(() => localStorage.getItem('lmnp-theme'))).toBe('dark');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.getByRole('button', { name: 'Réglages' }).click();
await expect(page.getByRole('radio', { name: 'Sombre' })).toBeChecked();
});
test('adapts authenticated navigation to the viewport', async ({ page }, testInfo) => {
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.goto('/');
const navigation = page.getByTestId('primary-navigation');
await expect(navigation).toBeVisible();
const navigationBox = await navigation.boundingBox();
const viewport = page.viewportSize();
expect(navigationBox).not.toBeNull();
expect(viewport).not.toBeNull();
if (testInfo.project.name === 'desktop') {
expect(navigationBox?.x).toBe(0);
expect(navigationBox?.height).toBe(viewport?.height);
} else {
expect(Math.round((navigationBox?.y ?? 0) + (navigationBox?.height ?? 0))).toBe(
viewport?.height,
);
}
await navigation.getByRole('button', { name: 'Appartements' }).click();
await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('keeps apartment add and edit workflows usable', async ({ page }) => {
const apartments = [
{
id: 'apt-1',
name: 'T2 République',
address: '12 rue de la République, Lyon',
purchasePrice: 185000,
description: 'Deuxième étage',
},
];
await mockUser(page, true);
await page.route('**/api/apartments**', async (route) => {
const request = route.request();
const method = request.method();
const id = new URL(request.url()).pathname.split('/').at(-1);
if (method === 'POST') {
const apartment = { id: 'apt-2', ...request.postDataJSON() };
apartments.push(apartment);
await route.fulfill({ contentType: 'application/json', json: apartment });
return;
}
if (method === 'PUT') {
const index = apartments.findIndex((apartment) => apartment.id === id);
apartments[index] = { id, ...request.postDataJSON() };
await route.fulfill({ contentType: 'application/json', json: apartments[index] });
return;
}
if (method === 'DELETE') {
const index = apartments.findIndex((apartment) => apartment.id === id);
apartments.splice(index, 1);
await route.fulfill({ status: 204 });
return;
}
await route.fulfill({ contentType: 'application/json', json: apartments });
});
await page.goto('/');
await page
.getByTestId('primary-navigation')
.getByRole('button', { name: 'Appartements' })
.click();
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByLabel("Nom de l'appartement").fill('T2 République rénové');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeVisible();
await page.getByRole('button', { name: 'Ajouter un appartement' }).click();
await page.getByLabel("Nom de l'appartement").fill('Studio Croix-Rousse');
await page.getByLabel('Adresse complète').fill('8 montée de la Boucle, Lyon');
await page.getByLabel("Prix d'acquisition").fill('125000');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'Studio Croix-Rousse' })).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
const renovatedApartment = page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name: 'T2 République rénové' }) });
await renovatedApartment.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeHidden();
await expectNoHorizontalOverflow(page);
});
test('keeps the secured API check and logout available', async ({ page }) => {
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.route('**/api/hello', async (route) => {
await route.fulfill({ contentType: 'text/plain', body: 'Service disponible' });
});
await page.route('**/logout', async (route) => {
await route.fulfill({ contentType: 'text/html', body: '<h1>Signed out</h1>' });
});
await page.goto('/');
await page.getByRole('button', { name: 'Tester la connexion' }).click();
await expect(page.getByText('Service disponible')).toBeVisible();
await page.getByTestId('primary-navigation').getByRole('button', { name: 'Réglages' }).click();
await page.getByTestId('session-settings').getByRole('button', { name: 'Déconnexion' }).click();
await expect(page).toHaveURL(/\/logout$/);
});
test('follows operating-system changes for the System theme', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('lmnp-theme', 'system'));
await page.emulateMedia({ colorScheme: 'dark' });
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.emulateMedia({ colorScheme: 'light' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});
+8 -1
View File
@@ -7,15 +7,20 @@
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"lint": "ng lint" "lint": "ng lint"
}, },
"private": true, "private": true,
"packageManager": "npm@12.0.1", "packageManager": "bun@1.3.14",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0", "@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0", "@angular/router": "^22.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -26,6 +31,8 @@
"@angular/cli": "^22.0.6", "@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"angular-eslint": "22.1.0", "angular-eslint": "22.1.0",
"eslint": "^10.6.0", "eslint": "^10.6.0",
+44
View File
@@ -0,0 +1,44 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env['CI']),
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: process.env['CI'] ? 'line' : 'html',
use: {
baseURL: 'http://127.0.0.1:4200',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
projects: [
{
name: 'mobile',
use: {
...devices['Desktop Chrome'],
viewport: { width: 360, height: 800 },
},
},
{
name: 'tablet',
use: {
...devices['Desktop Chrome'],
viewport: { width: 768, height: 1024 },
},
},
{
name: 'desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 800 },
},
},
],
webServer: {
command: 'bun run start -- --host 127.0.0.1 --port 4200',
url: 'http://127.0.0.1:4200',
reuseExistingServer: !process.env['CI'],
timeout: 120_000,
},
});
+842 -503
View File
File diff suppressed because it is too large. Load diff
+398 -136
View File
@@ -1,208 +1,470 @@
<div class="app-container"> @if (!isLoggedIn) {
<div class="background-decorations"> <main class="sign-in-shell">
<div class="blob blob-1"></div> <section class="sign-in-panel">
<div class="blob blob-2"></div> <div class="brand-lockup">
</div> <app-brand-mark class="brand-mark" />
<div>
<main class="main-card">
<header class="card-header">
<div class="logo-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="logo-icon">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<h1>LMNP Organizer</h1> <h1>LMNP Organizer</h1>
<p class="subtitle">Votre assistant de gestion et fiscalité immobilière</p> <p>Gestion locative & fiscalité</p>
</header> </div>
</div>
<section class="card-body"> <div class="sign-in-copy">
@if (!isLoggedIn) { <span class="context-label">Espace bailleur</span>
<div class="auth-state locked"> <h2>Votre gestion locative, enfin lisible.</h2>
<div class="shield-icon"> <p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> Pilotez vos appartements et gardez vos démarches LMNP au même endroit, sur mobile comme
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/> sur ordinateur.
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> </p>
</svg>
</div> </div>
<h2>Connexion Sécurisée Requise</h2>
<p>Connectez-vous avec Keycloak / voidAuth pour accéder à votre espace de gestion locative.</p> <button mat-flat-button class="primary-action sign-in-action" (click)="login()">
<button (click)="login()" class="btn btn-primary btn-glow"> Se connecter avec OIDC
<span>Se connecter avec OIDC</span> <svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="btn-arrow"> viewBox="0 0 24 24"
<path d="M5 12h14"/> fill="none"
<path d="m12 5 7 7-7 7"/> stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg> </svg>
</button> </button>
<p class="security-note">
<span aria-hidden="true">●</span>
Connexion sécurisée via OpenID Connect
</p>
</section>
<aside class="sign-in-story" aria-label="Aperçu du produit">
<div class="story-card">
<p class="story-kicker">Pensé pour aller à l'essentiel</p>
<h2>Un espace calme pour vos décisions de bailleur.</h2>
<div class="story-list">
<div>
<span>01</span>
<p>Vos biens accessibles en quelques gestes.</p>
</div> </div>
} @else { <div>
<div class="auth-state unlocked"> <span>02</span>
<!-- Multi-Tab Navigation Menu --> <p>Une interface adaptée à chaque écran.</p>
<nav class="tab-nav"> </div>
<button (click)="activeTab.set('dashboard')" [class.active]="activeTab() === 'dashboard'" class="tab-btn"> <div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon"> <span>03</span>
<rect width="7" height="9" x="3" y="3" rx="1"/> <p>Vos outils LMNP réunis progressivement.</p>
<rect width="7" height="5" x="14" y="3" rx="1"/> </div>
<rect width="7" height="9" x="14" y="12" rx="1"/> </div>
<rect width="7" height="5" x="3" y="16" rx="1"/> </div>
</aside>
</main>
} @else {
<div class="workspace-shell">
<aside class="app-navigation" data-testid="primary-navigation">
<div class="sidebar-brand">
<app-brand-mark class="brand-mark" />
<div>
<strong>LMNP</strong>
<span>Organizer</span>
</div>
</div>
<nav class="navigation-items" aria-label="Navigation principale">
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'dashboard'"
[attr.aria-current]="activeTab() === 'dashboard' ? 'page' : null"
(click)="activeTab.set('dashboard')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<rect x="3" y="3" width="7" height="7" rx="2" />
<rect x="14" y="3" width="7" height="7" rx="2" />
<rect x="3" y="14" width="7" height="7" rx="2" />
<rect x="14" y="14" width="7" height="7" rx="2" />
</svg> </svg>
<span>Tableau de Bord</span> <span>Tableau de bord</span>
</button> </button>
<button (click)="activeTab.set('apartments')" [class.active]="activeTab() === 'apartments'" class="tab-btn"> <button
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon"> mat-button
<path d="M2 22h20"/> type="button"
<path d="M4 22V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v18"/> class="navigation-item"
<path d="M12 18h.01"/> [class.active]="activeTab() === 'apartments'"
<path d="M8 6h2"/> [attr.aria-current]="activeTab() === 'apartments' ? 'page' : null"
<path d="M14 6h2"/> (click)="activeTab.set('apartments')"
<path d="M8 10h2"/> >
<path d="M14 10h2"/> <svg
<path d="M8 14h2"/> viewBox="0 0 24 24"
<path d="M14 14h2"/> fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16" />
<path d="M2 21h20M8 7h2M14 7h2M8 11h2M14 11h2M8 15h2M14 15h2" />
</svg> </svg>
<span>Appartements</span> <span>Appartements</span>
</button> </button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'settings'"
[attr.aria-current]="activeTab() === 'settings' ? 'page' : null"
(click)="activeTab.set('settings')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21h-4v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3v-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.16.38.37.72.6 1 .3.3.7.45 1.1.45h.09v4h-.09c-.4 0-.8.15-1.1.45-.23.28-.44.62-.6 1Z"
/>
</svg>
<span>Réglages</span>
</button>
</nav> </nav>
<!-- Tab Content: Dashboard --> <div class="sidebar-user">
@if (activeTab() === 'dashboard') { <div class="compact-avatar">{{ userInitial() }}</div>
<div class="tab-pane"> <div>
<div class="user-profile"> <strong>{{ username }}</strong>
<div class="avatar"> <span>{{ email }}</span>
{{ username.charAt(0).toUpperCase() }}
</div> </div>
<div class="user-details"> <button mat-button type="button" class="logout-action" (click)="logout()">
<h3>{{ username }}</h3> Déconnexion
<p class="user-email">{{ email }}</p>
<span class="badge badge-success">Bailleur Connecté</span>
</div>
</div>
<div class="api-demo-section">
<h4>Vérification API Sécurisée</h4>
<p>Testez la transmission et la validation du jeton JWT auprès du backend Java 25.</p>
<div class="api-actions">
<button (click)="callHelloApi()" class="btn btn-secondary">
Appeler /api/hello
</button> </button>
</div> </div>
</aside>
<main class="workspace-main">
<header class="mobile-header">
<div class="mobile-brand">
<app-brand-mark class="brand-mark" />
<strong>LMNP Organizer</strong>
</div>
<div class="compact-avatar">{{ userInitial() }}</div>
</header>
<div class="page-container">
@if (activeTab() === 'dashboard') {
<section class="page-section dashboard-page">
<header class="page-heading">
<div>
<p class="eyebrow">Bonjour {{ username }}</p>
<h1>Tableau de bord</h1>
<p>Retrouvez rapidement les actions utiles à votre gestion locative.</p>
</div>
<span class="status-pill"><span></span>Espace sécurisé</span>
</header>
<div class="dashboard-grid">
<article class="feature-card feature-card-primary">
<div class="feature-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16M2 21h20" />
<path d="M8 8h8M8 12h8M8 16h4" />
</svg>
</div>
<div>
<p class="eyebrow">Votre portefeuille</p>
<h2>
{{ apartments().length }} appartement{{ apartments().length > 1 ? 's' : '' }}
</h2>
<p>Centralisez les informations essentielles de vos biens.</p>
</div>
<button mat-button type="button" (click)="activeTab.set('apartments')">
Gérer mes appartements
</button>
</article>
<article class="feature-card user-profile">
<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>
<span class="role-chip">Bailleur connecté</span>
</div>
</article>
<article class="feature-card api-demo-section">
<div>
<p class="eyebrow">État du service</p>
<h2>Connexion au serveur</h2>
<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)="callHelloApi()">
Tester la connexion
</button>
</div>
@if (helloMessage()) { @if (helloMessage()) {
<div class="api-response-wrapper"> <div class="api-response-wrapper">
<span class="response-label">Réponse du backend :</span> <span>Réponse du serveur</span>
<code class="api-response-code">{{ helloMessage() }}</code> <code class="api-response-code">{{ helloMessage() }}</code>
</div> </div>
} }
</article>
</div> </div>
</div> </section>
} }
<!-- Tab Content: Apartments -->
@if (activeTab() === 'apartments') { @if (activeTab() === 'apartments') {
<div class="tab-pane"> <section class="page-section apartments-page">
@if (!showForm()) { @if (!showForm()) {
<div class="section-header"> <header class="page-heading">
<h2>Mon Portefeuille de Biens</h2> <div>
<button (click)="openAddForm()" class="btn btn-primary btn-sm"> <p class="eyebrow">Portefeuille</p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="btn-icon"> <h1>Mes appartements</h1>
<path d="M5 12h14"/> <p>Les biens rattachés à votre activité LMNP.</p>
<path d="M12 5v14"/>
</svg>
<span>Ajouter un appartement</span>
</button>
</div> </div>
<button
mat-flat-button
type="button"
class="primary-action"
(click)="openAddForm()"
>
<span class="button-plus" aria-hidden="true">+</span>
Ajouter un appartement
</button>
</header>
@if (apartments().length === 0) { @if (apartments().length === 0) {
<div class="empty-state"> <article class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="empty-icon"> <div class="empty-illustration" aria-hidden="true">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<polyline points="9 22 9 12 15 12 15 22"/> <path d="m4 10 8-6 8 6v10H4z" />
<path d="M9 20v-6h6v6" />
</svg> </svg>
<h3>Aucun appartement enregistré</h3>
<p>Commencez par ajouter votre premier bien immobilier LMNP à votre portefeuille.</p>
</div> </div>
<p class="eyebrow">Commencez ici</p>
<h2>Aucun appartement enregistré</h2>
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
<button mat-stroked-button type="button" (click)="openAddForm()">
Ajouter un appartement
</button>
</article>
} @else { } @else {
<div class="apartments-grid"> <div class="apartments-grid">
@for (apt of apartments(); track apt.id) { @for (apt of apartments(); track apt.id) {
<div class="apartment-card"> <article class="apartment-card">
<div class="apartment-card-topline">
<span class="property-label">Bien immobilier</span>
<span class="property-value"
>{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span
>
</div>
<div class="apt-info"> <div class="apt-info">
<h3>{{ apt.name }}</h3> <h2>{{ apt.name }}</h2>
<p class="apt-address"> <p class="apt-address">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-icon"> <svg
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/> viewBox="0 0 24 24"
<circle cx="12" cy="10" r="3"/> fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M20 10c0 5-8 11-8 11S4 15 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="2.5" />
</svg> </svg>
{{ apt.address }} {{ apt.address }}
</p> </p>
<p class="apt-price">{{ apt.purchasePrice.toLocaleString('fr-FR') }} &euro;</p>
@if (apt.description) { @if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p> <p class="apt-desc">{{ apt.description }}</p>
} }
</div> </div>
<div class="apt-actions"> <div class="apt-actions">
<button (click)="openEditForm(apt)" class="btn btn-outline btn-xs btn-warning"> <button mat-button type="button" (click)="openEditForm(apt)">
Modifier Modifier
</button> </button>
@if (apt.id) { @if (apt.id) {
<button (click)="deleteApartment(apt.id)" class="btn btn-outline btn-xs btn-danger"> <button
mat-button
type="button"
class="danger-action"
(click)="deleteApartment(apt.id)"
>
Supprimer Supprimer
</button> </button>
} }
</div> </div>
</div> </article>
} }
</div> </div>
} }
} @else { } @else {
<div class="form-container"> <div class="form-page">
<h2>{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}</h2> <header class="page-heading form-heading">
<div>
@if (formError) { <button mat-button type="button" class="back-action" (click)="closeForm()">
<div class="alert alert-error"> ← Retour
{{ formError }} </button>
<p class="eyebrow">Portefeuille</p>
<h1>
{{ editingApartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}
</h1>
<p>Renseignez les informations principales du bien.</p>
</div> </div>
</header>
<form (submit)="saveApartment(); $event.preventDefault()" class="apartment-form">
@if (formError) {
<div class="alert-error" role="alert">{{ formError }}</div>
} }
<form (submit)="saveApartment(); $event.preventDefault()" class="crud-form"> <div class="form-grid">
<div class="form-group"> <mat-form-field appearance="outline">
<label for="aptName">Nom de l'appartement *</label> <mat-label>Nom de l'appartement</mat-label>
<input type="text" id="aptName" name="name" [(ngModel)]="formName" placeholder="Ex. T2 Cosy Paris 15" required> <input
</div> matInput
type="text"
name="name"
[(ngModel)]="formName"
placeholder="T2 Paris 15"
required
/>
</mat-form-field>
<div class="form-group"> <mat-form-field appearance="outline">
<label for="aptAddress">Adresse complète *</label> <mat-label>Prix d'acquisition</mat-label>
<input type="text" id="aptAddress" name="address" [(ngModel)]="formAddress" placeholder="Ex. 45 rue des Entrepreneurs, 75015 Paris" required> <input
</div> matInput
type="number"
name="purchasePrice"
[(ngModel)]="formPurchasePrice"
min="1"
required
/>
<span matTextSuffix>€</span>
</mat-form-field>
<div class="form-group"> <mat-form-field appearance="outline" class="full-field">
<label for="aptPrice">Prix d'acquisition (&euro;) *</label> <mat-label>Adresse complète</mat-label>
<input type="number" id="aptPrice" name="purchasePrice" [(ngModel)]="formPurchasePrice" min="1" placeholder="Ex. 180000" required> <input
</div> matInput
type="text"
name="address"
[(ngModel)]="formAddress"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/>
</mat-form-field>
<div class="form-group"> <mat-form-field appearance="outline" class="full-field">
<label for="aptDesc">Description / Notes</label> <mat-label>Description ou notes</mat-label>
<textarea id="aptDesc" name="description" [(ngModel)]="formDescription" rows="3" placeholder="Ex. T2 en rez-de-chaussée, meublé, cuisine équipée."></textarea> <textarea
matInput
name="description"
[(ngModel)]="formDescription"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" (click)="closeForm()" class="btn btn-outline">Annuler</button> <button mat-button type="button" (click)="closeForm()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer</button> <button mat-flat-button type="submit" class="primary-action">
Enregistrer
</button>
</div> </div>
</form> </form>
</div> </div>
} }
</div>
}
<button (click)="logout()" class="btn btn-outline btn-logout">
Se déconnecter
</button>
</div>
}
</section> </section>
}
<footer class="card-footer"> @if (activeTab() === 'settings') {
<p>LMNP Organizer &copy; 2026. Sécurisé avec OpenID Connect et Spring Boot 4.</p> <section class="page-section settings-page">
</footer> <header class="page-heading">
<div>
<p class="eyebrow">Préférences</p>
<h1>Réglages</h1>
<p>Adaptez LMNP Organizer à votre façon de travailler.</p>
</div>
</header>
<article class="settings-card" aria-labelledby="appearance-heading">
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 3a9 9 0 1 0 9 9c0-.5-.04-1-.12-1.47A7 7 0 0 1 12 3Z" />
</svg>
</span>
<div>
<p class="eyebrow">Interface</p>
<h2 id="appearance-heading">Apparence</h2>
<p>Le réglage est mémorisé uniquement sur cet appareil.</p>
</div>
</div>
<mat-radio-group
class="theme-options"
aria-label="Thème de l'application"
[value]="themePreference()"
(change)="themeService.setPreference($event.value)"
>
<mat-radio-button value="light">
<span class="theme-option-copy"
><strong>Clair</strong><small>Une interface lumineuse et douce.</small></span
>
</mat-radio-button>
<mat-radio-button value="dark">
<span class="theme-option-copy"
><strong>Sombre</strong
><small>Un contraste réduit en faible lumière.</small></span
>
</mat-radio-button>
<mat-radio-button value="system">
<span class="theme-option-copy"
><strong>Système</strong
><small>Suit automatiquement votre appareil.</small></span
>
</mat-radio-button>
</mat-radio-group>
</article>
<article
class="settings-card session-settings"
data-testid="session-settings"
aria-labelledby="session-heading"
>
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
</span>
<div>
<p class="eyebrow">Compte</p>
<h2 id="session-heading">Session</h2>
<p>Gérez la session ouverte sur cet appareil.</p>
</div>
</div>
<div class="session-action">
<div>
<strong>{{ username }}</strong>
<span>{{ email }}</span>
</div>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section>
}
</div>
</main> </main>
</div> </div>
}
+30 -12
View File
@@ -1,20 +1,33 @@
import { Component, signal, inject, OnInit, effect } from '@angular/core'; import { Component, computed, signal, inject, OnInit, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { ApartmentService, Apartment } from './apartment.service'; import { ApartmentService, Apartment } from './apartment.service';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { ThemeService } from './theme.service';
import { BrandMark } from './brand-mark';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [FormsModule], imports: [
BrandMark,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css' styleUrl: './app.css',
}) })
export class App implements OnInit { export class App implements OnInit {
protected readonly title = signal('LMNP Organizer'); protected readonly title = signal('LMNP Organizer');
readonly helloMessage = signal<string>(''); readonly helloMessage = signal<string>('');
// Tab & CRUD States // Tab & CRUD States
readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard'); readonly activeTab = signal<'dashboard' | 'apartments' | 'settings'>('dashboard');
readonly apartments = signal<Apartment[]>([]); readonly apartments = signal<Apartment[]>([]);
readonly showForm = signal<boolean>(false); readonly showForm = signal<boolean>(false);
readonly editingApartment = signal<Apartment | null>(null); readonly editingApartment = signal<Apartment | null>(null);
@@ -29,9 +42,12 @@ export class App implements OnInit {
private readonly _isLoggedIn = signal<boolean>(false); private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>(''); private readonly _username = signal<string>('');
private readonly _email = signal<string>(''); private readonly _email = signal<string>('');
readonly userInitial = computed(() => this._username().charAt(0).toUpperCase());
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly apartmentService = inject(ApartmentService); private readonly apartmentService = inject(ApartmentService);
protected readonly themeService = inject(ThemeService);
readonly themePreference = this.themeService.preference;
constructor() { constructor() {
effect(() => { effect(() => {
@@ -42,7 +58,9 @@ export class App implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.http.get<{ authenticated: boolean, username?: string, email?: string }>('/api/user').subscribe({ this.http
.get<{ authenticated: boolean; username?: string; email?: string }>('/api/user')
.subscribe({
next: (user) => { next: (user) => {
if (user.authenticated) { if (user.authenticated) {
this._isLoggedIn.set(true); this._isLoggedIn.set(true);
@@ -59,7 +77,7 @@ export class App implements OnInit {
this._isLoggedIn.set(false); this._isLoggedIn.set(false);
this._username.set(''); this._username.set('');
this._email.set(''); this._email.set('');
} },
}); });
} }
@@ -89,7 +107,7 @@ export class App implements OnInit {
error: (err) => { error: (err) => {
console.error(err); console.error(err);
this.helloMessage.set('Failed to connect to backend: ' + err.statusText); this.helloMessage.set('Failed to connect to backend: ' + err.statusText);
} },
}); });
} }
@@ -97,7 +115,7 @@ export class App implements OnInit {
loadApartments() { loadApartments() {
this.apartmentService.getApartments().subscribe({ this.apartmentService.getApartments().subscribe({
next: (data) => this.apartments.set(data), next: (data) => this.apartments.set(data),
error: (err) => console.error('Error loading apartments', err) error: (err) => console.error('Error loading apartments', err),
}); });
} }
@@ -136,7 +154,7 @@ export class App implements OnInit {
name: this.formName.trim(), name: this.formName.trim(),
address: this.formAddress.trim(), address: this.formAddress.trim(),
purchasePrice: this.formPurchasePrice, purchasePrice: this.formPurchasePrice,
description: this.formDescription.trim() description: this.formDescription.trim(),
}; };
const editMode = this.editingApartment(); const editMode = this.editingApartment();
@@ -149,7 +167,7 @@ export class App implements OnInit {
error: (err) => { error: (err) => {
console.error(err); console.error(err);
this.formError = 'Erreur lors de la modification.'; this.formError = 'Erreur lors de la modification.';
} },
}); });
} else { } else {
this.apartmentService.createApartment(apartmentData).subscribe({ this.apartmentService.createApartment(apartmentData).subscribe({
@@ -160,7 +178,7 @@ export class App implements OnInit {
error: (err) => { error: (err) => {
console.error(err); console.error(err);
this.formError = 'Erreur lors de la création.'; this.formError = 'Erreur lors de la création.';
} },
}); });
} }
} }
@@ -169,7 +187,7 @@ export class App implements OnInit {
if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) { if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) {
this.apartmentService.deleteApartment(id).subscribe({ this.apartmentService.deleteApartment(id).subscribe({
next: () => this.loadApartments(), next: () => this.loadApartments(),
error: (err) => console.error('Error deleting apartment', err) error: (err) => console.error('Error deleting apartment', err),
}); });
} }
} }
+24
View File
@@ -0,0 +1,24 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-brand-mark',
template: `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="m4 10 8-6 8 6v9a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" />
<path d="M9 20v-6h6v6" />
</svg>
`,
styles: `
:host {
display: grid;
place-items: center;
}
svg {
width: 55%;
height: 55%;
}
`,
host: { 'aria-hidden': 'true' },
})
export class BrandMark {}
+45
View File
@@ -0,0 +1,45 @@
import { DOCUMENT } from '@angular/common';
import { effect, inject, Injectable, signal } from '@angular/core';
export type ThemePreference = 'light' | 'dark' | 'system';
const THEME_STORAGE_KEY = 'lmnp-theme';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly document = inject(DOCUMENT);
private readonly colorSchemeQuery =
typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)')
: null;
private readonly systemUsesDark = signal(this.colorSchemeQuery?.matches ?? false);
readonly preference = signal<ThemePreference>(this.readPreference());
constructor() {
this.colorSchemeQuery?.addEventListener('change', (event) =>
this.systemUsesDark.set(event.matches),
);
effect(() => {
const preference = this.preference();
const resolvedTheme =
preference === 'system' ? (this.systemUsesDark() ? 'dark' : 'light') : preference;
localStorage.setItem(THEME_STORAGE_KEY, preference);
this.document.documentElement.dataset['theme'] = resolvedTheme;
this.document.documentElement.dataset['themePreference'] = preference;
});
}
setPreference(preference: ThemePreference): void {
this.preference.set(preference);
}
private readPreference(): ThemePreference {
const storedPreference = localStorage.getItem(THEME_STORAGE_KEY);
return storedPreference === 'dark' || storedPreference === 'system'
? storedPreference
: 'light';
}
}
-4
View File
@@ -1,4 +0,0 @@
/* You can add global styles to this file, and also import other style files */
body {
margin: 0;
}
+95
View File
@@ -0,0 +1,95 @@
@use '@angular/material' as mat;
@include mat.core();
html {
@include mat.theme(
(
color: (
primary: mat.$cyan-palette,
tertiary: mat.$azure-palette,
theme-type: color-scheme,
),
typography: (
Inter,
ui-sans-serif,
system-ui,
sans-serif,
),
density: 0,
)
);
color-scheme: light;
--canvas: #f4f6f2;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-soft: #e9efeb;
--surface-accent: #dcefeb;
--text: #17211e;
--text-muted: #687670;
--text-subtle: #8c9994;
--accent: #0f766e;
--accent-strong: #0a5f59;
--accent-contrast: #ffffff;
--border: #dce5df;
--border-strong: #c7d3cd;
--danger: #b42318;
--danger-soft: #fee9e7;
--shadow: 0 24px 60px rgba(28, 49, 42, 0.09);
}
html[data-theme='dark'] {
color-scheme: dark;
--canvas: #101513;
--surface: #171d1b;
--surface-raised: #1b2320;
--surface-soft: #202a26;
--surface-accent: #173a36;
--text: #f0f5f2;
--text-muted: #a7b5af;
--text-subtle: #7f8e88;
--accent: #63c7bc;
--accent-strong: #86d7cf;
--accent-contrast: #08201d;
--border: #2c3733;
--border-strong: #3a4944;
--danger: #ffb4ab;
--danger-soft: #4a2421;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
min-height: 100%;
margin: 0;
overflow-x: hidden;
}
body {
background: var(--canvas);
color: var(--text);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
text-rendering: optimizeLegibility;
transition:
background-color 180ms ease,
color 180ms ease;
}
button,
input,
textarea {
font: inherit;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "e2e/**/*.ts"]
}