From 26ab03708482d82814eddeaa91911322bf778b29 Mon Sep 17 00:00:00 2001 From: Nessar Date: Wed, 15 Jul 2026 19:55:07 +0200 Subject: [PATCH] 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. --- .../controller/ApartmentControllerTest.java | 168 ++-- frontend/README.md | 18 + frontend/proxy.conf.json | 18 + frontend/src/app/app.config.ts | 13 +- frontend/src/app/app.css | 940 +----------------- frontend/src/app/app.html | 476 +-------- frontend/src/app/app.spec.ts | 139 +-- frontend/src/app/app.ts | 100 +- .../apartment-form/apartment-form.css | 85 ++ .../apartment-form/apartment-form.html | 66 ++ .../apartment-form/apartment-form.spec.ts | 53 + .../apartment-form/apartment-form.ts | 50 + .../apartment-list/apartment-list.css | 150 +++ .../apartment-list/apartment-list.html | 70 ++ .../apartment-list/apartment-list.ts | 16 + .../apartments-page/apartments-page.html | 16 + .../apartments-page/apartments-page.spec.ts | 49 + .../apartments-page/apartments-page.ts | 43 + .../src/app/{ => components}/brand-mark.ts | 0 .../dashboard-page/dashboard-page.css | 195 ++++ .../dashboard-page/dashboard-page.html | 54 + .../dashboard-page/dashboard-page.ts | 18 + .../settings-page/settings-page.css | 150 +++ .../settings-page/settings-page.html | 69 ++ .../components/settings-page/settings-page.ts | 18 + .../components/sign-in-page/sign-in-page.css | 123 +++ .../components/sign-in-page/sign-in-page.html | 59 ++ .../components/sign-in-page/sign-in-page.ts | 13 + .../workspace-navigation.css | 156 +++ .../workspace-navigation.html | 84 ++ .../workspace-navigation.ts | 20 + .../{ => services}/apartment.service.spec.ts | 39 +- .../app/{ => services}/apartment.service.ts | 2 +- .../app/{ => services}/config.service.spec.ts | 2 +- .../src/app/{ => services}/config.service.ts | 2 +- .../src/app/{ => services}/theme.service.ts | 0 frontend/src/styles.scss | 53 + 37 files changed, 1877 insertions(+), 1650 deletions(-) create mode 100644 frontend/proxy.conf.json create mode 100644 frontend/src/app/components/apartment-form/apartment-form.css create mode 100644 frontend/src/app/components/apartment-form/apartment-form.html create mode 100644 frontend/src/app/components/apartment-form/apartment-form.spec.ts create mode 100644 frontend/src/app/components/apartment-form/apartment-form.ts create mode 100644 frontend/src/app/components/apartment-list/apartment-list.css create mode 100644 frontend/src/app/components/apartment-list/apartment-list.html create mode 100644 frontend/src/app/components/apartment-list/apartment-list.ts create mode 100644 frontend/src/app/components/apartments-page/apartments-page.html create mode 100644 frontend/src/app/components/apartments-page/apartments-page.spec.ts create mode 100644 frontend/src/app/components/apartments-page/apartments-page.ts rename frontend/src/app/{ => components}/brand-mark.ts (100%) create mode 100644 frontend/src/app/components/dashboard-page/dashboard-page.css create mode 100644 frontend/src/app/components/dashboard-page/dashboard-page.html create mode 100644 frontend/src/app/components/dashboard-page/dashboard-page.ts create mode 100644 frontend/src/app/components/settings-page/settings-page.css create mode 100644 frontend/src/app/components/settings-page/settings-page.html create mode 100644 frontend/src/app/components/settings-page/settings-page.ts create mode 100644 frontend/src/app/components/sign-in-page/sign-in-page.css create mode 100644 frontend/src/app/components/sign-in-page/sign-in-page.html create mode 100644 frontend/src/app/components/sign-in-page/sign-in-page.ts create mode 100644 frontend/src/app/components/workspace-navigation/workspace-navigation.css create mode 100644 frontend/src/app/components/workspace-navigation/workspace-navigation.html create mode 100644 frontend/src/app/components/workspace-navigation/workspace-navigation.ts rename frontend/src/app/{ => services}/apartment.service.spec.ts (69%) rename frontend/src/app/{ => services}/apartment.service.ts (97%) rename frontend/src/app/{ => services}/config.service.spec.ts (93%) rename frontend/src/app/{ => services}/config.service.ts (88%) rename frontend/src/app/{ => services}/theme.service.ts (100%) diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java index ed89219..ac969f2 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java @@ -10,7 +10,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.util.Collections; import java.util.List; import java.util.Optional; @@ -25,105 +24,108 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @AutoConfigureMockMvc class ApartmentControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; - @MockitoBean - private ApartmentRepository apartmentRepository; + @MockitoBean + private ApartmentRepository apartmentRepository; - @Test - void whenGetApartmentsWithoutToken_thenUnauthorized() throws Exception { - mockMvc.perform(get("/api/apartments")) - .andExpect(status().isUnauthorized()); - } + @Test + void whenGetApartmentsWithoutToken_thenUnauthorized() throws Exception { + mockMvc.perform(get("/api/apartments")) + .andExpect(status().isUnauthorized()); + } - @Test - void whenGetApartmentsWithToken_thenOk() throws Exception { - Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); - when(apartmentRepository.findAll()).thenReturn(List.of(apt)); + @Test + void whenGetApartmentsWithToken_thenOk() throws Exception { + Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); + when(apartmentRepository.findAll()).thenReturn(List.of(apt)); - mockMvc.perform(get("/api/apartments").with(oidcLogin())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value("1")) - .andExpect(jsonPath("$[0].name").value("Appartement 1")); - } + mockMvc.perform(get("/api/apartments").with(oidcLogin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].name").value("Appartement 1")); + } - @Test - void whenGetApartmentByIdNotFound_thenNotFound() throws Exception { - when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); + @Test + void whenGetApartmentByIdNotFound_thenNotFound() throws Exception { + when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); - mockMvc.perform(get("/api/apartments/999").with(oidcLogin())) - .andExpect(status().isNotFound()); - } + mockMvc.perform(get("/api/apartments/999").with(oidcLogin())) + .andExpect(status().isNotFound()); + } - @Test - void whenGetApartmentByIdFound_thenOk() throws Exception { - Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); - when(apartmentRepository.findById("1")).thenReturn(Optional.of(apt)); + @Test + void whenGetApartmentByIdFound_thenOk() throws Exception { + Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); + when(apartmentRepository.findById("1")).thenReturn(Optional.of(apt)); - mockMvc.perform(get("/api/apartments/1").with(oidcLogin())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value("1")); - } + mockMvc.perform(get("/api/apartments/1").with(oidcLogin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("1")); + } - @Test - void whenCreateApartment_thenCreated() throws Exception { - Apartment savedApt = new Apartment("1", "default-landlord", "New Apartment", "Address", 200000.0, "Desc"); - when(apartmentRepository.save(any(Apartment.class))).thenReturn(savedApt); + @Test + void whenCreateApartment_thenCreated() throws Exception { + Apartment savedApt = new Apartment("1", "default-landlord", "New Apartment", "Address", 200000.0, "Desc"); + when(apartmentRepository.save(any(Apartment.class))).thenReturn(savedApt); - mockMvc.perform(post("/api/apartments") - .with(oidcLogin()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"New Apartment\",\"address\":\"Address\",\"purchasePrice\":200000.0,\"description\":\"Desc\"}")) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value("1")) - .andExpect(jsonPath("$.bailleurId").value("default-landlord")); - } + mockMvc.perform(post("/api/apartments") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"name\":\"New Apartment\",\"address\":\"Address\",\"purchasePrice\":200000.0,\"description\":\"Desc\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("1")) + .andExpect(jsonPath("$.bailleurId").value("default-landlord")); + } - @Test - void whenUpdateApartmentNotFound_thenNotFound() throws Exception { - when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); + @Test + void whenUpdateApartmentNotFound_thenNotFound() throws Exception { + when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); - mockMvc.perform(put("/api/apartments/999") - .with(oidcLogin()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) - .andExpect(status().isNotFound()); - } + mockMvc.perform(put("/api/apartments/999") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) + .andExpect(status().isNotFound()); + } - @Test - void whenUpdateApartmentFound_thenOk() throws Exception { - Apartment existing = new Apartment("1", "default-landlord", "Old Name", "Old Address", 100000.0, "Old Desc"); - Apartment updated = new Apartment("1", "default-landlord", "New Name", "New Address", 120000.0, "New Desc"); - - when(apartmentRepository.findById("1")).thenReturn(Optional.of(existing)); - when(apartmentRepository.save(any(Apartment.class))).thenReturn(updated); + @Test + void whenUpdateApartmentFound_thenOk() throws Exception { + Apartment existing = new Apartment("1", "default-landlord", "Old Name", "Old Address", 100000.0, "Old Desc"); + Apartment updated = new Apartment("1", "default-landlord", "New Name", "New Address", 120000.0, "New Desc"); - mockMvc.perform(put("/api/apartments/1") - .with(oidcLogin()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("New Name")) - .andExpect(jsonPath("$.purchasePrice").value(120000.0)); - } + when(apartmentRepository.findById("1")).thenReturn(Optional.of(existing)); + when(apartmentRepository.save(any(Apartment.class))).thenReturn(updated); - @Test - void whenDeleteApartmentNotFound_thenNotFound() throws Exception { - when(apartmentRepository.existsById("999")).thenReturn(false); + mockMvc.perform(put("/api/apartments/1") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("New Name")) + .andExpect(jsonPath("$.purchasePrice").value(120000.0)); + } - mockMvc.perform(delete("/api/apartments/999").with(oidcLogin())) - .andExpect(status().isNotFound()); - } + @Test + void whenDeleteApartmentNotFound_thenNotFound() throws Exception { + when(apartmentRepository.existsById("999")).thenReturn(false); - @Test - void whenDeleteApartmentFound_thenNoContent() throws Exception { - when(apartmentRepository.existsById("1")).thenReturn(true); - doNothing().when(apartmentRepository).deleteById("1"); + mockMvc.perform(delete("/api/apartments/999").with(oidcLogin())) + .andExpect(status().isNotFound()); + } - mockMvc.perform(delete("/api/apartments/1").with(oidcLogin())) - .andExpect(status().isNoContent()); + @Test + void whenDeleteApartmentFound_thenNoContent() throws Exception { + when(apartmentRepository.existsById("1")).thenReturn(true); + doNothing().when(apartmentRepository).deleteById("1"); - verify(apartmentRepository, times(1)).deleteById("1"); - } + mockMvc.perform(delete("/api/apartments/1").with(oidcLogin())) + .andExpect(status().isNoContent()); + + verify(apartmentRepository, times(1)).deleteById("1"); + } } diff --git a/frontend/README.md b/frontend/README.md index 0c18753..833c46f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -44,6 +44,24 @@ To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use th bun run test -- --watch=false ``` +## Component design + +Build components around a cohesive, user-visible responsibility: a page section, a reusable control, +or an independently testable interaction. Do not split a component merely to reduce its line count. + +- Keep `App` responsible for application-level orchestration, authentication, and cross-feature state. +- Prefer presentational components: accept data through inputs and report user intent through outputs. + A component may inject a service only when it owns a self-contained feature and its data lifecycle. +- Keep transient interaction state with the component that owns it. For example, a form owns its field + values and validation, then emits a validated draft. +- Give non-trivial components their own directory and co-locate the TypeScript class, template, + component-specific styles, and tests. Shared primitives may stay as single files. +- Add focused unit tests for components with behavior: input transformation, conditional UI, validation, + or emitted actions. Do not add tests solely for static wrapper markup. + +Component-specific styles belong beside the component and use Angular's default view encapsulation. +Keep [`src/styles.scss`](./src/styles.scss) for design tokens, reset rules, and reusable visual primitives. + ## Running end-to-end tests For end-to-end (e2e) testing, run: diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 0000000..f955049 --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,18 @@ +{ + "/api": { + "target": "http://localhost:8080", + "secure": false + }, + "/oauth2": { + "target": "http://localhost:8080", + "secure": false + }, + "/login/oauth2": { + "target": "http://localhost:8080", + "secure": false + }, + "/logout": { + "target": "http://localhost:8080", + "secure": false + } +} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 0df0178..79c6e82 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,9 +1,14 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideAppInitializer, inject } from '@angular/core'; +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideAppInitializer, + inject, +} from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { routes } from './app.routes'; -import { ConfigService } from './config.service'; +import { ConfigService } from './services/config.service'; export const appConfig: ApplicationConfig = { providers: [ @@ -13,6 +18,6 @@ export const appConfig: ApplicationConfig = { provideAppInitializer(() => { const configService = inject(ConfigService); return configService.loadConfig(); - }) - ] + }), + ], }; diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css index 0f77fda..d68b275 100644 --- a/frontend/src/app/app.css +++ b/frontend/src/app/app.css @@ -4,198 +4,11 @@ color: var(--text); } -svg { - display: block; -} - -h1, -h2, -h3, -p { - margin-top: 0; -} - -.brand-mark { - display: grid; - width: 42px; - height: 42px; - flex: 0 0 42px; - place-items: center; - border-radius: 14px; - background: var(--accent); - color: var(--accent-contrast); -} - -.primary-action.mat-mdc-unelevated-button { - --mdc-filled-button-container-color: var(--accent); - --mdc-filled-button-label-text-color: var(--accent-contrast); - min-height: 44px; - border-radius: 13px; - padding-inline: 20px; - font-weight: 700; -} - -.eyebrow, -.context-label, -.story-kicker, -.property-label { - margin-bottom: 8px; - color: var(--accent); - font-size: 0.72rem; - font-weight: 800; - letter-spacing: 0.11em; - text-transform: uppercase; -} - -/* Sign in */ -.sign-in-shell { - min-height: 100vh; - background: var(--canvas); -} - -.sign-in-panel { - display: flex; - min-height: 100vh; - flex-direction: column; - justify-content: center; - padding: 32px 24px; -} - -.brand-lockup { - display: flex; - align-items: center; - gap: 13px; - margin-bottom: clamp(64px, 14vh, 120px); -} - -.brand-lockup h1 { - margin-bottom: 2px; - font-size: 1.02rem; - font-weight: 800; - letter-spacing: -0.02em; -} - -.brand-lockup p { - margin-bottom: 0; - color: var(--text-muted); - font-size: 0.76rem; -} - -.sign-in-copy { - max-width: 510px; -} - -.sign-in-copy h2 { - max-width: 480px; - margin-bottom: 18px; - font-size: clamp(2.35rem, 9vw, 4.8rem); - font-weight: 750; - letter-spacing: -0.065em; - line-height: 0.98; -} - -.sign-in-copy > p { - max-width: 470px; - margin-bottom: 30px; - color: var(--text-muted); - font-size: 1rem; - line-height: 1.7; -} - -.sign-in-action { - width: 100%; - max-width: 360px; -} - -.sign-in-action svg { - width: 19px; - height: 19px; - margin-left: 8px; -} - -.security-note { - display: flex; - align-items: center; - gap: 8px; - margin: 17px 0 0; - color: var(--text-subtle); - font-size: 0.78rem; -} - -.security-note span { - color: var(--accent); - font-size: 0.55rem; -} - -.sign-in-story { - display: none; -} - -/* App shell and navigation */ .workspace-shell { min-height: 100vh; background: var(--canvas); } -.app-navigation { - position: fixed; - z-index: 20; - right: 0; - bottom: 0; - left: 0; - height: calc(72px + env(safe-area-inset-bottom)); - padding: 6px 10px env(safe-area-inset-bottom); - border-top: 1px solid var(--border); - background: color-mix(in srgb, var(--surface) 94%, transparent); - box-shadow: 0 -10px 30px rgba(25, 40, 35, 0.06); - backdrop-filter: blur(18px); -} - -.sidebar-brand, -.sidebar-user { - display: none; -} - -.navigation-items { - display: grid; - height: 100%; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 4px; -} - -.navigation-item.mat-mdc-button { - --mdc-text-button-label-text-color: var(--text-muted); - min-width: 0; - height: 60px; - border-radius: 14px; - padding: 5px 4px; - font-weight: 650; - letter-spacing: 0; -} - -.navigation-item .mat-mdc-button-touch-target { - height: 60px; -} - -.navigation-item svg { - width: 21px; - height: 21px; - margin: 0 auto 4px; -} - -.navigation-item span { - display: block; - overflow: hidden; - font-size: 0.66rem; - text-overflow: ellipsis; - white-space: nowrap; -} - -.navigation-item.active { - --mdc-text-button-label-text-color: var(--accent); - background: var(--surface-accent); -} - .workspace-main { min-width: 0; min-height: 100vh; @@ -225,21 +38,17 @@ p { border-radius: 11px; } -.compact-avatar, -.large-avatar { +.compact-avatar { display: grid; + width: 34px; + height: 34px; + flex: 0 0 34px; place-items: center; border-radius: 50%; background: var(--surface-accent); color: var(--accent); - font-weight: 800; -} - -.compact-avatar { - width: 34px; - height: 34px; - flex: 0 0 34px; font-size: 0.82rem; + font-weight: 800; } .page-container { @@ -249,759 +58,20 @@ p { padding: 30px 20px 112px; } -.page-section { - animation: page-in 180ms ease-out; -} - -@keyframes page-in { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.page-heading { - margin-bottom: 30px; -} - -.page-heading h1 { - margin-bottom: 8px; - font-size: clamp(2rem, 7vw, 3.2rem); - font-weight: 750; - letter-spacing: -0.055em; - line-height: 1.02; -} - -.page-heading > div > p:last-child, -.page-heading > p { - margin-bottom: 0; - color: var(--text-muted); - line-height: 1.6; -} - -.status-pill, -.role-chip { - display: inline-flex; - align-items: center; - width: fit-content; - border-radius: 999px; - background: var(--surface-accent); - color: var(--accent-strong); - font-size: 0.76rem; - font-weight: 750; -} - -.status-pill { - gap: 7px; - margin-top: 18px; - padding: 8px 12px; -} - -.status-pill span { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--accent); -} - -/* Dashboard */ -.dashboard-grid { - display: grid; - grid-template-columns: 1fr; - gap: 16px; -} - -.feature-card, -.apartment-card, -.empty-state, -.apartment-form, -.settings-card { - border: 1px solid var(--border); - border-radius: 22px; - background: var(--surface); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); -} - -.feature-card { - padding: 22px; -} - -.feature-card h2, -.settings-card h2 { - margin-bottom: 9px; - font-size: 1.35rem; - letter-spacing: -0.035em; -} - -.feature-card p:not(.eyebrow), -.settings-card p:not(.eyebrow) { - color: var(--text-muted); - line-height: 1.55; -} - -.feature-card-primary { - display: grid; - gap: 18px; - border-color: transparent; - background: var(--surface-accent); -} - -.feature-card-primary .mat-mdc-button { - --mdc-text-button-label-text-color: var(--accent-strong); - width: fit-content; - padding-inline: 0; - font-weight: 750; -} - -.feature-icon, -.settings-icon, -.empty-illustration { - display: grid; - place-items: center; - border-radius: 16px; - background: var(--surface); - color: var(--accent); -} - -.feature-icon { - width: 48px; - height: 48px; -} - -.feature-icon svg, -.settings-icon svg { - width: 24px; - height: 24px; -} - -.user-profile { - display: flex; - align-items: center; - gap: 16px; -} - -.large-avatar { - width: 58px; - height: 58px; - flex: 0 0 58px; - font-size: 1.25rem; -} - -.user-details { - min-width: 0; -} - -.user-details h3 { - margin-bottom: 3px; - font-size: 1.15rem; -} - -.user-email { - overflow: hidden; - margin-bottom: 11px; - font-size: 0.84rem; - text-overflow: ellipsis; - white-space: nowrap; -} - -.role-chip { - padding: 5px 9px; -} - -.api-demo-section { - display: grid; - gap: 16px; -} - -.api-actions .mat-mdc-outlined-button { - --mdc-outlined-button-label-text-color: var(--accent); - --mdc-outlined-button-outline-color: var(--border-strong); - border-radius: 12px; -} - -.api-response-wrapper { - padding: 14px; - border-radius: 13px; - background: var(--surface-soft); -} - -.api-response-wrapper > span { - display: block; - margin-bottom: 5px; - color: var(--text-subtle); - font-size: 0.72rem; - font-weight: 700; - text-transform: uppercase; -} - -.api-response-code { - color: var(--accent-strong); - font-size: 0.83rem; - overflow-wrap: anywhere; -} - -/* Apartments */ -.page-heading .primary-action { - width: 100%; - margin-top: 20px; -} - -.button-plus { - margin-right: 4px; - font-size: 1.25rem; - font-weight: 400; -} - -.empty-state { - padding: 46px 22px; - text-align: center; -} - -.empty-illustration { - width: 68px; - height: 68px; - margin: 0 auto 22px; - background: var(--surface-accent); -} - -.empty-illustration svg { - width: 34px; - height: 34px; -} - -.empty-state h2 { - margin-bottom: 8px; - font-size: 1.45rem; -} - -.empty-state > p:not(.eyebrow) { - max-width: 440px; - margin: 0 auto 22px; - color: var(--text-muted); - line-height: 1.6; -} - -.empty-state .mat-mdc-outlined-button { - --mdc-outlined-button-label-text-color: var(--accent); - --mdc-outlined-button-outline-color: var(--border-strong); - border-radius: 12px; -} - -.apartments-grid { - display: grid; - grid-template-columns: 1fr; - gap: 16px; -} - -.apartment-card { - padding: 20px; -} - -.apartment-card-topline { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 14px; - margin-bottom: 22px; -} - -.property-label { - margin: 0; -} - -.property-value { - font-size: 1rem; - font-weight: 800; -} - -.apt-info h2 { - margin-bottom: 9px; - font-size: 1.35rem; - letter-spacing: -0.035em; -} - -.apt-address { - display: flex; - align-items: flex-start; - gap: 8px; - margin-bottom: 12px; - color: var(--text-muted); - font-size: 0.88rem; - line-height: 1.5; -} - -.apt-address svg { - width: 17px; - height: 17px; - flex: 0 0 17px; - margin-top: 2px; -} - -.apt-desc { - margin-bottom: 0; - color: var(--text-muted); - font-size: 0.88rem; - line-height: 1.55; -} - -.apt-actions { - display: flex; - gap: 4px; - justify-content: flex-end; - margin-top: 20px; - padding-top: 12px; - border-top: 1px solid var(--border); -} - -.apt-actions .mat-mdc-button { - --mdc-text-button-label-text-color: var(--accent); -} - -.apt-actions .danger-action.mat-mdc-button { - --mdc-text-button-label-text-color: var(--danger); -} - -/* Apartment form */ -.back-action.mat-mdc-button { - --mdc-text-button-label-text-color: var(--text-muted); - margin: 0 0 20px -16px; -} - -.apartment-form { - padding: 20px; -} - -.form-grid { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 2px; -} - -.form-grid mat-form-field { - width: 100%; -} - -.alert-error { - margin-bottom: 18px; - padding: 13px 15px; - border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent); - border-radius: 12px; - background: var(--danger-soft); - color: var(--danger); - font-size: 0.87rem; -} - -.form-actions { - display: flex; - flex-direction: column-reverse; - gap: 8px; - margin-top: 6px; -} - -.form-actions button { - width: 100%; -} - -/* Settings */ -.settings-card { - overflow: hidden; -} - -.settings-copy { - display: flex; - gap: 15px; - padding: 22px; - border-bottom: 1px solid var(--border); -} - -.settings-icon { - width: 46px; - height: 46px; - flex: 0 0 46px; - background: var(--surface-accent); -} - -.settings-copy h2 { - margin-bottom: 5px; -} - -.settings-copy p:last-child { - margin-bottom: 0; - font-size: 0.88rem; -} - -.theme-options { - display: grid; - padding: 8px; -} - -.theme-options mat-radio-button { - border-radius: 14px; -} - -.theme-options mat-radio-button:hover { - background: var(--surface-soft); -} - -.theme-option-copy { - display: flex; - flex-direction: column; - padding: 12px 8px; -} - -.theme-option-copy strong { - color: var(--text); -} - -.theme-option-copy small { - margin-top: 3px; - color: var(--text-muted); - white-space: normal; -} - -.session-settings { - margin-top: 16px; -} - -.session-action { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 18px; - padding: 22px; -} - -.session-action > div { - display: flex; - min-width: 0; - flex-direction: column; -} - -.session-action strong, -.session-action span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.session-action span { - margin-top: 3px; - color: var(--text-muted); - font-size: 0.82rem; -} - -.session-action .mat-mdc-outlined-button { - --mdc-outlined-button-label-text-color: var(--danger); - --mdc-outlined-button-outline-color: var(--border-strong); - border-radius: 12px; -} - @media (min-width: 700px) { .page-container { padding: 42px 36px 116px; } - - .page-heading { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 28px; - } - - .page-heading .primary-action { - width: auto; - flex: 0 0 auto; - margin-top: 0; - } - - .dashboard-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .feature-card-primary, - .api-demo-section { - grid-column: span 2; - } - - .apartments-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .apartment-form { - padding: 30px; - } - - .form-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: 18px; - } - - .full-field { - grid-column: span 2; - } - - .form-actions { - flex-direction: row; - justify-content: flex-end; - } - - .form-actions button { - width: auto; - } } @media (min-width: 960px) { - .sign-in-shell { - display: grid; - grid-template-columns: minmax(520px, 0.92fr) minmax(480px, 1.08fr); - } - - .sign-in-panel { - padding: 56px clamp(56px, 7vw, 112px); - } - - .sign-in-story { - display: grid; - min-height: 100vh; - place-items: end start; - padding: 64px; - background: - radial-gradient(circle at 75% 20%, rgba(99, 199, 188, 0.24), transparent 30%), - linear-gradient(145deg, #153d38, #0e2925 55%, #0a1d1a); - color: #effbf8; - } - - .story-card { - max-width: 560px; - } - - .story-kicker { - color: #7ed6cc; - } - - .story-card h2 { - margin-bottom: 42px; - font-size: clamp(2.4rem, 4vw, 4.5rem); - letter-spacing: -0.06em; - line-height: 1; - } - - .story-list { - display: grid; - gap: 0; - } - - .story-list > div { - display: grid; - grid-template-columns: 42px 1fr; - gap: 12px; - padding: 17px 0; - border-top: 1px solid rgba(255, 255, 255, 0.13); - } - - .story-list span { - color: #7ed6cc; - font-size: 0.75rem; - font-weight: 800; - } - - .story-list p { - margin-bottom: 0; - color: rgba(239, 251, 248, 0.76); - } - - .workspace-shell { - display: block; - } - - .app-navigation { - top: 0; - right: auto; - bottom: auto; - width: 256px; - height: 100vh; - padding: 26px 18px; - border-top: 0; - border-right: 1px solid var(--border); - background: var(--surface); - box-shadow: none; - backdrop-filter: none; - } - - .sidebar-brand { - display: flex; - align-items: center; - gap: 12px; - padding: 0 8px 32px; - } - - .sidebar-brand > div { - display: flex; - flex-direction: column; - font-size: 0.86rem; - line-height: 1.15; - } - - .sidebar-brand strong { - font-size: 1rem; - } - - .sidebar-brand span:last-child { - color: var(--text-muted); - } - - .navigation-items { - display: flex; - height: auto; - flex-direction: column; - gap: 6px; - } - - .navigation-item.mat-mdc-button { - width: 100%; - height: 48px; - justify-content: flex-start; - border-radius: 13px; - padding: 0 14px; - } - - .navigation-item .mat-mdc-button-touch-target { - height: 48px; - } - - .navigation-item .mdc-button__label { - display: flex; - align-items: center; - gap: 12px; - } - - .navigation-item svg { - width: 20px; - height: 20px; - margin: 0; - } - - .navigation-item span { - font-size: 0.86rem; - } - - .sidebar-user { - position: absolute; - right: 18px; - bottom: 24px; - left: 18px; - display: grid; - grid-template-columns: 36px minmax(0, 1fr); - gap: 10px; - padding: 14px; - border: 1px solid var(--border); - border-radius: 16px; - background: var(--surface-soft); - } - - .sidebar-user > div:nth-child(2) { - display: flex; - min-width: 0; - flex-direction: column; - justify-content: center; - } - - .sidebar-user strong, - .sidebar-user span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .sidebar-user strong { - font-size: 0.82rem; - } - - .sidebar-user span { - color: var(--text-muted); - font-size: 0.69rem; - } - - .logout-action.mat-mdc-button { - --mdc-text-button-label-text-color: var(--text-muted); - grid-column: 1 / -1; - height: 34px; - border-top: 1px solid var(--border); - border-radius: 0; - font-size: 0.75rem; - } - .workspace-main { margin-left: 256px; } - .mobile-header { display: none; } - .page-container { padding: 64px clamp(48px, 6vw, 82px); } - - .dashboard-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .feature-card-primary { - grid-column: span 2; - min-height: 250px; - } - - .user-profile { - grid-column: span 1; - } - - .api-demo-section { - grid-column: span 3; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - } - - .api-response-wrapper { - grid-column: 1 / -1; - } - - .settings-card { - display: grid; - grid-template-columns: minmax(280px, 0.8fr) minmax(360px, 1.2fr); - } - - .settings-copy { - border-right: 1px solid var(--border); - border-bottom: 0; - } - - .session-action { - align-items: flex-start; - justify-content: center; - } -} - -@media (min-width: 1180px) { - .apartments-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .form-page { - max-width: 900px; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - } } diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 46eb439..5928e7e 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,152 +1,15 @@ @if (!isLoggedIn) { -
- - - -
+ } @else {
- +
@@ -159,310 +22,31 @@
@if (activeTab() === 'dashboard') { -
-
-
-

Bonjour {{ username }}

-

Tableau de bord

-

Retrouvez rapidement les actions utiles à votre gestion locative.

-
- Espace sécurisé -
- -
-
- -
-

Votre portefeuille

-

- {{ apartments().length }} appartement{{ apartments().length > 1 ? 's' : '' }} -

-

Centralisez les informations essentielles de vos biens.

-
- -
- - - -
-
-

État du service

-

Connexion au serveur

-

Vérifiez que votre espace communique correctement avec le service sécurisé.

-
-
- -
- @if (helloMessage()) { -
- Réponse du serveur - {{ helloMessage() }} -
- } -
-
-
+ } - @if (activeTab() === 'apartments') { -
- @if (!showForm()) { -
-
-

Portefeuille

-

Mes appartements

-

Les biens rattachés à votre activité LMNP.

-
- -
- - @if (apartments().length === 0) { -
- -

Commencez ici

-

Aucun appartement enregistré

-

Ajoutez votre premier bien pour construire votre portefeuille.

- -
- } @else { -
- @for (apt of apartments(); track apt.id) { -
-
- Bien immobilier - {{ apt.purchasePrice.toLocaleString('fr-FR') }} € -
-
-

{{ apt.name }}

-

- - {{ apt.address }} -

- @if (apt.description) { -

{{ apt.description }}

- } -
-
- - @if (apt.id) { - - } -
-
- } -
- } - } @else { -
-
-
- -

Portefeuille

-

- {{ editingApartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }} -

-

Renseignez les informations principales du bien.

-
-
- -
- @if (formError) { - - } - -
- - Nom de l'appartement - - - - - Prix d'acquisition - - € - - - - Adresse complète - - - - - Description ou notes - - -
- -
- - -
-
-
- } -
+ } - @if (activeTab() === 'settings') { -
-
-
-

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. - - -
- -
-
- -
-

Compte

-

Session

-

Gérez la session ouverte sur cet appareil.

-
-
-
-
- {{ username }} - {{ email }} -
- -
-
-
+ }
diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index abaa45f..d6c0057 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { App } from './app'; import { HttpClient } from '@angular/common/http'; -import { ApartmentService } from './apartment.service'; +import { ApartmentService } from './services/apartment.service'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; @@ -21,24 +21,32 @@ describe('App', () => { return of(mockUserResponse); } return of('Hello from mocked API!'); - } + }, }; mockApartmentService = { - getApartments: () => of([ - { id: '1', name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' } - ]), - getApartment: (id: string) => of({ id, name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }), + getApartments: () => + of([ + { + id: '1', + name: 'Apt 1', + address: 'Addr 1', + purchasePrice: 100000, + description: 'Desc 1', + }, + ]), + getApartment: (id: string) => + of({ id, name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }), createApartment: (apt: any) => of({ id: '2', ...apt }), updateApartment: (id: string, apt: any) => of({ id, ...apt }), - deleteApartment: () => of(undefined) + deleteApartment: () => of(undefined), }; originalLocation = window.location; delete (window as any).location; locationMock = { href: '', - origin: 'http://localhost' + origin: 'http://localhost', }; (window as any).location = locationMock; @@ -46,8 +54,8 @@ describe('App', () => { imports: [App], providers: [ { provide: HttpClient, useValue: mockHttpClient }, - { provide: ApartmentService, useValue: mockApartmentService } - ] + { provide: ApartmentService, useValue: mockApartmentService }, + ], }).compileComponents(); }); @@ -123,7 +131,7 @@ describe('App', () => { it('should render logged in state when user is authenticated', async () => { mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; const fixture = TestBed.createComponent(App); - + fixture.detectChanges(); await fixture.whenStable(); @@ -136,7 +144,7 @@ describe('App', () => { mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; - + fixture.detectChanges(); await fixture.whenStable(); @@ -151,7 +159,9 @@ describe('App', () => { // Verify response message is shown expect(app.helloMessage()).toBe('Hello from mocked API!'); fixture.detectChanges(); - expect(compiled.querySelector('.api-response-code')?.textContent).toContain('Hello from mocked API!'); + expect(compiled.querySelector('.api-response-code')?.textContent).toContain( + 'Hello from mocked API!', + ); }); // Apartment CRUD tests @@ -185,58 +195,6 @@ describe('App', () => { expect(app.activeTab()).toBe('apartments'); }); - it('should open add form and reset fields', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - fixture.detectChanges(); - await fixture.whenStable(); - - app.openAddForm(); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(app.showForm()).toBe(true); - expect(app.editingApartment()).toBeNull(); - expect(app.formName).toBe(''); - expect(app.formPurchasePrice).toBe(0); - }); - - it('should open edit form and populate fields', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - const apt = { id: '123', name: 'Apartment 123', address: 'Addr', purchasePrice: 90000, description: 'Test' }; - - fixture.detectChanges(); - await fixture.whenStable(); - - app.openEditForm(apt); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(app.showForm()).toBe(true); - expect(app.editingApartment()).toEqual(apt); - expect(app.formName).toBe('Apartment 123'); - expect(app.formPurchasePrice).toBe(90000); - }); - - it('should display error if fields are invalid on save', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - fixture.detectChanges(); - await fixture.whenStable(); - - app.openAddForm(); - fixture.detectChanges(); - await fixture.whenStable(); - - app.saveApartment(); - fixture.detectChanges(); - await fixture.whenStable(); - expect(app.formError).toContain('Veuillez remplir tous les champs obligatoires'); - }); - it('should save a new apartment successfully', async () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; @@ -245,16 +203,14 @@ describe('App', () => { fixture.detectChanges(); await fixture.whenStable(); - app.openAddForm(); - fixture.detectChanges(); - await fixture.whenStable(); - - app.formName = 'New Apt'; - app.formAddress = 'New Addr'; - app.formPurchasePrice = 200000; - app.formDescription = 'New Desc'; - - app.saveApartment(); + app.saveApartment({ + apartment: { + name: 'New Apt', + address: 'New Addr', + purchasePrice: 200000, + description: 'New Desc', + }, + }); fixture.detectChanges(); await fixture.whenStable(); @@ -262,30 +218,34 @@ describe('App', () => { name: 'New Apt', address: 'New Addr', purchasePrice: 200000, - description: 'New Desc' + description: 'New Desc', }); - expect(app.showForm()).toBe(false); }); it('should update an existing apartment successfully', async () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; const updateSpy = vi.spyOn(mockApartmentService, 'updateApartment'); - const apt = { id: '1', name: 'Old Apt', address: 'Old Addr', purchasePrice: 100000, description: 'Old Desc' }; + const apt = { + id: '1', + name: 'Old Apt', + address: 'Old Addr', + purchasePrice: 100000, + description: 'Old Desc', + }; fixture.detectChanges(); await fixture.whenStable(); - app.openEditForm(apt); - fixture.detectChanges(); - await fixture.whenStable(); - - app.formName = 'Updated Apt'; - app.formAddress = 'Updated Addr'; - app.formPurchasePrice = 110000; - app.formDescription = 'Updated Desc'; - - app.saveApartment(); + app.saveApartment({ + apartmentId: apt.id, + apartment: { + name: 'Updated Apt', + address: 'Updated Addr', + purchasePrice: 110000, + description: 'Updated Desc', + }, + }); fixture.detectChanges(); await fixture.whenStable(); @@ -293,9 +253,8 @@ describe('App', () => { name: 'Updated Apt', address: 'Updated Addr', purchasePrice: 110000, - description: 'Updated Desc' + description: 'Updated Desc', }); - expect(app.showForm()).toBe(false); }); it('should delete an apartment when confirmed', async () => { diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index cf163c1..e9c5540 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,23 +1,27 @@ 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'; +import { ApartmentService, Apartment } from './services/apartment.service'; +import { ThemeService } from './services/theme.service'; +import { BrandMark } from './components/brand-mark'; +import { SignInPage } from './components/sign-in-page/sign-in-page'; +import { + WorkspaceNavigation, + WorkspaceTab, +} from './components/workspace-navigation/workspace-navigation'; +import { DashboardPage } from './components/dashboard-page/dashboard-page'; +import { ApartmentsPage } from './components/apartments-page/apartments-page'; +import { ApartmentSaveRequest } from './components/apartments-page/apartments-page'; +import { SettingsPage } from './components/settings-page/settings-page'; @Component({ selector: 'app-root', imports: [ BrandMark, - FormsModule, - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatRadioModule, + SignInPage, + WorkspaceNavigation, + DashboardPage, + ApartmentsPage, + SettingsPage, ], templateUrl: './app.html', styleUrl: './app.css', @@ -27,18 +31,8 @@ export class App implements OnInit { readonly helloMessage = signal(''); // Tab & CRUD States - readonly activeTab = signal<'dashboard' | 'apartments' | 'settings'>('dashboard'); + readonly activeTab = signal('dashboard'); readonly apartments = signal([]); - readonly showForm = signal(false); - readonly editingApartment = signal(null); - - // Form Fields - formName = ''; - formAddress = ''; - formPurchasePrice = 0; - formDescription = ''; - formError = ''; - private readonly _isLoggedIn = signal(false); private readonly _username = signal(''); private readonly _email = signal(''); @@ -119,66 +113,20 @@ export class App implements OnInit { }); } - openAddForm() { - this.editingApartment.set(null); - this.formName = ''; - this.formAddress = ''; - this.formPurchasePrice = 0; - this.formDescription = ''; - this.formError = ''; - this.showForm.set(true); - } - - openEditForm(apartment: Apartment) { - this.editingApartment.set(apartment); - this.formName = apartment.name; - this.formAddress = apartment.address; - this.formPurchasePrice = apartment.purchasePrice; - this.formDescription = apartment.description; - this.formError = ''; - this.showForm.set(true); - } - - closeForm() { - this.showForm.set(false); - this.editingApartment.set(null); - } - - saveApartment() { - if (!this.formName.trim() || !this.formAddress.trim() || this.formPurchasePrice <= 0) { - this.formError = 'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).'; - return; - } - - const apartmentData: Apartment = { - name: this.formName.trim(), - address: this.formAddress.trim(), - purchasePrice: this.formPurchasePrice, - description: this.formDescription.trim(), - }; - - const editMode = this.editingApartment(); - if (editMode && editMode.id) { - this.apartmentService.updateApartment(editMode.id, apartmentData).subscribe({ + saveApartment({ apartment, apartmentId }: ApartmentSaveRequest): void { + if (apartmentId) { + this.apartmentService.updateApartment(apartmentId, apartment).subscribe({ next: () => { this.loadApartments(); - this.closeForm(); - }, - error: (err) => { - console.error(err); - this.formError = 'Erreur lors de la modification.'; }, + error: (err) => console.error('Error updating apartment', err), }); } else { - this.apartmentService.createApartment(apartmentData).subscribe({ + this.apartmentService.createApartment(apartment).subscribe({ next: () => { this.loadApartments(); - this.closeForm(); - }, - error: (err) => { - console.error(err); - this.formError = 'Erreur lors de la création.'; }, + error: (err) => console.error('Error creating apartment', err), }); } } diff --git a/frontend/src/app/components/apartment-form/apartment-form.css b/frontend/src/app/components/apartment-form/apartment-form.css new file mode 100644 index 0000000..e42a8b5 --- /dev/null +++ b/frontend/src/app/components/apartment-form/apartment-form.css @@ -0,0 +1,85 @@ +:host { + display: block; +} +.page-heading { + margin-bottom: 30px; +} +.page-heading h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 7vw, 3.2rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.02; +} +.page-heading > div > p:last-child { + margin-bottom: 0; + color: var(--text-muted); + line-height: 1.6; +} +.back-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + margin: 0 0 20px -16px; +} +.apartment-form { + padding: 20px; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); +} +.form-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 2px; +} +.form-grid mat-form-field { + width: 100%; +} +.alert-error { + margin-bottom: 18px; + padding: 13px 15px; + border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent); + border-radius: 12px; + background: var(--danger-soft); + color: var(--danger); + font-size: 0.87rem; +} +.form-actions { + display: flex; + flex-direction: column-reverse; + gap: 8px; + margin-top: 6px; +} +.form-actions button { + width: 100%; +} +@media (min-width: 700px) { + .page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + } + .apartment-form { + padding: 30px; + } + .form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; + } + .full-field { + grid-column: span 2; + } + .form-actions { + flex-direction: row; + justify-content: flex-end; + } + .form-actions button { + width: auto; + } +} +@media (min-width: 1180px) { + .form-page { + max-width: 900px; + } +} diff --git a/frontend/src/app/components/apartment-form/apartment-form.html b/frontend/src/app/components/apartment-form/apartment-form.html new file mode 100644 index 0000000..f808b32 --- /dev/null +++ b/frontend/src/app/components/apartment-form/apartment-form.html @@ -0,0 +1,66 @@ +
+
+
+ +

Portefeuille

+

{{ apartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}

+

Renseignez les informations principales du bien.

+
+
+
+ @if (error()) { + + } +
+ Nom de l'appartement + Prix d'acquisition€ + Adresse complète + Description ou notes + +
+
+ +
+
+
diff --git a/frontend/src/app/components/apartment-form/apartment-form.spec.ts b/frontend/src/app/components/apartment-form/apartment-form.spec.ts new file mode 100644 index 0000000..6e9acd5 --- /dev/null +++ b/frontend/src/app/components/apartment-form/apartment-form.spec.ts @@ -0,0 +1,53 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { ApartmentForm } from './apartment-form'; + +describe('ApartmentForm', () => { + it('initializes its fields from the apartment being edited', () => { + const fixture = TestBed.createComponent(ApartmentForm); + fixture.componentRef.setInput('apartment', { + id: 'apt-1', + name: 'T2 Paris 15', + address: '45 rue des Entrepreneurs, 75015 Paris', + purchasePrice: 250000, + description: 'Cinquième étage', + }); + fixture.detectChanges(); + + const form = fixture.componentInstance; + expect(form.name()).toBe('T2 Paris 15'); + expect(form.purchasePrice()).toBe(250000); + }); + + it('shows a validation error instead of emitting an incomplete apartment', () => { + const fixture = TestBed.createComponent(ApartmentForm); + const form = fixture.componentInstance; + const saved = vi.fn(); + form.saved.subscribe(saved); + + form.save(); + + expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires'); + expect(saved).not.toHaveBeenCalled(); + }); + + it('emits a trimmed apartment draft when valid', () => { + const fixture = TestBed.createComponent(ApartmentForm); + const form = fixture.componentInstance; + const saved = vi.fn(); + form.saved.subscribe(saved); + form.name.set(' T2 Paris 15 '); + form.address.set(' 45 rue des Entrepreneurs '); + form.purchasePrice.set(250000); + form.description.set(' Cinquième étage '); + + form.save(); + + expect(saved).toHaveBeenCalledWith({ + name: 'T2 Paris 15', + address: '45 rue des Entrepreneurs', + purchasePrice: 250000, + description: 'Cinquième étage', + }); + }); +}); diff --git a/frontend/src/app/components/apartment-form/apartment-form.ts b/frontend/src/app/components/apartment-form/apartment-form.ts new file mode 100644 index 0000000..1a32d2d --- /dev/null +++ b/frontend/src/app/components/apartment-form/apartment-form.ts @@ -0,0 +1,50 @@ +import { Component, effect, input, output, signal } from '@angular/core'; +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 { Apartment } from '../../services/apartment.service'; + +@Component({ + selector: 'app-apartment-form', + imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule], + templateUrl: './apartment-form.html', + styleUrl: './apartment-form.css', +}) +export class ApartmentForm { + readonly apartment = input(null); + readonly closed = output(); + readonly saved = output(); + + readonly name = signal(''); + readonly address = signal(''); + readonly purchasePrice = signal(0); + readonly description = signal(''); + readonly error = signal(''); + + constructor() { + effect(() => this.resetForm(this.apartment())); + } + + save(): void { + if (!this.name().trim() || !this.address().trim() || this.purchasePrice() <= 0) { + this.error.set('Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).'); + return; + } + + this.saved.emit({ + name: this.name().trim(), + address: this.address().trim(), + purchasePrice: this.purchasePrice(), + description: this.description().trim(), + }); + } + + private resetForm(apartment: Apartment | null): void { + this.name.set(apartment?.name ?? ''); + this.address.set(apartment?.address ?? ''); + this.purchasePrice.set(apartment?.purchasePrice ?? 0); + this.description.set(apartment?.description ?? ''); + this.error.set(''); + } +} diff --git a/frontend/src/app/components/apartment-list/apartment-list.css b/frontend/src/app/components/apartment-list/apartment-list.css new file mode 100644 index 0000000..a321793 --- /dev/null +++ b/frontend/src/app/components/apartment-list/apartment-list.css @@ -0,0 +1,150 @@ +:host { + display: block; +} +.page-heading { + margin-bottom: 30px; +} +.page-heading h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 7vw, 3.2rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.02; +} +.page-heading > div > p:last-child { + margin-bottom: 0; + color: var(--text-muted); + line-height: 1.6; +} +.page-heading .primary-action { + width: 100%; + margin-top: 20px; +} +.button-plus { + margin-right: 4px; + font-size: 1.25rem; + font-weight: 400; +} +.empty-state, +.apartment-card { + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); +} +.empty-state { + padding: 46px 22px; + text-align: center; +} +.empty-illustration { + display: grid; + width: 68px; + height: 68px; + place-items: center; + margin: 0 auto 22px; + border-radius: 16px; + background: var(--surface-accent); + color: var(--accent); +} +.empty-illustration svg { + width: 34px; + height: 34px; +} +.empty-state h2 { + margin-bottom: 8px; + font-size: 1.45rem; +} +.empty-state > p:not(.eyebrow) { + max-width: 440px; + margin: 0 auto 22px; + color: var(--text-muted); + line-height: 1.6; +} +.empty-state .mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: var(--accent); + --mdc-outlined-button-outline-color: var(--border-strong); + border-radius: 12px; +} +.apartments-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} +.apartment-card { + padding: 20px; +} +.apartment-card-topline { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 14px; + margin-bottom: 22px; +} +.property-label { + margin: 0; +} +.property-value { + font-size: 1rem; + font-weight: 800; +} +.apt-info h2 { + margin-bottom: 9px; + font-size: 1.35rem; + letter-spacing: -0.035em; +} +.apt-address { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 12px; + color: var(--text-muted); + font-size: 0.88rem; + line-height: 1.5; +} +.apt-address svg { + width: 17px; + height: 17px; + flex: 0 0 17px; + margin-top: 2px; +} +.apt-desc { + margin-bottom: 0; + color: var(--text-muted); + font-size: 0.88rem; + line-height: 1.55; +} +.apt-actions { + display: flex; + gap: 4px; + justify-content: flex-end; + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.apt-actions .mat-mdc-button { + --mdc-text-button-label-text-color: var(--accent); +} +.apt-actions .danger-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--danger); +} +@media (min-width: 700px) { + .page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + } + .page-heading .primary-action { + width: auto; + flex: 0 0 auto; + margin-top: 0; + } + .apartments-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (min-width: 1180px) { + .apartments-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/frontend/src/app/components/apartment-list/apartment-list.html b/frontend/src/app/components/apartment-list/apartment-list.html new file mode 100644 index 0000000..2bcccfb --- /dev/null +++ b/frontend/src/app/components/apartment-list/apartment-list.html @@ -0,0 +1,70 @@ +
+
+

Portefeuille

+

Mes appartements

+

Les biens rattachés à votre activité LMNP.

+
+ +
+ +@if (apartments().length === 0) { +
+ +

Commencez ici

+

Aucun appartement enregistré

+

Ajoutez votre premier bien pour construire votre portefeuille.

+ +
+} @else { +
+ @for (apt of apartments(); track apt.id) { +
+
+ Bien immobilier + {{ apt.purchasePrice.toLocaleString('fr-FR') }} € +
+
+

{{ apt.name }}

+

+ + {{ apt.address }} +

+ @if (apt.description) { +

{{ apt.description }}

+ } +
+
+ + @if (apt.id) { + + } +
+
+ } +
+} diff --git a/frontend/src/app/components/apartment-list/apartment-list.ts b/frontend/src/app/components/apartment-list/apartment-list.ts new file mode 100644 index 0000000..4b029d2 --- /dev/null +++ b/frontend/src/app/components/apartment-list/apartment-list.ts @@ -0,0 +1,16 @@ +import { Component, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { Apartment } from '../../services/apartment.service'; + +@Component({ + selector: 'app-apartment-list', + imports: [MatButtonModule], + templateUrl: './apartment-list.html', + styleUrl: './apartment-list.css', +}) +export class ApartmentList { + readonly apartments = input.required(); + readonly addRequested = output(); + readonly editRequested = output(); + readonly deleteRequested = output(); +} diff --git a/frontend/src/app/components/apartments-page/apartments-page.html b/frontend/src/app/components/apartments-page/apartments-page.html new file mode 100644 index 0000000..fd6e65e --- /dev/null +++ b/frontend/src/app/components/apartments-page/apartments-page.html @@ -0,0 +1,16 @@ +
+ @if (!showForm()) { + + } @else { + + } +
diff --git a/frontend/src/app/components/apartments-page/apartments-page.spec.ts b/frontend/src/app/components/apartments-page/apartments-page.spec.ts new file mode 100644 index 0000000..71d8835 --- /dev/null +++ b/frontend/src/app/components/apartments-page/apartments-page.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { ApartmentsPage } from './apartments-page'; + +describe('ApartmentsPage', () => { + it('opens the form for the apartment selected for editing', () => { + const apartment = { + id: 'apt-1', + name: 'T2 Paris 15', + address: '45 rue des Entrepreneurs, 75015 Paris', + purchasePrice: 250000, + description: '', + }; + const fixture = TestBed.createComponent(ApartmentsPage); + fixture.componentRef.setInput('apartments', [apartment]); + fixture.detectChanges(); + + const page = fixture.componentInstance; + page.openEditForm(apartment); + + expect(page.showForm()).toBe(true); + expect(page.editingApartment()).toEqual(apartment); + }); + + it('emits the edited apartment together with its id', () => { + const apartment = { + id: 'apt-1', + name: 'T2 Paris 15', + address: '45 rue des Entrepreneurs, 75015 Paris', + purchasePrice: 250000, + description: '', + }; + const fixture = TestBed.createComponent(ApartmentsPage); + fixture.componentRef.setInput('apartments', [apartment]); + fixture.detectChanges(); + const page = fixture.componentInstance; + const apartmentSaved = vi.fn(); + page.apartmentSaved.subscribe(apartmentSaved); + page.openEditForm(apartment); + + page.saveApartment({ ...apartment, name: 'T2 rénové' }); + + expect(apartmentSaved).toHaveBeenCalledWith({ + apartment: { ...apartment, name: 'T2 rénové' }, + apartmentId: 'apt-1', + }); + expect(page.showForm()).toBe(false); + }); +}); diff --git a/frontend/src/app/components/apartments-page/apartments-page.ts b/frontend/src/app/components/apartments-page/apartments-page.ts new file mode 100644 index 0000000..693cbb8 --- /dev/null +++ b/frontend/src/app/components/apartments-page/apartments-page.ts @@ -0,0 +1,43 @@ +import { Component, input, output, signal } from '@angular/core'; +import { Apartment } from '../../services/apartment.service'; +import { ApartmentForm } from '../apartment-form/apartment-form'; +import { ApartmentList } from '../apartment-list/apartment-list'; + +export interface ApartmentSaveRequest { + apartment: Apartment; + apartmentId?: string; +} + +@Component({ + selector: 'app-apartments-page', + imports: [ApartmentForm, ApartmentList], + templateUrl: './apartments-page.html', +}) +export class ApartmentsPage { + readonly apartments = input.required(); + readonly apartmentSaved = output(); + readonly deleteRequested = output(); + + readonly showForm = signal(false); + readonly editingApartment = signal(null); + + openAddForm(): void { + this.editingApartment.set(null); + this.showForm.set(true); + } + + openEditForm(apartment: Apartment): void { + this.editingApartment.set(apartment); + this.showForm.set(true); + } + + closeForm(): void { + this.showForm.set(false); + this.editingApartment.set(null); + } + + saveApartment(apartment: Apartment): void { + this.apartmentSaved.emit({ apartment, apartmentId: this.editingApartment()?.id }); + this.closeForm(); + } +} diff --git a/frontend/src/app/brand-mark.ts b/frontend/src/app/components/brand-mark.ts similarity index 100% rename from frontend/src/app/brand-mark.ts rename to frontend/src/app/components/brand-mark.ts diff --git a/frontend/src/app/components/dashboard-page/dashboard-page.css b/frontend/src/app/components/dashboard-page/dashboard-page.css new file mode 100644 index 0000000..c5514d7 --- /dev/null +++ b/frontend/src/app/components/dashboard-page/dashboard-page.css @@ -0,0 +1,195 @@ +:host { + display: block; +} +.page-section { + animation: page-in 180ms ease-out; +} +@keyframes page-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.page-heading { + margin-bottom: 30px; +} +.page-heading h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 7vw, 3.2rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.02; +} +.page-heading > div > p:last-child { + margin-bottom: 0; + color: var(--text-muted); + line-height: 1.6; +} +.status-pill, +.role-chip { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 999px; + background: var(--surface-accent); + color: var(--accent-strong); + font-size: 0.76rem; + font-weight: 750; +} +.status-pill { + gap: 7px; + margin-top: 18px; + padding: 8px 12px; +} +.status-pill span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} +.dashboard-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} +.feature-card { + padding: 22px; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); +} +.feature-card h2 { + margin-bottom: 9px; + font-size: 1.35rem; + letter-spacing: -0.035em; +} +.feature-card p:not(.eyebrow) { + color: var(--text-muted); + line-height: 1.55; +} +.feature-card-primary { + display: grid; + gap: 18px; + border-color: transparent; + background: var(--surface-accent); +} +.feature-card-primary .mat-mdc-button { + --mdc-text-button-label-text-color: var(--accent-strong); + width: fit-content; + padding-inline: 0; + font-weight: 750; +} +.feature-icon { + display: grid; + width: 48px; + height: 48px; + place-items: center; + border-radius: 16px; + background: var(--surface); + color: var(--accent); +} +.feature-icon svg { + width: 24px; + height: 24px; +} +.user-profile { + display: flex; + align-items: center; + gap: 16px; +} +.large-avatar { + display: grid; + width: 58px; + height: 58px; + flex: 0 0 58px; + place-items: center; + border-radius: 50%; + background: var(--surface-accent); + color: var(--accent); + font-size: 1.25rem; + font-weight: 800; +} +.user-details { + min-width: 0; +} +.user-details h3 { + margin-bottom: 3px; + font-size: 1.15rem; +} +.user-email { + overflow: hidden; + margin-bottom: 11px; + font-size: 0.84rem; + text-overflow: ellipsis; + white-space: nowrap; +} +.role-chip { + padding: 5px 9px; +} +.api-demo-section { + display: grid; + gap: 16px; +} +.api-actions .mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: var(--accent); + --mdc-outlined-button-outline-color: var(--border-strong); + border-radius: 12px; +} +.api-response-wrapper { + padding: 14px; + border-radius: 13px; + background: var(--surface-soft); +} +.api-response-wrapper > span { + display: block; + margin-bottom: 5px; + color: var(--text-subtle); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; +} +.api-response-code { + color: var(--accent-strong); + font-size: 0.83rem; + overflow-wrap: anywhere; +} +@media (min-width: 700px) { + .page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + } + .dashboard-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .feature-card-primary, + .api-demo-section { + grid-column: span 2; + } +} +@media (min-width: 960px) { + .dashboard-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .feature-card-primary { + grid-column: span 2; + min-height: 250px; + } + .user-profile { + grid-column: span 1; + } + .api-demo-section { + grid-column: span 3; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + } + .api-response-wrapper { + grid-column: 1 / -1; + } +} diff --git a/frontend/src/app/components/dashboard-page/dashboard-page.html b/frontend/src/app/components/dashboard-page/dashboard-page.html new file mode 100644 index 0000000..6d5e8f9 --- /dev/null +++ b/frontend/src/app/components/dashboard-page/dashboard-page.html @@ -0,0 +1,54 @@ +
+
+
+

Bonjour {{ username() }}

+

Tableau de bord

+

Retrouvez rapidement les actions utiles à votre gestion locative.

+
+ Espace sécurisé +
+
+
+ +
+

Votre portefeuille

+

{{ apartmentCount() }} appartement{{ apartmentCount() > 1 ? 's' : '' }}

+

Centralisez les informations essentielles de vos biens.

+
+ +
+ +
+
+

État du service

+

Connexion au serveur

+

Vérifiez que votre espace communique correctement avec le service sécurisé.

+
+
+ +
+ @if (helloMessage()) { +
+ Réponse du serveur{{ helloMessage() }} +
+ } +
+
+
diff --git a/frontend/src/app/components/dashboard-page/dashboard-page.ts b/frontend/src/app/components/dashboard-page/dashboard-page.ts new file mode 100644 index 0000000..904eb56 --- /dev/null +++ b/frontend/src/app/components/dashboard-page/dashboard-page.ts @@ -0,0 +1,18 @@ +import { Component, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'app-dashboard-page', + imports: [MatButtonModule], + templateUrl: './dashboard-page.html', + styleUrl: './dashboard-page.css', +}) +export class DashboardPage { + readonly username = input.required(); + readonly email = input.required(); + readonly userInitial = input.required(); + readonly apartmentCount = input.required(); + readonly helloMessage = input.required(); + readonly apartmentsRequested = output(); + readonly connectionCheckRequested = output(); +} diff --git a/frontend/src/app/components/settings-page/settings-page.css b/frontend/src/app/components/settings-page/settings-page.css new file mode 100644 index 0000000..6f5f855 --- /dev/null +++ b/frontend/src/app/components/settings-page/settings-page.css @@ -0,0 +1,150 @@ +:host { + display: block; +} +.page-section { + animation: page-in 180ms ease-out; +} +@keyframes page-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.page-heading { + margin-bottom: 30px; +} +.page-heading h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 7vw, 3.2rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.02; +} +.page-heading > div > p:last-child { + margin-bottom: 0; + color: var(--text-muted); + line-height: 1.6; +} +.settings-card { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); +} +.settings-card h2 { + margin-bottom: 9px; + font-size: 1.35rem; + letter-spacing: -0.035em; +} +.settings-card p:not(.eyebrow) { + color: var(--text-muted); + line-height: 1.55; +} +.settings-copy { + display: flex; + gap: 15px; + padding: 22px; + border-bottom: 1px solid var(--border); +} +.settings-icon { + display: grid; + width: 46px; + height: 46px; + flex: 0 0 46px; + place-items: center; + border-radius: 16px; + background: var(--surface-accent); + color: var(--accent); +} +.settings-icon svg { + width: 24px; + height: 24px; +} +.settings-copy h2 { + margin-bottom: 5px; +} +.settings-copy p:last-child { + margin-bottom: 0; + font-size: 0.88rem; +} +.theme-options { + display: grid; + padding: 8px; +} +.theme-options mat-radio-button { + border-radius: 14px; +} +.theme-options mat-radio-button:hover { + background: var(--surface-soft); +} +.theme-option-copy { + display: flex; + flex-direction: column; + padding: 12px 8px; +} +.theme-option-copy strong { + color: var(--text); +} +.theme-option-copy small { + margin-top: 3px; + color: var(--text-muted); + white-space: normal; +} +.session-settings { + margin-top: 16px; +} +.session-action { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 18px; + padding: 22px; +} +.session-action > div { + display: flex; + min-width: 0; + flex-direction: column; +} +.session-action strong, +.session-action span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-action span { + margin-top: 3px; + color: var(--text-muted); + font-size: 0.82rem; +} +.session-action .mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: var(--danger); + --mdc-outlined-button-outline-color: var(--border-strong); + border-radius: 12px; +} +@media (min-width: 700px) { + .page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + } +} +@media (min-width: 960px) { + .settings-card { + display: grid; + grid-template-columns: minmax(280px, 0.8fr) minmax(360px, 1.2fr); + } + .settings-copy { + border-right: 1px solid var(--border); + border-bottom: 0; + } + .session-action { + align-items: flex-start; + justify-content: center; + } +} diff --git a/frontend/src/app/components/settings-page/settings-page.html b/frontend/src/app/components/settings-page/settings-page.html new file mode 100644 index 0000000..bfd34bf --- /dev/null +++ b/frontend/src/app/components/settings-page/settings-page.html @@ -0,0 +1,69 @@ +
+
+
+

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. + +
+
+
+ +
+

