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) {
-
-
-
-
-
-
LMNP Organizer
-
Gestion locative & fiscalité
-
-
-
-
-
Espace bailleur
-
Votre gestion locative, enfin lisible.
-
- Pilotez vos appartements et gardez vos démarches LMNP au même endroit, sur mobile comme
- sur ordinateur.
-
-
-
-
-
-
- ●
- Connexion sécurisée via OpenID Connect
-
-
-
-
-
+
} @else {
-
+
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 @@
+
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.
+
+
+
+
+ {{ userInitial() }}
+
+
Compte actif
+
{{ username() }}
+
{{ email() }}
+
Bailleur connecté
+
+
+
+
+
É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 @@
+
+
+
+
+
+
LMNP Organizer
+
Gestion locative & fiscalité
+
+
+
+
+
Espace bailleur
+
Votre gestion locative, enfin lisible.
+
+ Pilotez vos appartements et gardez vos démarches LMNP au même endroit, sur mobile comme sur
+ ordinateur.
+
+
+
+
+
+
+ ●
+ Connexion sécurisée via OpenID Connect
+
+
+
+
+
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;
+ }
+}