diff --git a/CONTEXT.md b/CONTEXT.md index 55cce10..45965f2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -260,7 +260,9 @@ _Avoid_: Tâche, rappel Personne physique ou entité fiscale (ex. indivision) propriétaire des appartements, titulaire des prêts et émettrice des baux. _Avoid_: Propriétaire, bailleur social -La première version du profil bailleur ne couvre qu'une personne physique : prénom, nom, e-mail, téléphone et adresse postale. Chaque profil est lié au sujet OIDC (`sub`) de son compte ; l'e-mail est une coordonnée modifiable mais unique. Chaque bailleur possède un portefeuille isolé des autres comptes. Le profil est modifiable depuis les réglages. Ses coordonnées sont reprises dans leur intégralité dans les baux et les quittances de loyer. Aucun de ces PDF ne peut être généré tant que le profil n'est pas complet. +**Profil bailleur** (Code: `LandlordProfile`): +Coordonnées personnelles du bailleur — prénom, nom, e-mail, téléphone et adresse postale — complètes seulement lorsque les cinq sont renseignées. Elles sont liées au sujet OIDC (`sub`) de son compte ; l'e-mail est modifiable mais unique, le portefeuille reste isolé et les coordonnées sont reprises dans les baux et quittances, qui ne peuvent être générés avant leur complétion. +_Avoid_: Compte OIDC, profil utilisateur **Liasse fiscale** (Code: `TaxDeclarationPack`): Ensemble des documents comptables déclaratifs (formulaires Cerfa 2033-A à 2033-G en France) à transmettre annuellement à l'administration fiscale au régime réel. diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts index 7e3a9e0..7be1284 100644 --- a/frontend/e2e/app.spec.ts +++ b/frontend/e2e/app.spec.ts @@ -1,7 +1,8 @@ import { expect, Page, test } from '@playwright/test'; -async function mockUser(page: Page, authenticated: boolean): Promise { +async function mockUser(page: Page, authenticated: boolean, completeProfile = true): Promise { await page.unroute('**/api/user'); + await page.unroute('**/api/landlord-profile'); await page.route('**/api/user', async (route) => { await route.fulfill({ contentType: 'application/json', @@ -10,6 +11,23 @@ async function mockUser(page: Page, authenticated: boolean): Promise { : { authenticated: false, username: '', email: '' }, }); }); + if (!authenticated) return; + + const profile = { + firstName: 'Alice', + lastName: 'Martin', + email: 'alice@example.com', + phone: completeProfile ? '+33600000000' : '', + address: completeProfile ? '1 rue de Paris' : '', + complete: completeProfile, + version: 0, + }; + await page.route('**/api/landlord-profile', async (route) => { + if (route.request().method() === 'PUT') { + Object.assign(profile, route.request().postDataJSON(), { complete: true, version: 1 }); + } + await route.fulfill({ contentType: 'application/json', json: profile }); + }); } async function mockEmptyApartmentList(page: Page): Promise { @@ -49,6 +67,25 @@ test('starts the OIDC login flow from the sign-in screen', async ({ page }) => { await expect(page).toHaveURL(/\/oauth2\/authorization\/oidc$/); }); +test('requires a newly connected landlord to complete their profile', async ({ page }) => { + await mockUser(page, true, false); + + await page.goto('/'); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('heading', { name: 'Complétez votre profil' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Déconnexion' })).toBeVisible(); + + await dialog.getByLabel('Téléphone').fill('+33612345678'); + await dialog.getByLabel('Adresse postale').fill('1 rue des Lilas, Lyon'); + await dialog.getByRole('button', { name: 'Enregistrer le profil' }).click(); + + await expect(dialog).toBeHidden(); + await expect(page.getByTestId('primary-navigation')).toBeVisible(); +}); + test('persists a dark appearance preference from Settings', async ({ page }) => { await mockUser(page, true); await mockEmptyApartmentList(page); diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 0ed1c2e..dec48ae 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,9 +1,17 @@ -@if (session.status() === 'checking' || session.status() === 'signing-out') { +@if ( + session.status() === 'checking' || + session.status() === 'signing-out' || + (session.isLoggedIn() && profileCheckStatus() === 'checking') +) {
{{ - session.status() === 'signing-out' ? 'Déconnexion…' : 'Vérification de votre session…' + session.status() === 'signing-out' + ? 'Déconnexion…' + : profileCheckStatus() === 'checking' + ? 'Vérification de votre profil…' + : 'Vérification de votre session…' }}
} @else if (session.status() === 'error') { diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index 915a6dd..12e2b50 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -1,7 +1,11 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; import { provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; import { App } from './app'; +import { LandlordProfileService } from './services/landlord-profile.service'; import { SessionService, SessionStatus } from './services/session.service'; describe('App', () => { @@ -14,6 +18,8 @@ describe('App', () => { refresh: vi.fn(), logout: vi.fn(), }; + const landlordProfile = { getProfile: vi.fn() }; + const dialog = { open: vi.fn() }; beforeEach(() => { status.set('checking'); @@ -21,8 +27,24 @@ describe('App', () => { session.username.set(''); session.email.set(''); vi.clearAllMocks(); + landlordProfile.getProfile.mockReturnValue( + of({ + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '+33600000000', + address: '1 rue de Paris', + complete: true, + version: 0, + }), + ); TestBed.configureTestingModule({ - providers: [provideRouter([]), { provide: SessionService, useValue: session }], + providers: [ + provideRouter([]), + { provide: SessionService, useValue: session }, + { provide: LandlordProfileService, useValue: landlordProfile }, + { provide: MatDialog, useValue: dialog }, + ], }); }); @@ -64,4 +86,54 @@ describe('App', () => { expect(fixture.nativeElement.querySelector('app-workspace-navigation')).not.toBeNull(); expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull(); }); + + it('opens an unclosable profile-completion dialog for an incomplete landlord profile', () => { + status.set('authenticated'); + session.isLoggedIn.set(true); + landlordProfile.getProfile.mockReturnValueOnce( + of({ + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + complete: false, + version: 0, + }), + ); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(dialog.open).toHaveBeenCalledOnce(); + expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), { + disableClose: true, + closeOnNavigation: false, + data: expect.objectContaining({ + profile: expect.objectContaining({ phone: '', address: '' }), + }), + }); + }); + + it('does not open the profile-completion dialog for a complete landlord profile', () => { + status.set('authenticated'); + session.isLoggedIn.set(true); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(dialog.open).not.toHaveBeenCalled(); + }); + + it('keeps profile completion available when the initial profile request fails', () => { + status.set('authenticated'); + session.isLoggedIn.set(true); + landlordProfile.getProfile.mockReturnValueOnce(throwError(() => new Error('offline'))); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), { + disableClose: true, + closeOnNavigation: false, + data: { profile: null }, + }); + }); }); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 1955af6..d23fe3c 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,11 +1,16 @@ -import { Component, computed, inject, OnInit } from '@angular/core'; +import { Component, computed, effect, inject, OnInit, signal, untracked } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialogModule } from '@angular/material/dialog'; +import { MatDialog } from '@angular/material/dialog'; import { RouterOutlet } from '@angular/router'; import { BrandMark } from './components/brand-mark'; +import { LandlordProfileCompletionDialog } from './components/landlord-profile-completion-dialog/landlord-profile-completion-dialog'; import { SignInPage } from './components/sign-in-page/sign-in-page'; import { WorkspaceNavigation } from './components/workspace-navigation/workspace-navigation'; +import { + LandlordProfileResponse, + LandlordProfileService, +} from './services/landlord-profile.service'; import { SessionService } from './services/session.service'; import { ThemeService } from './services/theme.service'; @@ -18,7 +23,6 @@ import { ThemeService } from './services/theme.service'; RouterOutlet, MatProgressSpinnerModule, MatButtonModule, - MatDialogModule, ], templateUrl: './app.html', styleUrl: './app.css', @@ -27,6 +31,32 @@ export class App implements OnInit { readonly session = inject(SessionService); readonly theme = inject(ThemeService); readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase()); + private readonly dialog = inject(MatDialog); + private readonly landlordProfile = inject(LandlordProfileService); + readonly profileCheckStatus = signal<'idle' | 'checking' | 'checked'>('idle'); + + constructor() { + effect((onCleanup) => { + if (!this.session.isLoggedIn()) { + this.profileCheckStatus.set('idle'); + return; + } + if (untracked(this.profileCheckStatus) !== 'idle') return; + + this.profileCheckStatus.set('checking'); + const profileRequest = this.landlordProfile.getProfile().subscribe({ + next: (profile) => { + this.profileCheckStatus.set('checked'); + if (!profile.complete) this.openProfileCompletion(profile); + }, + error: () => { + this.profileCheckStatus.set('checked'); + this.openProfileCompletion(null); + }, + }); + onCleanup(() => profileRequest.unsubscribe()); + }); + } ngOnInit() { this.session.refresh(); @@ -39,4 +69,12 @@ export class App implements OnInit { logout() { this.session.logout(); } + + private openProfileCompletion(profile: LandlordProfileResponse | null): void { + this.dialog.open(LandlordProfileCompletionDialog, { + disableClose: true, + closeOnNavigation: false, + data: { profile }, + }); + } } diff --git a/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.css b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.css new file mode 100644 index 0000000..0b5db33 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.css @@ -0,0 +1,30 @@ +.profile-dialog-content { + display: grid; + width: min(36rem, calc(100vw - 3rem)); + max-width: 100%; + gap: var(--space-4, 1rem); + max-height: 75vh; + overscroll-behavior-y: contain; + container-type: inline-size; +} + +.profile-dialog-content > p, +.profile-error p { + margin: 0; + color: var(--text-muted, #687670); + line-height: 1.5; +} + +.profile-loading, +.profile-error { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3, 0.75rem); + min-height: 3rem; +} + +.profile-error { + align-items: flex-start; + flex-direction: column; +} diff --git a/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.html b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.html new file mode 100644 index 0000000..d7c65a8 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.html @@ -0,0 +1,25 @@ +

Complétez votre profil

+ +

Vos coordonnées sont nécessaires pour créer vos baux et vos quittances.

+ + @if (profileStatus() === 'loading') { +
+
+ } @else if (profileStatus() === 'error') { +
+

{{ error() }}

+ +
+ } @else { + + } +
+ + + diff --git a/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.spec.ts b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.spec.ts new file mode 100644 index 0000000..1d20414 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.spec.ts @@ -0,0 +1,142 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { LandlordProfileService } from '../../services/landlord-profile.service'; +import { SessionService } from '../../services/session.service'; +import { + LandlordProfileCompletionDialog, + LandlordProfileCompletionDialogData, +} from './landlord-profile-completion-dialog'; + +describe('LandlordProfileCompletionDialog', () => { + const profile = { getProfile: vi.fn(), updateProfile: vi.fn() }; + const dialogRef = { close: vi.fn() }; + const session = { logout: vi.fn() }; + const data: LandlordProfileCompletionDialogData = { + profile: { + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + complete: false, + version: 0, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + data.profile = { + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + complete: false, + version: 0, + }; + TestBed.configureTestingModule({ + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: LandlordProfileService, useValue: profile }, + { provide: SessionService, useValue: session }, + ], + }); + }); + + it('saves the completed profile before closing the dialog', () => { + profile.updateProfile.mockReturnValue( + of({ ...data.profile, phone: '+33600000000', address: '1 rue A', complete: true }), + ); + const fixture = TestBed.createComponent(LandlordProfileCompletionDialog); + const component = fixture.componentInstance; + fixture.detectChanges(); + component.profile.update((current) => ({ + ...current, + phone: '+33600000000', + address: '1 rue A', + })); + + component.save(component.profile()); + + expect(profile.updateProfile).toHaveBeenCalledWith({ + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '+33600000000', + address: '1 rue A', + }); + expect(dialogRef.close).toHaveBeenCalledOnce(); + }); + + it('keeps the dialog open and explains a rejected save', () => { + profile.updateProfile.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + error: { message: 'Cette adresse e-mail est déjà utilisée.' }, + status: 409, + }), + ), + ); + const fixture = TestBed.createComponent(LandlordProfileCompletionDialog); + const component = fixture.componentInstance; + fixture.detectChanges(); + component.profile.update((current) => ({ + ...current, + phone: '+33600000000', + address: '1 rue A', + })); + + component.save(component.profile()); + fixture.detectChanges(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.'); + expect(fixture.nativeElement.textContent).toContain('Réessayer'); + }); + + it('retries an initial profile load without releasing the dialog', () => { + data.profile = null; + profile.getProfile.mockReturnValue( + of({ + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + complete: false, + version: 0, + }), + ); + const fixture = TestBed.createComponent(LandlordProfileCompletionDialog); + const component = fixture.componentInstance; + fixture.detectChanges(); + + component.retry(); + fixture.detectChanges(); + + expect(profile.getProfile).toHaveBeenCalledOnce(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('input#profile-phone').value).toBe(''); + }); + + it('keeps the load error visible when a retry fails', () => { + data.profile = null; + profile.getProfile.mockReturnValue(throwError(() => new Error('offline'))); + const fixture = TestBed.createComponent(LandlordProfileCompletionDialog); + const component = fixture.componentInstance; + fixture.detectChanges(); + + component.retry(); + fixture.detectChanges(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain( + 'Impossible de charger votre profil bailleur. Réessayez.', + ); + }); +}); diff --git a/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.ts b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.ts new file mode 100644 index 0000000..73dd7a7 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-completion-dialog/landlord-profile-completion-dialog.ts @@ -0,0 +1,94 @@ +import { Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form'; +import { + LandlordProfile, + LandlordProfileResponse, + LandlordProfileService, +} from '../../services/landlord-profile.service'; +import { httpErrorMessage } from '../../services/http-error-message'; +import { SessionService } from '../../services/session.service'; + +export interface LandlordProfileCompletionDialogData { + profile: LandlordProfileResponse | null; +} + +@Component({ + selector: 'app-landlord-profile-completion-dialog', + imports: [MatButtonModule, MatDialogModule, MatProgressSpinnerModule, LandlordProfileForm], + templateUrl: './landlord-profile-completion-dialog.html', + styleUrl: './landlord-profile-completion-dialog.css', +}) +export class LandlordProfileCompletionDialog { + readonly data = inject(MAT_DIALOG_DATA); + private readonly dialogRef = inject(MatDialogRef); + private readonly landlordProfile = inject(LandlordProfileService); + private readonly session = inject(SessionService); + private readonly destroyRef = inject(DestroyRef); + + readonly profileStatus = signal<'ready' | 'loading' | 'error'>( + this.data.profile ? 'ready' : 'error', + ); + readonly saving = signal(false); + readonly error = signal( + this.data.profile ? '' : 'Impossible de charger votre profil bailleur. Réessayez.', + ); + readonly profile = signal(this.profileFields(this.data.profile)); + + retry(): void { + this.profileStatus.set('loading'); + this.error.set(''); + this.landlordProfile + .getProfile() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (profile) => { + if (profile.complete) { + this.dialogRef.close(); + return; + } + this.profile.set(this.profileFields(profile)); + this.profileStatus.set('ready'); + }, + error: () => { + this.profileStatus.set('error'); + this.error.set('Impossible de charger votre profil bailleur. Réessayez.'); + }, + }); + } + + save(profile: LandlordProfile): void { + if (this.saving()) return; + this.error.set(''); + this.saving.set(true); + this.landlordProfile + .updateProfile(profile) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.dialogRef.close(), + error: (error: unknown) => { + this.error.set( + httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'), + ); + this.saving.set(false); + }, + }); + } + + logout(): void { + this.session.logout(); + } + + private profileFields(profile: LandlordProfile | null): LandlordProfile { + return { + firstName: profile?.firstName ?? '', + lastName: profile?.lastName ?? '', + email: profile?.email ?? '', + phone: profile?.phone ?? '', + address: profile?.address ?? '', + }; + } +} diff --git a/frontend/src/app/components/landlord-profile-form/landlord-profile-form.css b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.css new file mode 100644 index 0000000..fe367b6 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.css @@ -0,0 +1,43 @@ +:host { + display: block; + container-type: inline-size; +} + +.profile-form { + display: grid; + gap: var(--space-4, 1rem); +} + +.profile-error { + margin: 0; + color: var(--danger, #b42318); +} + +.profile-fields { + display: grid; + min-width: 0; + margin: 0; + padding: 0; + border: 0; + gap: var(--space-1, 0.25rem) var(--space-3, 0.75rem); +} + +.profile-fields mat-form-field { + width: 100%; +} + +.profile-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; +} + +@container (min-width: 32rem) { + .profile-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .profile-fields .full-field { + grid-column: 1 / -1; + } +} diff --git a/frontend/src/app/components/landlord-profile-form/landlord-profile-form.html b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.html new file mode 100644 index 0000000..5d405c9 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.html @@ -0,0 +1,65 @@ +
+ @if (error()) { + + } +
+ Prénom + Nom + E-mail + Téléphone + Adresse postale +
+
+ +
+
diff --git a/frontend/src/app/components/landlord-profile-form/landlord-profile-form.spec.ts b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.spec.ts new file mode 100644 index 0000000..2d38fd1 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.spec.ts @@ -0,0 +1,53 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { LandlordProfileForm } from './landlord-profile-form'; + +describe('LandlordProfileForm', () => { + it('explains incomplete profile data without requesting a save', () => { + const fixture = TestBed.createComponent(LandlordProfileForm); + const component = fixture.componentInstance; + const saveRequested = vi.fn(); + component.saveRequested.subscribe(saveRequested); + fixture.componentRef.setInput('profile', { + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + }); + fixture.detectChanges(); + + component.submit(); + fixture.detectChanges(); + + expect(saveRequested).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain( + 'Veuillez renseigner tous les champs obligatoires.', + ); + }); + + it('emits a complete profile for saving', () => { + const fixture = TestBed.createComponent(LandlordProfileForm); + const component = fixture.componentInstance; + const saveRequested = vi.fn(); + component.saveRequested.subscribe(saveRequested); + fixture.componentRef.setInput('profile', { + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '+33600000000', + address: '1 rue de Paris', + }); + fixture.detectChanges(); + + component.submit(); + + expect(saveRequested).toHaveBeenCalledWith({ + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '+33600000000', + address: '1 rue de Paris', + }); + }); +}); diff --git a/frontend/src/app/components/landlord-profile-form/landlord-profile-form.ts b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.ts new file mode 100644 index 0000000..83196c1 --- /dev/null +++ b/frontend/src/app/components/landlord-profile-form/landlord-profile-form.ts @@ -0,0 +1,68 @@ +import { Component, computed, effect, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { email, form, FormField, FormRoot, required } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { LandlordProfile } from '../../services/landlord-profile.service'; + +const EMPTY_PROFILE: LandlordProfile = { + firstName: '', + lastName: '', + email: '', + phone: '', + address: '', +}; + +@Component({ + selector: 'app-landlord-profile-form', + imports: [ + FormsModule, + FormRoot, + FormField, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + ], + templateUrl: './landlord-profile-form.html', + styleUrl: './landlord-profile-form.css', +}) +export class LandlordProfileForm { + readonly profile = input(EMPTY_PROFILE); + readonly saving = input(false); + readonly saveError = input(''); + readonly saveRequested = output(); + + readonly profileModel = signal({ ...this.profile() }); + readonly profileForm = form(this.profileModel, (f) => { + required(f.firstName, { message: 'Veuillez renseigner tous les champs obligatoires.' }); + required(f.lastName, { message: 'Veuillez renseigner tous les champs obligatoires.' }); + required(f.email, { message: 'Veuillez renseigner tous les champs obligatoires.' }); + required(f.phone, { message: 'Veuillez renseigner tous les champs obligatoires.' }); + required(f.address, { message: 'Veuillez renseigner tous les champs obligatoires.' }); + email(f.email, { message: 'Saisissez une adresse e-mail valide.' }); + }); + private readonly validationError = signal(''); + readonly error = computed(() => this.validationError() || this.saveError()); + + constructor() { + effect(() => this.profileModel.set({ ...this.profile() })); + } + + submit(): void { + if (this.saving()) return; + + this.profileForm().markAsTouched(); + if (this.profileForm().invalid()) { + this.validationError.set( + this.profileForm().errorSummary()[0]?.message ?? 'Saisissez des informations valides.', + ); + return; + } + + this.validationError.set(''); + this.saveRequested.emit(this.profileModel()); + } +} diff --git a/frontend/src/app/components/settings-page/settings-page.css b/frontend/src/app/components/settings-page/settings-page.css index b3bd05c..a343829 100644 --- a/frontend/src/app/components/settings-page/settings-page.css +++ b/frontend/src/app/components/settings-page/settings-page.css @@ -90,7 +90,6 @@ flex-direction: column; } .profile-message .alert-error, -.profile-form .alert-error, .profile-success { margin: 0 0 16px; } @@ -98,19 +97,6 @@ color: var(--accent-strong, #0a5f59); font-weight: 600; } -.profile-fields { - display: grid; - gap: 4px 12px; -} -.profile-fields mat-form-field { - width: 100%; -} -.profile-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - margin-top: 8px; -} .session-action { display: flex; flex-direction: column; @@ -152,10 +138,4 @@ align-items: flex-start; justify-content: center; } - .profile-fields { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .profile-fields .full-field { - grid-column: 1 / -1; - } } diff --git a/frontend/src/app/components/settings-page/settings-page.html b/frontend/src/app/components/settings-page/settings-page.html index d40b548..5f1ba71 100644 --- a/frontend/src/app/components/settings-page/settings-page.html +++ b/frontend/src/app/components/settings-page/settings-page.html @@ -1,99 +1,94 @@
-
-
-

Préférences

-

Réglages

-

Adaptez LMNP Organizer à votre façon de travailler.

-
-
-
-
- -
-

Interface

-

Apparence

-

Le réglage est mémorisé uniquement sur cet appareil.

-
-
- - ClairUne interface - lumineuse et douce. - SombreUn contraste - réduit en faible lumière. - SystèmeSuit - automatiquement votre appareil. - -
-
-
- -
-

Bailleur

-

Profil personnel

-

Ces coordonnées sont utilisées dans vos baux et vos quittances.

-
-
-
- @if (profileStatus() === 'loading') { -
-
- } @else if (profileStatus() === 'error') { -
- - -
- } @else { -
- @if (saveError()) { - - } - @if (saveSuccess()) { -

{{ saveSuccess() }}

- } -
-
- Prénom - Nom - E-mail - Téléphone - Adresse - postale -
-
- -
-
-
- } -
-
-
-
- -
-

Compte

-

Session

-

Gérez la session ouverte sur cet appareil.

-
-
-
-
- {{ session.username() }}{{ session.email() }} -
- -
-
-
\ No newline at end of file +
+
+

Préférences

+

Réglages

+

Adaptez LMNP Organizer à votre façon de travailler.

+
+
+
+
+ +
+

Interface

+

Apparence

+

Le réglage est mémorisé uniquement sur cet appareil.

+
+
+ + ClairUne interface lumineuse et douce. + SombreUn contraste réduit en faible lumière. + SystèmeSuit automatiquement votre appareil. + +
+
+
+ +
+

Bailleur

+

Profil personnel

+

Ces coordonnées sont utilisées dans vos baux et vos quittances.

+
+
+
+ @if (profileStatus() === 'loading') { +
+
+ } @else if (profileStatus() === 'error') { +
+ + +
+ } @else { + @if (saveSuccess()) { +

{{ saveSuccess() }}

+ } + + } +
+
+
+
+ +
+

Compte

+

Session

+

Gérez la session ouverte sur cet appareil.

+
+
+
+
+ {{ session.username() }}{{ session.email() }} +
+ +
+
+ diff --git a/frontend/src/app/components/settings-page/settings-page.spec.ts b/frontend/src/app/components/settings-page/settings-page.spec.ts index aa591a4..6a6426d 100644 --- a/frontend/src/app/components/settings-page/settings-page.spec.ts +++ b/frontend/src/app/components/settings-page/settings-page.spec.ts @@ -48,24 +48,13 @@ describe('SettingsPage', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(fixture.nativeElement.querySelector('input#firstName').value).toBe('Camille'); - expect(fixture.nativeElement.querySelector('input#email').value).toBe('camille@example.com'); - expect(fixture.nativeElement.querySelector('input#address').value).toBe('1 rue de Paris'); - }); - - it('shows a useful error instead of saving an incomplete profile', () => { - const fixture = TestBed.createComponent(SettingsPage); - fixture.detectChanges(); - const component = fixture.componentInstance; - component.profileModel.update((p) => ({ ...p, phone: '' })); - - component.save(); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain( - 'Veuillez renseigner tous les champs obligatoires.', + expect(fixture.nativeElement.querySelector('input#profile-firstName').value).toBe('Camille'); + expect(fixture.nativeElement.querySelector('input#profile-email').value).toBe( + 'camille@example.com', + ); + expect(fixture.nativeElement.querySelector('input#profile-address').value).toBe( + '1 rue de Paris', ); - expect(profile.updateProfile).not.toHaveBeenCalled(); }); it('keeps the entered values and explains a rejected profile save', async () => { @@ -80,10 +69,13 @@ describe('SettingsPage', () => { ); const fixture = TestBed.createComponent(SettingsPage); fixture.detectChanges(); - const component = fixture.componentInstance; - component.profileModel.update((p) => ({ ...p, email: 'alex@example.com' })); + await fixture.whenStable(); + const email = fixture.nativeElement.querySelector('input#profile-email') as HTMLInputElement; + email.value = 'alex@example.com'; + email.dispatchEvent(new Event('input')); + fixture.detectChanges(); - component.save(); + fixture.nativeElement.querySelector('button.primary-action').click(); fixture.detectChanges(); await fixture.whenStable(); @@ -95,6 +87,8 @@ describe('SettingsPage', () => { address: '1 rue de Paris', }); expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.'); - expect(fixture.nativeElement.querySelector('input#email').value).toBe('alex@example.com'); + expect(fixture.nativeElement.querySelector('input#profile-email').value).toBe( + 'alex@example.com', + ); }); }); diff --git a/frontend/src/app/components/settings-page/settings-page.ts b/frontend/src/app/components/settings-page/settings-page.ts index 99551a3..f0aea60 100644 --- a/frontend/src/app/components/settings-page/settings-page.ts +++ b/frontend/src/app/components/settings-page/settings-page.ts @@ -1,14 +1,12 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faAddressCard, faMoon, faUser } from '@fortawesome/free-solid-svg-icons'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatRadioModule } from '@angular/material/radio'; -import { FormsModule } from '@angular/forms'; -import { FormRoot, FormField, form, required, email } from '@angular/forms/signals'; -import { HttpErrorResponse } from '@angular/common/http'; +import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form'; +import { httpErrorMessage } from '../../services/http-error-message'; import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service'; import { SessionService } from '../../services/session.service'; import { ThemeService } from '../../services/theme.service'; @@ -17,14 +15,10 @@ import { ThemeService } from '../../services/theme.service'; selector: 'app-settings-page', imports: [ FontAwesomeModule, - FormsModule, - FormRoot, - FormField, MatButtonModule, - MatFormFieldModule, - MatInputModule, MatProgressSpinnerModule, MatRadioModule, + LandlordProfileForm, ], templateUrl: './settings-page.html', styleUrl: './settings-page.css', @@ -36,6 +30,7 @@ export class SettingsPage { readonly session = inject(SessionService); readonly theme = inject(ThemeService); private readonly landlordProfile = inject(LandlordProfileService); + private readonly destroyRef = inject(DestroyRef); readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading'); readonly saving = signal(false); @@ -48,15 +43,6 @@ export class SettingsPage { address: '', }); - readonly profileForm = form(this.profileModel, (f) => { - required(f.firstName, { message: 'Veuillez renseigner tous les champs obligatoires.' }); - required(f.lastName, { message: 'Veuillez renseigner tous les champs obligatoires.' }); - required(f.email, { message: 'Veuillez renseigner tous les champs obligatoires.' }); - required(f.phone, { message: 'Veuillez renseigner tous les champs obligatoires.' }); - required(f.address, { message: 'Veuillez renseigner tous les champs obligatoires.' }); - email(f.email, { message: 'Saisissez une adresse e-mail valide.' }); - }); - readonly profileError = signal(''); readonly saveError = signal(''); readonly saveSuccess = signal(''); @@ -72,57 +58,41 @@ export class SettingsPage { loadProfile(): void { this.profileStatus.set('loading'); this.profileError.set(''); - this.landlordProfile.getProfile().subscribe({ - next: (profile) => { - this.profileModel.set(profile); - this.profileStatus.set('ready'); - }, - error: () => { - this.profileStatus.set('error'); - this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.'); - }, - }); + this.landlordProfile + .getProfile() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (profile) => { + this.profileModel.set(profile); + this.profileStatus.set('ready'); + }, + error: () => { + this.profileStatus.set('error'); + this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.'); + }, + }); } - save(): void { + save(profile: LandlordProfile): void { if (this.saving()) return; this.saveSuccess.set(''); - - this.profileForm().markAsTouched(); - if (this.profileForm().invalid()) { - const errors = this.profileForm().errorSummary(); - this.saveError.set(errors[0]?.message ?? 'Saisissez des informations valides.'); - return; - } - this.saveError.set(''); this.saving.set(true); - this.landlordProfile.updateProfile(this.profileModel()).subscribe({ - next: (savedProfile) => { - this.profileModel.set(savedProfile); - this.saveSuccess.set('Profil bailleur enregistré.'); - this.saving.set(false); - }, - error: (error: unknown) => { - this.saveError.set(this.saveErrorMessage(error)); - this.saving.set(false); - }, - }); - } - - private saveErrorMessage(error: unknown): string { - if (error instanceof HttpErrorResponse && this.hasMessage(error.error)) { - return error.error.message; - } - return 'Impossible d’enregistrer votre profil bailleur. Réessayez.'; - } - - private hasMessage(value: unknown): value is { message: string } { - return ( - typeof value === 'object' && - value !== null && - 'message' in value && - typeof value.message === 'string' - ); + this.landlordProfile + .updateProfile(profile) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (savedProfile) => { + this.profileModel.set(savedProfile); + this.saveSuccess.set('Profil bailleur enregistré.'); + this.saving.set(false); + }, + error: (error: unknown) => { + this.saveError.set( + httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'), + ); + this.saving.set(false); + }, + }); } } diff --git a/frontend/src/app/services/landlord-profile.service.ts b/frontend/src/app/services/landlord-profile.service.ts index a5aa84d..c07042e 100644 --- a/frontend/src/app/services/landlord-profile.service.ts +++ b/frontend/src/app/services/landlord-profile.service.ts @@ -10,15 +10,20 @@ export interface LandlordProfile { address: string; } +export interface LandlordProfileResponse extends LandlordProfile { + complete: boolean; + version: number; +} + @Injectable({ providedIn: 'root' }) export class LandlordProfileService { private readonly http = inject(HttpClient); - getProfile(): Observable { - return this.http.get('/api/landlord-profile'); + getProfile(): Observable { + return this.http.get('/api/landlord-profile'); } - updateProfile(profile: LandlordProfile): Observable { - return this.http.put('/api/landlord-profile', profile); + updateProfile(profile: LandlordProfile): Observable { + return this.http.put('/api/landlord-profile', profile); } }