Compte

+

Session

+

Gérez la session ouverte sur cet appareil.

+
+
+
+
+ {{ username() }}{{ email() }} +
+ +
+
+
diff --git a/frontend/src/app/components/settings-page/settings-page.ts b/frontend/src/app/components/settings-page/settings-page.ts new file mode 100644 index 0000000..db09a1e --- /dev/null +++ b/frontend/src/app/components/settings-page/settings-page.ts @@ -0,0 +1,18 @@ +import { Component, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { ThemePreference } from '../../services/theme.service'; + +@Component({ + selector: 'app-settings-page', + imports: [MatButtonModule, MatRadioModule], + templateUrl: './settings-page.html', + styleUrl: './settings-page.css', +}) +export class SettingsPage { + readonly username = input.required(); + readonly email = input.required(); + readonly themePreference = input.required(); + readonly themePreferenceChanged = output(); + readonly logoutRequested = output(); +} diff --git a/frontend/src/app/components/sign-in-page/sign-in-page.css b/frontend/src/app/components/sign-in-page/sign-in-page.css new file mode 100644 index 0000000..0a7c5e4 --- /dev/null +++ b/frontend/src/app/components/sign-in-page/sign-in-page.css @@ -0,0 +1,123 @@ +:host { + display: block; +} +.sign-in-shell { + min-height: 100vh; + background: var(--canvas); +} +.sign-in-panel { + display: flex; + min-height: 100vh; + flex-direction: column; + justify-content: center; + padding: 32px 24px; +} +.brand-lockup { + display: flex; + align-items: center; + gap: 13px; + margin-bottom: clamp(64px, 14vh, 120px); +} +.brand-lockup h1 { + margin-bottom: 2px; + font-size: 1.02rem; + font-weight: 800; + letter-spacing: -0.02em; +} +.brand-lockup p { + margin-bottom: 0; + color: var(--text-muted); + font-size: 0.76rem; +} +.sign-in-copy { + max-width: 510px; +} +.sign-in-copy h2 { + max-width: 480px; + margin-bottom: 18px; + font-size: clamp(2.35rem, 9vw, 4.8rem); + font-weight: 750; + letter-spacing: -0.065em; + line-height: 0.98; +} +.sign-in-copy > p { + max-width: 470px; + margin-bottom: 30px; + color: var(--text-muted); + font-size: 1rem; + line-height: 1.7; +} +.sign-in-action { + width: 100%; + max-width: 360px; +} +.sign-in-action svg { + width: 19px; + height: 19px; + margin-left: 8px; +} +.security-note { + display: flex; + align-items: center; + gap: 8px; + margin: 17px 0 0; + color: var(--text-subtle); + font-size: 0.78rem; +} +.security-note span { + color: var(--accent); + font-size: 0.55rem; +} +.sign-in-story { + display: none; +} +@media (min-width: 960px) { + .sign-in-shell { + display: grid; + grid-template-columns: minmax(520px, 0.92fr) minmax(480px, 1.08fr); + } + .sign-in-panel { + padding: 56px clamp(56px, 7vw, 112px); + } + .sign-in-story { + display: grid; + min-height: 100vh; + place-items: end start; + padding: 64px; + background: + radial-gradient(circle at 75% 20%, rgba(99, 199, 188, 0.24), transparent 30%), + linear-gradient(145deg, #153d38, #0e2925 55%, #0a1d1a); + color: #effbf8; + } + .story-card { + max-width: 560px; + } + .story-kicker { + color: #7ed6cc; + } + .story-card h2 { + margin-bottom: 42px; + font-size: clamp(2.4rem, 4vw, 4.5rem); + letter-spacing: -0.06em; + line-height: 1; + } + .story-list { + display: grid; + } + .story-list > div { + display: grid; + grid-template-columns: 42px 1fr; + gap: 12px; + padding: 17px 0; + border-top: 1px solid rgba(255, 255, 255, 0.13); + } + .story-list span { + color: #7ed6cc; + font-size: 0.75rem; + font-weight: 800; + } + .story-list p { + margin-bottom: 0; + color: rgba(239, 251, 248, 0.76); + } +} diff --git a/frontend/src/app/components/sign-in-page/sign-in-page.html b/frontend/src/app/components/sign-in-page/sign-in-page.html new file mode 100644 index 0000000..cd26b7e --- /dev/null +++ b/frontend/src/app/components/sign-in-page/sign-in-page.html @@ -0,0 +1,59 @@ +
+ + + +
diff --git a/frontend/src/app/components/sign-in-page/sign-in-page.ts b/frontend/src/app/components/sign-in-page/sign-in-page.ts new file mode 100644 index 0000000..c75f59c --- /dev/null +++ b/frontend/src/app/components/sign-in-page/sign-in-page.ts @@ -0,0 +1,13 @@ +import { Component, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { BrandMark } from '../brand-mark'; + +@Component({ + selector: 'app-sign-in-page', + imports: [BrandMark, MatButtonModule], + templateUrl: './sign-in-page.html', + styleUrl: './sign-in-page.css', +}) +export class SignInPage { + readonly loginRequested = output(); +} diff --git a/frontend/src/app/components/workspace-navigation/workspace-navigation.css b/frontend/src/app/components/workspace-navigation/workspace-navigation.css new file mode 100644 index 0000000..37a956e --- /dev/null +++ b/frontend/src/app/components/workspace-navigation/workspace-navigation.css @@ -0,0 +1,156 @@ +:host { + display: block; +} +.app-navigation { + position: fixed; + z-index: 20; + right: 0; + bottom: 0; + left: 0; + height: calc(72px + env(safe-area-inset-bottom)); + padding: 6px 10px env(safe-area-inset-bottom); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: 0 -10px 30px rgba(25, 40, 35, 0.06); + backdrop-filter: blur(18px); +} +.sidebar-brand, +.sidebar-user { + display: none; +} +.navigation-items { + display: grid; + height: 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; +} +.navigation-item.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + min-width: 0; + height: 60px; + border-radius: 14px; + padding: 5px 4px; + font-weight: 650; + letter-spacing: 0; +} +.navigation-item .mat-mdc-button-touch-target { + height: 60px; +} +.navigation-item svg { + width: 21px; + height: 21px; + margin: 0 auto 4px; +} +.navigation-item span { + display: block; + overflow: hidden; + font-size: 0.66rem; + text-overflow: ellipsis; + white-space: nowrap; +} +.navigation-item.active { + --mdc-text-button-label-text-color: var(--accent); + background: var(--surface-accent); +} +@media (min-width: 960px) { + .app-navigation { + top: 0; + right: auto; + bottom: auto; + width: 256px; + height: 100vh; + padding: 26px 18px; + border-top: 0; + border-right: 1px solid var(--border); + background: var(--surface); + box-shadow: none; + backdrop-filter: none; + } + .sidebar-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 32px; + } + .sidebar-brand > div { + display: flex; + flex-direction: column; + font-size: 0.86rem; + line-height: 1.15; + } + .sidebar-brand strong { + font-size: 1rem; + } + .sidebar-brand span:last-child { + color: var(--text-muted); + } + .navigation-items { + display: flex; + height: auto; + flex-direction: column; + gap: 6px; + } + .navigation-item.mat-mdc-button { + width: 100%; + height: 48px; + justify-content: flex-start; + border-radius: 13px; + padding: 0 14px; + } + .navigation-item .mat-mdc-button-touch-target { + height: 48px; + } + .navigation-item .mdc-button__label { + display: flex; + align-items: center; + gap: 12px; + } + .navigation-item svg { + width: 20px; + height: 20px; + margin: 0; + } + .navigation-item span { + font-size: 0.86rem; + } + .sidebar-user { + position: absolute; + right: 18px; + bottom: 24px; + left: 18px; + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 10px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface-soft); + } + .sidebar-user > div:nth-child(2) { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + } + .sidebar-user strong, + .sidebar-user span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .sidebar-user strong { + font-size: 0.82rem; + } + .sidebar-user span { + color: var(--text-muted); + font-size: 0.69rem; + } + .logout-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + grid-column: 1 / -1; + height: 34px; + border-top: 1px solid var(--border); + border-radius: 0; + font-size: 0.75rem; + } +} diff --git a/frontend/src/app/components/workspace-navigation/workspace-navigation.html b/frontend/src/app/components/workspace-navigation/workspace-navigation.html new file mode 100644 index 0000000..97ba524 --- /dev/null +++ b/frontend/src/app/components/workspace-navigation/workspace-navigation.html @@ -0,0 +1,84 @@ + diff --git a/frontend/src/app/components/workspace-navigation/workspace-navigation.ts b/frontend/src/app/components/workspace-navigation/workspace-navigation.ts new file mode 100644 index 0000000..b75ba44 --- /dev/null +++ b/frontend/src/app/components/workspace-navigation/workspace-navigation.ts @@ -0,0 +1,20 @@ +import { Component, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { BrandMark } from '../brand-mark'; + +export type WorkspaceTab = 'dashboard' | 'apartments' | 'settings'; + +@Component({ + selector: 'app-workspace-navigation', + imports: [BrandMark, MatButtonModule], + templateUrl: './workspace-navigation.html', + styleUrl: './workspace-navigation.css', +}) +export class WorkspaceNavigation { + readonly activeTab = input.required(); + readonly username = input.required(); + readonly email = input.required(); + readonly userInitial = input.required(); + readonly tabSelected = output(); + readonly logoutRequested = output(); +} diff --git a/frontend/src/app/apartment.service.spec.ts b/frontend/src/app/services/apartment.service.spec.ts similarity index 69% rename from frontend/src/app/apartment.service.spec.ts rename to frontend/src/app/services/apartment.service.spec.ts index f5628b1..cea3822 100644 --- a/frontend/src/app/apartment.service.spec.ts +++ b/frontend/src/app/services/apartment.service.spec.ts @@ -13,14 +13,11 @@ describe('ApartmentService', () => { get: vi.fn().mockReturnValue(of([])), post: vi.fn().mockReturnValue(of({})), put: vi.fn().mockReturnValue(of({})), - delete: vi.fn().mockReturnValue(of({})) + delete: vi.fn().mockReturnValue(of({})), }; TestBed.configureTestingModule({ - providers: [ - ApartmentService, - { provide: HttpClient, useValue: mockHttpClient } - ] + providers: [ApartmentService, { provide: HttpClient, useValue: mockHttpClient }], }); service = TestBed.inject(ApartmentService); @@ -32,41 +29,57 @@ describe('ApartmentService', () => { it('should call GET /api/apartments', () => { const mockApts: Apartment[] = [ - { id: '1', name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' } + { id: '1', name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }, ]; mockHttpClient.get.mockReturnValue(of(mockApts)); - service.getApartments().subscribe(data => { + service.getApartments().subscribe((data) => { expect(data).toEqual(mockApts); }); expect(mockHttpClient.get).toHaveBeenCalledWith('/api/apartments'); }); it('should call GET /api/apartments/:id', () => { - const mockApt: Apartment = { id: '1', name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }; + const mockApt: Apartment = { + id: '1', + name: 'Apt 1', + address: 'Addr 1', + purchasePrice: 100000, + description: 'Desc 1', + }; mockHttpClient.get.mockReturnValue(of(mockApt)); - service.getApartment('1').subscribe(data => { + service.getApartment('1').subscribe((data) => { expect(data).toEqual(mockApt); }); expect(mockHttpClient.get).toHaveBeenCalledWith('/api/apartments/1'); }); it('should call POST /api/apartments', () => { - const mockApt: Apartment = { name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }; + const mockApt: Apartment = { + name: 'Apt 1', + address: 'Addr 1', + purchasePrice: 100000, + description: 'Desc 1', + }; mockHttpClient.post.mockReturnValue(of({ id: '2', ...mockApt })); - service.createApartment(mockApt).subscribe(data => { + service.createApartment(mockApt).subscribe((data) => { expect(data.id).toBe('2'); }); expect(mockHttpClient.post).toHaveBeenCalledWith('/api/apartments', mockApt); }); it('should call PUT /api/apartments/:id', () => { - const mockApt: Apartment = { name: 'Updated Apt', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }; + const mockApt: Apartment = { + name: 'Updated Apt', + address: 'Addr 1', + purchasePrice: 100000, + description: 'Desc 1', + }; mockHttpClient.put.mockReturnValue(of({ id: '1', ...mockApt })); - service.updateApartment('1', mockApt).subscribe(data => { + service.updateApartment('1', mockApt).subscribe((data) => { expect(data.name).toBe('Updated Apt'); }); expect(mockHttpClient.put).toHaveBeenCalledWith('/api/apartments/1', mockApt); diff --git a/frontend/src/app/apartment.service.ts b/frontend/src/app/services/apartment.service.ts similarity index 97% rename from frontend/src/app/apartment.service.ts rename to frontend/src/app/services/apartment.service.ts index 9d4f933..7b50817 100644 --- a/frontend/src/app/apartment.service.ts +++ b/frontend/src/app/services/apartment.service.ts @@ -12,7 +12,7 @@ export interface Apartment { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ApartmentService { private readonly http = inject(HttpClient); diff --git a/frontend/src/app/config.service.spec.ts b/frontend/src/app/services/config.service.spec.ts similarity index 93% rename from frontend/src/app/config.service.spec.ts rename to frontend/src/app/services/config.service.spec.ts index 7854209..c0a37c1 100644 --- a/frontend/src/app/config.service.spec.ts +++ b/frontend/src/app/services/config.service.spec.ts @@ -6,7 +6,7 @@ describe('ConfigService', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [ConfigService] + providers: [ConfigService], }); service = TestBed.inject(ConfigService); diff --git a/frontend/src/app/config.service.ts b/frontend/src/app/services/config.service.ts similarity index 88% rename from frontend/src/app/config.service.ts rename to frontend/src/app/services/config.service.ts index 3f9f199..1a117a0 100644 --- a/frontend/src/app/config.service.ts +++ b/frontend/src/app/services/config.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ConfigService { async loadConfig(): Promise { diff --git a/frontend/src/app/theme.service.ts b/frontend/src/app/services/theme.service.ts similarity index 100% rename from frontend/src/app/theme.service.ts rename to frontend/src/app/services/theme.service.ts diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 9f1b5ed..14f72b2 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -93,3 +93,56 @@ input, textarea { font: inherit; } + +svg { + display: block; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +.brand-mark { + display: grid; + width: 42px; + height: 42px; + flex: 0 0 42px; + place-items: center; + border-radius: 14px; + background: var(--accent); + color: var(--accent-contrast); +} + +.primary-action.mat-mdc-unelevated-button { + --mdc-filled-button-container-color: var(--accent); + --mdc-filled-button-label-text-color: var(--accent-contrast); + min-height: 44px; + border-radius: 13px; + padding-inline: 20px; + font-weight: 700; +} + +.eyebrow, +.context-label, +.story-kicker, +.property-label { + margin-bottom: 8px; + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +}