feat(frontend): add mobile-first Material redesign (#17)
This commit is contained in:
1 parent
fd3fc0123a
commit
1a70bcd126
15 files changed
+1781
-721
No files matched your search
+842
-503
File diff suppressed because it is too large.
Load diff
+442
-180
@@ -1,208 +1,470 @@
|
||||
<div class="app-container">
|
||||
<div class="background-decorations">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
</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>
|
||||
<p class="subtitle">Votre assistant de gestion et fiscalité immobilière</p>
|
||||
</header>
|
||||
|
||||
<section class="card-body">
|
||||
@if (!isLoggedIn) {
|
||||
<div class="auth-state locked">
|
||||
<div class="shield-icon">
|
||||
<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">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Connexion Sécurisée Requise</h2>
|
||||
<p>Connectez-vous avec Keycloak / voidAuth pour accéder à votre espace de gestion locative.</p>
|
||||
<button (click)="login()" class="btn btn-primary btn-glow">
|
||||
<span>Se connecter avec OIDC</span>
|
||||
<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">
|
||||
<path d="M5 12h14"/>
|
||||
<path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
@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>
|
||||
} @else {
|
||||
<div class="auth-state unlocked">
|
||||
<!-- Multi-Tab Navigation Menu -->
|
||||
<nav class="tab-nav">
|
||||
<button (click)="activeTab.set('dashboard')" [class.active]="activeTab() === 'dashboard'" class="tab-btn">
|
||||
<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">
|
||||
<rect width="7" height="9" x="3" y="3" rx="1"/>
|
||||
<rect width="7" height="5" x="14" y="3" rx="1"/>
|
||||
<rect width="7" height="9" x="14" y="12" rx="1"/>
|
||||
<rect width="7" height="5" x="3" y="16" rx="1"/>
|
||||
</svg>
|
||||
<span>Tableau de Bord</span>
|
||||
</button>
|
||||
<button (click)="activeTab.set('apartments')" [class.active]="activeTab() === 'apartments'" class="tab-btn">
|
||||
<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">
|
||||
<path d="M2 22h20"/>
|
||||
<path d="M4 22V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v18"/>
|
||||
<path d="M12 18h.01"/>
|
||||
<path d="M8 6h2"/>
|
||||
<path d="M14 6h2"/>
|
||||
<path d="M8 10h2"/>
|
||||
<path d="M14 10h2"/>
|
||||
<path d="M8 14h2"/>
|
||||
<path d="M14 14h2"/>
|
||||
</svg>
|
||||
<span>Appartements</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Dashboard -->
|
||||
@if (activeTab() === 'dashboard') {
|
||||
<div class="tab-pane">
|
||||
<div class="user-profile">
|
||||
<div class="avatar">
|
||||
{{ username.charAt(0).toUpperCase() }}
|
||||
<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>
|
||||
} @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>
|
||||
|
||||
<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="badge badge-success">Bailleur Connecté</span>
|
||||
<span class="role-chip">Bailleur connecté</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<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>
|
||||
<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 (click)="callHelloApi()" class="btn btn-secondary">
|
||||
Appeler /api/hello
|
||||
<button mat-stroked-button type="button" (click)="callHelloApi()">
|
||||
Tester la connexion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (helloMessage()) {
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Tab Content: Apartments -->
|
||||
@if (activeTab() === 'apartments') {
|
||||
<div class="tab-pane">
|
||||
@if (!showForm()) {
|
||||
<div class="section-header">
|
||||
<h2>Mon Portefeuille de Biens</h2>
|
||||
<button (click)="openAddForm()" class="btn btn-primary btn-sm">
|
||||
<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">
|
||||
<path d="M5 12h14"/>
|
||||
<path d="M12 5v14"/>
|
||||
</svg>
|
||||
<span>Ajouter un appartement</span>
|
||||
</button>
|
||||
@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) {
|
||||
<div 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">
|
||||
<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"/>
|
||||
@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>
|
||||
<h3>Aucun appartement enregistré</h3>
|
||||
<p>Commencez par ajouter votre premier bien immobilier LMNP à votre portefeuille.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="apartments-grid">
|
||||
@for (apt of apartments(); track apt.id) {
|
||||
<div class="apartment-card">
|
||||
<div class="apt-info">
|
||||
<h3>{{ apt.name }}</h3>
|
||||
<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">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
{{ apt.address }}
|
||||
</p>
|
||||
<p class="apt-price">{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</p>
|
||||
@if (apt.description) {
|
||||
<p class="apt-desc">{{ apt.description }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="apt-actions">
|
||||
<button (click)="openEditForm(apt)" class="btn btn-outline btn-xs btn-warning">
|
||||
Modifier
|
||||
</button>
|
||||
@if (apt.id) {
|
||||
<button (click)="deleteApartment(apt.id)" class="btn btn-outline btn-xs btn-danger">
|
||||
Supprimer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</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 {
|
||||
<div class="form-container">
|
||||
<h2>{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}</h2>
|
||||
|
||||
@if (formError) {
|
||||
<div class="alert alert-error">
|
||||
{{ formError }}
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
|
||||
<form (submit)="saveApartment(); $event.preventDefault()" class="crud-form">
|
||||
<div class="form-group">
|
||||
<label for="aptName">Nom de l'appartement *</label>
|
||||
<input type="text" id="aptName" name="name" [(ngModel)]="formName" placeholder="Ex. T2 Cosy Paris 15" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aptAddress">Adresse complète *</label>
|
||||
<input type="text" id="aptAddress" name="address" [(ngModel)]="formAddress" placeholder="Ex. 45 rue des Entrepreneurs, 75015 Paris" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aptPrice">Prix d'acquisition (€) *</label>
|
||||
<input type="number" id="aptPrice" name="purchasePrice" [(ngModel)]="formPurchasePrice" min="1" placeholder="Ex. 180000" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aptDesc">Description / Notes</label>
|
||||
<textarea id="aptDesc" name="description" [(ngModel)]="formDescription" rows="3" placeholder="Ex. T2 en rez-de-chaussée, meublé, cuisine équipée."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" (click)="closeForm()" class="btn btn-outline">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
|
||||
<button (click)="logout()" class="btn btn-outline btn-logout">
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
<form (submit)="saveApartment(); $event.preventDefault()" class="apartment-form">
|
||||
@if (formError) {
|
||||
<div class="alert-error" role="alert">{{ formError }}</div>
|
||||
}
|
||||
|
||||
<footer class="card-footer">
|
||||
<p>LMNP Organizer © 2026. Sécurisé avec OpenID Connect et Spring Boot 4.</p>
|
||||
</footer>
|
||||
</main>
|
||||
</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>
|
||||
}
|
||||
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
+44
-26
@@ -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 { ApartmentService, Apartment } from './apartment.service';
|
||||
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({
|
||||
selector: 'app-root',
|
||||
imports: [FormsModule],
|
||||
imports: [
|
||||
BrandMark,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatRadioModule,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App implements OnInit {
|
||||
protected readonly title = signal('LMNP Organizer');
|
||||
readonly helloMessage = signal<string>('');
|
||||
|
||||
// Tab & CRUD States
|
||||
readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard');
|
||||
readonly activeTab = signal<'dashboard' | 'apartments' | 'settings'>('dashboard');
|
||||
readonly apartments = signal<Apartment[]>([]);
|
||||
readonly showForm = signal<boolean>(false);
|
||||
readonly editingApartment = signal<Apartment | null>(null);
|
||||
@@ -29,9 +42,12 @@ export class App implements OnInit {
|
||||
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);
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
readonly themePreference = this.themeService.preference;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -42,25 +58,27 @@ export class App implements OnInit {
|
||||
}
|
||||
|
||||
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.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('');
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching user authentication status', err);
|
||||
this._isLoggedIn.set(false);
|
||||
this._username.set('');
|
||||
this._email.set('');
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get isLoggedIn(): boolean {
|
||||
@@ -89,7 +107,7 @@ export class App implements OnInit {
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
this.helloMessage.set('Failed to connect to backend: ' + err.statusText);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +115,7 @@ export class App implements OnInit {
|
||||
loadApartments() {
|
||||
this.apartmentService.getApartments().subscribe({
|
||||
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(),
|
||||
address: this.formAddress.trim(),
|
||||
purchasePrice: this.formPurchasePrice,
|
||||
description: this.formDescription.trim()
|
||||
description: this.formDescription.trim(),
|
||||
};
|
||||
|
||||
const editMode = this.editingApartment();
|
||||
@@ -149,7 +167,7 @@ export class App implements OnInit {
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
this.formError = 'Erreur lors de la modification.';
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.apartmentService.createApartment(apartmentData).subscribe({
|
||||
@@ -160,7 +178,7 @@ export class App implements OnInit {
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
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 ?')) {
|
||||
this.apartmentService.deleteApartment(id).subscribe({
|
||||
next: () => this.loadApartments(),
|
||||
error: (err) => console.error('Error deleting apartment', err)
|
||||
error: (err) => console.error('Error deleting apartment', err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user