feat: add brand mark component and dashboard page

- Introduced a new `BrandMark` component for branding purposes.
- Created a `DashboardPage` component with a responsive layout, including sections for user profile, apartment management, and API demo.
- Added styles for the dashboard page to enhance user experience.
- Implemented a settings page with theme preferences and session management features.
- Developed a sign-in page with branding and user-friendly design.
- Established a workspace navigation component for easy access to different sections of the application.
- Implemented an `ApartmentService` for managing apartment data with corresponding unit tests.
- Created a `ConfigService` for loading application configuration.
- Added a `ThemeService` to manage user theme preferences dynamically.
This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent 3e552cad90
commit 26ab037084
37 files changed
+1877 -1650

No files matched your search

+30 -446
View File
@@ -1,152 +1,15 @@
@if (!isLoggedIn) {
<main class="sign-in-shell">
<section class="sign-in-panel">
<div class="brand-lockup">
<app-brand-mark class="brand-mark" />
<div>
<h1>LMNP Organizer</h1>
<p>Gestion locative & fiscalité</p>
</div>
</div>
<div class="sign-in-copy">
<span class="context-label">Espace bailleur</span>
<h2>Votre gestion locative, enfin lisible.</h2>
<p>
Pilotez vos appartements et gardez vos démarches LMNP au même endroit, sur mobile comme
sur ordinateur.
</p>
</div>
<button mat-flat-button class="primary-action sign-in-action" (click)="login()">
Se connecter avec OIDC
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</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>
<span>02</span>
<p>Une interface adaptée à chaque écran.</p>
</div>
<div>
<span>03</span>
<p>Vos outils LMNP réunis progressivement.</p>
</div>
</div>
</div>
</aside>
</main>
<app-sign-in-page (loginRequested)="login()" />
} @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>
<span>Tableau de bord</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'apartments'"
[attr.aria-current]="activeTab() === 'apartments' ? 'page' : null"
(click)="activeTab.set('apartments')"
>
<svg
viewBox="0 0 24 24"
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>
<span>Appartements</span>
</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>
<div class="sidebar-user">
<div class="compact-avatar">{{ userInitial() }}</div>
<div>
<strong>{{ username }}</strong>
<span>{{ email }}</span>
</div>
<button mat-button type="button" class="logout-action" (click)="logout()">
Déconnexion
</button>
</div>
</aside>
<app-workspace-navigation
[activeTab]="activeTab()"
[username]="username"
[email]="email"
[userInitial]="userInitial()"
(tabSelected)="activeTab.set($event)"
(logoutRequested)="logout()"
/>
<main class="workspace-main">
<header class="mobile-header">
@@ -159,310 +22,31 @@
<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()) {
<div class="api-response-wrapper">
<span>Réponse du serveur</span>
<code class="api-response-code">{{ helloMessage() }}</code>
</div>
}
</article>
</div>
</section>
<app-dashboard-page
[username]="username"
[email]="email"
[userInitial]="userInitial()"
[apartmentCount]="apartments().length"
[helloMessage]="helloMessage()"
(apartmentsRequested)="activeTab.set('apartments')"
(connectionCheckRequested)="callHelloApi()"
/>
}
@if (activeTab() === 'apartments') {
<section class="page-section apartments-page">
@if (!showForm()) {
<header class="page-heading">
<div>
<p class="eyebrow">Portefeuille</p>
<h1>Mes appartements</h1>
<p>Les biens rattachés à votre activité LMNP.</p>
</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) {
<article class="empty-state">
<div class="empty-illustration" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="m4 10 8-6 8 6v10H4z" />
<path d="M9 20v-6h6v6" />
</svg>
</div>
<p class="eyebrow">Commencez ici</p>
<h2>Aucun appartement enregistré</h2>
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
<button mat-stroked-button type="button" (click)="openAddForm()">
Ajouter un appartement
</button>
</article>
} @else {
<div class="apartments-grid">
@for (apt of apartments(); track apt.id) {
<article class="apartment-card">
<div class="apartment-card-topline">
<span class="property-label">Bien immobilier</span>
<span class="property-value"
>{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span
>
</div>
<div class="apt-info">
<h2>{{ apt.name }}</h2>
<p class="apt-address">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M20 10c0 5-8 11-8 11S4 15 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
{{ apt.address }}
</p>
@if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p>
}
</div>
<div class="apt-actions">
<button mat-button type="button" (click)="openEditForm(apt)">
Modifier
</button>
@if (apt.id) {
<button
mat-button
type="button"
class="danger-action"
(click)="deleteApartment(apt.id)"
>
Supprimer
</button>
}
</div>
</article>
}
</div>
}
} @else {
<div class="form-page">
<header class="page-heading form-heading">
<div>
<button mat-button type="button" class="back-action" (click)="closeForm()">
← Retour
</button>
<p class="eyebrow">Portefeuille</p>
<h1>
{{ editingApartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}
</h1>
<p>Renseignez les informations principales du bien.</p>
</div>
</header>
<form (submit)="saveApartment(); $event.preventDefault()" class="apartment-form">
@if (formError) {
<div class="alert-error" role="alert">{{ formError }}</div>
}
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Nom de l'appartement</mat-label>
<input
matInput
type="text"
name="name"
[(ngModel)]="formName"
placeholder="T2 Paris 15"
required
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Prix d'acquisition</mat-label>
<input
matInput
type="number"
name="purchasePrice"
[(ngModel)]="formPurchasePrice"
min="1"
required
/>
<span matTextSuffix>€</span>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Adresse complète</mat-label>
<input
matInput
type="text"
name="address"
[(ngModel)]="formAddress"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Description ou notes</mat-label>
<textarea
matInput
name="description"
[(ngModel)]="formDescription"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closeForm()">Annuler</button>
<button mat-flat-button type="submit" class="primary-action">
Enregistrer
</button>
</div>
</form>
</div>
}
</section>
<app-apartments-page
[apartments]="apartments()"
(deleteRequested)="deleteApartment($event)"
(apartmentSaved)="saveApartment($event)"
/>
}
@if (activeTab() === 'settings') {
<section class="page-section settings-page">
<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>
<app-settings-page
[username]="username"
[email]="email"
[themePreference]="themePreference()"
(themePreferenceChanged)="themeService.setPreference($event)"
(logoutRequested)="logout()"
/>
}
</div>
</main>