chore: add husky pre-commit hook and lint-staged configuration
- Created a pre-commit hook in .husky to run lint-staged. - Added .lintstagedrc.mjs to format frontend files with Prettier and apply Spotless to backend Java files. - Introduced .prettierignore to exclude specific directories from formatting.
This commit is contained in:
1 parent
5d2d85aa8c
commit
878da155bd
564 files changed
+61467
-47179
No files matched your search
@@ -1,24 +1,28 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { MAT_NATIVE_DATE_FORMATS, MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/core';
|
||||
import {
|
||||
MAT_NATIVE_DATE_FORMATS,
|
||||
MAT_DATE_LOCALE,
|
||||
provideNativeDateAdapter,
|
||||
} from '@angular/material/core';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
const frenchDateFormats = {
|
||||
...MAT_NATIVE_DATE_FORMATS,
|
||||
display: {
|
||||
...MAT_NATIVE_DATE_FORMATS.display,
|
||||
dateInput: { year: 'numeric', month: '2-digit', day: '2-digit' },
|
||||
},
|
||||
...MAT_NATIVE_DATE_FORMATS,
|
||||
display: {
|
||||
...MAT_NATIVE_DATE_FORMATS.display,
|
||||
dateInput: { year: 'numeric', month: '2-digit', day: '2-digit' },
|
||||
},
|
||||
};
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideNativeDateAdapter(frenchDateFormats),
|
||||
{ provide: MAT_DATE_LOCALE, useValue: 'fr-FR' },
|
||||
],
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideNativeDateAdapter(frenchDateFormats),
|
||||
{ provide: MAT_DATE_LOCALE, useValue: 'fr-FR' },
|
||||
],
|
||||
};
|
||||
+58
-58
@@ -1,93 +1,93 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-state {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--canvas);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--canvas);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-state strong {
|
||||
color: var(--text);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--canvas);
|
||||
min-height: 100vh;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.workspace-main {
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
height: 66px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
height: 66px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.92rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.mobile-brand .brand-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-basis: 34px;
|
||||
border-radius: 11px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-basis: 34px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.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-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
width: 100%;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 20px 112px;
|
||||
width: 100%;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 20px 112px;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.page-container {
|
||||
padding: 42px 36px 116px;
|
||||
}
|
||||
.page-container {
|
||||
padding: 42px 36px 116px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.workspace-main {
|
||||
margin-left: 256px;
|
||||
}
|
||||
.mobile-header {
|
||||
display: none;
|
||||
}
|
||||
.page-container {
|
||||
padding: 64px clamp(48px, 6vw, 82px);
|
||||
}
|
||||
.workspace-main {
|
||||
margin-left: 256px;
|
||||
}
|
||||
.mobile-header {
|
||||
display: none;
|
||||
}
|
||||
.page-container {
|
||||
padding: 64px clamp(48px, 6vw, 82px);
|
||||
}
|
||||
}
|
||||
+38
-38
@@ -1,46 +1,46 @@
|
||||
@if (
|
||||
session.status() === 'checking' ||
|
||||
session.status() === 'signing-out' ||
|
||||
(session.isLoggedIn() && profileCheckStatus() === 'checking')
|
||||
session.status() === 'checking' ||
|
||||
session.status() === 'signing-out' ||
|
||||
(session.isLoggedIn() && profileCheckStatus() === 'checking')
|
||||
) {
|
||||
<section class="session-state" role="status" aria-live="polite">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<mat-spinner diameter="34" />
|
||||
<strong>{{
|
||||
session.status() === 'signing-out'
|
||||
? 'Déconnexion…'
|
||||
: profileCheckStatus() === 'checking'
|
||||
? 'Vérification de votre profil…'
|
||||
: 'Vérification de votre session…'
|
||||
}}</strong>
|
||||
</section>
|
||||
<section class="session-state" role="status" aria-live="polite">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<mat-spinner diameter="34" />
|
||||
<strong>{{
|
||||
session.status() === 'signing-out'
|
||||
? 'Déconnexion…'
|
||||
: profileCheckStatus() === 'checking'
|
||||
? 'Vérification de votre profil…'
|
||||
: 'Vérification de votre session…'
|
||||
}}</strong>
|
||||
</section>
|
||||
} @else if (session.status() === 'error') {
|
||||
<section class="session-state" role="alert">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<strong>Impossible de vérifier votre session</strong>
|
||||
<p>Vérifiez votre connexion puis réessayez.</p>
|
||||
<button mat-flat-button type="button" class="primary-action" (click)="session.refresh()">
|
||||
Réessayer
|
||||
</button>
|
||||
</section>
|
||||
<section class="session-state" role="alert">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<strong>Impossible de vérifier votre session</strong>
|
||||
<p>Vérifiez votre connexion puis réessayez.</p>
|
||||
<button mat-flat-button type="button" class="primary-action" (click)="session.refresh()">
|
||||
Réessayer
|
||||
</button>
|
||||
</section>
|
||||
} @else if (!session.isLoggedIn()) {
|
||||
<app-sign-in-page (loginRequested)="login()" />
|
||||
<app-sign-in-page (loginRequested)="login()" />
|
||||
} @else {
|
||||
<div class="workspace-shell">
|
||||
<app-workspace-navigation [userInitial]="userInitial()" (logoutRequested)="logout()" />
|
||||
<div class="workspace-shell">
|
||||
<app-workspace-navigation [userInitial]="userInitial()" (logoutRequested)="logout()" />
|
||||
|
||||
<main class="workspace-main">
|
||||
<header class="mobile-header">
|
||||
<div class="mobile-brand">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<strong>LMNP Organizer</strong>
|
||||
</div>
|
||||
<div class="compact-avatar">{{ userInitial() }}</div>
|
||||
</header>
|
||||
<main class="workspace-main">
|
||||
<header class="mobile-header">
|
||||
<div class="mobile-brand">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<strong>LMNP Organizer</strong>
|
||||
</div>
|
||||
<div class="compact-avatar">{{ userInitial() }}</div>
|
||||
</header>
|
||||
|
||||
<div class="page-container">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div class="page-container">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
@@ -2,20 +2,20 @@ import { describe, expect, it } from 'vitest';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
describe('routes', () => {
|
||||
it('maps every workspace page to a URL', () => {
|
||||
expect(routes.map((route) => route.path)).toEqual([
|
||||
'dashboard',
|
||||
'apartments',
|
||||
'tenants',
|
||||
'leases/:leaseId/finalize',
|
||||
'leases/:leaseId/end',
|
||||
'leases/:leaseId/condition-reports/:type',
|
||||
'leases/:leaseId/inventory',
|
||||
'leases',
|
||||
'rent-payments',
|
||||
'settings',
|
||||
'',
|
||||
'**',
|
||||
]);
|
||||
});
|
||||
it('maps every workspace page to a URL', () => {
|
||||
expect(routes.map((route) => route.path)).toEqual([
|
||||
'dashboard',
|
||||
'apartments',
|
||||
'tenants',
|
||||
'leases/:leaseId/finalize',
|
||||
'leases/:leaseId/end',
|
||||
'leases/:leaseId/condition-reports/:type',
|
||||
'leases/:leaseId/inventory',
|
||||
'leases',
|
||||
'rent-payments',
|
||||
'settings',
|
||||
'',
|
||||
'**',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -11,16 +11,16 @@ import { ConditionReportPage } from './components/condition-report-page/conditio
|
||||
import { InventoryPage } from './components/inventory-page/inventory-page';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'dashboard', component: DashboardPage },
|
||||
{ path: 'apartments', component: ApartmentsPage },
|
||||
{ path: 'tenants', component: TenantsPage },
|
||||
{ path: 'leases/:leaseId/finalize', component: LeaseFinalizationPage },
|
||||
{ path: 'leases/:leaseId/end', component: LeaseEndPage },
|
||||
{ path: 'leases/:leaseId/condition-reports/:type', component: ConditionReportPage },
|
||||
{ path: 'leases/:leaseId/inventory', component: InventoryPage },
|
||||
{ path: 'leases', component: LeasesPage },
|
||||
{ path: 'rent-payments', component: RentPaymentsPage },
|
||||
{ path: 'settings', component: SettingsPage },
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
{ path: 'dashboard', component: DashboardPage },
|
||||
{ path: 'apartments', component: ApartmentsPage },
|
||||
{ path: 'tenants', component: TenantsPage },
|
||||
{ path: 'leases/:leaseId/finalize', component: LeaseFinalizationPage },
|
||||
{ path: 'leases/:leaseId/end', component: LeaseEndPage },
|
||||
{ path: 'leases/:leaseId/condition-reports/:type', component: ConditionReportPage },
|
||||
{ path: 'leases/:leaseId/inventory', component: InventoryPage },
|
||||
{ path: 'leases', component: LeasesPage },
|
||||
{ path: 'rent-payments', component: RentPaymentsPage },
|
||||
{ path: 'settings', component: SettingsPage },
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
];
|
||||
+112
-112
@@ -9,131 +9,131 @@ import { LandlordProfileService } from './services/landlord-profile.service';
|
||||
import { SessionService, SessionStatus } from './services/session.service';
|
||||
|
||||
describe('App', () => {
|
||||
const status = signal<SessionStatus>('checking');
|
||||
const session = {
|
||||
status,
|
||||
isLoggedIn: signal(false),
|
||||
username: signal(''),
|
||||
email: signal(''),
|
||||
refresh: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
const landlordProfile = { getProfile: vi.fn() };
|
||||
const dialog = { open: vi.fn() };
|
||||
const status = signal<SessionStatus>('checking');
|
||||
const session = {
|
||||
status,
|
||||
isLoggedIn: signal(false),
|
||||
username: signal(''),
|
||||
email: signal(''),
|
||||
refresh: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
const landlordProfile = { getProfile: vi.fn() };
|
||||
const dialog = { open: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
status.set('checking');
|
||||
session.isLoggedIn.set(false);
|
||||
session.username.set('');
|
||||
session.email.set('');
|
||||
vi.clearAllMocks();
|
||||
landlordProfile.getProfile.mockReturnValue(
|
||||
of({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
complete: true,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: SessionService, useValue: session },
|
||||
{ provide: LandlordProfileService, useValue: landlordProfile },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
status.set('checking');
|
||||
session.isLoggedIn.set(false);
|
||||
session.username.set('');
|
||||
session.email.set('');
|
||||
vi.clearAllMocks();
|
||||
landlordProfile.getProfile.mockReturnValue(
|
||||
of({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
complete: true,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: SessionService, useValue: session },
|
||||
{ provide: LandlordProfileService, useValue: landlordProfile },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a neutral session loader before authentication resolves', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
it('shows a neutral session loader before authentication resolves', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Vérification de votre session');
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Vérification de votre session');
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a retry when the session cannot be checked', () => {
|
||||
status.set('error');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
it('offers a retry when the session cannot be checked', () => {
|
||||
status.set('error');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de vérifier votre session');
|
||||
fixture.nativeElement.querySelector('button').click();
|
||||
expect(session.refresh).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de vérifier votre session');
|
||||
fixture.nativeElement.querySelector('button').click();
|
||||
expect(session.refresh).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the sign-in page for an anonymous session', () => {
|
||||
status.set('anonymous');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
it('renders the sign-in page for an anonymous session', () => {
|
||||
status.set('anonymous');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-workspace-navigation')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-workspace-navigation')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the workspace shell for an authenticated session', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
session.username.set('Camille');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
it('renders the workspace shell for an authenticated session', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
session.username.set('Camille');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-workspace-navigation')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('app-workspace-navigation')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens an unclosable profile-completion dialog for an incomplete landlord profile', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
landlordProfile.getProfile.mockReturnValueOnce(
|
||||
of({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
it('opens an unclosable profile-completion dialog for an incomplete landlord profile', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
landlordProfile.getProfile.mockReturnValueOnce(
|
||||
of({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
|
||||
disableClose: true,
|
||||
closeOnNavigation: false,
|
||||
data: expect.objectContaining({
|
||||
profile: expect.objectContaining({ phone: '', address: '' }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
|
||||
disableClose: true,
|
||||
closeOnNavigation: false,
|
||||
data: expect.objectContaining({
|
||||
profile: expect.objectContaining({ phone: '', address: '' }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open the profile-completion dialog for a complete landlord profile', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
it('does not open the profile-completion dialog for a complete landlord profile', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps profile completion available when the initial profile request fails', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
landlordProfile.getProfile.mockReturnValueOnce(throwError(() => new Error('offline')));
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
it('keeps profile completion available when the initial profile request fails', () => {
|
||||
status.set('authenticated');
|
||||
session.isLoggedIn.set(true);
|
||||
landlordProfile.getProfile.mockReturnValueOnce(throwError(() => new Error('offline')));
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
|
||||
disableClose: true,
|
||||
closeOnNavigation: false,
|
||||
data: { profile: null },
|
||||
});
|
||||
});
|
||||
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
|
||||
disableClose: true,
|
||||
closeOnNavigation: false,
|
||||
data: { profile: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
+56
-56
@@ -8,73 +8,73 @@ import { LandlordProfileCompletionDialog } from './components/landlord-profile-c
|
||||
import { SignInPage } from './components/sign-in-page/sign-in-page';
|
||||
import { WorkspaceNavigation } from './components/workspace-navigation/workspace-navigation';
|
||||
import {
|
||||
LandlordProfileResponse,
|
||||
LandlordProfileService,
|
||||
LandlordProfileResponse,
|
||||
LandlordProfileService,
|
||||
} from './services/landlord-profile.service';
|
||||
import { SessionService } from './services/session.service';
|
||||
import { ThemeService } from './services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
BrandMark,
|
||||
SignInPage,
|
||||
WorkspaceNavigation,
|
||||
RouterOutlet,
|
||||
MatProgressSpinnerModule,
|
||||
MatButtonModule,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
BrandMark,
|
||||
SignInPage,
|
||||
WorkspaceNavigation,
|
||||
RouterOutlet,
|
||||
MatProgressSpinnerModule,
|
||||
MatButtonModule,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App implements OnInit {
|
||||
readonly session = inject(SessionService);
|
||||
readonly theme = inject(ThemeService);
|
||||
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly landlordProfile = inject(LandlordProfileService);
|
||||
readonly profileCheckStatus = signal<'idle' | 'checking' | 'checked'>('idle');
|
||||
readonly session = inject(SessionService);
|
||||
readonly theme = inject(ThemeService);
|
||||
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly landlordProfile = inject(LandlordProfileService);
|
||||
readonly profileCheckStatus = signal<'idle' | 'checking' | 'checked'>('idle');
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
if (!this.session.isLoggedIn()) {
|
||||
this.profileCheckStatus.set('idle');
|
||||
return;
|
||||
}
|
||||
if (untracked(this.profileCheckStatus) !== 'idle') return;
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
if (!this.session.isLoggedIn()) {
|
||||
this.profileCheckStatus.set('idle');
|
||||
return;
|
||||
}
|
||||
if (untracked(this.profileCheckStatus) !== 'idle') return;
|
||||
|
||||
this.profileCheckStatus.set('checking');
|
||||
const profileRequest = this.landlordProfile.getProfile().subscribe({
|
||||
next: (profile) => {
|
||||
this.profileCheckStatus.set('checked');
|
||||
if (!profile.complete) this.openProfileCompletion(profile);
|
||||
},
|
||||
error: () => {
|
||||
this.profileCheckStatus.set('checked');
|
||||
this.openProfileCompletion(null);
|
||||
},
|
||||
});
|
||||
onCleanup(() => profileRequest.unsubscribe());
|
||||
});
|
||||
}
|
||||
this.profileCheckStatus.set('checking');
|
||||
const profileRequest = this.landlordProfile.getProfile().subscribe({
|
||||
next: (profile) => {
|
||||
this.profileCheckStatus.set('checked');
|
||||
if (!profile.complete) this.openProfileCompletion(profile);
|
||||
},
|
||||
error: () => {
|
||||
this.profileCheckStatus.set('checked');
|
||||
this.openProfileCompletion(null);
|
||||
},
|
||||
});
|
||||
onCleanup(() => profileRequest.unsubscribe());
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.session.refresh();
|
||||
}
|
||||
ngOnInit() {
|
||||
this.session.refresh();
|
||||
}
|
||||
|
||||
login() {
|
||||
window.location.href = '/oauth2/authorization/oidc';
|
||||
}
|
||||
login() {
|
||||
window.location.href = '/oauth2/authorization/oidc';
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.session.logout();
|
||||
}
|
||||
logout() {
|
||||
this.session.logout();
|
||||
}
|
||||
|
||||
private openProfileCompletion(profile: LandlordProfileResponse | null): void {
|
||||
this.dialog.open(LandlordProfileCompletionDialog, {
|
||||
disableClose: true,
|
||||
closeOnNavigation: false,
|
||||
data: { profile },
|
||||
});
|
||||
}
|
||||
private openProfileCompletion(profile: LandlordProfileResponse | null): void {
|
||||
this.dialog.open(LandlordProfileCompletionDialog, {
|
||||
disableClose: true,
|
||||
closeOnNavigation: false,
|
||||
data: { profile },
|
||||
});
|
||||
}
|
||||
}
|
||||
+70
-66
@@ -7,81 +7,85 @@ import { FileDownloadService } from '../../services/file-download.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { withoutRecordKey, withoutSetValue } from '../../services/immutable-state';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
} from '../confirmation-dialog/confirmation-dialog';
|
||||
|
||||
@Injectable()
|
||||
export class ApartmentDocumentActions {
|
||||
private readonly apartments = inject(ApartmentService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly downloads = inject(FileDownloadService);
|
||||
private readonly apartments = inject(ApartmentService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly downloads = inject(FileDownloadService);
|
||||
|
||||
readonly confirmingDeleteIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly deletingIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly deleteErrors = signal<Record<string, string>>({});
|
||||
readonly confirmingDeleteIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly deletingIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly deleteErrors = signal<Record<string, string>>({});
|
||||
|
||||
download(apartmentId: string, document: ApartmentDocument): void {
|
||||
this.downloads.download(
|
||||
this.downloadKey(document.id),
|
||||
this.apartments.downloadDocument(apartmentId, document.id),
|
||||
document.fileName,
|
||||
'Impossible de télécharger ce document.',
|
||||
);
|
||||
}
|
||||
download(apartmentId: string, document: ApartmentDocument): void {
|
||||
this.downloads.download(
|
||||
this.downloadKey(document.id),
|
||||
this.apartments.downloadDocument(apartmentId, document.id),
|
||||
document.fileName,
|
||||
'Impossible de télécharger ce document.',
|
||||
);
|
||||
}
|
||||
|
||||
downloadPending(documentId: string): boolean {
|
||||
return this.downloads.isPending(this.downloadKey(documentId));
|
||||
}
|
||||
downloadPending(documentId: string): boolean {
|
||||
return this.downloads.isPending(this.downloadKey(documentId));
|
||||
}
|
||||
|
||||
downloadError(documentId: string): string {
|
||||
return this.downloads.error(this.downloadKey(documentId));
|
||||
}
|
||||
downloadError(documentId: string): string {
|
||||
return this.downloads.error(this.downloadKey(documentId));
|
||||
}
|
||||
|
||||
requestDelete(apartmentId: string, document: ApartmentDocument, onDeleted: () => void): void {
|
||||
if (this.confirmingDeleteIds().has(document.id) || this.deletingIds().has(document.id)) return;
|
||||
this.confirmingDeleteIds.update((ids) => new Set(ids).add(document.id));
|
||||
const data: ConfirmationDialogData = {
|
||||
title: 'Supprimer ce document ?',
|
||||
message: `Le fichier « ${document.fileName} » ne sera plus disponible pour les prochains baux.`,
|
||||
confirmLabel: 'Supprimer',
|
||||
};
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
this.confirmingDeleteIds.update((ids) => withoutSetValue(ids, document.id));
|
||||
if (confirmed) this.delete(apartmentId, document.id, onDeleted);
|
||||
});
|
||||
}
|
||||
requestDelete(apartmentId: string, document: ApartmentDocument, onDeleted: () => void): void {
|
||||
if (this.confirmingDeleteIds().has(document.id) || this.deletingIds().has(document.id))
|
||||
return;
|
||||
this.confirmingDeleteIds.update((ids) => new Set(ids).add(document.id));
|
||||
const data: ConfirmationDialogData = {
|
||||
title: 'Supprimer ce document ?',
|
||||
message: `Le fichier « ${document.fileName} » ne sera plus disponible pour les prochains baux.`,
|
||||
confirmLabel: 'Supprimer',
|
||||
};
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
this.confirmingDeleteIds.update((ids) => withoutSetValue(ids, document.id));
|
||||
if (confirmed) this.delete(apartmentId, document.id, onDeleted);
|
||||
});
|
||||
}
|
||||
|
||||
private delete(apartmentId: string, documentId: string, onDeleted: () => void): void {
|
||||
this.deletingIds.update((ids) => new Set(ids).add(documentId));
|
||||
this.deleteErrors.update((errors) => withoutRecordKey(errors, documentId));
|
||||
this.apartments
|
||||
.deleteDocument(apartmentId, documentId)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.deletingIds.update((ids) => withoutSetValue(ids, documentId))),
|
||||
)
|
||||
.subscribe({
|
||||
next: onDeleted,
|
||||
error: (error: unknown) =>
|
||||
this.deleteErrors.update((errors) => ({
|
||||
...errors,
|
||||
[documentId]: httpErrorMessage(error, 'Impossible de supprimer ce document.'),
|
||||
})),
|
||||
});
|
||||
}
|
||||
private delete(apartmentId: string, documentId: string, onDeleted: () => void): void {
|
||||
this.deletingIds.update((ids) => new Set(ids).add(documentId));
|
||||
this.deleteErrors.update((errors) => withoutRecordKey(errors, documentId));
|
||||
this.apartments
|
||||
.deleteDocument(apartmentId, documentId)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.deletingIds.update((ids) => withoutSetValue(ids, documentId))),
|
||||
)
|
||||
.subscribe({
|
||||
next: onDeleted,
|
||||
error: (error: unknown) =>
|
||||
this.deleteErrors.update((errors) => ({
|
||||
...errors,
|
||||
[documentId]: httpErrorMessage(
|
||||
error,
|
||||
'Impossible de supprimer ce document.',
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private downloadKey(documentId: string): string {
|
||||
return `apartment-document:${documentId}`;
|
||||
}
|
||||
private downloadKey(documentId: string): string {
|
||||
return `apartment-document:${documentId}`;
|
||||
}
|
||||
}
|
||||
+56
-56
@@ -1,115 +1,115 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.document-manager {
|
||||
margin-block-start: var(--space-6, 1.5rem);
|
||||
padding: var(--space-5, 1.25rem);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-xl, 1.375rem);
|
||||
background: var(--surface, #ffffff);
|
||||
margin-block-start: var(--space-6, 1.5rem);
|
||||
padding: var(--space-5, 1.25rem);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-xl, 1.375rem);
|
||||
background: var(--surface, #ffffff);
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0;
|
||||
color: var(--text-muted, #687670);
|
||||
margin: 0;
|
||||
color: var(--text-muted, #687670);
|
||||
}
|
||||
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
margin: var(--space-5, 1.25rem) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
margin: var(--space-5, 1.25rem) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.document-list li {
|
||||
min-width: 0;
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.document-list h3,
|
||||
.file-name {
|
||||
margin-block-end: var(--space-2, 0.5rem);
|
||||
margin-block-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
.file-name,
|
||||
.document-list dt {
|
||||
color: var(--text-muted, #687670);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #687670);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.document-list dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
|
||||
gap: var(--space-3, 0.75rem);
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
|
||||
gap: var(--space-3, 0.75rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.document-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
}
|
||||
|
||||
.document-list dd {
|
||||
margin: var(--space-1, 0.25rem) 0 0;
|
||||
margin: var(--space-1, 0.25rem) 0 0;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
margin-block-start: var(--space-6, 1.5rem);
|
||||
padding-block-start: var(--space-5, 1.25rem);
|
||||
border-block-start: 1px solid var(--border, #dce5df);
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
margin-block-start: var(--space-6, 1.5rem);
|
||||
padding-block-start: var(--space-5, 1.25rem);
|
||||
border-block-start: 1px solid var(--border, #dce5df);
|
||||
}
|
||||
|
||||
.upload-form h3,
|
||||
.form-error {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-form fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger, #b42318);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.upload-submit {
|
||||
justify-self: start;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.file-picker {
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
min-width: 0;
|
||||
color: var(--text, #17211e);
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
min-width: 0;
|
||||
color: var(--text, #17211e);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.file-picker input {
|
||||
min-height: 2.75rem;
|
||||
max-width: 100%;
|
||||
min-height: 2.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.file-picker small {
|
||||
color: var(--text-muted, #687670);
|
||||
font-weight: 400;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-muted, #687670);
|
||||
font-weight: 400;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.date-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr));
|
||||
gap: var(--space-3, 0.75rem);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr));
|
||||
gap: var(--space-3, 0.75rem);
|
||||
}
|
||||
+135
-118
@@ -1,123 +1,140 @@
|
||||
<section
|
||||
class="document-manager"
|
||||
aria-labelledby="apartment-documents-heading"
|
||||
[attr.aria-busy]="loading()"
|
||||
class="document-manager"
|
||||
aria-labelledby="apartment-documents-heading"
|
||||
[attr.aria-busy]="loading()"
|
||||
>
|
||||
<header>
|
||||
<h2 id="apartment-documents-heading">Documents du logement</h2>
|
||||
<p>Conservez ici les diagnostics et pièces utiles au bail.</p>
|
||||
</header>
|
||||
<header>
|
||||
<h2 id="apartment-documents-heading">Documents du logement</h2>
|
||||
<p>Conservez ici les diagnostics et pièces utiles au bail.</p>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<p role="status">Chargement des documents…</p>
|
||||
} @else if (loadError()) {
|
||||
<div class="load-error" role="alert">
|
||||
<p>{{ loadError() }}</p>
|
||||
<button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else if (documents().length === 0) {
|
||||
<p>Aucun document enregistré.</p>
|
||||
} @else {
|
||||
<ul class="document-list">
|
||||
@for (document of documents(); track document.id) {
|
||||
<li>
|
||||
<h3>{{ typeLabel(document.type) }}</h3>
|
||||
<p class="file-name">{{ document.fileName }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Établi le</dt>
|
||||
<dd>{{ civilDate(document.establishedOn) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expiration</dt>
|
||||
<dd>
|
||||
{{ document.expiresOn ? civilDate(document.expiresOn) : 'Sans expiration' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="document-actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="download-document"
|
||||
[attr.aria-label]="'Télécharger ' + document.fileName"
|
||||
[disabled]="actions.downloadPending(document.id)"
|
||||
(click)="download(document)"
|
||||
>
|
||||
{{ actions.downloadPending(document.id) ? 'Téléchargement…' : 'Télécharger' }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="delete-document"
|
||||
[attr.aria-label]="'Supprimer ' + document.fileName"
|
||||
[disabled]="
|
||||
actions.confirmingDeleteIds().has(document.id) ||
|
||||
actions.deletingIds().has(document.id)
|
||||
"
|
||||
(click)="requestDelete(document)"
|
||||
>
|
||||
{{ actions.deletingIds().has(document.id) ? 'Suppression…' : 'Supprimer' }}
|
||||
</button>
|
||||
</div>
|
||||
@if (
|
||||
actions.downloadError(document.id) || actions.deleteErrors()[document.id];
|
||||
as actionError
|
||||
) {
|
||||
<p class="form-error" role="alert">{{ actionError }}</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@if (loading()) {
|
||||
<p role="status">Chargement des documents…</p>
|
||||
} @else if (loadError()) {
|
||||
<div class="load-error" role="alert">
|
||||
<p>{{ loadError() }}</p>
|
||||
<button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else if (documents().length === 0) {
|
||||
<p>Aucun document enregistré.</p>
|
||||
} @else {
|
||||
<ul class="document-list">
|
||||
@for (document of documents(); track document.id) {
|
||||
<li>
|
||||
<h3>{{ typeLabel(document.type) }}</h3>
|
||||
<p class="file-name">{{ document.fileName }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Établi le</dt>
|
||||
<dd>{{ civilDate(document.establishedOn) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expiration</dt>
|
||||
<dd>
|
||||
{{
|
||||
document.expiresOn
|
||||
? civilDate(document.expiresOn)
|
||||
: 'Sans expiration'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="document-actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="download-document"
|
||||
[attr.aria-label]="'Télécharger ' + document.fileName"
|
||||
[disabled]="actions.downloadPending(document.id)"
|
||||
(click)="download(document)"
|
||||
>
|
||||
{{
|
||||
actions.downloadPending(document.id)
|
||||
? 'Téléchargement…'
|
||||
: 'Télécharger'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="delete-document"
|
||||
[attr.aria-label]="'Supprimer ' + document.fileName"
|
||||
[disabled]="
|
||||
actions.confirmingDeleteIds().has(document.id) ||
|
||||
actions.deletingIds().has(document.id)
|
||||
"
|
||||
(click)="requestDelete(document)"
|
||||
>
|
||||
{{
|
||||
actions.deletingIds().has(document.id)
|
||||
? 'Suppression…'
|
||||
: 'Supprimer'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
@if (
|
||||
actions.downloadError(document.id) || actions.deleteErrors()[document.id];
|
||||
as actionError
|
||||
) {
|
||||
<p class="form-error" role="alert">{{ actionError }}</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<form
|
||||
class="upload-form"
|
||||
[formRoot]="uploadForm"
|
||||
[attr.aria-busy]="uploading()"
|
||||
(submit)="submitUpload(); $event.preventDefault()"
|
||||
>
|
||||
<h3>Ajouter ou remplacer un document</h3>
|
||||
<fieldset [disabled]="uploading()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Type de document</mat-label>
|
||||
<mat-select [formField]="uploadForm.type">
|
||||
<mat-option value="">Choisissez un type</mat-option>
|
||||
@for (type of documentTypes; track type.value) {
|
||||
<mat-option [value]="type.value">{{ type.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<label class="file-picker">
|
||||
<span>Fichier PDF</span>
|
||||
<input type="file" accept=".pdf,application/pdf" (change)="selectFile($event)" />
|
||||
<small>{{ selectedFile()?.name ?? 'Aucun fichier sélectionné' }}</small>
|
||||
</label>
|
||||
<div class="date-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date d’établissement</mat-label>
|
||||
<input matInput id="establishedOn" type="date" [formField]="uploadForm.establishedOn" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date d’expiration (facultative)</mat-label>
|
||||
<input matInput id="expiresOn" type="date" [formField]="uploadForm.expiresOn" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (validationError() || uploadError()) {
|
||||
<p class="form-error" role="alert">{{ validationError() || uploadError() }}</p>
|
||||
}
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
class="primary-action upload-submit"
|
||||
[disabled]="uploading()"
|
||||
>
|
||||
@if (uploading()) {
|
||||
Envoi…
|
||||
} @else {
|
||||
Téléverser / remplacer
|
||||
}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form
|
||||
class="upload-form"
|
||||
[formRoot]="uploadForm"
|
||||
[attr.aria-busy]="uploading()"
|
||||
(submit)="submitUpload(); $event.preventDefault()"
|
||||
>
|
||||
<h3>Ajouter ou remplacer un document</h3>
|
||||
<fieldset [disabled]="uploading()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Type de document</mat-label>
|
||||
<mat-select [formField]="uploadForm.type">
|
||||
<mat-option value="">Choisissez un type</mat-option>
|
||||
@for (type of documentTypes; track type.value) {
|
||||
<mat-option [value]="type.value">{{ type.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<label class="file-picker">
|
||||
<span>Fichier PDF</span>
|
||||
<input type="file" accept=".pdf,application/pdf" (change)="selectFile($event)" />
|
||||
<small>{{ selectedFile()?.name ?? 'Aucun fichier sélectionné' }}</small>
|
||||
</label>
|
||||
<div class="date-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date d’établissement</mat-label>
|
||||
<input
|
||||
matInput
|
||||
id="establishedOn"
|
||||
type="date"
|
||||
[formField]="uploadForm.establishedOn"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date d’expiration (facultative)</mat-label>
|
||||
<input matInput id="expiresOn" type="date" [formField]="uploadForm.expiresOn" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (validationError() || uploadError()) {
|
||||
<p class="form-error" role="alert">{{ validationError() || uploadError() }}</p>
|
||||
}
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
class="primary-action upload-submit"
|
||||
[disabled]="uploading()"
|
||||
>
|
||||
@if (uploading()) {
|
||||
Envoi…
|
||||
} @else {
|
||||
Téléverser / remplacer
|
||||
}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
+296
-282
@@ -4,320 +4,334 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { ApartmentDocumentManager } from './apartment-document-manager';
|
||||
|
||||
describe('ApartmentDocumentManager', () => {
|
||||
let http: HttpTestingController;
|
||||
let http: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
http.verify();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
http.verify();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows a truthful initial loading state before documents are known', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
it('shows a truthful initial loading state before documents are known', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
|
||||
const region = fixture.nativeElement.querySelector('section');
|
||||
expect(region.getAttribute('aria-busy')).toBe('true');
|
||||
expect(region.textContent).toContain('Chargement des documents');
|
||||
expect(region.textContent).not.toContain('Aucun document');
|
||||
const region = fixture.nativeElement.querySelector('section');
|
||||
expect(region.getAttribute('aria-busy')).toBe('true');
|
||||
expect(region.textContent).toContain('Chargement des documents');
|
||||
expect(region.textContent).not.toContain('Aucun document');
|
||||
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
});
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
});
|
||||
|
||||
it('lists current documents with French labels and civil dates', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||
{
|
||||
id: 'document-1',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: '2036-07-19',
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'a'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'document-2',
|
||||
type: 'CO_OWNERSHIP_EXTRACT',
|
||||
establishedOn: '2026-07-18',
|
||||
expiresOn: null,
|
||||
fileName: 'reglement.pdf',
|
||||
sha256: 'b'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T11:00:00Z',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
it('lists current documents with French labels and civil dates', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||
{
|
||||
id: 'document-1',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: '2036-07-19',
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'a'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'document-2',
|
||||
type: 'CO_OWNERSHIP_EXTRACT',
|
||||
establishedOn: '2026-07-18',
|
||||
expiresOn: null,
|
||||
fileName: 'reglement.pdf',
|
||||
sha256: 'b'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T11:00:00Z',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const rows = fixture.nativeElement.querySelectorAll('li');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('Diagnostic de performance énergétique (DPE)');
|
||||
expect(rows[0].textContent).toContain('19 juillet 2026');
|
||||
expect(rows[0].textContent).toContain('19 juillet 2036');
|
||||
expect(rows[1].textContent).toContain('Extrait du règlement de copropriété');
|
||||
expect(rows[1].textContent).toContain('Sans expiration');
|
||||
});
|
||||
const rows = fixture.nativeElement.querySelectorAll('li');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('Diagnostic de performance énergétique (DPE)');
|
||||
expect(rows[0].textContent).toContain('19 juillet 2026');
|
||||
expect(rows[0].textContent).toContain('19 juillet 2036');
|
||||
expect(rows[1].textContent).toContain('Extrait du règlement de copropriété');
|
||||
expect(rows[1].textContent).toContain('Sans expiration');
|
||||
});
|
||||
|
||||
it('explains that a document type is required before upload', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
it('explains that a document type is required before upload', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Choisissez un type de document.');
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Choisissez un type de document.');
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
|
||||
it('requires a PDF after the document type is selected', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
it('requires a PDF after the document type is selected', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Sélectionnez un fichier PDF.');
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Sélectionnez un fichier PDF.');
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
|
||||
it('requires a civil establishment date before upload', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
it('requires a civil establishment date before upload', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
|
||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: { item: () => file } });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
|
||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
configurable: true,
|
||||
value: { item: () => file },
|
||||
});
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Renseignez la date d’établissement du document.',
|
||||
);
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Renseignez la date d’établissement du document.',
|
||||
);
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
|
||||
it('rejects an expiry date before the establishment date', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
it('rejects an expiry date before the establishment date', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
|
||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: { item: () => file } });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
const establishedOn: HTMLInputElement = fixture.nativeElement.querySelector('#establishedOn');
|
||||
const expiresOn: HTMLInputElement = fixture.nativeElement.querySelector('#expiresOn');
|
||||
if (!establishedOn || !expiresOn) throw new Error('Champs de dates introuvables.');
|
||||
establishedOn.value = '2026-07-19';
|
||||
establishedOn.dispatchEvent(new Event('input'));
|
||||
expiresOn.value = '2026-07-18';
|
||||
expiresOn.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
|
||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
configurable: true,
|
||||
value: { item: () => file },
|
||||
});
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
const establishedOn: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('#establishedOn');
|
||||
const expiresOn: HTMLInputElement = fixture.nativeElement.querySelector('#expiresOn');
|
||||
if (!establishedOn || !expiresOn) throw new Error('Champs de dates introuvables.');
|
||||
establishedOn.value = '2026-07-19';
|
||||
establishedOn.dispatchEvent(new Event('input'));
|
||||
expiresOn.value = '2026-07-18';
|
||||
expiresOn.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
|
||||
);
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
|
||||
);
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
|
||||
it('uploads once, shows local progress, and publishes the replacement', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
it('uploads once, shows local progress, and publishes the replacement', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
const file = new File(['pdf'], 'dpe-2026.pdf', { type: 'application/pdf' });
|
||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: { item: () => file } });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
const establishedOn: HTMLInputElement = fixture.nativeElement.querySelector('#establishedOn');
|
||||
establishedOn.value = '2026-07-19';
|
||||
establishedOn.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const submit: HTMLButtonElement = fixture.nativeElement.querySelector('.upload-submit');
|
||||
submit.click();
|
||||
submit.click();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('mat-select').click();
|
||||
fixture.detectChanges();
|
||||
const dpeOption = [...document.querySelectorAll('mat-option')].find((option) =>
|
||||
option.textContent?.includes('Diagnostic de performance énergétique'),
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(dpeOption as HTMLElement).click();
|
||||
const file = new File(['pdf'], 'dpe-2026.pdf', { type: 'application/pdf' });
|
||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
configurable: true,
|
||||
value: { item: () => file },
|
||||
});
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
const establishedOn: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('#establishedOn');
|
||||
establishedOn.value = '2026-07-19';
|
||||
establishedOn.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const submit: HTMLButtonElement = fixture.nativeElement.querySelector('.upload-submit');
|
||||
submit.click();
|
||||
submit.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const upload = http.expectOne('/api/apartments/apartment-1/documents');
|
||||
expect(upload.request.method).toBe('POST');
|
||||
const formData = upload.request.body as FormData;
|
||||
expect(formData.get('file')).toBe(file);
|
||||
expect(formData.get('type')).toBe('DPE');
|
||||
expect(formData.get('establishedOn')).toBe('2026-07-19');
|
||||
expect(submit.disabled).toBe(true);
|
||||
expect(submit.textContent).toContain('Envoi…');
|
||||
upload.flush({
|
||||
id: 'document-2',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: null,
|
||||
fileName: 'dpe-2026.pdf',
|
||||
sha256: 'c'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T12:00:00Z',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const upload = http.expectOne('/api/apartments/apartment-1/documents');
|
||||
expect(upload.request.method).toBe('POST');
|
||||
const formData = upload.request.body as FormData;
|
||||
expect(formData.get('file')).toBe(file);
|
||||
expect(formData.get('type')).toBe('DPE');
|
||||
expect(formData.get('establishedOn')).toBe('2026-07-19');
|
||||
expect(submit.disabled).toBe(true);
|
||||
expect(submit.textContent).toContain('Envoi…');
|
||||
upload.flush({
|
||||
id: 'document-2',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: null,
|
||||
fileName: 'dpe-2026.pdf',
|
||||
sha256: 'c'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T12:00:00Z',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('dpe-2026.pdf');
|
||||
expect(submit.disabled).toBe(false);
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('dpe-2026.pdf');
|
||||
expect(submit.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a load failure and retries without presenting an empty result', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http
|
||||
.expectOne('/api/apartments/apartment-1/documents')
|
||||
.flush({}, { status: 500, statusText: 'Server Error' });
|
||||
fixture.detectChanges();
|
||||
it('shows a load failure and retries without presenting an empty result', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush(
|
||||
{},
|
||||
{ status: 500, statusText: 'Server Error' },
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de charger les documents du logement.',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Aucun document enregistré.');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de charger les documents du logement.',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Aucun document enregistré.');
|
||||
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Chargement des documents');
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
});
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Chargement des documents');
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||
});
|
||||
|
||||
it('downloads a document once with local pending feedback', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||
{
|
||||
id: 'document-1',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: null,
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'a'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: vi.fn(() => 'blob:document'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
it('downloads a document once with local pending feedback', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||
{
|
||||
id: 'document-1',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: null,
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'a'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: vi.fn(() => 'blob:document'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
|
||||
const download: HTMLButtonElement = fixture.nativeElement.querySelector('.download-document');
|
||||
download.click();
|
||||
download.click();
|
||||
fixture.detectChanges();
|
||||
const download: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.download-document');
|
||||
download.click();
|
||||
download.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
|
||||
expect(request.request.method).toBe('GET');
|
||||
expect(download.disabled).toBe(true);
|
||||
expect(download.textContent).toContain('Téléchargement…');
|
||||
request.flush(new Blob(['pdf'], { type: 'application/pdf' }));
|
||||
fixture.detectChanges();
|
||||
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
|
||||
expect(request.request.method).toBe('GET');
|
||||
expect(download.disabled).toBe(true);
|
||||
expect(download.textContent).toContain('Téléchargement…');
|
||||
request.flush(new Blob(['pdf'], { type: 'application/pdf' }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(URL.createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(download.disabled).toBe(false);
|
||||
});
|
||||
expect(URL.createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(download.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes a document only after an explicit confirmation', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||
{
|
||||
id: 'document-1',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: null,
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'a'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
vi.useFakeTimers();
|
||||
it('deletes a document only after an explicit confirmation', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||
{
|
||||
id: 'document-1',
|
||||
type: 'DPE',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: null,
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'a'.repeat(64),
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
vi.useFakeTimers();
|
||||
|
||||
fixture.nativeElement.querySelector('.delete-document').click();
|
||||
fixture.detectChanges();
|
||||
http.expectNone((request) => request.method === 'DELETE');
|
||||
const confirm: HTMLButtonElement | null = document.querySelector('.confirm-action');
|
||||
if (!confirm) throw new Error('Confirmation de suppression introuvable.');
|
||||
confirm.click();
|
||||
await vi.runAllTimersAsync();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.delete-document').click();
|
||||
fixture.detectChanges();
|
||||
http.expectNone((request) => request.method === 'DELETE');
|
||||
const confirm: HTMLButtonElement | null = document.querySelector('.confirm-action');
|
||||
if (!confirm) throw new Error('Confirmation de suppression introuvable.');
|
||||
confirm.click();
|
||||
await vi.runAllTimersAsync();
|
||||
fixture.detectChanges();
|
||||
|
||||
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
|
||||
expect(request.request.method).toBe('DELETE');
|
||||
const deleteButton: HTMLButtonElement = fixture.nativeElement.querySelector('.delete-document');
|
||||
expect(deleteButton.disabled).toBe(true);
|
||||
expect(deleteButton.textContent).toContain('Suppression…');
|
||||
request.flush(null);
|
||||
fixture.detectChanges();
|
||||
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
|
||||
expect(request.request.method).toBe('DELETE');
|
||||
const deleteButton: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.delete-document');
|
||||
expect(deleteButton.disabled).toBe(true);
|
||||
expect(deleteButton.textContent).toContain('Suppression…');
|
||||
request.flush(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
|
||||
expect(fixture.nativeElement.textContent).toContain('Aucun document enregistré.');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
|
||||
expect(fixture.nativeElement.textContent).toContain('Aucun document enregistré.');
|
||||
});
|
||||
});
|
||||
+172
-163
@@ -9,189 +9,198 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { finalize } from 'rxjs';
|
||||
import {
|
||||
ApartmentDocument,
|
||||
ApartmentDocumentType,
|
||||
ApartmentService,
|
||||
ApartmentDocument,
|
||||
ApartmentDocumentType,
|
||||
ApartmentService,
|
||||
} from '../../services/apartment.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { ApartmentDocumentActions } from './apartment-document-actions.service';
|
||||
import {
|
||||
apartmentDocumentTypeLabel,
|
||||
apartmentDocumentTypes,
|
||||
apartmentDocumentTypeLabel,
|
||||
apartmentDocumentTypes,
|
||||
} from '../../services/apartment-document-label';
|
||||
|
||||
const frenchDate = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'app-apartment-document-manager',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormField,
|
||||
FormRoot,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './apartment-document-manager.html',
|
||||
styleUrl: './apartment-document-manager.css',
|
||||
providers: [ApartmentDocumentActions],
|
||||
selector: 'app-apartment-document-manager',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormField,
|
||||
FormRoot,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './apartment-document-manager.html',
|
||||
styleUrl: './apartment-document-manager.css',
|
||||
providers: [ApartmentDocumentActions],
|
||||
})
|
||||
export class ApartmentDocumentManager {
|
||||
private readonly apartments = inject(ApartmentService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private requestId = 0;
|
||||
private readonly apartments = inject(ApartmentService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private requestId = 0;
|
||||
|
||||
readonly actions = inject(ApartmentDocumentActions);
|
||||
readonly apartmentId = input.required<string>();
|
||||
readonly documents = signal<ApartmentDocument[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly documentTypes = apartmentDocumentTypes;
|
||||
readonly uploadModel = signal({
|
||||
type: '' as ApartmentDocumentType | '',
|
||||
establishedOn: '',
|
||||
expiresOn: '',
|
||||
});
|
||||
readonly uploadForm = form(this.uploadModel, (fields) => {
|
||||
required(fields.type);
|
||||
required(fields.establishedOn);
|
||||
validate(fields.expiresOn, ({ value, valueOf }) =>
|
||||
value() && value() < valueOf(fields.establishedOn)
|
||||
? {
|
||||
kind: 'expiry-before-establishment',
|
||||
message:
|
||||
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
});
|
||||
readonly selectedFile = signal<File | null>(null);
|
||||
readonly uploading = signal(false);
|
||||
readonly validationError = signal('');
|
||||
readonly uploadError = signal('');
|
||||
readonly actions = inject(ApartmentDocumentActions);
|
||||
readonly apartmentId = input.required<string>();
|
||||
readonly documents = signal<ApartmentDocument[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly documentTypes = apartmentDocumentTypes;
|
||||
readonly uploadModel = signal({
|
||||
type: '' as ApartmentDocumentType | '',
|
||||
establishedOn: '',
|
||||
expiresOn: '',
|
||||
});
|
||||
readonly uploadForm = form(this.uploadModel, (fields) => {
|
||||
required(fields.type);
|
||||
required(fields.establishedOn);
|
||||
validate(fields.expiresOn, ({ value, valueOf }) =>
|
||||
value() && value() < valueOf(fields.establishedOn)
|
||||
? {
|
||||
kind: 'expiry-before-establishment',
|
||||
message:
|
||||
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
});
|
||||
readonly selectedFile = signal<File | null>(null);
|
||||
readonly uploading = signal(false);
|
||||
readonly validationError = signal('');
|
||||
readonly uploadError = signal('');
|
||||
|
||||
constructor() {
|
||||
effect(() => this.load(this.apartmentId()));
|
||||
}
|
||||
constructor() {
|
||||
effect(() => this.load(this.apartmentId()));
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.load(this.apartmentId());
|
||||
}
|
||||
retry(): void {
|
||||
this.load(this.apartmentId());
|
||||
}
|
||||
|
||||
typeLabel(type: ApartmentDocumentType): string {
|
||||
return apartmentDocumentTypeLabel(type);
|
||||
}
|
||||
typeLabel(type: ApartmentDocumentType): string {
|
||||
return apartmentDocumentTypeLabel(type);
|
||||
}
|
||||
|
||||
civilDate(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
civilDate(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
|
||||
download(document: ApartmentDocument): void {
|
||||
this.actions.download(this.apartmentId(), document);
|
||||
}
|
||||
download(document: ApartmentDocument): void {
|
||||
this.actions.download(this.apartmentId(), document);
|
||||
}
|
||||
|
||||
requestDelete(document: ApartmentDocument): void {
|
||||
this.actions.requestDelete(this.apartmentId(), document, () =>
|
||||
this.documents.update((documents) =>
|
||||
documents.filter((candidate) => candidate.id !== document.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
requestDelete(document: ApartmentDocument): void {
|
||||
this.actions.requestDelete(this.apartmentId(), document, () =>
|
||||
this.documents.update((documents) =>
|
||||
documents.filter((candidate) => candidate.id !== document.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
submitUpload(): void {
|
||||
if (this.uploading()) return;
|
||||
this.uploadForm().markAsTouched();
|
||||
const model = this.uploadModel();
|
||||
const file = this.selectedFile();
|
||||
if (!model.type) {
|
||||
this.validationError.set('Choisissez un type de document.');
|
||||
} else if (!file) {
|
||||
this.validationError.set('Sélectionnez un fichier PDF.');
|
||||
} else if (!model.establishedOn) {
|
||||
this.validationError.set('Renseignez la date d’établissement du document.');
|
||||
} else if (model.expiresOn && model.expiresOn < model.establishedOn) {
|
||||
this.validationError.set(
|
||||
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
|
||||
);
|
||||
} else {
|
||||
this.validationError.set('');
|
||||
this.uploadError.set('');
|
||||
this.uploading.set(true);
|
||||
this.apartments
|
||||
.uploadDocument(this.apartmentId(), {
|
||||
file,
|
||||
type: model.type,
|
||||
establishedOn: model.establishedOn,
|
||||
expiresOn: model.expiresOn || null,
|
||||
})
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.uploading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (uploaded) => {
|
||||
this.documents.update((documents) =>
|
||||
[...documents.filter((document) => document.type !== uploaded.type), uploaded].sort(
|
||||
(left, right) =>
|
||||
apartmentDocumentTypes.findIndex((type) => type.value === left.type) -
|
||||
apartmentDocumentTypes.findIndex((type) => type.value === right.type),
|
||||
),
|
||||
);
|
||||
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
|
||||
this.selectedFile.set(null);
|
||||
},
|
||||
error: (error: unknown) =>
|
||||
this.uploadError.set(httpErrorMessage(error, 'Impossible de téléverser ce document.')),
|
||||
});
|
||||
}
|
||||
}
|
||||
submitUpload(): void {
|
||||
if (this.uploading()) return;
|
||||
this.uploadForm().markAsTouched();
|
||||
const model = this.uploadModel();
|
||||
const file = this.selectedFile();
|
||||
if (!model.type) {
|
||||
this.validationError.set('Choisissez un type de document.');
|
||||
} else if (!file) {
|
||||
this.validationError.set('Sélectionnez un fichier PDF.');
|
||||
} else if (!model.establishedOn) {
|
||||
this.validationError.set('Renseignez la date d’établissement du document.');
|
||||
} else if (model.expiresOn && model.expiresOn < model.establishedOn) {
|
||||
this.validationError.set(
|
||||
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
|
||||
);
|
||||
} else {
|
||||
this.validationError.set('');
|
||||
this.uploadError.set('');
|
||||
this.uploading.set(true);
|
||||
this.apartments
|
||||
.uploadDocument(this.apartmentId(), {
|
||||
file,
|
||||
type: model.type,
|
||||
establishedOn: model.establishedOn,
|
||||
expiresOn: model.expiresOn || null,
|
||||
})
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.uploading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (uploaded) => {
|
||||
this.documents.update((documents) =>
|
||||
[
|
||||
...documents.filter((document) => document.type !== uploaded.type),
|
||||
uploaded,
|
||||
].sort(
|
||||
(left, right) =>
|
||||
apartmentDocumentTypes.findIndex(
|
||||
(type) => type.value === left.type,
|
||||
) -
|
||||
apartmentDocumentTypes.findIndex(
|
||||
(type) => type.value === right.type,
|
||||
),
|
||||
),
|
||||
);
|
||||
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
|
||||
this.selectedFile.set(null);
|
||||
},
|
||||
error: (error: unknown) =>
|
||||
this.uploadError.set(
|
||||
httpErrorMessage(error, 'Impossible de téléverser ce document.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
selectFile(event: Event): void {
|
||||
const input = event.target;
|
||||
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||
if (
|
||||
file &&
|
||||
file.name.toLowerCase().endsWith('.pdf') &&
|
||||
(file.type === 'application/pdf' || file.type === '')
|
||||
) {
|
||||
this.selectedFile.set(file);
|
||||
this.validationError.set('');
|
||||
} else {
|
||||
this.selectedFile.set(null);
|
||||
if (file) this.validationError.set('Sélectionnez un fichier PDF.');
|
||||
}
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
selectFile(event: Event): void {
|
||||
const input = event.target;
|
||||
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||
if (
|
||||
file &&
|
||||
file.name.toLowerCase().endsWith('.pdf') &&
|
||||
(file.type === 'application/pdf' || file.type === '')
|
||||
) {
|
||||
this.selectedFile.set(file);
|
||||
this.validationError.set('');
|
||||
} else {
|
||||
this.selectedFile.set(null);
|
||||
if (file) this.validationError.set('Sélectionnez un fichier PDF.');
|
||||
}
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
|
||||
private load(apartmentId: string): void {
|
||||
const requestId = ++this.requestId;
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.apartments
|
||||
.getDocuments(apartmentId)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => {
|
||||
if (requestId === this.requestId) this.loading.set(false);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (documents) => {
|
||||
if (requestId === this.requestId) this.documents.set(documents);
|
||||
},
|
||||
error: () => {
|
||||
if (requestId === this.requestId) {
|
||||
this.loadError.set('Impossible de charger les documents du logement.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
private load(apartmentId: string): void {
|
||||
const requestId = ++this.requestId;
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.apartments
|
||||
.getDocuments(apartmentId)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => {
|
||||
if (requestId === this.requestId) this.loading.set(false);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (documents) => {
|
||||
if (requestId === this.requestId) this.documents.set(documents);
|
||||
},
|
||||
error: () => {
|
||||
if (requestId === this.requestId) {
|
||||
this.loadError.set('Impossible de charger les documents du logement.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 28px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.form-section + .form-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.section-heading {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-heading h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.section-heading p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.form-page {
|
||||
max-width: 980px;
|
||||
}
|
||||
.form-page {
|
||||
max-width: 980px;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,100 @@
|
||||
<div class="form-page">
|
||||
<header class="page-heading form-heading">
|
||||
<div>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="back-action"
|
||||
[disabled]="saving()"
|
||||
(click)="closed.emit()"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<p class="eyebrow">Portefeuille</p>
|
||||
<h1>{{ apartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}</h1>
|
||||
<p>Renseignez les informations principales et juridiques du bien.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
[formRoot]="apartmentForm"
|
||||
(submit)="save(); $event.preventDefault()"
|
||||
class="apartment-form"
|
||||
[attr.aria-busy]="saving()"
|
||||
>
|
||||
@if (saveError()) {
|
||||
<div class="alert-error" role="alert">{{ saveError() }}</div>
|
||||
}
|
||||
@if (error()) {
|
||||
<div class="alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
<p class="visually-hidden" role="status" aria-live="polite">
|
||||
{{ saving() ? 'Enregistrement en cours.' : '' }}
|
||||
</p>
|
||||
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<section class="form-section" aria-labelledby="general-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="general-heading">Informations générales</h2>
|
||||
<p>Les informations utilisées dans votre portefeuille.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom de l'appartement</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="name"
|
||||
[formField]="apartmentForm.name"
|
||||
placeholder="T2 Paris 15"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prix d'acquisition</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="purchasePrice"
|
||||
[formField]="apartmentForm.purchasePrice"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse complète</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="address"
|
||||
[formField]="apartmentForm.address"
|
||||
placeholder="45 rue des Entrepreneurs, 75015 Paris"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Description ou notes</mat-label
|
||||
><textarea
|
||||
matInput
|
||||
id="description"
|
||||
[formField]="apartmentForm.description"
|
||||
rows="4"
|
||||
placeholder="Étage, agencement, équipements…"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
<header class="page-heading form-heading">
|
||||
<div>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="back-action"
|
||||
[disabled]="saving()"
|
||||
(click)="closed.emit()"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<p class="eyebrow">Portefeuille</p>
|
||||
<h1>{{ apartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}</h1>
|
||||
<p>Renseignez les informations principales et juridiques du bien.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
[formRoot]="apartmentForm"
|
||||
(submit)="save(); $event.preventDefault()"
|
||||
class="apartment-form"
|
||||
[attr.aria-busy]="saving()"
|
||||
>
|
||||
@if (saveError()) {
|
||||
<div class="alert-error" role="alert">{{ saveError() }}</div>
|
||||
}
|
||||
@if (error()) {
|
||||
<div class="alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
<p class="visually-hidden" role="status" aria-live="polite">
|
||||
{{ saving() ? 'Enregistrement en cours.' : '' }}
|
||||
</p>
|
||||
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<section class="form-section" aria-labelledby="general-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="general-heading">Informations générales</h2>
|
||||
<p>Les informations utilisées dans votre portefeuille.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom de l'appartement</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="name"
|
||||
[formField]="apartmentForm.name"
|
||||
placeholder="T2 Paris 15"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prix d'acquisition</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="purchasePrice"
|
||||
[formField]="apartmentForm.purchasePrice"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse complète</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="address"
|
||||
[formField]="apartmentForm.address"
|
||||
placeholder="45 rue des Entrepreneurs, 75015 Paris"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Description ou notes</mat-label
|
||||
><textarea
|
||||
matInput
|
||||
id="description"
|
||||
[formField]="apartmentForm.description"
|
||||
rows="4"
|
||||
placeholder="Étage, agencement, équipements…"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<app-apartment-legal-profile-form [legalProfileForm]="apartmentForm.legalProfile" />
|
||||
<app-private-area-editor
|
||||
[areas]="apartmentModel().privateAreas"
|
||||
(areasChanged)="setPrivateAreas($event)"
|
||||
/>
|
||||
<app-apartment-legal-profile-form [legalProfileForm]="apartmentForm.legalProfile" />
|
||||
<app-private-area-editor
|
||||
[areas]="apartmentModel().privateAreas"
|
||||
(areasChanged)="setPrivateAreas($event)"
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="closed.emit()">Annuler</button
|
||||
><button mat-flat-button type="submit" class="primary-action">
|
||||
<span>
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else {
|
||||
Enregistrer
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="closed.emit()">Annuler</button
|
||||
><button mat-flat-button type="submit" class="primary-action">
|
||||
<span>
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else {
|
||||
Enregistrer
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
@@ -4,244 +4,244 @@ import { ApartmentForm } from './apartment-form';
|
||||
import { emptyApartmentLegalProfile } from '../../services/apartment.service';
|
||||
|
||||
describe('ApartmentForm', () => {
|
||||
const completeLegalProfile = {
|
||||
taxIdentifier: '751150001234',
|
||||
buildingType: 'APARTMENT_BUILDING' as const,
|
||||
ownershipRegime: 'Copropriété',
|
||||
constructionPeriod: '1990_2005' as const,
|
||||
habitableSurface: 42,
|
||||
equipment: 'Cuisine équipée',
|
||||
heating: 'Individuel électrique',
|
||||
hotWater: 'Ballon électrique',
|
||||
energyRating: 'D' as const,
|
||||
estimatedAnnualEnergyCost: 850,
|
||||
estimatedAnnualEnergyCostReferenceYear: 2021,
|
||||
destination: 'Habitation',
|
||||
privateAccessories: 'Cave',
|
||||
commonAreas: 'Local vélos',
|
||||
internetAccess: 'Fibre',
|
||||
departmentCode: '75',
|
||||
};
|
||||
const completeLegalProfile = {
|
||||
taxIdentifier: '751150001234',
|
||||
buildingType: 'APARTMENT_BUILDING' as const,
|
||||
ownershipRegime: 'Copropriété',
|
||||
constructionPeriod: '1990_2005' as const,
|
||||
habitableSurface: 42,
|
||||
equipment: 'Cuisine équipée',
|
||||
heating: 'Individuel électrique',
|
||||
hotWater: 'Ballon électrique',
|
||||
energyRating: 'D' as const,
|
||||
estimatedAnnualEnergyCost: 850,
|
||||
estimatedAnnualEnergyCostReferenceYear: 2021,
|
||||
destination: 'Habitation',
|
||||
privateAccessories: 'Cave',
|
||||
commonAreas: 'Local vélos',
|
||||
internetAccess: 'Fibre',
|
||||
departmentCode: '75',
|
||||
};
|
||||
|
||||
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',
|
||||
privateAreas: [],
|
||||
legalProfile: completeLegalProfile,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
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',
|
||||
privateAreas: [],
|
||||
legalProfile: completeLegalProfile,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const form = fixture.componentInstance;
|
||||
expect(form.apartmentModel().name).toBe('T2 Paris 15');
|
||||
expect(form.apartmentModel().purchasePrice).toBe(250000);
|
||||
expect(form.apartmentModel().legalProfile).toEqual(completeLegalProfile);
|
||||
});
|
||||
const form = fixture.componentInstance;
|
||||
expect(form.apartmentModel().name).toBe('T2 Paris 15');
|
||||
expect(form.apartmentModel().purchasePrice).toBe(250000);
|
||||
expect(form.apartmentModel().legalProfile).toEqual(completeLegalProfile);
|
||||
});
|
||||
|
||||
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);
|
||||
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();
|
||||
form.save();
|
||||
|
||||
expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires');
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires');
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects whitespace-only fields before the backend does', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
const form = fixture.componentInstance;
|
||||
const saved = vi.fn();
|
||||
form.saved.subscribe(saved);
|
||||
form.apartmentModel.set({
|
||||
name: ' ',
|
||||
address: ' ',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
});
|
||||
it('rejects whitespace-only fields before the backend does', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
const form = fixture.componentInstance;
|
||||
const saved = vi.fn();
|
||||
form.saved.subscribe(saved);
|
||||
form.apartmentModel.set({
|
||||
name: ' ',
|
||||
address: ' ',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
});
|
||||
|
||||
form.save();
|
||||
form.save();
|
||||
|
||||
expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires');
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
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.apartmentModel.set({
|
||||
name: ' T2 Paris 15 ',
|
||||
address: ' 45 rue des Entrepreneurs ',
|
||||
purchasePrice: 250000,
|
||||
description: ' Cinquième étage ',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...completeLegalProfile,
|
||||
taxIdentifier: ' 751150001234 ',
|
||||
equipment: ' Cuisine équipée ',
|
||||
departmentCode: ' 2a ',
|
||||
},
|
||||
});
|
||||
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.apartmentModel.set({
|
||||
name: ' T2 Paris 15 ',
|
||||
address: ' 45 rue des Entrepreneurs ',
|
||||
purchasePrice: 250000,
|
||||
description: ' Cinquième étage ',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...completeLegalProfile,
|
||||
taxIdentifier: ' 751150001234 ',
|
||||
equipment: ' Cuisine équipée ',
|
||||
departmentCode: ' 2a ',
|
||||
},
|
||||
});
|
||||
|
||||
form.save();
|
||||
form.save();
|
||||
|
||||
expect(saved).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs',
|
||||
purchasePrice: 250000,
|
||||
description: 'Cinquième étage',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...completeLegalProfile,
|
||||
taxIdentifier: '751150001234',
|
||||
equipment: 'Cuisine équipée',
|
||||
departmentCode: '2A',
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(saved).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs',
|
||||
purchasePrice: 250000,
|
||||
description: 'Cinquième étage',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...completeLegalProfile,
|
||||
taxIdentifier: '751150001234',
|
||||
equipment: 'Cuisine équipée',
|
||||
departmentCode: '2A',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the whole form and preserves a save error while pending', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.componentRef.setInput('saving', true);
|
||||
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
|
||||
fixture.detectChanges();
|
||||
it('disables the whole form and preserves a save error while pending', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.componentRef.setInput('saving', true);
|
||||
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('[role=status]').closest('fieldset')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Enregistrement…');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible d’enregistrer cet appartement.',
|
||||
);
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('[role=status]').closest('fieldset')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Enregistrement…');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible d’enregistrer cet appartement.',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves entered values after a save fails', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const form = fixture.componentInstance;
|
||||
form.apartmentModel.set({
|
||||
name: 'T2 rénové',
|
||||
address: '45 rue des Entrepreneurs',
|
||||
purchasePrice: 265000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
});
|
||||
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
it('preserves entered values after a save fails', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const form = fixture.componentInstance;
|
||||
form.apartmentModel.set({
|
||||
name: 'T2 rénové',
|
||||
address: '45 rue des Entrepreneurs',
|
||||
purchasePrice: 265000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
});
|
||||
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input#name').value).toBe('T2 rénové');
|
||||
expect(fixture.nativeElement.querySelector('input#address').value).toBe(
|
||||
'45 rue des Entrepreneurs',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible d’enregistrer cet appartement.',
|
||||
);
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('input#name').value).toBe('T2 rénové');
|
||||
expect(fixture.nativeElement.querySelector('input#address').value).toBe(
|
||||
'45 rue des Entrepreneurs',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible d’enregistrer cet appartement.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects negative legal-profile numbers', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
const form = fixture.componentInstance;
|
||||
const saved = vi.fn();
|
||||
form.saved.subscribe(saved);
|
||||
form.apartmentModel.set({
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: { ...emptyApartmentLegalProfile(), habitableSurface: -1 },
|
||||
});
|
||||
it('rejects negative legal-profile numbers', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
const form = fixture.componentInstance;
|
||||
const saved = vi.fn();
|
||||
form.saved.subscribe(saved);
|
||||
form.apartmentModel.set({
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: { ...emptyApartmentLegalProfile(), habitableSurface: -1 },
|
||||
});
|
||||
|
||||
form.save();
|
||||
form.save();
|
||||
|
||||
expect(form.error()).toContain('surface habitable');
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(form.error()).toContain('surface habitable');
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns an emptied nullable number field back into null', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const surfaceInput: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input#habitableSurface');
|
||||
it('turns an emptied nullable number field back into null', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const surfaceInput: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input#habitableSurface');
|
||||
|
||||
surfaceInput.value = '42';
|
||||
surfaceInput.dispatchEvent(new Event('input'));
|
||||
surfaceInput.value = '';
|
||||
surfaceInput.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
surfaceInput.value = '42';
|
||||
surfaceInput.dispatchEvent(new Event('input'));
|
||||
surfaceInput.value = '';
|
||||
surfaceInput.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.apartmentModel().legalProfile.habitableSurface).toBeNull();
|
||||
});
|
||||
expect(fixture.componentInstance.apartmentModel().legalProfile.habitableSurface).toBeNull();
|
||||
});
|
||||
|
||||
it('asks for the energy reference year when an estimated cost is provided', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.componentRef.setInput('apartment', {
|
||||
id: 'apartment-1',
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...emptyApartmentLegalProfile(),
|
||||
estimatedAnnualEnergyCost: 850,
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const saved = vi.fn();
|
||||
fixture.componentInstance.saved.subscribe(saved);
|
||||
it('asks for the energy reference year when an estimated cost is provided', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.componentRef.setInput('apartment', {
|
||||
id: 'apartment-1',
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...emptyApartmentLegalProfile(),
|
||||
estimatedAnnualEnergyCost: 850,
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const saved = vi.fn();
|
||||
fixture.componentInstance.saved.subscribe(saved);
|
||||
|
||||
fixture.nativeElement.querySelector('button[type=submit]').click();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('button[type=submit]').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
"Renseignez l'année de référence du coût énergétique.",
|
||||
);
|
||||
});
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
"Renseignez l'année de référence du coût énergétique.",
|
||||
);
|
||||
});
|
||||
|
||||
it('clears an energy reference year that has no estimated cost', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.componentRef.setInput('apartment', {
|
||||
id: 'apartment-1',
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...emptyApartmentLegalProfile(),
|
||||
estimatedAnnualEnergyCostReferenceYear: 2021,
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const saved = vi.fn();
|
||||
fixture.componentInstance.saved.subscribe(saved);
|
||||
it('clears an energy reference year that has no estimated cost', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.componentRef.setInput('apartment', {
|
||||
id: 'apartment-1',
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...emptyApartmentLegalProfile(),
|
||||
estimatedAnnualEnergyCostReferenceYear: 2021,
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const saved = vi.fn();
|
||||
fixture.componentInstance.saved.subscribe(saved);
|
||||
|
||||
fixture.nativeElement.querySelector('button[type=submit]').click();
|
||||
fixture.nativeElement.querySelector('button[type=submit]').click();
|
||||
|
||||
expect(saved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
legalProfile: expect.objectContaining({
|
||||
estimatedAnnualEnergyCostReferenceYear: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(saved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
legalProfile: expect.objectContaining({
|
||||
estimatedAnnualEnergyCostReferenceYear: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,137 +6,137 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import {
|
||||
Apartment,
|
||||
ApartmentLegalProfile,
|
||||
emptyApartmentLegalProfile,
|
||||
PrivateArea,
|
||||
Apartment,
|
||||
ApartmentLegalProfile,
|
||||
emptyApartmentLegalProfile,
|
||||
PrivateArea,
|
||||
} from '../../services/apartment.service';
|
||||
import { ApartmentLegalProfileForm } from '../apartment-legal-profile-form/apartment-legal-profile-form';
|
||||
import { PrivateAreaEditor } from '../private-area-editor/private-area-editor';
|
||||
|
||||
const requiredFieldsError =
|
||||
'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).';
|
||||
'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).';
|
||||
|
||||
@Component({
|
||||
selector: 'app-apartment-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ApartmentLegalProfileForm,
|
||||
PrivateAreaEditor,
|
||||
],
|
||||
templateUrl: './apartment-form.html',
|
||||
styleUrl: './apartment-form.css',
|
||||
selector: 'app-apartment-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ApartmentLegalProfileForm,
|
||||
PrivateAreaEditor,
|
||||
],
|
||||
templateUrl: './apartment-form.html',
|
||||
styleUrl: './apartment-form.css',
|
||||
})
|
||||
export class ApartmentForm {
|
||||
readonly apartment = input<Apartment | null>(null);
|
||||
readonly saving = input(false);
|
||||
readonly saveError = input('');
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<Apartment>();
|
||||
readonly apartment = input<Apartment | null>(null);
|
||||
readonly saving = input(false);
|
||||
readonly saveError = input('');
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<Apartment>();
|
||||
|
||||
readonly apartmentModel = signal<Apartment>({
|
||||
name: '',
|
||||
address: '',
|
||||
purchasePrice: 0,
|
||||
description: '',
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
privateAreas: [],
|
||||
});
|
||||
readonly apartmentModel = signal<Apartment>({
|
||||
name: '',
|
||||
address: '',
|
||||
purchasePrice: 0,
|
||||
description: '',
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
privateAreas: [],
|
||||
});
|
||||
|
||||
readonly apartmentForm = form(this.apartmentModel, (f) => {
|
||||
required(f.name, { message: requiredFieldsError });
|
||||
required(f.address, { message: requiredFieldsError });
|
||||
required(f.purchasePrice, { message: requiredFieldsError });
|
||||
min(f.purchasePrice, 1, { message: requiredFieldsError });
|
||||
min(f.legalProfile.habitableSurface, 0, {
|
||||
message: 'La surface habitable doit être positive ou nulle.',
|
||||
});
|
||||
min(f.legalProfile.estimatedAnnualEnergyCost, 0, {
|
||||
message: 'Le coût annuel énergétique doit être positif ou nul.',
|
||||
});
|
||||
required(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, {
|
||||
when: ({ valueOf }) => valueOf(f.legalProfile.estimatedAnnualEnergyCost) !== null,
|
||||
message: "Renseignez l'année de référence du coût énergétique.",
|
||||
});
|
||||
min(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 1900, {
|
||||
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
|
||||
});
|
||||
max(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 2100, {
|
||||
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
|
||||
});
|
||||
});
|
||||
readonly apartmentForm = form(this.apartmentModel, (f) => {
|
||||
required(f.name, { message: requiredFieldsError });
|
||||
required(f.address, { message: requiredFieldsError });
|
||||
required(f.purchasePrice, { message: requiredFieldsError });
|
||||
min(f.purchasePrice, 1, { message: requiredFieldsError });
|
||||
min(f.legalProfile.habitableSurface, 0, {
|
||||
message: 'La surface habitable doit être positive ou nulle.',
|
||||
});
|
||||
min(f.legalProfile.estimatedAnnualEnergyCost, 0, {
|
||||
message: 'Le coût annuel énergétique doit être positif ou nul.',
|
||||
});
|
||||
required(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, {
|
||||
when: ({ valueOf }) => valueOf(f.legalProfile.estimatedAnnualEnergyCost) !== null,
|
||||
message: "Renseignez l'année de référence du coût énergétique.",
|
||||
});
|
||||
min(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 1900, {
|
||||
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
|
||||
});
|
||||
max(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 2100, {
|
||||
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
|
||||
});
|
||||
});
|
||||
|
||||
readonly error = signal('');
|
||||
readonly error = signal('');
|
||||
|
||||
constructor() {
|
||||
effect(() => this.resetForm(this.apartment()));
|
||||
}
|
||||
constructor() {
|
||||
effect(() => this.resetForm(this.apartment()));
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.saving()) return;
|
||||
this.apartmentForm().markAsTouched();
|
||||
if (
|
||||
this.apartmentForm().invalid() ||
|
||||
!this.apartmentModel().name.trim() ||
|
||||
!this.apartmentModel().address.trim()
|
||||
) {
|
||||
const firstError = this.apartmentForm().errorSummary()[0];
|
||||
this.error.set(firstError?.message ?? requiredFieldsError);
|
||||
return;
|
||||
}
|
||||
save(): void {
|
||||
if (this.saving()) return;
|
||||
this.apartmentForm().markAsTouched();
|
||||
if (
|
||||
this.apartmentForm().invalid() ||
|
||||
!this.apartmentModel().name.trim() ||
|
||||
!this.apartmentModel().address.trim()
|
||||
) {
|
||||
const firstError = this.apartmentForm().errorSummary()[0];
|
||||
this.error.set(firstError?.message ?? requiredFieldsError);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saved.emit({
|
||||
id: this.apartment()?.id,
|
||||
name: this.apartmentModel().name.trim(),
|
||||
address: this.apartmentModel().address.trim(),
|
||||
purchasePrice: this.apartmentModel().purchasePrice,
|
||||
description: this.apartmentModel().description.trim(),
|
||||
legalProfile: this.normalizedLegalProfile(),
|
||||
privateAreas: this.apartmentModel().privateAreas,
|
||||
});
|
||||
}
|
||||
this.saved.emit({
|
||||
id: this.apartment()?.id,
|
||||
name: this.apartmentModel().name.trim(),
|
||||
address: this.apartmentModel().address.trim(),
|
||||
purchasePrice: this.apartmentModel().purchasePrice,
|
||||
description: this.apartmentModel().description.trim(),
|
||||
legalProfile: this.normalizedLegalProfile(),
|
||||
privateAreas: this.apartmentModel().privateAreas,
|
||||
});
|
||||
}
|
||||
|
||||
setPrivateAreas(privateAreas: PrivateArea[]): void {
|
||||
this.apartmentModel.update((apartment) => ({ ...apartment, privateAreas }));
|
||||
}
|
||||
setPrivateAreas(privateAreas: PrivateArea[]): void {
|
||||
this.apartmentModel.update((apartment) => ({ ...apartment, privateAreas }));
|
||||
}
|
||||
|
||||
private resetForm(apartment: Apartment | null): void {
|
||||
this.apartmentModel.set({
|
||||
id: apartment?.id,
|
||||
name: apartment?.name ?? '',
|
||||
address: apartment?.address ?? '',
|
||||
purchasePrice: apartment?.purchasePrice ?? 0,
|
||||
description: apartment?.description ?? '',
|
||||
legalProfile: apartment?.legalProfile ?? emptyApartmentLegalProfile(),
|
||||
privateAreas: apartment?.privateAreas ?? [],
|
||||
});
|
||||
this.error.set('');
|
||||
}
|
||||
private resetForm(apartment: Apartment | null): void {
|
||||
this.apartmentModel.set({
|
||||
id: apartment?.id,
|
||||
name: apartment?.name ?? '',
|
||||
address: apartment?.address ?? '',
|
||||
purchasePrice: apartment?.purchasePrice ?? 0,
|
||||
description: apartment?.description ?? '',
|
||||
legalProfile: apartment?.legalProfile ?? emptyApartmentLegalProfile(),
|
||||
privateAreas: apartment?.privateAreas ?? [],
|
||||
});
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
private normalizedLegalProfile(): ApartmentLegalProfile {
|
||||
const profile = this.apartmentModel().legalProfile;
|
||||
return {
|
||||
...profile,
|
||||
estimatedAnnualEnergyCostReferenceYear:
|
||||
profile.estimatedAnnualEnergyCost === null
|
||||
? null
|
||||
: profile.estimatedAnnualEnergyCostReferenceYear,
|
||||
taxIdentifier: profile.taxIdentifier.trim(),
|
||||
ownershipRegime: profile.ownershipRegime.trim(),
|
||||
equipment: profile.equipment.trim(),
|
||||
heating: profile.heating.trim(),
|
||||
hotWater: profile.hotWater.trim(),
|
||||
destination: profile.destination.trim(),
|
||||
privateAccessories: profile.privateAccessories.trim(),
|
||||
commonAreas: profile.commonAreas.trim(),
|
||||
internetAccess: profile.internetAccess.trim(),
|
||||
departmentCode: profile.departmentCode.trim().toUpperCase(),
|
||||
};
|
||||
}
|
||||
private normalizedLegalProfile(): ApartmentLegalProfile {
|
||||
const profile = this.apartmentModel().legalProfile;
|
||||
return {
|
||||
...profile,
|
||||
estimatedAnnualEnergyCostReferenceYear:
|
||||
profile.estimatedAnnualEnergyCost === null
|
||||
? null
|
||||
: profile.estimatedAnnualEnergyCostReferenceYear,
|
||||
taxIdentifier: profile.taxIdentifier.trim(),
|
||||
ownershipRegime: profile.ownershipRegime.trim(),
|
||||
equipment: profile.equipment.trim(),
|
||||
heating: profile.heating.trim(),
|
||||
hotWater: profile.hotWater.trim(),
|
||||
destination: profile.destination.trim(),
|
||||
privateAccessories: profile.privateAccessories.trim(),
|
||||
commonAreas: profile.commonAreas.trim(),
|
||||
internetAccess: profile.internetAccess.trim(),
|
||||
departmentCode: profile.departmentCode.trim().toUpperCase(),
|
||||
};
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -1,24 +1,24 @@
|
||||
:host {
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.form-section + .form-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.section-heading {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-heading h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.section-heading p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
+156
-153
@@ -1,161 +1,164 @@
|
||||
<section class="form-section" aria-labelledby="legal-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="legal-heading">Identification juridique</h2>
|
||||
<p>Ces champs facultatifs seront réutilisés dans les baux.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Identifiant fiscal du logement</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="taxIdentifier"
|
||||
[formField]="legalProfileForm().taxIdentifier"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Département</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="departmentCode"
|
||||
[formField]="legalProfileForm().departmentCode"
|
||||
placeholder="75, 2A, 971…"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Type de bâtiment</mat-label
|
||||
><mat-select id="buildingType" [formField]="legalProfileForm().buildingType">
|
||||
<mat-option [value]="null">Non renseigné</mat-option>
|
||||
<mat-option value="APARTMENT_BUILDING">Immeuble collectif</mat-option>
|
||||
<mat-option value="INDIVIDUAL_HOUSE">Maison individuelle</mat-option>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Période de construction</mat-label
|
||||
><mat-select id="constructionPeriod" [formField]="legalProfileForm().constructionPeriod">
|
||||
<mat-option [value]="null">Non renseignée</mat-option>
|
||||
<mat-option value="BEFORE_1949">Avant 1949</mat-option>
|
||||
<mat-option value="1949_1974">De 1949 à 1974</mat-option>
|
||||
<mat-option value="1975_1989">De 1975 à 1989</mat-option>
|
||||
<mat-option value="1990_2005">De 1990 à 2005</mat-option>
|
||||
<mat-option value="2006_2012">De 2006 à 2012</mat-option>
|
||||
<mat-option value="AFTER_2012">Après 2012</mat-option>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Régime juridique de l'immeuble</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="ownershipRegime"
|
||||
[formField]="legalProfileForm().ownershipRegime"
|
||||
placeholder="Copropriété, monopropriété…"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<h2 id="legal-heading">Identification juridique</h2>
|
||||
<p>Ces champs facultatifs seront réutilisés dans les baux.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Identifiant fiscal du logement</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="taxIdentifier"
|
||||
[formField]="legalProfileForm().taxIdentifier"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Département</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="departmentCode"
|
||||
[formField]="legalProfileForm().departmentCode"
|
||||
placeholder="75, 2A, 971…"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Type de bâtiment</mat-label
|
||||
><mat-select id="buildingType" [formField]="legalProfileForm().buildingType">
|
||||
<mat-option [value]="null">Non renseigné</mat-option>
|
||||
<mat-option value="APARTMENT_BUILDING">Immeuble collectif</mat-option>
|
||||
<mat-option value="INDIVIDUAL_HOUSE">Maison individuelle</mat-option>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Période de construction</mat-label
|
||||
><mat-select
|
||||
id="constructionPeriod"
|
||||
[formField]="legalProfileForm().constructionPeriod"
|
||||
>
|
||||
<mat-option [value]="null">Non renseignée</mat-option>
|
||||
<mat-option value="BEFORE_1949">Avant 1949</mat-option>
|
||||
<mat-option value="1949_1974">De 1949 à 1974</mat-option>
|
||||
<mat-option value="1975_1989">De 1975 à 1989</mat-option>
|
||||
<mat-option value="1990_2005">De 1990 à 2005</mat-option>
|
||||
<mat-option value="2006_2012">De 2006 à 2012</mat-option>
|
||||
<mat-option value="AFTER_2012">Après 2012</mat-option>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Régime juridique de l'immeuble</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="ownershipRegime"
|
||||
[formField]="legalProfileForm().ownershipRegime"
|
||||
placeholder="Copropriété, monopropriété…"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="characteristics-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="characteristics-heading">Caractéristiques du logement</h2>
|
||||
<p>Surface, usage et performance énergétique.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Surface habitable</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="habitableSurface"
|
||||
[formField]="legalProfileForm().habitableSurface"
|
||||
/><span matTextSuffix>m²</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Classe énergétique</mat-label
|
||||
><mat-select id="energyRating" [formField]="legalProfileForm().energyRating">
|
||||
<mat-option [value]="null">Non renseignée</mat-option>
|
||||
@for (rating of energyRatings; track rating) {
|
||||
<mat-option [value]="rating">{{ rating }}</mat-option>
|
||||
}
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Coût annuel énergétique estimé</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="estimatedAnnualEnergyCost"
|
||||
[formField]="legalProfileForm().estimatedAnnualEnergyCost"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Année de référence du coût énergétique</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="estimatedAnnualEnergyCostReferenceYear"
|
||||
[formField]="legalProfileForm().estimatedAnnualEnergyCostReferenceYear"
|
||||
placeholder="2021"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Destination des locaux</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="destination"
|
||||
[formField]="legalProfileForm().destination"
|
||||
placeholder="Usage d'habitation"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<h2 id="characteristics-heading">Caractéristiques du logement</h2>
|
||||
<p>Surface, usage et performance énergétique.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Surface habitable</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="habitableSurface"
|
||||
[formField]="legalProfileForm().habitableSurface"
|
||||
/><span matTextSuffix>m²</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Classe énergétique</mat-label
|
||||
><mat-select id="energyRating" [formField]="legalProfileForm().energyRating">
|
||||
<mat-option [value]="null">Non renseignée</mat-option>
|
||||
@for (rating of energyRatings; track rating) {
|
||||
<mat-option [value]="rating">{{ rating }}</mat-option>
|
||||
}
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Coût annuel énergétique estimé</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="estimatedAnnualEnergyCost"
|
||||
[formField]="legalProfileForm().estimatedAnnualEnergyCost"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Année de référence du coût énergétique</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="estimatedAnnualEnergyCostReferenceYear"
|
||||
[formField]="legalProfileForm().estimatedAnnualEnergyCostReferenceYear"
|
||||
placeholder="2021"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Destination des locaux</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="destination"
|
||||
[formField]="legalProfileForm().destination"
|
||||
placeholder="Usage d'habitation"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="equipment-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="equipment-heading">Équipements et accès</h2>
|
||||
<p>Décrivez librement les installations disponibles.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Équipements du logement</mat-label
|
||||
><textarea
|
||||
matInput
|
||||
id="equipment"
|
||||
[formField]="legalProfileForm().equipment"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Chauffage</mat-label
|
||||
><input matInput type="text" id="heating" [formField]="legalProfileForm().heating"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Eau chaude</mat-label
|
||||
><input matInput type="text" id="hotWater" [formField]="legalProfileForm().hotWater"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Accessoires privatifs</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="privateAccessories"
|
||||
[formField]="legalProfileForm().privateAccessories"
|
||||
placeholder="Cave, parking, garage…"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Parties et équipements communs</mat-label
|
||||
><textarea
|
||||
matInput
|
||||
id="commonAreas"
|
||||
[formField]="legalProfileForm().commonAreas"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Accès internet et communications</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="internetAccess"
|
||||
[formField]="legalProfileForm().internetAccess"
|
||||
placeholder="Fibre, câble, antenne…"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<h2 id="equipment-heading">Équipements et accès</h2>
|
||||
<p>Décrivez librement les installations disponibles.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Équipements du logement</mat-label
|
||||
><textarea
|
||||
matInput
|
||||
id="equipment"
|
||||
[formField]="legalProfileForm().equipment"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Chauffage</mat-label
|
||||
><input matInput type="text" id="heating" [formField]="legalProfileForm().heating"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Eau chaude</mat-label
|
||||
><input matInput type="text" id="hotWater" [formField]="legalProfileForm().hotWater"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Accessoires privatifs</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="privateAccessories"
|
||||
[formField]="legalProfileForm().privateAccessories"
|
||||
placeholder="Cave, parking, garage…"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Parties et équipements communs</mat-label
|
||||
><textarea
|
||||
matInput
|
||||
id="commonAreas"
|
||||
[formField]="legalProfileForm().commonAreas"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Accès internet et communications</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="internetAccess"
|
||||
[formField]="legalProfileForm().internetAccess"
|
||||
placeholder="Fibre, câble, antenne…"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
+6
-6
@@ -7,12 +7,12 @@ import { MatSelectModule } from '@angular/material/select';
|
||||
import { ApartmentLegalProfile } from '../../services/apartment.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-apartment-legal-profile-form',
|
||||
imports: [FormsModule, FormField, MatFormFieldModule, MatInputModule, MatSelectModule],
|
||||
templateUrl: './apartment-legal-profile-form.html',
|
||||
styleUrl: './apartment-legal-profile-form.css',
|
||||
selector: 'app-apartment-legal-profile-form',
|
||||
imports: [FormsModule, FormField, MatFormFieldModule, MatInputModule, MatSelectModule],
|
||||
templateUrl: './apartment-legal-profile-form.html',
|
||||
styleUrl: './apartment-legal-profile-form.css',
|
||||
})
|
||||
export class ApartmentLegalProfileForm {
|
||||
readonly legalProfileForm = input.required<FieldTree<ApartmentLegalProfile>>();
|
||||
readonly energyRatings = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const;
|
||||
readonly legalProfileForm = input.required<FieldTree<ApartmentLegalProfile>>();
|
||||
readonly energyRatings = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const;
|
||||
}
|
||||
@@ -1,123 +1,123 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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);
|
||||
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 fa-icon {
|
||||
display: flex;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.empty-illustration fa-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state > p:not(.eyebrow) {
|
||||
max-width: 440px;
|
||||
margin: 0 auto 22px;
|
||||
line-height: 1.6;
|
||||
max-width: 440px;
|
||||
margin: 0 auto 22px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.apartments-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.apartment-card-topline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.property-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.apt-info h2 {
|
||||
margin-bottom: 9px;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.035em;
|
||||
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;
|
||||
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 fa-icon {
|
||||
display: flex;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 17px;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 17px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.apt-address fa-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.apt-desc {
|
||||
margin-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
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);
|
||||
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);
|
||||
--mdc-text-button-label-text-color: var(--accent);
|
||||
}
|
||||
|
||||
.apt-actions .danger-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
}
|
||||
|
||||
.card-error {
|
||||
margin: 14px 0 0;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.apartments-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.apartments-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1180px) {
|
||||
.apartments-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.apartments-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -11,82 +11,102 @@
|
||||
|
||||
<div class="content-region" [attr.aria-busy]="loadState().loading">
|
||||
@if (loadState().loaded && loadState().loading) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des appartements" />
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des appartements" />
|
||||
}
|
||||
@if (loadState().loaded && loadState().error) {
|
||||
<div class="refresh-error" role="alert">
|
||||
<p>{{ loadState().error }}</p>
|
||||
<button mat-button type="button" (click)="retryRequested.emit()">Réessayer</button>
|
||||
</div>
|
||||
<div class="refresh-error" role="alert">
|
||||
<p>{{ loadState().error }}</p>
|
||||
<button mat-button type="button" (click)="retryRequested.emit()">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
@if (!loadState().loaded && loadState().loading) {
|
||||
<div class="apartments-grid loading-region" aria-busy="true" aria-label="Chargement des appartements">
|
||||
@for (placeholder of [1, 2, 3]; track placeholder) {
|
||||
<article class="apartment-card skeleton-card" aria-hidden="true">
|
||||
<span class="skeleton-line skeleton-line-short"></span>
|
||||
<span class="skeleton-line skeleton-line-title"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line skeleton-line-medium"></span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
} @else if (!loadState().loaded && loadState().error) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadState().error }}</p>
|
||||
<button mat-stroked-button type="button" (click)="retryRequested.emit()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (apartments().length === 0) {
|
||||
<article class="empty-state">
|
||||
<div class="empty-illustration" aria-hidden="true">
|
||||
<fa-icon [icon]="faHouse" />
|
||||
</div>
|
||||
<p class="eyebrow">Commencez ici</p>
|
||||
<h2>Aucun appartement enregistré</h2>
|
||||
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
|
||||
<button mat-stroked-button type="button" (click)="addRequested.emit()">
|
||||
Ajouter un appartement
|
||||
</button>
|
||||
</article>
|
||||
} @else {
|
||||
<div class="apartments-grid">
|
||||
@for (apt of apartments(); track apt.id) {
|
||||
<article class="apartment-card" [attr.aria-busy]="!!apt.id && deletingIds().has(apt.id)">
|
||||
<div class="apartment-card-topline">
|
||||
<span class="property-label">Bien immobilier</span>
|
||||
<span class="property-value">{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span>
|
||||
</div>
|
||||
<div class="apt-info">
|
||||
<h2>{{ apt.name }}</h2>
|
||||
<p class="apt-address">
|
||||
<fa-icon [icon]="faLocationDot" aria-hidden="true" />
|
||||
{{ apt.address }}
|
||||
</p>
|
||||
@if (apt.description) {
|
||||
<p class="apt-desc">{{ apt.description }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (apt.id && deleteErrors()[apt.id]) {
|
||||
<p class="card-error" role="alert">{{ deleteErrors()[apt.id] }}</p>
|
||||
<div
|
||||
class="apartments-grid loading-region"
|
||||
aria-busy="true"
|
||||
aria-label="Chargement des appartements"
|
||||
>
|
||||
@for (placeholder of [1, 2, 3]; track placeholder) {
|
||||
<article class="apartment-card skeleton-card" aria-hidden="true">
|
||||
<span class="skeleton-line skeleton-line-short"></span>
|
||||
<span class="skeleton-line skeleton-line-title"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line skeleton-line-medium"></span>
|
||||
</article>
|
||||
}
|
||||
<div class="apt-actions" aria-live="polite">
|
||||
<button mat-button type="button" [disabled]="!!apt.id && deletingIds().has(apt.id)"
|
||||
(click)="editRequested.emit(apt)">
|
||||
Modifier
|
||||
</button>
|
||||
@if (apt.id) {
|
||||
<button mat-button type="button" class="danger-action" [disabled]="deletingIds().has(apt.id)"
|
||||
(click)="deleteRequested.emit(apt.id)">
|
||||
@if (deletingIds().has(apt.id)) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
|
||||
} @else {
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (!loadState().loaded && loadState().error) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadState().error }}</p>
|
||||
<button mat-stroked-button type="button" (click)="retryRequested.emit()">
|
||||
Réessayer
|
||||
</button>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
} @else if (apartments().length === 0) {
|
||||
<article class="empty-state">
|
||||
<div class="empty-illustration" aria-hidden="true">
|
||||
<fa-icon [icon]="faHouse" />
|
||||
</div>
|
||||
<p class="eyebrow">Commencez ici</p>
|
||||
<h2>Aucun appartement enregistré</h2>
|
||||
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
|
||||
<button mat-stroked-button type="button" (click)="addRequested.emit()">
|
||||
Ajouter un appartement
|
||||
</button>
|
||||
</article>
|
||||
} @else {
|
||||
<div class="apartments-grid">
|
||||
@for (apt of apartments(); track apt.id) {
|
||||
<article
|
||||
class="apartment-card"
|
||||
[attr.aria-busy]="!!apt.id && deletingIds().has(apt.id)"
|
||||
>
|
||||
<div class="apartment-card-topline">
|
||||
<span class="property-label">Bien immobilier</span>
|
||||
<span class="property-value"
|
||||
>{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span
|
||||
>
|
||||
</div>
|
||||
<div class="apt-info">
|
||||
<h2>{{ apt.name }}</h2>
|
||||
<p class="apt-address">
|
||||
<fa-icon [icon]="faLocationDot" aria-hidden="true" />
|
||||
{{ apt.address }}
|
||||
</p>
|
||||
@if (apt.description) {
|
||||
<p class="apt-desc">{{ apt.description }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (apt.id && deleteErrors()[apt.id]) {
|
||||
<p class="card-error" role="alert">{{ deleteErrors()[apt.id] }}</p>
|
||||
}
|
||||
<div class="apt-actions" aria-live="polite">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="!!apt.id && deletingIds().has(apt.id)"
|
||||
(click)="editRequested.emit(apt)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
@if (apt.id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
[disabled]="deletingIds().has(apt.id)"
|
||||
(click)="deleteRequested.emit(apt.id)"
|
||||
>
|
||||
@if (deletingIds().has(apt.id)) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
|
||||
} @else {
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,20 +8,20 @@ import { Apartment } from '../../services/apartment.service';
|
||||
import { LoadState } from '../../services/workspace-resource';
|
||||
|
||||
@Component({
|
||||
selector: 'app-apartment-list',
|
||||
imports: [FontAwesomeModule, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
templateUrl: './apartment-list.html',
|
||||
styleUrl: './apartment-list.css',
|
||||
selector: 'app-apartment-list',
|
||||
imports: [FontAwesomeModule, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
templateUrl: './apartment-list.html',
|
||||
styleUrl: './apartment-list.css',
|
||||
})
|
||||
export class ApartmentList {
|
||||
readonly faHouse = faHouse;
|
||||
readonly faLocationDot = faLocationDot;
|
||||
readonly apartments = input.required<Apartment[]>();
|
||||
readonly loadState = input.required<LoadState>();
|
||||
readonly deletingIds = input<ReadonlySet<string>>(new Set());
|
||||
readonly deleteErrors = input<Record<string, string>>({});
|
||||
readonly addRequested = output<void>();
|
||||
readonly editRequested = output<Apartment>();
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly retryRequested = output<void>();
|
||||
readonly faHouse = faHouse;
|
||||
readonly faLocationDot = faLocationDot;
|
||||
readonly apartments = input.required<Apartment[]>();
|
||||
readonly loadState = input.required<LoadState>();
|
||||
readonly deletingIds = input<ReadonlySet<string>>(new Set());
|
||||
readonly deleteErrors = input<Record<string, string>>({});
|
||||
readonly addRequested = output<void>();
|
||||
readonly editRequested = output<Apartment>();
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly retryRequested = output<void>();
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
<section class="page-section apartments-page">
|
||||
@if (!showForm()) {
|
||||
<app-apartment-list
|
||||
[apartments]="workspace.apartments()"
|
||||
[loadState]="workspace.apartmentLoadState()"
|
||||
[deletingIds]="workspace.deletingApartmentIds()"
|
||||
[deleteErrors]="workspace.apartmentDeleteErrors()"
|
||||
(addRequested)="openAddForm()"
|
||||
(editRequested)="openEditForm($event)"
|
||||
(deleteRequested)="workspace.deleteApartment($event)"
|
||||
(retryRequested)="workspace.loadApartments()"
|
||||
/>
|
||||
} @else {
|
||||
<app-apartment-form
|
||||
[apartment]="editingApartment()"
|
||||
[saving]="workspace.apartmentSaving(editingApartment()?.id)"
|
||||
[saveError]="workspace.apartmentSaveError(editingApartment()?.id)"
|
||||
(closed)="closeForm()"
|
||||
(saved)="saveApartment($event)"
|
||||
/>
|
||||
@if (editingApartment()?.id; as apartmentId) {
|
||||
<app-apartment-document-manager [apartmentId]="apartmentId" />
|
||||
}
|
||||
}
|
||||
@if (!showForm()) {
|
||||
<app-apartment-list
|
||||
[apartments]="workspace.apartments()"
|
||||
[loadState]="workspace.apartmentLoadState()"
|
||||
[deletingIds]="workspace.deletingApartmentIds()"
|
||||
[deleteErrors]="workspace.apartmentDeleteErrors()"
|
||||
(addRequested)="openAddForm()"
|
||||
(editRequested)="openEditForm($event)"
|
||||
(deleteRequested)="workspace.deleteApartment($event)"
|
||||
(retryRequested)="workspace.loadApartments()"
|
||||
/>
|
||||
} @else {
|
||||
<app-apartment-form
|
||||
[apartment]="editingApartment()"
|
||||
[saving]="workspace.apartmentSaving(editingApartment()?.id)"
|
||||
[saveError]="workspace.apartmentSaveError(editingApartment()?.id)"
|
||||
(closed)="closeForm()"
|
||||
(saved)="saveApartment($event)"
|
||||
/>
|
||||
@if (editingApartment()?.id; as apartmentId) {
|
||||
<app-apartment-document-manager [apartmentId]="apartmentId" />
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -8,188 +8,192 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
|
||||
describe('ApartmentsPage', () => {
|
||||
let http: HttpTestingController;
|
||||
const workspace = {
|
||||
apartments: signal<Apartment[]>([]),
|
||||
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
apartmentSaving: vi.fn(() => false),
|
||||
apartmentSaveError: vi.fn(() => ''),
|
||||
clearApartmentSaveError: vi.fn(),
|
||||
deletingApartmentIds: signal<ReadonlySet<string>>(new Set()),
|
||||
apartmentDeleteErrors: signal<Record<string, string>>({}),
|
||||
loadApartments: () => undefined,
|
||||
saveApartment: vi.fn(),
|
||||
deleteApartment: () => undefined,
|
||||
};
|
||||
let http: HttpTestingController;
|
||||
const workspace = {
|
||||
apartments: signal<Apartment[]>([]),
|
||||
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
apartmentSaving: vi.fn(() => false),
|
||||
apartmentSaveError: vi.fn(() => ''),
|
||||
clearApartmentSaveError: vi.fn(),
|
||||
deletingApartmentIds: signal<ReadonlySet<string>>(new Set()),
|
||||
apartmentDeleteErrors: signal<Record<string, string>>({}),
|
||||
loadApartments: () => undefined,
|
||||
saveApartment: vi.fn(),
|
||||
deleteApartment: () => undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workspace.apartments.set([]);
|
||||
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
workspace.deletingApartmentIds.set(new Set());
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workspace.apartments.set([]);
|
||||
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
workspace.deletingApartmentIds.set(new Set());
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('shows apartment-shaped placeholders instead of a false empty state on first load', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
it('shows apartment-shaped placeholders instead of a false empty state on first load', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelectorAll('.skeleton-card').length).toBeGreaterThan(0);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Aucun appartement enregistré');
|
||||
});
|
||||
expect(fixture.nativeElement.querySelectorAll('.skeleton-card').length).toBeGreaterThan(0);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Aucun appartement enregistré');
|
||||
});
|
||||
|
||||
it('shows a retryable error instead of an empty state when the first load fails', () => {
|
||||
workspace.apartmentLoadState.set({
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: 'Impossible de charger les appartements.',
|
||||
});
|
||||
const loadApartments = vi.spyOn(workspace, 'loadApartments');
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
it('shows a retryable error instead of an empty state when the first load fails', () => {
|
||||
workspace.apartmentLoadState.set({
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: 'Impossible de charger les appartements.',
|
||||
});
|
||||
const loadApartments = vi.spyOn(workspace, 'loadApartments');
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de charger les appartements.');
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
expect(loadApartments).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Aucun appartement enregistré');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de charger les appartements.',
|
||||
);
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
expect(loadApartments).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Aucun appartement enregistré');
|
||||
});
|
||||
|
||||
it('shows the empty state only after a successful empty load', () => {
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
it('shows the empty state only after a successful empty load', () => {
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Aucun appartement enregistré');
|
||||
expect(fixture.nativeElement.querySelector('.skeleton-card')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.load-error')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Aucun appartement enregistré');
|
||||
expect(fixture.nativeElement.querySelector('.skeleton-card')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.load-error')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps established apartments visible during a background refresh', () => {
|
||||
workspace.apartments.set([
|
||||
{
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: true, error: '' });
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
it('keeps established apartments visible during a background refresh', () => {
|
||||
workspace.apartments.set([
|
||||
{
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: true, error: '' });
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('T2 Paris 15');
|
||||
expect(fixture.nativeElement.querySelector('.content-region').getAttribute('aria-busy')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('mat-progress-bar')).not.toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('T2 Paris 15');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.content-region').getAttribute('aria-busy'),
|
||||
).toBe('true');
|
||||
expect(fixture.nativeElement.querySelector('mat-progress-bar')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the apartment being deleted', () => {
|
||||
workspace.apartments.set([
|
||||
{
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.deletingApartmentIds.set(new Set(['apt-1']));
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
it('disables only the apartment being deleted', () => {
|
||||
workspace.apartments.set([
|
||||
{
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.deletingApartmentIds.set(new Set(['apt-1']));
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const deleteButton = fixture.nativeElement.querySelector('.danger-action');
|
||||
expect(deleteButton.disabled).toBe(true);
|
||||
expect(deleteButton.textContent).toContain('Suppression…');
|
||||
expect(fixture.nativeElement.querySelector('.primary-action').disabled).toBe(false);
|
||||
});
|
||||
const deleteButton = fixture.nativeElement.querySelector('.danger-action');
|
||||
expect(deleteButton.disabled).toBe(true);
|
||||
expect(deleteButton.textContent).toContain('Suppression…');
|
||||
expect(fixture.nativeElement.querySelector('.primary-action').disabled).toBe(false);
|
||||
});
|
||||
|
||||
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: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
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: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const page = fixture.componentInstance;
|
||||
page.openEditForm(apartment);
|
||||
const page = fixture.componentInstance;
|
||||
page.openEditForm(apartment);
|
||||
|
||||
expect(page.showForm()).toBe(true);
|
||||
expect(page.editingApartment()).toEqual(apartment);
|
||||
});
|
||||
expect(page.showForm()).toBe(true);
|
||||
expect(page.editingApartment()).toEqual(apartment);
|
||||
});
|
||||
|
||||
it('manages documents only while editing an existing apartment', () => {
|
||||
const apartment = {
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
it('manages documents only while editing an existing apartment', () => {
|
||||
const apartment = {
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.openAddForm();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-apartment-document-manager')).toBeNull();
|
||||
fixture.componentInstance.openAddForm();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-apartment-document-manager')).toBeNull();
|
||||
|
||||
fixture.componentInstance.openEditForm(apartment);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-apartment-document-manager')).not.toBeNull();
|
||||
http.expectOne('/api/apartments/apt-1/documents').flush([]);
|
||||
});
|
||||
fixture.componentInstance.openEditForm(apartment);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-apartment-document-manager'),
|
||||
).not.toBeNull();
|
||||
http.expectOne('/api/apartments/apt-1/documents').flush([]);
|
||||
});
|
||||
|
||||
it('keeps the form open until the edited apartment is confirmed', () => {
|
||||
const apartment = {
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.componentInstance;
|
||||
let confirmSave: (() => void) | undefined;
|
||||
workspace.saveApartment.mockImplementation((_value, _id, onSuccess) => {
|
||||
confirmSave = onSuccess;
|
||||
});
|
||||
page.openEditForm(apartment);
|
||||
it('keeps the form open until the edited apartment is confirmed', () => {
|
||||
const apartment = {
|
||||
id: 'apt-1',
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.componentInstance;
|
||||
let confirmSave: (() => void) | undefined;
|
||||
workspace.saveApartment.mockImplementation((_value, _id, onSuccess) => {
|
||||
confirmSave = onSuccess;
|
||||
});
|
||||
page.openEditForm(apartment);
|
||||
|
||||
page.saveApartment({ ...apartment, name: 'T2 rénové' });
|
||||
page.saveApartment({ ...apartment, name: 'T2 rénové' });
|
||||
|
||||
expect(workspace.saveApartment).toHaveBeenCalledWith(
|
||||
{ ...apartment, name: 'T2 rénové' },
|
||||
'apt-1',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(page.showForm()).toBe(true);
|
||||
confirmSave?.();
|
||||
expect(page.showForm()).toBe(false);
|
||||
});
|
||||
expect(workspace.saveApartment).toHaveBeenCalledWith(
|
||||
{ ...apartment, name: 'T2 rénové' },
|
||||
'apt-1',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(page.showForm()).toBe(true);
|
||||
confirmSave?.();
|
||||
expect(page.showForm()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,38 +6,40 @@ import { ApartmentList } from '../apartment-list/apartment-list';
|
||||
import { ApartmentDocumentManager } from '../apartment-document-manager/apartment-document-manager';
|
||||
|
||||
@Component({
|
||||
selector: 'app-apartments-page',
|
||||
imports: [ApartmentDocumentManager, ApartmentForm, ApartmentList],
|
||||
templateUrl: './apartments-page.html',
|
||||
selector: 'app-apartments-page',
|
||||
imports: [ApartmentDocumentManager, ApartmentForm, ApartmentList],
|
||||
templateUrl: './apartments-page.html',
|
||||
})
|
||||
export class ApartmentsPage {
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
|
||||
readonly showForm = signal(false);
|
||||
readonly editingApartment = signal<Apartment | null>(null);
|
||||
readonly showForm = signal(false);
|
||||
readonly editingApartment = signal<Apartment | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.workspace.loadApartments();
|
||||
}
|
||||
constructor() {
|
||||
this.workspace.loadApartments();
|
||||
}
|
||||
|
||||
openAddForm(): void {
|
||||
this.workspace.clearApartmentSaveError();
|
||||
this.editingApartment.set(null);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
openAddForm(): void {
|
||||
this.workspace.clearApartmentSaveError();
|
||||
this.editingApartment.set(null);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
openEditForm(apartment: Apartment): void {
|
||||
this.workspace.clearApartmentSaveError(apartment.id);
|
||||
this.editingApartment.set(apartment);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
openEditForm(apartment: Apartment): void {
|
||||
this.workspace.clearApartmentSaveError(apartment.id);
|
||||
this.editingApartment.set(apartment);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
this.showForm.set(false);
|
||||
this.editingApartment.set(null);
|
||||
}
|
||||
closeForm(): void {
|
||||
this.showForm.set(false);
|
||||
this.editingApartment.set(null);
|
||||
}
|
||||
|
||||
saveApartment(apartment: Apartment): void {
|
||||
this.workspace.saveApartment(apartment, this.editingApartment()?.id, () => this.closeForm());
|
||||
}
|
||||
saveApartment(apartment: Apartment): void {
|
||||
this.workspace.saveApartment(apartment, this.editingApartment()?.id, () =>
|
||||
this.closeForm(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,28 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { faHouse } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-brand-mark',
|
||||
imports: [FontAwesomeModule],
|
||||
template: ` <fa-icon [icon]="faHouse" /> `,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
selector: 'app-brand-mark',
|
||||
imports: [FontAwesomeModule],
|
||||
template: ` <fa-icon [icon]="faHouse" /> `,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
fa-icon {
|
||||
display: flex;
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
fa-icon {
|
||||
display: flex;
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
fa-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
host: { 'aria-hidden': 'true' },
|
||||
fa-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
host: { 'aria-hidden': 'true' },
|
||||
})
|
||||
export class BrandMark {
|
||||
readonly faHouse = faHouse;
|
||||
readonly faHouse = faHouse;
|
||||
}
|
||||
+107
-99
@@ -1,107 +1,115 @@
|
||||
<section>
|
||||
<h2>Date et fuseau</h2>
|
||||
<label>
|
||||
Date civile
|
||||
<input type="date" required [value]="report().occurredOn" (input)="setOccurredOn($event)" />
|
||||
</label>
|
||||
<label>
|
||||
Fuseau IANA du navigateur
|
||||
<input required [value]="report().timeZone" (input)="setTimeZone($event)" />
|
||||
</label>
|
||||
<h2>Date et fuseau</h2>
|
||||
<label>
|
||||
Date civile
|
||||
<input type="date" required [value]="report().occurredOn" (input)="setOccurredOn($event)" />
|
||||
</label>
|
||||
<label>
|
||||
Fuseau IANA du navigateur
|
||||
<input required [value]="report().timeZone" (input)="setTimeZone($event)" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Parties et mandats</h2>
|
||||
@for (participant of report().participants; track participant.partyId; let index = $index) {
|
||||
<fieldset>
|
||||
<legend>Partie {{ index + 1 }}</legend>
|
||||
<p>
|
||||
{{ participant.name }} — {{ participant.role === 'LANDLORD' ? 'Bailleur' : 'Locataire' }}
|
||||
</p>
|
||||
<label>
|
||||
Présence
|
||||
<select
|
||||
[value]="participant.attendance"
|
||||
(change)="updateParticipant(index, 'attendance', $event)"
|
||||
>
|
||||
<option value="PRESENT">Présent</option>
|
||||
<option value="REPRESENTED">Représenté</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (participant.attendance === 'REPRESENTED') {
|
||||
<label>
|
||||
Mandataire
|
||||
<input
|
||||
required
|
||||
[value]="participant.representativeName"
|
||||
(input)="updateParticipant(index, 'representativeName', $event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
<h2>Parties et mandats</h2>
|
||||
@for (participant of report().participants; track participant.partyId; let index = $index) {
|
||||
<fieldset>
|
||||
<legend>Partie {{ index + 1 }}</legend>
|
||||
<p>
|
||||
{{ participant.name }} —
|
||||
{{ participant.role === 'LANDLORD' ? 'Bailleur' : 'Locataire' }}
|
||||
</p>
|
||||
<label>
|
||||
Présence
|
||||
<select
|
||||
[value]="participant.attendance"
|
||||
(change)="updateParticipant(index, 'attendance', $event)"
|
||||
>
|
||||
<option value="PRESENT">Présent</option>
|
||||
<option value="REPRESENTED">Représenté</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (participant.attendance === 'REPRESENTED') {
|
||||
<label>
|
||||
Mandataire
|
||||
<input
|
||||
required
|
||||
[value]="participant.representativeName"
|
||||
(input)="updateParticipant(index, 'representativeName', $event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Zones et sujets observés</h2>
|
||||
@for (zone of report().zones; track zone.id) {
|
||||
<fieldset>
|
||||
<legend>{{ zone.label || 'Zone' }}</legend>
|
||||
<label>
|
||||
Espace privatif
|
||||
<select
|
||||
required
|
||||
[value]="zone.privateAreaId"
|
||||
(change)="updateZone(zone.id, 'privateAreaId', $event)"
|
||||
>
|
||||
@for (area of privateAreas(); track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Libellé
|
||||
<input required [value]="zone.label" (input)="updateZone(zone.id, 'label', $event)" />
|
||||
</label>
|
||||
<label>
|
||||
Équipement
|
||||
<select [value]="zone.equipment" (change)="updateZone(zone.id, 'equipment', $event)">
|
||||
<option value="NONE">Aucun</option>
|
||||
<option value="CLIMATISATION">Climatisation</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (canRemoveZone(zone.id)) {
|
||||
<button type="button" (click)="removeZone(zone.id)">Supprimer cette zone</button>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addZone()">Ajouter une zone</button>
|
||||
@for (observation of report().observations; track observation.subjectId) {
|
||||
<fieldset>
|
||||
<legend>{{ observationLabel(observation.subjectId) }}</legend>
|
||||
<label>
|
||||
État
|
||||
<select
|
||||
[value]="observation.state"
|
||||
(change)="updateObservation(observation.subjectId, 'state', $event)"
|
||||
>
|
||||
@for (state of states; track state) {
|
||||
<option [value]="state">{{ state }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (needsObservation(observation.state)) {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="needsObservation(observation.state)"
|
||||
[value]="observation.observation"
|
||||
(input)="updateObservation(observation.subjectId, 'observation', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
}
|
||||
<h2>Zones et sujets observés</h2>
|
||||
@for (zone of report().zones; track zone.id) {
|
||||
<fieldset>
|
||||
<legend>{{ zone.label || 'Zone' }}</legend>
|
||||
<label>
|
||||
Espace privatif
|
||||
<select
|
||||
required
|
||||
[value]="zone.privateAreaId"
|
||||
(change)="updateZone(zone.id, 'privateAreaId', $event)"
|
||||
>
|
||||
@for (area of privateAreas(); track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Libellé
|
||||
<input
|
||||
required
|
||||
[value]="zone.label"
|
||||
(input)="updateZone(zone.id, 'label', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Équipement
|
||||
<select
|
||||
[value]="zone.equipment"
|
||||
(change)="updateZone(zone.id, 'equipment', $event)"
|
||||
>
|
||||
<option value="NONE">Aucun</option>
|
||||
<option value="CLIMATISATION">Climatisation</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (canRemoveZone(zone.id)) {
|
||||
<button type="button" (click)="removeZone(zone.id)">Supprimer cette zone</button>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addZone()">Ajouter une zone</button>
|
||||
@for (observation of report().observations; track observation.subjectId) {
|
||||
<fieldset>
|
||||
<legend>{{ observationLabel(observation.subjectId) }}</legend>
|
||||
<label>
|
||||
État
|
||||
<select
|
||||
[value]="observation.state"
|
||||
(change)="updateObservation(observation.subjectId, 'state', $event)"
|
||||
>
|
||||
@for (state of states; track state) {
|
||||
<option [value]="state">{{ state }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (needsObservation(observation.state)) {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="needsObservation(observation.state)"
|
||||
[value]="observation.observation"
|
||||
(input)="updateObservation(observation.subjectId, 'observation', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
}
|
||||
</section>
|
||||
+110
-96
@@ -2,118 +2,132 @@ import { Component, input, output } from '@angular/core';
|
||||
import { ConditionState, PropertyConditionReport } from '../../services/condition-report.service';
|
||||
|
||||
const states: readonly ConditionState[] = [
|
||||
'NEUF',
|
||||
'BON_ETAT',
|
||||
'ETAT_USAGE',
|
||||
'DEGRADE',
|
||||
'ABSENT',
|
||||
'NON_APPLICABLE',
|
||||
'NEUF',
|
||||
'BON_ETAT',
|
||||
'ETAT_USAGE',
|
||||
'DEGRADE',
|
||||
'ABSENT',
|
||||
'NON_APPLICABLE',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-details-section',
|
||||
templateUrl: './condition-report-details-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
selector: 'app-condition-report-details-section',
|
||||
templateUrl: './condition-report-details-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportDetailsSection {
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly states = states;
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly states = states;
|
||||
|
||||
setOccurredOn(event: Event): void {
|
||||
this.update((report) => ({ ...report, occurredOn: this.value(event) }));
|
||||
}
|
||||
setOccurredOn(event: Event): void {
|
||||
this.update((report) => ({ ...report, occurredOn: this.value(event) }));
|
||||
}
|
||||
|
||||
setTimeZone(event: Event): void {
|
||||
this.update((report) => ({ ...report, timeZone: this.value(event) }));
|
||||
}
|
||||
setTimeZone(event: Event): void {
|
||||
this.update((report) => ({ ...report, timeZone: this.value(event) }));
|
||||
}
|
||||
|
||||
updateParticipant(index: number, field: 'attendance' | 'representativeName', event: Event): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
participants: report.participants.map((participant, item) =>
|
||||
item === index ? { ...participant, [field]: this.value(event) } : participant,
|
||||
),
|
||||
}));
|
||||
}
|
||||
updateParticipant(
|
||||
index: number,
|
||||
field: 'attendance' | 'representativeName',
|
||||
event: Event,
|
||||
): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
participants: report.participants.map((participant, item) =>
|
||||
item === index ? { ...participant, [field]: this.value(event) } : participant,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
addZone(): void {
|
||||
const id = crypto.randomUUID();
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: [
|
||||
...report.zones,
|
||||
{ id, privateAreaId: this.privateAreas()[0]?.id ?? '', label: '', equipment: 'NONE' },
|
||||
],
|
||||
observations: [...report.observations, { subjectId: id, state: 'BON_ETAT', observation: '' }],
|
||||
}));
|
||||
}
|
||||
addZone(): void {
|
||||
const id = crypto.randomUUID();
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: [
|
||||
...report.zones,
|
||||
{
|
||||
id,
|
||||
privateAreaId: this.privateAreas()[0]?.id ?? '',
|
||||
label: '',
|
||||
equipment: 'NONE',
|
||||
},
|
||||
],
|
||||
observations: [
|
||||
...report.observations,
|
||||
{ subjectId: id, state: 'BON_ETAT', observation: '' },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
updateZone(id: string, field: 'privateAreaId' | 'label' | 'equipment', event: Event): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: report.zones.map((zone) =>
|
||||
zone.id === id ? { ...zone, [field]: this.value(event) } : zone,
|
||||
),
|
||||
}));
|
||||
}
|
||||
updateZone(id: string, field: 'privateAreaId' | 'label' | 'equipment', event: Event): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: report.zones.map((zone) =>
|
||||
zone.id === id ? { ...zone, [field]: this.value(event) } : zone,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
removeZone(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: report.zones.filter((zone) => zone.id !== id),
|
||||
observations: report.observations.filter((observation) => observation.subjectId !== id),
|
||||
zonePhotoAbsences: report.zonePhotoAbsences.filter((absence) => absence.zoneId !== id),
|
||||
}));
|
||||
}
|
||||
removeZone(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: report.zones.filter((zone) => zone.id !== id),
|
||||
observations: report.observations.filter((observation) => observation.subjectId !== id),
|
||||
zonePhotoAbsences: report.zonePhotoAbsences.filter((absence) => absence.zoneId !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
canRemoveZone(id: string): boolean {
|
||||
return (
|
||||
!id.startsWith('inventory:') &&
|
||||
![':floor', ':walls', ':ceiling'].some((suffix) => id.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
canRemoveZone(id: string): boolean {
|
||||
return (
|
||||
!id.startsWith('inventory:') &&
|
||||
![':floor', ':walls', ':ceiling'].some((suffix) => id.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
updateObservation(id: string, field: 'state' | 'observation', event: Event): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
observations: report.observations.map((observation) =>
|
||||
observation.subjectId === id ? { ...observation, [field]: this.value(event) } : observation,
|
||||
),
|
||||
}));
|
||||
}
|
||||
updateObservation(id: string, field: 'state' | 'observation', event: Event): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
observations: report.observations.map((observation) =>
|
||||
observation.subjectId === id
|
||||
? { ...observation, [field]: this.value(event) }
|
||||
: observation,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
needsObservation(state: ConditionState): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state);
|
||||
}
|
||||
needsObservation(state: ConditionState): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state);
|
||||
}
|
||||
|
||||
privateAreas(): { id: string; label: string }[] {
|
||||
const areas = new Map<string, string>();
|
||||
for (const zone of this.report().zones) {
|
||||
if (!zone.privateAreaId || areas.has(zone.privateAreaId)) continue;
|
||||
const separator = zone.label.indexOf(' — ');
|
||||
areas.set(
|
||||
zone.privateAreaId,
|
||||
separator < 0 ? zone.privateAreaId : zone.label.slice(0, separator),
|
||||
);
|
||||
}
|
||||
return [...areas].map(([id, label]) => ({ id, label }));
|
||||
}
|
||||
privateAreas(): { id: string; label: string }[] {
|
||||
const areas = new Map<string, string>();
|
||||
for (const zone of this.report().zones) {
|
||||
if (!zone.privateAreaId || areas.has(zone.privateAreaId)) continue;
|
||||
const separator = zone.label.indexOf(' — ');
|
||||
areas.set(
|
||||
zone.privateAreaId,
|
||||
separator < 0 ? zone.privateAreaId : zone.label.slice(0, separator),
|
||||
);
|
||||
}
|
||||
return [...areas].map(([id, label]) => ({ id, label }));
|
||||
}
|
||||
|
||||
observationLabel(subjectId: string): string {
|
||||
return this.report().zones.find((zone) => zone.id === subjectId)?.label || 'Zone';
|
||||
}
|
||||
observationLabel(subjectId: string): string {
|
||||
return this.report().zones.find((zone) => zone.id === subjectId)?.label || 'Zone';
|
||||
}
|
||||
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
<form (submit)="$event.preventDefault(); save()">
|
||||
<fieldset [disabled]="report().status !== 'DRAFT' || saving() || finalizing()">
|
||||
<app-condition-report-details-section
|
||||
[report]="report()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
/>
|
||||
<app-condition-report-inventory-section
|
||||
[report]="report()"
|
||||
[type]="type()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
/>
|
||||
<app-condition-report-evidence-section
|
||||
[leaseId]="leaseId()"
|
||||
[type]="type()"
|
||||
[report]="report()"
|
||||
[entryPhotos]="entryPhotos()"
|
||||
[editingDirty]="dirty()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
(errorOccurred)="errorOccurred.emit($event)"
|
||||
(successOccurred)="successOccurred.emit($event)"
|
||||
(reloadRequested)="reloadRequested.emit()"
|
||||
/>
|
||||
</fieldset>
|
||||
@if (report().status === 'DRAFT') {
|
||||
<button type="submit" [disabled]="saving()">
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer le brouillon' }}
|
||||
</button>
|
||||
}
|
||||
<fieldset [disabled]="report().status !== 'DRAFT' || saving() || finalizing()">
|
||||
<app-condition-report-details-section
|
||||
[report]="report()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
/>
|
||||
<app-condition-report-inventory-section
|
||||
[report]="report()"
|
||||
[type]="type()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
/>
|
||||
<app-condition-report-evidence-section
|
||||
[leaseId]="leaseId()"
|
||||
[type]="type()"
|
||||
[report]="report()"
|
||||
[entryPhotos]="entryPhotos()"
|
||||
[editingDirty]="dirty()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
(errorOccurred)="errorOccurred.emit($event)"
|
||||
(successOccurred)="successOccurred.emit($event)"
|
||||
(reloadRequested)="reloadRequested.emit()"
|
||||
/>
|
||||
</fieldset>
|
||||
@if (report().status === 'DRAFT') {
|
||||
<button type="submit" [disabled]="saving()">
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer le brouillon' }}
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
@@ -1,184 +1,188 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import {
|
||||
ConditionPhoto,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
PropertyConditionReport,
|
||||
SaveConditionReportRequest,
|
||||
ConditionPhoto,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
PropertyConditionReport,
|
||||
SaveConditionReportRequest,
|
||||
} from '../../services/condition-report.service';
|
||||
import { ConditionReportDetailsSection } from './condition-report-details-section';
|
||||
import { ConditionReportEvidenceSection } from './condition-report-evidence-section';
|
||||
import { ConditionReportInventorySection } from './condition-report-inventory-section';
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-editor',
|
||||
imports: [
|
||||
ConditionReportDetailsSection,
|
||||
ConditionReportInventorySection,
|
||||
ConditionReportEvidenceSection,
|
||||
],
|
||||
templateUrl: './condition-report-editor.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
selector: 'app-condition-report-editor',
|
||||
imports: [
|
||||
ConditionReportDetailsSection,
|
||||
ConditionReportInventorySection,
|
||||
ConditionReportEvidenceSection,
|
||||
],
|
||||
templateUrl: './condition-report-editor.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportEditor {
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly entryPhotos = input<ConditionPhoto[]>([]);
|
||||
readonly dirty = input(false);
|
||||
readonly saving = input(false);
|
||||
readonly finalizing = input(false);
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly saveRequested = output<SaveConditionReportRequest>();
|
||||
readonly errorOccurred = output<string>();
|
||||
readonly successOccurred = output<string>();
|
||||
readonly reloadRequested = output<void>();
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly entryPhotos = input<ConditionPhoto[]>([]);
|
||||
readonly dirty = input(false);
|
||||
readonly saving = input(false);
|
||||
readonly finalizing = input(false);
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly saveRequested = output<SaveConditionReportRequest>();
|
||||
readonly errorOccurred = output<string>();
|
||||
readonly successOccurred = output<string>();
|
||||
readonly reloadRequested = output<void>();
|
||||
|
||||
changeReport(report: PropertyConditionReport): void {
|
||||
if (!this.saving() && !this.finalizing()) this.reportChanged.emit(report);
|
||||
}
|
||||
changeReport(report: PropertyConditionReport): void {
|
||||
if (!this.saving() && !this.finalizing()) this.reportChanged.emit(report);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const report = this.report();
|
||||
const error = this.validationError(report);
|
||||
if (this.saving() || error) {
|
||||
if (error) this.errorOccurred.emit(error);
|
||||
return;
|
||||
}
|
||||
this.saveRequested.emit(this.request(report));
|
||||
}
|
||||
save(): void {
|
||||
const report = this.report();
|
||||
const error = this.validationError(report);
|
||||
if (this.saving() || error) {
|
||||
if (error) this.errorOccurred.emit(error);
|
||||
return;
|
||||
}
|
||||
this.saveRequested.emit(this.request(report));
|
||||
}
|
||||
|
||||
private request(report: PropertyConditionReport): SaveConditionReportRequest {
|
||||
return {
|
||||
version: report.version,
|
||||
occurredOn: report.occurredOn,
|
||||
timeZone: report.timeZone,
|
||||
participants: report.participants,
|
||||
forwardingAddresses: report.forwardingAddresses,
|
||||
zones: report.zones,
|
||||
observations: report.observations,
|
||||
meterReadings: report.meterReadings,
|
||||
accessMeans: report.accessMeans,
|
||||
inventoryConditions: report.inventoryConditions,
|
||||
zonePhotoAbsences: report.zonePhotoAbsences.map(({ zoneId, absence }) => ({
|
||||
zoneId,
|
||||
reason: absence.reason,
|
||||
})),
|
||||
entryPhotoDispositions: report.entryPhotoDispositions,
|
||||
};
|
||||
}
|
||||
private request(report: PropertyConditionReport): SaveConditionReportRequest {
|
||||
return {
|
||||
version: report.version,
|
||||
occurredOn: report.occurredOn,
|
||||
timeZone: report.timeZone,
|
||||
participants: report.participants,
|
||||
forwardingAddresses: report.forwardingAddresses,
|
||||
zones: report.zones,
|
||||
observations: report.observations,
|
||||
meterReadings: report.meterReadings,
|
||||
accessMeans: report.accessMeans,
|
||||
inventoryConditions: report.inventoryConditions,
|
||||
zonePhotoAbsences: report.zonePhotoAbsences.map(({ zoneId, absence }) => ({
|
||||
zoneId,
|
||||
reason: absence.reason,
|
||||
})),
|
||||
entryPhotoDispositions: report.entryPhotoDispositions,
|
||||
};
|
||||
}
|
||||
|
||||
private validationError(report: PropertyConditionReport): string {
|
||||
if (!report.occurredOn || !report.timeZone)
|
||||
return 'Renseignez la date et le fuseau horaire IANA du navigateur.';
|
||||
if (
|
||||
report.participants.some(
|
||||
(participant) =>
|
||||
!participant.partyId.trim() ||
|
||||
!participant.name.trim() ||
|
||||
(participant.attendance === 'REPRESENTED' && !participant.representativeName.trim()),
|
||||
)
|
||||
)
|
||||
return 'Renseignez chaque partie et le mandataire des parties représentées.';
|
||||
if (report.zones.some((zone) => !zone.id.trim() || !zone.label.trim()))
|
||||
return 'Renseignez l’identifiant et le libellé de chaque zone.';
|
||||
if (
|
||||
report.observations.some(
|
||||
(observation) =>
|
||||
!observation.subjectId.trim() ||
|
||||
this.needsObservation(observation.state, observation.observation),
|
||||
)
|
||||
)
|
||||
return 'Chaque état dégradé, absent ou non applicable exige une observation.';
|
||||
if (
|
||||
report.meterReadings.some(
|
||||
(meter) =>
|
||||
!meter.id.trim() ||
|
||||
!meter.unit.trim() ||
|
||||
(meter.state === 'RECORDED' &&
|
||||
(!Number.isFinite(meter.index) || meter.index === null || meter.index < 0)) ||
|
||||
(meter.state === 'INACCESSIBLE' && (meter.index !== null || !meter.observation.trim())) ||
|
||||
(meter.type === 'OTHER' && !meter.label.trim()),
|
||||
)
|
||||
)
|
||||
return 'Renseignez le type, l’index non négatif, l’unité et le libellé des compteurs personnalisés.';
|
||||
if (
|
||||
report.accessMeans.some(
|
||||
(access) =>
|
||||
!access.id.trim() ||
|
||||
!access.destination.trim() ||
|
||||
!Number.isInteger(access.quantity) ||
|
||||
access.quantity < 0,
|
||||
)
|
||||
)
|
||||
return 'Renseignez les moyens d’accès et leur quantité entière non négative.';
|
||||
if (
|
||||
report.inventoryConditions.some(
|
||||
(condition) =>
|
||||
!condition.inventoryLineId.trim() ||
|
||||
!Number.isInteger(condition.contractualQuantity) ||
|
||||
condition.contractualQuantity < 0 ||
|
||||
condition.splits.reduce((sum, split) => sum + split.quantity, 0) !==
|
||||
condition.contractualQuantity ||
|
||||
condition.splits.some(
|
||||
(split) =>
|
||||
!Number.isInteger(split.quantity) ||
|
||||
split.quantity < 0 ||
|
||||
this.needsObservation(split.state, split.observation),
|
||||
),
|
||||
)
|
||||
)
|
||||
return 'Répartissez exactement chaque quantité d’inventaire par état et observez les états dégradé, absent ou non applicable.';
|
||||
if (
|
||||
report.zones.some(
|
||||
(zone) =>
|
||||
!report.photos.some((photo) => photo.zoneId === zone.id) &&
|
||||
!report.zonePhotoAbsences
|
||||
.find((absence) => absence.zoneId === zone.id)
|
||||
?.absence.reason.trim(),
|
||||
)
|
||||
)
|
||||
return 'Ajoutez une photo ou un motif d’absence pour chaque zone.';
|
||||
if (
|
||||
this.type() === 'EXIT' &&
|
||||
report.participants.some(
|
||||
(participant) =>
|
||||
participant.role === 'TENANT' &&
|
||||
!report.forwardingAddresses
|
||||
.find((address) => address.tenantPartyId === participant.partyId)
|
||||
?.address.trim(),
|
||||
)
|
||||
)
|
||||
return 'Renseignez l’adresse de correspondance de chaque locataire.';
|
||||
if (this.type() === 'EXIT') {
|
||||
const usedExitPhotoIds = new Set<string>();
|
||||
for (const entryPhoto of this.entryPhotos()) {
|
||||
const disposition = report.entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === entryPhoto.reference,
|
||||
);
|
||||
if (!disposition?.exitPhotoId) {
|
||||
if (!disposition?.absenceReason?.trim()) {
|
||||
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const exitPhoto = report.photos.find(
|
||||
(photo) => photo.reference === disposition.exitPhotoId,
|
||||
);
|
||||
if (
|
||||
!exitPhoto ||
|
||||
exitPhoto.zoneId !== entryPhoto.zoneId ||
|
||||
usedExitPhotoIds.has(exitPhoto.reference)
|
||||
) {
|
||||
return 'Chaque photo d’entrée doit être liée à une photo de sortie unique de la même zone.';
|
||||
}
|
||||
usedExitPhotoIds.add(exitPhoto.reference);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
private validationError(report: PropertyConditionReport): string {
|
||||
if (!report.occurredOn || !report.timeZone)
|
||||
return 'Renseignez la date et le fuseau horaire IANA du navigateur.';
|
||||
if (
|
||||
report.participants.some(
|
||||
(participant) =>
|
||||
!participant.partyId.trim() ||
|
||||
!participant.name.trim() ||
|
||||
(participant.attendance === 'REPRESENTED' &&
|
||||
!participant.representativeName.trim()),
|
||||
)
|
||||
)
|
||||
return 'Renseignez chaque partie et le mandataire des parties représentées.';
|
||||
if (report.zones.some((zone) => !zone.id.trim() || !zone.label.trim()))
|
||||
return 'Renseignez l’identifiant et le libellé de chaque zone.';
|
||||
if (
|
||||
report.observations.some(
|
||||
(observation) =>
|
||||
!observation.subjectId.trim() ||
|
||||
this.needsObservation(observation.state, observation.observation),
|
||||
)
|
||||
)
|
||||
return 'Chaque état dégradé, absent ou non applicable exige une observation.';
|
||||
if (
|
||||
report.meterReadings.some(
|
||||
(meter) =>
|
||||
!meter.id.trim() ||
|
||||
!meter.unit.trim() ||
|
||||
(meter.state === 'RECORDED' &&
|
||||
(!Number.isFinite(meter.index) ||
|
||||
meter.index === null ||
|
||||
meter.index < 0)) ||
|
||||
(meter.state === 'INACCESSIBLE' &&
|
||||
(meter.index !== null || !meter.observation.trim())) ||
|
||||
(meter.type === 'OTHER' && !meter.label.trim()),
|
||||
)
|
||||
)
|
||||
return 'Renseignez le type, l’index non négatif, l’unité et le libellé des compteurs personnalisés.';
|
||||
if (
|
||||
report.accessMeans.some(
|
||||
(access) =>
|
||||
!access.id.trim() ||
|
||||
!access.destination.trim() ||
|
||||
!Number.isInteger(access.quantity) ||
|
||||
access.quantity < 0,
|
||||
)
|
||||
)
|
||||
return 'Renseignez les moyens d’accès et leur quantité entière non négative.';
|
||||
if (
|
||||
report.inventoryConditions.some(
|
||||
(condition) =>
|
||||
!condition.inventoryLineId.trim() ||
|
||||
!Number.isInteger(condition.contractualQuantity) ||
|
||||
condition.contractualQuantity < 0 ||
|
||||
condition.splits.reduce((sum, split) => sum + split.quantity, 0) !==
|
||||
condition.contractualQuantity ||
|
||||
condition.splits.some(
|
||||
(split) =>
|
||||
!Number.isInteger(split.quantity) ||
|
||||
split.quantity < 0 ||
|
||||
this.needsObservation(split.state, split.observation),
|
||||
),
|
||||
)
|
||||
)
|
||||
return 'Répartissez exactement chaque quantité d’inventaire par état et observez les états dégradé, absent ou non applicable.';
|
||||
if (
|
||||
report.zones.some(
|
||||
(zone) =>
|
||||
!report.photos.some((photo) => photo.zoneId === zone.id) &&
|
||||
!report.zonePhotoAbsences
|
||||
.find((absence) => absence.zoneId === zone.id)
|
||||
?.absence.reason.trim(),
|
||||
)
|
||||
)
|
||||
return 'Ajoutez une photo ou un motif d’absence pour chaque zone.';
|
||||
if (
|
||||
this.type() === 'EXIT' &&
|
||||
report.participants.some(
|
||||
(participant) =>
|
||||
participant.role === 'TENANT' &&
|
||||
!report.forwardingAddresses
|
||||
.find((address) => address.tenantPartyId === participant.partyId)
|
||||
?.address.trim(),
|
||||
)
|
||||
)
|
||||
return 'Renseignez l’adresse de correspondance de chaque locataire.';
|
||||
if (this.type() === 'EXIT') {
|
||||
const usedExitPhotoIds = new Set<string>();
|
||||
for (const entryPhoto of this.entryPhotos()) {
|
||||
const disposition = report.entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === entryPhoto.reference,
|
||||
);
|
||||
if (!disposition?.exitPhotoId) {
|
||||
if (!disposition?.absenceReason?.trim()) {
|
||||
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const exitPhoto = report.photos.find(
|
||||
(photo) => photo.reference === disposition.exitPhotoId,
|
||||
);
|
||||
if (
|
||||
!exitPhoto ||
|
||||
exitPhoto.zoneId !== entryPhoto.zoneId ||
|
||||
usedExitPhotoIds.has(exitPhoto.reference)
|
||||
) {
|
||||
return 'Chaque photo d’entrée doit être liée à une photo de sortie unique de la même zone.';
|
||||
}
|
||||
usedExitPhotoIds.add(exitPhoto.reference);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private needsObservation(state: ConditionState, observation: string): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state) && !observation.trim();
|
||||
}
|
||||
private needsObservation(state: ConditionState, observation: string): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state) && !observation.trim();
|
||||
}
|
||||
}
|
||||
+92
-85
@@ -1,90 +1,97 @@
|
||||
<section>
|
||||
<h2>Parcours photos guidé</h2>
|
||||
<p>Chaque zone exige une photo ou un motif d’absence.</p>
|
||||
@for (zone of report().zones; track zone.id) {
|
||||
<article>
|
||||
<h3>{{ zone.label }}</h3>
|
||||
<label>
|
||||
Motif d’absence
|
||||
<input [value]="absenceReason(zone.id)" (input)="setZoneAbsence(zone.id, $event)" />
|
||||
</label>
|
||||
<label>
|
||||
Prendre ou choisir une photo
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
|
||||
capture="environment"
|
||||
[disabled]="editingDirty()"
|
||||
(change)="selectPhoto(zone.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (pendingPhotos()[zone.id]; as pending) {
|
||||
<img [src]="pending.previewUrl" alt="Aperçu de la photo de {{ zone.label }}" />
|
||||
<p>{{ pending.file.name }}</p>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id || editingDirty()"
|
||||
(click)="uploadPhoto(zone.id)"
|
||||
>
|
||||
{{ busyPhotoZone() === zone.id ? 'Téléversement…' : 'Utiliser cette photo' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id"
|
||||
(click)="retakePhoto(zone.id)"
|
||||
>
|
||||
Reprendre le cliché
|
||||
</button>
|
||||
}
|
||||
@for (photo of report().photos; track photo.reference) {
|
||||
@if (photo.zoneId === zone.id) {
|
||||
<img
|
||||
[src]="photoUrl(type(), photo.reference)"
|
||||
alt="Photo enregistrée de {{ zone.label }}"
|
||||
/>
|
||||
<p>
|
||||
{{ photo.reference }}
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id || editingDirty()"
|
||||
(click)="removePhoto(photo)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
<h2>Parcours photos guidé</h2>
|
||||
<p>Chaque zone exige une photo ou un motif d’absence.</p>
|
||||
@for (zone of report().zones; track zone.id) {
|
||||
<article>
|
||||
<h3>{{ zone.label }}</h3>
|
||||
<label>
|
||||
Motif d’absence
|
||||
<input [value]="absenceReason(zone.id)" (input)="setZoneAbsence(zone.id, $event)" />
|
||||
</label>
|
||||
<label>
|
||||
Prendre ou choisir une photo
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
|
||||
capture="environment"
|
||||
[disabled]="editingDirty()"
|
||||
(change)="selectPhoto(zone.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (pendingPhotos()[zone.id]; as pending) {
|
||||
<img [src]="pending.previewUrl" alt="Aperçu de la photo de {{ zone.label }}" />
|
||||
<p>{{ pending.file.name }}</p>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id || editingDirty()"
|
||||
(click)="uploadPhoto(zone.id)"
|
||||
>
|
||||
{{ busyPhotoZone() === zone.id ? 'Téléversement…' : 'Utiliser cette photo' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id"
|
||||
(click)="retakePhoto(zone.id)"
|
||||
>
|
||||
Reprendre le cliché
|
||||
</button>
|
||||
}
|
||||
@for (photo of report().photos; track photo.reference) {
|
||||
@if (photo.zoneId === zone.id) {
|
||||
<img
|
||||
[src]="photoUrl(type(), photo.reference)"
|
||||
alt="Photo enregistrée de {{ zone.label }}"
|
||||
/>
|
||||
<p>
|
||||
{{ photo.reference }}
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id || editingDirty()"
|
||||
(click)="removePhoto(photo)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (type() === 'EXIT') {
|
||||
<section>
|
||||
<h2>Photos d’entrée à comparer</h2>
|
||||
@for (entryPhoto of entryPhotos(); track entryPhoto.reference) {
|
||||
<article>
|
||||
<img [src]="photoUrl('ENTRY', entryPhoto.reference)" alt="Photo d’entrée à comparer" />
|
||||
<p>{{ entryPhoto.reference }}</p>
|
||||
<label>
|
||||
Photo de sortie correspondante
|
||||
<select
|
||||
[value]="disposition(entryPhoto.reference).exitPhotoId ?? ''"
|
||||
(change)="setEntryPhotoDisposition(entryPhoto.reference, 'exitPhotoId', $event)"
|
||||
>
|
||||
<option value="">Aucune</option>
|
||||
@for (exitPhoto of exitPhotosFor(entryPhoto); track exitPhoto.reference) {
|
||||
<option [value]="exitPhoto.reference">{{ exitPhoto.reference }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Motif d’absence
|
||||
<input
|
||||
[value]="disposition(entryPhoto.reference).absenceReason ?? ''"
|
||||
(input)="setEntryPhotoDisposition(entryPhoto.reference, 'absenceReason', $event)"
|
||||
/>
|
||||
</label>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
<section>
|
||||
<h2>Photos d’entrée à comparer</h2>
|
||||
@for (entryPhoto of entryPhotos(); track entryPhoto.reference) {
|
||||
<article>
|
||||
<img
|
||||
[src]="photoUrl('ENTRY', entryPhoto.reference)"
|
||||
alt="Photo d’entrée à comparer"
|
||||
/>
|
||||
<p>{{ entryPhoto.reference }}</p>
|
||||
<label>
|
||||
Photo de sortie correspondante
|
||||
<select
|
||||
[value]="disposition(entryPhoto.reference).exitPhotoId ?? ''"
|
||||
(change)="
|
||||
setEntryPhotoDisposition(entryPhoto.reference, 'exitPhotoId', $event)
|
||||
"
|
||||
>
|
||||
<option value="">Aucune</option>
|
||||
@for (exitPhoto of exitPhotosFor(entryPhoto); track exitPhoto.reference) {
|
||||
<option [value]="exitPhoto.reference">{{ exitPhoto.reference }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Motif d’absence
|
||||
<input
|
||||
[value]="disposition(entryPhoto.reference).absenceReason ?? ''"
|
||||
(input)="
|
||||
setEntryPhotoDisposition(entryPhoto.reference, 'absenceReason', $event)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
+161
-150
@@ -1,176 +1,187 @@
|
||||
import { Component, OnDestroy, inject, input, output, signal } from '@angular/core';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
ConditionPhoto,
|
||||
ConditionReportService,
|
||||
ConditionReportType,
|
||||
EntryPhotoDisposition,
|
||||
PropertyConditionReport,
|
||||
ConditionPhoto,
|
||||
ConditionReportService,
|
||||
ConditionReportType,
|
||||
EntryPhotoDisposition,
|
||||
PropertyConditionReport,
|
||||
} from '../../services/condition-report.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
|
||||
interface PendingPhoto {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-evidence-section',
|
||||
templateUrl: './condition-report-evidence-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
selector: 'app-condition-report-evidence-section',
|
||||
templateUrl: './condition-report-evidence-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportEvidenceSection implements OnDestroy {
|
||||
private readonly reports = inject(ConditionReportService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly entryPhotos = input<ConditionPhoto[]>([]);
|
||||
readonly editingDirty = input(false);
|
||||
readonly busyPhotoZone = signal('');
|
||||
readonly pendingPhotos = signal<Record<string, PendingPhoto>>({});
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly errorOccurred = output<string>();
|
||||
readonly successOccurred = output<string>();
|
||||
readonly reloadRequested = output<void>();
|
||||
private readonly reports = inject(ConditionReportService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly entryPhotos = input<ConditionPhoto[]>([]);
|
||||
readonly editingDirty = input(false);
|
||||
readonly busyPhotoZone = signal('');
|
||||
readonly pendingPhotos = signal<Record<string, PendingPhoto>>({});
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly errorOccurred = output<string>();
|
||||
readonly successOccurred = output<string>();
|
||||
readonly reloadRequested = output<void>();
|
||||
|
||||
ngOnDestroy(): void {
|
||||
Object.values(this.pendingPhotos()).forEach(({ previewUrl }) =>
|
||||
URL.revokeObjectURL(previewUrl),
|
||||
);
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
Object.values(this.pendingPhotos()).forEach(({ previewUrl }) =>
|
||||
URL.revokeObjectURL(previewUrl),
|
||||
);
|
||||
}
|
||||
|
||||
setZoneAbsence(zoneId: string, event: Event): void {
|
||||
const reason = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zonePhotoAbsences: [
|
||||
...report.zonePhotoAbsences.filter((absence) => absence.zoneId !== zoneId),
|
||||
...(reason.trim() ? [{ zoneId, absence: { reason, author: '', recordedAt: '' } }] : []),
|
||||
],
|
||||
}));
|
||||
}
|
||||
setZoneAbsence(zoneId: string, event: Event): void {
|
||||
const reason = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zonePhotoAbsences: [
|
||||
...report.zonePhotoAbsences.filter((absence) => absence.zoneId !== zoneId),
|
||||
...(reason.trim()
|
||||
? [{ zoneId, absence: { reason, author: '', recordedAt: '' } }]
|
||||
: []),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
setEntryPhotoDisposition(
|
||||
entryPhotoId: string,
|
||||
field: keyof EntryPhotoDisposition,
|
||||
event: Event,
|
||||
): void {
|
||||
const value = this.value(event);
|
||||
this.update((report) => {
|
||||
const current = report.entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === entryPhotoId,
|
||||
) ?? { entryPhotoId };
|
||||
const next = { ...current, [field]: value || undefined };
|
||||
return {
|
||||
...report,
|
||||
entryPhotoDispositions: [
|
||||
...report.entryPhotoDispositions.filter((item) => item.entryPhotoId !== entryPhotoId),
|
||||
next,
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
setEntryPhotoDisposition(
|
||||
entryPhotoId: string,
|
||||
field: keyof EntryPhotoDisposition,
|
||||
event: Event,
|
||||
): void {
|
||||
const value = this.value(event);
|
||||
this.update((report) => {
|
||||
const current = report.entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === entryPhotoId,
|
||||
) ?? { entryPhotoId };
|
||||
const next = { ...current, [field]: value || undefined };
|
||||
return {
|
||||
...report,
|
||||
entryPhotoDispositions: [
|
||||
...report.entryPhotoDispositions.filter(
|
||||
(item) => item.entryPhotoId !== entryPhotoId,
|
||||
),
|
||||
next,
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
selectPhoto(zoneId: string, event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (!file) return;
|
||||
const previous = this.pendingPhotos()[zoneId];
|
||||
if (previous) URL.revokeObjectURL(previous.previewUrl);
|
||||
this.pendingPhotos.update((photos) => ({
|
||||
...photos,
|
||||
[zoneId]: { file, previewUrl: URL.createObjectURL(file) },
|
||||
}));
|
||||
}
|
||||
selectPhoto(zoneId: string, event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (!file) return;
|
||||
const previous = this.pendingPhotos()[zoneId];
|
||||
if (previous) URL.revokeObjectURL(previous.previewUrl);
|
||||
this.pendingPhotos.update((photos) => ({
|
||||
...photos,
|
||||
[zoneId]: { file, previewUrl: URL.createObjectURL(file) },
|
||||
}));
|
||||
}
|
||||
|
||||
retakePhoto(zoneId: string): void {
|
||||
const photo = this.pendingPhotos()[zoneId];
|
||||
if (!photo) return;
|
||||
URL.revokeObjectURL(photo.previewUrl);
|
||||
this.pendingPhotos.update((photos) => {
|
||||
const remaining = { ...photos };
|
||||
delete remaining[zoneId];
|
||||
return remaining;
|
||||
});
|
||||
}
|
||||
retakePhoto(zoneId: string): void {
|
||||
const photo = this.pendingPhotos()[zoneId];
|
||||
if (!photo) return;
|
||||
URL.revokeObjectURL(photo.previewUrl);
|
||||
this.pendingPhotos.update((photos) => {
|
||||
const remaining = { ...photos };
|
||||
delete remaining[zoneId];
|
||||
return remaining;
|
||||
});
|
||||
}
|
||||
|
||||
uploadPhoto(zoneId: string): void {
|
||||
const pending = this.pendingPhotos()[zoneId];
|
||||
if (!pending || this.busyPhotoZone() || this.editingDirty()) return;
|
||||
this.busyPhotoZone.set(zoneId);
|
||||
this.reports
|
||||
.uploadPhoto(this.leaseId(), this.type(), pending.file, zoneId)
|
||||
.pipe(rxFinalize(() => this.busyPhotoZone.set('')))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.retakePhoto(zoneId);
|
||||
this.reloadRequested.emit();
|
||||
this.successOccurred.emit('Photo ajoutée.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.errorOccurred.emit(
|
||||
httpErrorMessage(error, 'La photo a été refusée. Reprenez le cliché puis réessayez.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
uploadPhoto(zoneId: string): void {
|
||||
const pending = this.pendingPhotos()[zoneId];
|
||||
if (!pending || this.busyPhotoZone() || this.editingDirty()) return;
|
||||
this.busyPhotoZone.set(zoneId);
|
||||
this.reports
|
||||
.uploadPhoto(this.leaseId(), this.type(), pending.file, zoneId)
|
||||
.pipe(rxFinalize(() => this.busyPhotoZone.set('')))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.retakePhoto(zoneId);
|
||||
this.reloadRequested.emit();
|
||||
this.successOccurred.emit('Photo ajoutée.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.errorOccurred.emit(
|
||||
httpErrorMessage(
|
||||
error,
|
||||
'La photo a été refusée. Reprenez le cliché puis réessayez.',
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
removePhoto(photo: ConditionPhoto): void {
|
||||
if (this.busyPhotoZone() || this.editingDirty()) return;
|
||||
this.busyPhotoZone.set(photo.zoneId);
|
||||
this.reports
|
||||
.deletePhoto(this.leaseId(), this.type(), photo.reference, this.report().version)
|
||||
.pipe(rxFinalize(() => this.busyPhotoZone.set('')))
|
||||
.subscribe({
|
||||
next: () => this.reloadRequested.emit(),
|
||||
error: (error) =>
|
||||
this.errorOccurred.emit(httpErrorMessage(error, 'Impossible de supprimer cette photo.')),
|
||||
});
|
||||
}
|
||||
removePhoto(photo: ConditionPhoto): void {
|
||||
if (this.busyPhotoZone() || this.editingDirty()) return;
|
||||
this.busyPhotoZone.set(photo.zoneId);
|
||||
this.reports
|
||||
.deletePhoto(this.leaseId(), this.type(), photo.reference, this.report().version)
|
||||
.pipe(rxFinalize(() => this.busyPhotoZone.set('')))
|
||||
.subscribe({
|
||||
next: () => this.reloadRequested.emit(),
|
||||
error: (error) =>
|
||||
this.errorOccurred.emit(
|
||||
httpErrorMessage(error, 'Impossible de supprimer cette photo.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
absenceReason(zoneId: string): string {
|
||||
return (
|
||||
this.report().zonePhotoAbsences.find((absence) => absence.zoneId === zoneId)?.absence
|
||||
.reason ?? ''
|
||||
);
|
||||
}
|
||||
absenceReason(zoneId: string): string {
|
||||
return (
|
||||
this.report().zonePhotoAbsences.find((absence) => absence.zoneId === zoneId)?.absence
|
||||
.reason ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
disposition(entryPhotoId: string): EntryPhotoDisposition {
|
||||
return (
|
||||
this.report().entryPhotoDispositions.find((item) => item.entryPhotoId === entryPhotoId) ?? {
|
||||
entryPhotoId,
|
||||
}
|
||||
);
|
||||
}
|
||||
disposition(entryPhotoId: string): EntryPhotoDisposition {
|
||||
return (
|
||||
this.report().entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === entryPhotoId,
|
||||
) ?? {
|
||||
entryPhotoId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
exitPhotosFor(entryPhoto: ConditionPhoto): ConditionPhoto[] {
|
||||
const report = this.report();
|
||||
const current = this.disposition(entryPhoto.reference).exitPhotoId;
|
||||
const used = new Set(
|
||||
report.entryPhotoDispositions
|
||||
.filter((item) => item.entryPhotoId !== entryPhoto.reference)
|
||||
.map((item) => item.exitPhotoId)
|
||||
.filter((reference): reference is string => Boolean(reference)),
|
||||
);
|
||||
return report.photos.filter(
|
||||
(photo) =>
|
||||
photo.zoneId === entryPhoto.zoneId &&
|
||||
(photo.reference === current || !used.has(photo.reference)),
|
||||
);
|
||||
}
|
||||
exitPhotosFor(entryPhoto: ConditionPhoto): ConditionPhoto[] {
|
||||
const report = this.report();
|
||||
const current = this.disposition(entryPhoto.reference).exitPhotoId;
|
||||
const used = new Set(
|
||||
report.entryPhotoDispositions
|
||||
.filter((item) => item.entryPhotoId !== entryPhoto.reference)
|
||||
.map((item) => item.exitPhotoId)
|
||||
.filter((reference): reference is string => Boolean(reference)),
|
||||
);
|
||||
return report.photos.filter(
|
||||
(photo) =>
|
||||
photo.zoneId === entryPhoto.zoneId &&
|
||||
(photo.reference === current || !used.has(photo.reference)),
|
||||
);
|
||||
}
|
||||
|
||||
photoUrl(type: ConditionReportType, reference: string): string {
|
||||
return this.reports.photoUrl(this.leaseId(), type, reference);
|
||||
}
|
||||
photoUrl(type: ConditionReportType, reference: string): string {
|
||||
return this.reports.photoUrl(this.leaseId(), type, reference);
|
||||
}
|
||||
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLSelectElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLSelectElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
}
|
||||
+202
-181
@@ -1,192 +1,213 @@
|
||||
<section>
|
||||
<h2>Compteurs</h2>
|
||||
@for (meter of report().meterReadings; track meter.id) {
|
||||
<fieldset>
|
||||
<legend>{{ meter.label || meter.type }}</legend>
|
||||
<label>
|
||||
Type
|
||||
<select [value]="meter.type" (change)="updateMeter(meter.id, 'type', $event)">
|
||||
<option value="ELECTRICITE">Électricité</option>
|
||||
<option value="GAZ">Gaz</option>
|
||||
<option value="EAU_FROIDE">Eau froide</option>
|
||||
<option value="EAU_CHAUDE">Eau chaude</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Libellé / numéro
|
||||
<input
|
||||
[required]="meter.type === 'OTHER'"
|
||||
[value]="meter.label"
|
||||
(input)="updateMeter(meter.id, 'label', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Relevé
|
||||
<select [value]="meter.state" (change)="updateMeter(meter.id, 'state', $event)">
|
||||
<option value="RECORDED">Index relevé</option>
|
||||
<option value="INACCESSIBLE">Compteur inaccessible</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Unité
|
||||
<input required [value]="meter.unit" (input)="updateMeter(meter.id, 'unit', $event)" />
|
||||
</label>
|
||||
@if (meter.state === 'RECORDED') {
|
||||
<label>
|
||||
Index
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
[value]="meter.index"
|
||||
(input)="updateMeter(meter.id, 'index', $event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
Référence photo
|
||||
<input [value]="meter.photoId" (input)="updateMeter(meter.id, 'photoId', $event)" />
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (meter.state === 'INACCESSIBLE') {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="meter.state === 'INACCESSIBLE'"
|
||||
[value]="meter.observation"
|
||||
(input)="updateMeter(meter.id, 'observation', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeMeter(meter.id)">Supprimer ce compteur</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addMeter()">Ajouter un compteur</button>
|
||||
<h2>Compteurs</h2>
|
||||
@for (meter of report().meterReadings; track meter.id) {
|
||||
<fieldset>
|
||||
<legend>{{ meter.label || meter.type }}</legend>
|
||||
<label>
|
||||
Type
|
||||
<select [value]="meter.type" (change)="updateMeter(meter.id, 'type', $event)">
|
||||
<option value="ELECTRICITE">Électricité</option>
|
||||
<option value="GAZ">Gaz</option>
|
||||
<option value="EAU_FROIDE">Eau froide</option>
|
||||
<option value="EAU_CHAUDE">Eau chaude</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Libellé / numéro
|
||||
<input
|
||||
[required]="meter.type === 'OTHER'"
|
||||
[value]="meter.label"
|
||||
(input)="updateMeter(meter.id, 'label', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Relevé
|
||||
<select [value]="meter.state" (change)="updateMeter(meter.id, 'state', $event)">
|
||||
<option value="RECORDED">Index relevé</option>
|
||||
<option value="INACCESSIBLE">Compteur inaccessible</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Unité
|
||||
<input
|
||||
required
|
||||
[value]="meter.unit"
|
||||
(input)="updateMeter(meter.id, 'unit', $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (meter.state === 'RECORDED') {
|
||||
<label>
|
||||
Index
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
[value]="meter.index"
|
||||
(input)="updateMeter(meter.id, 'index', $event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
Référence photo
|
||||
<input [value]="meter.photoId" (input)="updateMeter(meter.id, 'photoId', $event)" />
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (meter.state === 'INACCESSIBLE') {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="meter.state === 'INACCESSIBLE'"
|
||||
[value]="meter.observation"
|
||||
(input)="updateMeter(meter.id, 'observation', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeMeter(meter.id)">Supprimer ce compteur</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addMeter()">Ajouter un compteur</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Moyens d’accès</h2>
|
||||
@for (access of report().accessMeans; track access.id) {
|
||||
<fieldset>
|
||||
<legend>{{ access.destination || 'Accès' }}</legend>
|
||||
<label>
|
||||
Type
|
||||
<select [value]="access.type" (change)="updateAccessMean(access.id, 'type', $event)">
|
||||
<option value="CLE">Clé</option>
|
||||
<option value="BADGE">Badge</option>
|
||||
<option value="TELECOMMANDE">Télécommande</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Destination
|
||||
<input
|
||||
required
|
||||
[value]="access.destination"
|
||||
(input)="updateAccessMean(access.id, 'destination', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="access.quantity"
|
||||
(input)="updateAccessMean(access.id, 'quantity', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
<textarea
|
||||
[value]="access.observation"
|
||||
(input)="updateAccessMean(access.id, 'observation', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeAccessMean(access.id)">
|
||||
Supprimer ce moyen d’accès
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addAccessMean()">Ajouter un moyen d’accès</button>
|
||||
<h2>Moyens d’accès</h2>
|
||||
@for (access of report().accessMeans; track access.id) {
|
||||
<fieldset>
|
||||
<legend>{{ access.destination || 'Accès' }}</legend>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
[value]="access.type"
|
||||
(change)="updateAccessMean(access.id, 'type', $event)"
|
||||
>
|
||||
<option value="CLE">Clé</option>
|
||||
<option value="BADGE">Badge</option>
|
||||
<option value="TELECOMMANDE">Télécommande</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Destination
|
||||
<input
|
||||
required
|
||||
[value]="access.destination"
|
||||
(input)="updateAccessMean(access.id, 'destination', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="access.quantity"
|
||||
(input)="updateAccessMean(access.id, 'quantity', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
<textarea
|
||||
[value]="access.observation"
|
||||
(input)="updateAccessMean(access.id, 'observation', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeAccessMean(access.id)">
|
||||
Supprimer ce moyen d’accès
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addAccessMean()">Ajouter un moyen d’accès</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Inventaire constaté</h2>
|
||||
@for (
|
||||
condition of report().inventoryConditions;
|
||||
track condition.inventoryLineId;
|
||||
let conditionIndex = $index
|
||||
) {
|
||||
<fieldset>
|
||||
<legend>{{ condition.designation }}</legend>
|
||||
<p>Espace : {{ condition.privateAreaLabel }}</p>
|
||||
<p>Quantité contractuelle : {{ condition.contractualQuantity }}</p>
|
||||
@for (split of condition.splits; track $index; let splitIndex = $index) {
|
||||
<fieldset>
|
||||
<legend>Répartition</legend>
|
||||
<label>
|
||||
État
|
||||
<select
|
||||
[value]="split.state"
|
||||
(change)="updateInventorySplit(conditionIndex, splitIndex, 'state', $event)"
|
||||
>
|
||||
@for (state of states; track state) {
|
||||
<option [value]="state">{{ state }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="split.quantity"
|
||||
(input)="updateInventorySplit(conditionIndex, splitIndex, 'quantity', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (needsObservation(split.state)) {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="needsObservation(split.state)"
|
||||
[value]="split.observation"
|
||||
(input)="updateInventorySplit(conditionIndex, splitIndex, 'observation', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeInventorySplit(conditionIndex, splitIndex)">
|
||||
Supprimer cette répartition
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addInventorySplit(conditionIndex)">
|
||||
Ajouter une répartition
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<h2>Inventaire constaté</h2>
|
||||
@for (
|
||||
condition of report().inventoryConditions;
|
||||
track condition.inventoryLineId;
|
||||
let conditionIndex = $index
|
||||
) {
|
||||
<fieldset>
|
||||
<legend>{{ condition.designation }}</legend>
|
||||
<p>Espace : {{ condition.privateAreaLabel }}</p>
|
||||
<p>Quantité contractuelle : {{ condition.contractualQuantity }}</p>
|
||||
@for (split of condition.splits; track $index; let splitIndex = $index) {
|
||||
<fieldset>
|
||||
<legend>Répartition</legend>
|
||||
<label>
|
||||
État
|
||||
<select
|
||||
[value]="split.state"
|
||||
(change)="
|
||||
updateInventorySplit(conditionIndex, splitIndex, 'state', $event)
|
||||
"
|
||||
>
|
||||
@for (state of states; track state) {
|
||||
<option [value]="state">{{ state }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="split.quantity"
|
||||
(input)="
|
||||
updateInventorySplit(conditionIndex, splitIndex, 'quantity', $event)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (needsObservation(split.state)) {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="needsObservation(split.state)"
|
||||
[value]="split.observation"
|
||||
(input)="
|
||||
updateInventorySplit(
|
||||
conditionIndex,
|
||||
splitIndex,
|
||||
'observation',
|
||||
$event
|
||||
)
|
||||
"
|
||||
></textarea>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeInventorySplit(conditionIndex, splitIndex)"
|
||||
>
|
||||
Supprimer cette répartition
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addInventorySplit(conditionIndex)">
|
||||
Ajouter une répartition
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (type() === 'EXIT') {
|
||||
<section>
|
||||
<h2>Adresses de correspondance</h2>
|
||||
@for (participant of report().participants; track participant.partyId) {
|
||||
@if (participant.role === 'TENANT') {
|
||||
<label>
|
||||
{{ participant.name || participant.partyId }}
|
||||
<textarea
|
||||
required
|
||||
[value]="forwardingAddress(participant.partyId)"
|
||||
(input)="updateForwardingAddress(participant.partyId, $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
<section>
|
||||
<h2>Adresses de correspondance</h2>
|
||||
@for (participant of report().participants; track participant.partyId) {
|
||||
@if (participant.role === 'TENANT') {
|
||||
<label>
|
||||
{{ participant.name || participant.partyId }}
|
||||
<textarea
|
||||
required
|
||||
[value]="forwardingAddress(participant.partyId)"
|
||||
(input)="updateForwardingAddress(participant.partyId, $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
+179
-172
@@ -1,198 +1,205 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import {
|
||||
AccessMeans,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
InventoryCondition,
|
||||
InventoryQuantitySplit,
|
||||
MeterReading,
|
||||
PropertyConditionReport,
|
||||
AccessMeans,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
InventoryCondition,
|
||||
InventoryQuantitySplit,
|
||||
MeterReading,
|
||||
PropertyConditionReport,
|
||||
} from '../../services/condition-report.service';
|
||||
|
||||
const states: readonly ConditionState[] = [
|
||||
'NEUF',
|
||||
'BON_ETAT',
|
||||
'ETAT_USAGE',
|
||||
'DEGRADE',
|
||||
'ABSENT',
|
||||
'NON_APPLICABLE',
|
||||
'NEUF',
|
||||
'BON_ETAT',
|
||||
'ETAT_USAGE',
|
||||
'DEGRADE',
|
||||
'ABSENT',
|
||||
'NON_APPLICABLE',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-inventory-section',
|
||||
templateUrl: './condition-report-inventory-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
selector: 'app-condition-report-inventory-section',
|
||||
templateUrl: './condition-report-inventory-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportInventorySection {
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly states = states;
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly states = states;
|
||||
|
||||
addMeter(): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: [
|
||||
...report.meterReadings,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'ELECTRICITE',
|
||||
state: 'RECORDED',
|
||||
index: 0,
|
||||
unit: 'kWh',
|
||||
label: '',
|
||||
photoId: '',
|
||||
observation: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
addMeter(): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: [
|
||||
...report.meterReadings,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'ELECTRICITE',
|
||||
state: 'RECORDED',
|
||||
index: 0,
|
||||
unit: 'kWh',
|
||||
label: '',
|
||||
photoId: '',
|
||||
observation: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
updateMeter(id: string, field: keyof MeterReading, event: Event): void {
|
||||
const value = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: report.meterReadings.map((meter) =>
|
||||
meter.id !== id
|
||||
? meter
|
||||
: field === 'state'
|
||||
? {
|
||||
...meter,
|
||||
state: value === 'INACCESSIBLE' ? 'INACCESSIBLE' : 'RECORDED',
|
||||
index: value === 'INACCESSIBLE' ? null : (meter.index ?? 0),
|
||||
}
|
||||
: { ...meter, [field]: field === 'index' ? Number(value) : value },
|
||||
),
|
||||
}));
|
||||
}
|
||||
updateMeter(id: string, field: keyof MeterReading, event: Event): void {
|
||||
const value = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: report.meterReadings.map((meter) =>
|
||||
meter.id !== id
|
||||
? meter
|
||||
: field === 'state'
|
||||
? {
|
||||
...meter,
|
||||
state: value === 'INACCESSIBLE' ? 'INACCESSIBLE' : 'RECORDED',
|
||||
index: value === 'INACCESSIBLE' ? null : (meter.index ?? 0),
|
||||
}
|
||||
: { ...meter, [field]: field === 'index' ? Number(value) : value },
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
removeMeter(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: report.meterReadings.filter((meter) => meter.id !== id),
|
||||
}));
|
||||
}
|
||||
removeMeter(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: report.meterReadings.filter((meter) => meter.id !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
addAccessMean(): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: [
|
||||
...report.accessMeans,
|
||||
{ id: crypto.randomUUID(), type: 'CLE', destination: '', quantity: 1, observation: '' },
|
||||
],
|
||||
}));
|
||||
}
|
||||
addAccessMean(): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: [
|
||||
...report.accessMeans,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'CLE',
|
||||
destination: '',
|
||||
quantity: 1,
|
||||
observation: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
updateAccessMean(id: string, field: keyof AccessMeans, event: Event): void {
|
||||
const value = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: report.accessMeans.map((access) =>
|
||||
access.id === id
|
||||
? { ...access, [field]: field === 'quantity' ? Number(value) : value }
|
||||
: access,
|
||||
),
|
||||
}));
|
||||
}
|
||||
updateAccessMean(id: string, field: keyof AccessMeans, event: Event): void {
|
||||
const value = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: report.accessMeans.map((access) =>
|
||||
access.id === id
|
||||
? { ...access, [field]: field === 'quantity' ? Number(value) : value }
|
||||
: access,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
removeAccessMean(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: report.accessMeans.filter((access) => access.id !== id),
|
||||
}));
|
||||
}
|
||||
removeAccessMean(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: report.accessMeans.filter((access) => access.id !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
addInventorySplit(index: number): void {
|
||||
this.updateInventoryCondition(index, (condition) => ({
|
||||
...condition,
|
||||
splits: [
|
||||
...condition.splits,
|
||||
{
|
||||
inventoryLineId: condition.inventoryLineId,
|
||||
state: 'BON_ETAT',
|
||||
quantity: 0,
|
||||
observation: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
addInventorySplit(index: number): void {
|
||||
this.updateInventoryCondition(index, (condition) => ({
|
||||
...condition,
|
||||
splits: [
|
||||
...condition.splits,
|
||||
{
|
||||
inventoryLineId: condition.inventoryLineId,
|
||||
state: 'BON_ETAT',
|
||||
quantity: 0,
|
||||
observation: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
updateInventorySplit(
|
||||
conditionIndex: number,
|
||||
splitIndex: number,
|
||||
field: keyof InventoryQuantitySplit,
|
||||
event: Event,
|
||||
): void {
|
||||
const value = this.value(event);
|
||||
this.updateInventoryCondition(conditionIndex, (condition) => ({
|
||||
...condition,
|
||||
splits: condition.splits.map((split, item) =>
|
||||
item === splitIndex
|
||||
? {
|
||||
...split,
|
||||
[field]: field === 'quantity' ? Number(value) : value,
|
||||
...(field === 'inventoryLineId'
|
||||
? {}
|
||||
: { inventoryLineId: condition.inventoryLineId }),
|
||||
}
|
||||
: split,
|
||||
),
|
||||
}));
|
||||
}
|
||||
updateInventorySplit(
|
||||
conditionIndex: number,
|
||||
splitIndex: number,
|
||||
field: keyof InventoryQuantitySplit,
|
||||
event: Event,
|
||||
): void {
|
||||
const value = this.value(event);
|
||||
this.updateInventoryCondition(conditionIndex, (condition) => ({
|
||||
...condition,
|
||||
splits: condition.splits.map((split, item) =>
|
||||
item === splitIndex
|
||||
? {
|
||||
...split,
|
||||
[field]: field === 'quantity' ? Number(value) : value,
|
||||
...(field === 'inventoryLineId'
|
||||
? {}
|
||||
: { inventoryLineId: condition.inventoryLineId }),
|
||||
}
|
||||
: split,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
removeInventorySplit(conditionIndex: number, splitIndex: number): void {
|
||||
this.updateInventoryCondition(conditionIndex, (condition) => ({
|
||||
...condition,
|
||||
splits: condition.splits.filter((_, item) => item !== splitIndex),
|
||||
}));
|
||||
}
|
||||
removeInventorySplit(conditionIndex: number, splitIndex: number): void {
|
||||
this.updateInventoryCondition(conditionIndex, (condition) => ({
|
||||
...condition,
|
||||
splits: condition.splits.filter((_, item) => item !== splitIndex),
|
||||
}));
|
||||
}
|
||||
|
||||
updateForwardingAddress(tenantPartyId: string, event: Event): void {
|
||||
const address = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
forwardingAddresses: [
|
||||
...report.forwardingAddresses.filter(
|
||||
(candidate) => candidate.tenantPartyId !== tenantPartyId,
|
||||
),
|
||||
{ tenantPartyId, address },
|
||||
],
|
||||
}));
|
||||
}
|
||||
updateForwardingAddress(tenantPartyId: string, event: Event): void {
|
||||
const address = this.value(event);
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
forwardingAddresses: [
|
||||
...report.forwardingAddresses.filter(
|
||||
(candidate) => candidate.tenantPartyId !== tenantPartyId,
|
||||
),
|
||||
{ tenantPartyId, address },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
forwardingAddress(tenantPartyId: string): string {
|
||||
return (
|
||||
this.report().forwardingAddresses.find((address) => address.tenantPartyId === tenantPartyId)
|
||||
?.address ?? ''
|
||||
);
|
||||
}
|
||||
forwardingAddress(tenantPartyId: string): string {
|
||||
return (
|
||||
this.report().forwardingAddresses.find(
|
||||
(address) => address.tenantPartyId === tenantPartyId,
|
||||
)?.address ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
needsObservation(state: ConditionState): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state);
|
||||
}
|
||||
needsObservation(state: ConditionState): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state);
|
||||
}
|
||||
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
|
||||
private updateInventoryCondition(
|
||||
index: number,
|
||||
change: (condition: InventoryCondition) => InventoryCondition,
|
||||
): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
inventoryConditions: report.inventoryConditions.map((condition, item) =>
|
||||
item === index ? change(condition) : condition,
|
||||
),
|
||||
}));
|
||||
}
|
||||
private updateInventoryCondition(
|
||||
index: number,
|
||||
change: (condition: InventoryCondition) => InventoryCondition,
|
||||
): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
inventoryConditions: report.inventoryConditions.map((condition, item) =>
|
||||
item === index ? change(condition) : condition,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
section {
|
||||
margin: 20px 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 10px 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
article {
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button {
|
||||
margin: 6px;
|
||||
margin: 6px;
|
||||
}
|
||||
img {
|
||||
max-width: min(100%, 480px);
|
||||
height: auto;
|
||||
max-width: min(100%, 480px);
|
||||
height: auto;
|
||||
}
|
||||
.photo-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1,302 +1,329 @@
|
||||
<section class="form-page" [attr.aria-busy]="loading() || saving() || finalizing()">
|
||||
<a routerLink="/leases">← Retour aux baux</a>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">État des lieux</p>
|
||||
<h1>{{ type === 'ENTRY' ? 'Entrée' : 'Sortie' }}</h1>
|
||||
<p>Constat contradictoire, photos de preuve et remise aux parties.</p>
|
||||
</div>
|
||||
</header>
|
||||
<a routerLink="/leases">← Retour aux baux</a>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">État des lieux</p>
|
||||
<h1>{{ type === 'ENTRY' ? 'Entrée' : 'Sortie' }}</h1>
|
||||
<p>Constat contradictoire, photos de preuve et remise aux parties.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading() && !report()) {
|
||||
<p role="status">Chargement de l’état des lieux…</p>
|
||||
} @else if (error() && !report()) {
|
||||
<p role="alert" class="operation-error">{{ error() }}</p>
|
||||
<button type="button" (click)="load()">Réessayer</button>
|
||||
} @else if (report(); as value) {
|
||||
@if (loading()) {
|
||||
<p role="status">Mise à jour de l’état des lieux…</p>
|
||||
}
|
||||
@if (error()) {
|
||||
<p role="alert" class="operation-error">{{ error() }}</p>
|
||||
}
|
||||
@if (success() && !error()) {
|
||||
<p role="status">{{ success() }}</p>
|
||||
}
|
||||
@if (value.leaseRevisionNumber !== null) {
|
||||
<p>
|
||||
Révision de bail source {{ value.leaseRevisionNumber }}
|
||||
@if (value.leaseRevisionFingerprint) {
|
||||
· empreinte {{ value.leaseRevisionFingerprint }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@if (loading() && !report()) {
|
||||
<p role="status">Chargement de l’état des lieux…</p>
|
||||
} @else if (error() && !report()) {
|
||||
<p role="alert" class="operation-error">{{ error() }}</p>
|
||||
<button type="button" (click)="load()">Réessayer</button>
|
||||
} @else if (report(); as value) {
|
||||
@if (loading()) {
|
||||
<p role="status">Mise à jour de l’état des lieux…</p>
|
||||
}
|
||||
@if (error()) {
|
||||
<p role="alert" class="operation-error">{{ error() }}</p>
|
||||
}
|
||||
@if (success() && !error()) {
|
||||
<p role="status">{{ success() }}</p>
|
||||
}
|
||||
@if (value.leaseRevisionNumber !== null) {
|
||||
<p>
|
||||
Révision de bail source {{ value.leaseRevisionNumber }}
|
||||
@if (value.leaseRevisionFingerprint) {
|
||||
· empreinte {{ value.leaseRevisionFingerprint }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-condition-report-editor
|
||||
[leaseId]="leaseId"
|
||||
[type]="type"
|
||||
[report]="value"
|
||||
[entryPhotos]="entryPhotos()"
|
||||
[dirty]="dirty()"
|
||||
[saving]="saving()"
|
||||
[finalizing]="finalizing()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
(saveRequested)="save($event)"
|
||||
(errorOccurred)="error.set($event)"
|
||||
(successOccurred)="success.set($event)"
|
||||
(reloadRequested)="load()"
|
||||
/>
|
||||
<app-condition-report-editor
|
||||
[leaseId]="leaseId"
|
||||
[type]="type"
|
||||
[report]="value"
|
||||
[entryPhotos]="entryPhotos()"
|
||||
[dirty]="dirty()"
|
||||
[saving]="saving()"
|
||||
[finalizing]="finalizing()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
(saveRequested)="save($event)"
|
||||
(errorOccurred)="error.set($event)"
|
||||
(successOccurred)="success.set($event)"
|
||||
(reloadRequested)="load()"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2>Révision et finalisation</h2>
|
||||
@if (value.status === 'DRAFT') {
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="revisionPending() || revisionDownloadPending() || dirty()"
|
||||
(click)="createRevision()"
|
||||
>
|
||||
{{
|
||||
revisionPending()
|
||||
? 'Génération…'
|
||||
: revisionDownloadPending()
|
||||
? 'Téléchargement…'
|
||||
: 'Générer et télécharger la révision PDF'
|
||||
}}
|
||||
</button>
|
||||
@if (revisionDownloadError()) {
|
||||
<p role="alert" class="operation-error">{{ revisionDownloadError() }}</p>
|
||||
}
|
||||
<label>
|
||||
Scan signé PDF
|
||||
<input type="file" accept="application/pdf,.pdf" (change)="selectSignedScan($event)" />
|
||||
</label>
|
||||
@if (signedScan(); as scan) {
|
||||
<p>{{ scan.name }} · attestation de correspondance incluse à la finalisation.</p>
|
||||
}
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="finalizationAttested()"
|
||||
[disabled]="finalizing()"
|
||||
(change)="setFinalizationAttested($event)"
|
||||
/>
|
||||
J’atteste que le scan signé correspond à la dernière révision générée.
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allPartiesSigned()"
|
||||
[disabled]="finalizing()"
|
||||
(change)="setAllPartiesSigned($event)"
|
||||
/>
|
||||
J’atteste que chaque partie présente ou son mandataire identifié a signé le document.
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
finalizing() ||
|
||||
!signedScan() ||
|
||||
!finalizationAttested() ||
|
||||
!allPartiesSigned() ||
|
||||
dirty()
|
||||
"
|
||||
(click)="finalize()"
|
||||
>
|
||||
{{ finalizing() ? 'Finalisation…' : 'Finaliser l’état des lieux' }}
|
||||
</button>
|
||||
} @else if (value.status === 'FINALIZED') {
|
||||
<p>État des lieux finalisé.</p>
|
||||
<button type="button" [disabled]="bundleDownloadPending()" (click)="downloadBundle()">
|
||||
{{ bundleDownloadPending() ? 'Téléchargement…' : 'Télécharger le dossier ZIP' }}
|
||||
</button>
|
||||
@if (bundleDownloadError()) {
|
||||
<p role="alert" class="operation-error">{{ bundleDownloadError() }}</p>
|
||||
}
|
||||
<h3>Attestations de remise</h3>
|
||||
@for (participant of value.participants; track participant.partyId) {
|
||||
@if (hasAttested(participant.partyId)) {
|
||||
<p>{{ participant.name }} : remise attestée.</p>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!!busyAttestationPartyId()"
|
||||
(click)="attestDelivery(participant.partyId)"
|
||||
>
|
||||
{{
|
||||
busyAttestationPartyId() === participant.partyId
|
||||
? 'Attestation…'
|
||||
: 'Attester la remise à ' + participant.name
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (type === 'ENTRY') {
|
||||
<label>
|
||||
Nature du complément
|
||||
<select [value]="supplementType()" (change)="setSupplementType($event)">
|
||||
<option value="GENERAL">Constat général (délai indicatif de 10 jours)</option>
|
||||
<option value="HEATING">Équipement de chauffage (premier mois de chauffe)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Date de demande du complément
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
[value]="supplementRequestedOn()"
|
||||
(input)="setSupplementRequestedOn($event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ajouts acceptés
|
||||
<textarea
|
||||
required
|
||||
[value]="supplementAcceptedAdditions()"
|
||||
(input)="setSupplementAcceptedAdditions($event)"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (supplementType() === 'HEATING') {
|
||||
<label>
|
||||
Début réel de la période de chauffe
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
[value]="heatingPeriodStartedOn()"
|
||||
(input)="setHeatingPeriodStartedOn($event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
@if (supplementIsLate()) {
|
||||
<p role="alert">
|
||||
La demande dépasse le délai indicatif (échéance : {{ supplementDeadline() }}). L’ajout
|
||||
reste possible avec une confirmation auditée.
|
||||
</p>
|
||||
<label>
|
||||
Motif du dépassement de délai
|
||||
<textarea
|
||||
required
|
||||
[value]="supplementReason()"
|
||||
(input)="setSupplementReason($event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="lateWarningConfirmed()"
|
||||
(change)="setLateWarningConfirmed($event)"
|
||||
/>
|
||||
Je confirme l’ajout malgré le dépassement du délai indicatif.
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
Zone des prochaines photos
|
||||
<select [value]="supplementPhotoZoneId()" (change)="setSupplementPhotoZoneId($event)">
|
||||
<option value="">Choisir une zone</option>
|
||||
@for (zone of value.zones; track zone.id) {
|
||||
<option [value]="zone.id">{{ zone.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Photos du complément (facultatives)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
|
||||
multiple
|
||||
[disabled]="supplementPending()"
|
||||
(change)="selectSupplementPhotos($event)"
|
||||
/>
|
||||
</label>
|
||||
@for (photo of supplementPhotos(); track $index; let index = $index) {
|
||||
<p>
|
||||
{{ photo.file.name }} — {{ photo.zoneId }}
|
||||
<button type="button" (click)="removeSupplementPhoto(index)">Retirer</button>
|
||||
</p>
|
||||
}
|
||||
<label>
|
||||
Complément signé PDF (obligatoire)
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
[disabled]="supplementPending()"
|
||||
(change)="selectSupplementSignedPdf($event)"
|
||||
/>
|
||||
</label>
|
||||
@if (supplementSignedPdf(); as pdf) {
|
||||
<p>{{ pdf.name }} · {{ supplementPhotos().length }} photo(s).</p>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="supplementPending() || !supplementSignedPdf()"
|
||||
(click)="appendSupplement()"
|
||||
>
|
||||
{{ supplementPending() ? 'Ajout…' : 'Ajouter le complément signé' }}
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<p role="status">Ce brouillon est obsolète car la révision du bail a changé.</p>
|
||||
}
|
||||
</section>
|
||||
<section>
|
||||
<h2>Révision et finalisation</h2>
|
||||
@if (value.status === 'DRAFT') {
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="revisionPending() || revisionDownloadPending() || dirty()"
|
||||
(click)="createRevision()"
|
||||
>
|
||||
{{
|
||||
revisionPending()
|
||||
? 'Génération…'
|
||||
: revisionDownloadPending()
|
||||
? 'Téléchargement…'
|
||||
: 'Générer et télécharger la révision PDF'
|
||||
}}
|
||||
</button>
|
||||
@if (revisionDownloadError()) {
|
||||
<p role="alert" class="operation-error">{{ revisionDownloadError() }}</p>
|
||||
}
|
||||
<label>
|
||||
Scan signé PDF
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
(change)="selectSignedScan($event)"
|
||||
/>
|
||||
</label>
|
||||
@if (signedScan(); as scan) {
|
||||
<p>
|
||||
{{ scan.name }} · attestation de correspondance incluse à la finalisation.
|
||||
</p>
|
||||
}
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="finalizationAttested()"
|
||||
[disabled]="finalizing()"
|
||||
(change)="setFinalizationAttested($event)"
|
||||
/>
|
||||
J’atteste que le scan signé correspond à la dernière révision générée.
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allPartiesSigned()"
|
||||
[disabled]="finalizing()"
|
||||
(change)="setAllPartiesSigned($event)"
|
||||
/>
|
||||
J’atteste que chaque partie présente ou son mandataire identifié a signé le
|
||||
document.
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
finalizing() ||
|
||||
!signedScan() ||
|
||||
!finalizationAttested() ||
|
||||
!allPartiesSigned() ||
|
||||
dirty()
|
||||
"
|
||||
(click)="finalize()"
|
||||
>
|
||||
{{ finalizing() ? 'Finalisation…' : 'Finaliser l’état des lieux' }}
|
||||
</button>
|
||||
} @else if (value.status === 'FINALIZED') {
|
||||
<p>État des lieux finalisé.</p>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="bundleDownloadPending()"
|
||||
(click)="downloadBundle()"
|
||||
>
|
||||
{{ bundleDownloadPending() ? 'Téléchargement…' : 'Télécharger le dossier ZIP' }}
|
||||
</button>
|
||||
@if (bundleDownloadError()) {
|
||||
<p role="alert" class="operation-error">{{ bundleDownloadError() }}</p>
|
||||
}
|
||||
<h3>Attestations de remise</h3>
|
||||
@for (participant of value.participants; track participant.partyId) {
|
||||
@if (hasAttested(participant.partyId)) {
|
||||
<p>{{ participant.name }} : remise attestée.</p>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!!busyAttestationPartyId()"
|
||||
(click)="attestDelivery(participant.partyId)"
|
||||
>
|
||||
{{
|
||||
busyAttestationPartyId() === participant.partyId
|
||||
? 'Attestation…'
|
||||
: 'Attester la remise à ' + participant.name
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (type === 'ENTRY') {
|
||||
<label>
|
||||
Nature du complément
|
||||
<select [value]="supplementType()" (change)="setSupplementType($event)">
|
||||
<option value="GENERAL">
|
||||
Constat général (délai indicatif de 10 jours)
|
||||
</option>
|
||||
<option value="HEATING">
|
||||
Équipement de chauffage (premier mois de chauffe)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Date de demande du complément
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
[value]="supplementRequestedOn()"
|
||||
(input)="setSupplementRequestedOn($event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ajouts acceptés
|
||||
<textarea
|
||||
required
|
||||
[value]="supplementAcceptedAdditions()"
|
||||
(input)="setSupplementAcceptedAdditions($event)"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (supplementType() === 'HEATING') {
|
||||
<label>
|
||||
Début réel de la période de chauffe
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
[value]="heatingPeriodStartedOn()"
|
||||
(input)="setHeatingPeriodStartedOn($event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
@if (supplementIsLate()) {
|
||||
<p role="alert">
|
||||
La demande dépasse le délai indicatif (échéance :
|
||||
{{ supplementDeadline() }}). L’ajout reste possible avec une
|
||||
confirmation auditée.
|
||||
</p>
|
||||
<label>
|
||||
Motif du dépassement de délai
|
||||
<textarea
|
||||
required
|
||||
[value]="supplementReason()"
|
||||
(input)="setSupplementReason($event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="lateWarningConfirmed()"
|
||||
(change)="setLateWarningConfirmed($event)"
|
||||
/>
|
||||
Je confirme l’ajout malgré le dépassement du délai indicatif.
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
Zone des prochaines photos
|
||||
<select
|
||||
[value]="supplementPhotoZoneId()"
|
||||
(change)="setSupplementPhotoZoneId($event)"
|
||||
>
|
||||
<option value="">Choisir une zone</option>
|
||||
@for (zone of value.zones; track zone.id) {
|
||||
<option [value]="zone.id">{{ zone.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Photos du complément (facultatives)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
|
||||
multiple
|
||||
[disabled]="supplementPending()"
|
||||
(change)="selectSupplementPhotos($event)"
|
||||
/>
|
||||
</label>
|
||||
@for (photo of supplementPhotos(); track $index; let index = $index) {
|
||||
<p>
|
||||
{{ photo.file.name }} — {{ photo.zoneId }}
|
||||
<button type="button" (click)="removeSupplementPhoto(index)">
|
||||
Retirer
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
<label>
|
||||
Complément signé PDF (obligatoire)
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
[disabled]="supplementPending()"
|
||||
(change)="selectSupplementSignedPdf($event)"
|
||||
/>
|
||||
</label>
|
||||
@if (supplementSignedPdf(); as pdf) {
|
||||
<p>{{ pdf.name }} · {{ supplementPhotos().length }} photo(s).</p>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="supplementPending() || !supplementSignedPdf()"
|
||||
(click)="appendSupplement()"
|
||||
>
|
||||
{{ supplementPending() ? 'Ajout…' : 'Ajouter le complément signé' }}
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<p role="status">Ce brouillon est obsolète car la révision du bail a changé.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (type === 'EXIT') {
|
||||
<section>
|
||||
<h2>Comparaison structurée</h2>
|
||||
<p>Les constats sont alignés sans responsabilité ni montant.</p>
|
||||
<button type="button" [disabled]="comparisonPending()" (click)="loadComparison()">
|
||||
{{ comparisonPending() ? 'Comparaison…' : 'Comparer entrée et sortie' }}
|
||||
</button>
|
||||
@for (section of comparisonSections(); track section.title) {
|
||||
<h3>{{ section.title }}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Référence</th>
|
||||
<th>Entrée</th>
|
||||
<th>Sortie</th>
|
||||
<th>Changement</th>
|
||||
<th>Motif d’absence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of section.lines; track line.reference) {
|
||||
<tr>
|
||||
<th>{{ line.reference }}</th>
|
||||
<td>{{ line.entry }}</td>
|
||||
<td>{{ line.exit }}</td>
|
||||
<td>{{ line.changed ? 'Oui' : 'Non' }}</td>
|
||||
<td>{{ line.absence }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (comparison(); as compared) {
|
||||
<h3>Photos</h3>
|
||||
@for (line of compared.photos; track line.reference) {
|
||||
<article class="photo-comparison">
|
||||
<div>
|
||||
<h4>Entrée</h4>
|
||||
@if (line.entry) {
|
||||
<img [src]="photoUrl('ENTRY', line.entry.reference)" alt="Photo d’entrée" />
|
||||
} @else {
|
||||
<p>Aucune photo d’entrée.</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h4>Sortie</h4>
|
||||
@if (line.exit) {
|
||||
<img [src]="photoUrl('EXIT', line.exit.reference)" alt="Photo de sortie" />
|
||||
} @else {
|
||||
<p>Aucune photo de sortie.</p>
|
||||
}
|
||||
</div>
|
||||
@if (line.absence) {
|
||||
<p>Motif d’absence : {{ line.absence.reason }}</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (type === 'EXIT') {
|
||||
<section>
|
||||
<h2>Comparaison structurée</h2>
|
||||
<p>Les constats sont alignés sans responsabilité ni montant.</p>
|
||||
<button type="button" [disabled]="comparisonPending()" (click)="loadComparison()">
|
||||
{{ comparisonPending() ? 'Comparaison…' : 'Comparer entrée et sortie' }}
|
||||
</button>
|
||||
@for (section of comparisonSections(); track section.title) {
|
||||
<h3>{{ section.title }}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Référence</th>
|
||||
<th>Entrée</th>
|
||||
<th>Sortie</th>
|
||||
<th>Changement</th>
|
||||
<th>Motif d’absence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of section.lines; track line.reference) {
|
||||
<tr>
|
||||
<th>{{ line.reference }}</th>
|
||||
<td>{{ line.entry }}</td>
|
||||
<td>{{ line.exit }}</td>
|
||||
<td>{{ line.changed ? 'Oui' : 'Non' }}</td>
|
||||
<td>{{ line.absence }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (comparison(); as compared) {
|
||||
<h3>Photos</h3>
|
||||
@for (line of compared.photos; track line.reference) {
|
||||
<article class="photo-comparison">
|
||||
<div>
|
||||
<h4>Entrée</h4>
|
||||
@if (line.entry) {
|
||||
<img
|
||||
[src]="photoUrl('ENTRY', line.entry.reference)"
|
||||
alt="Photo d’entrée"
|
||||
/>
|
||||
} @else {
|
||||
<p>Aucune photo d’entrée.</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h4>Sortie</h4>
|
||||
@if (line.exit) {
|
||||
<img
|
||||
[src]="photoUrl('EXIT', line.exit.reference)"
|
||||
alt="Photo de sortie"
|
||||
/>
|
||||
} @else {
|
||||
<p>Aucune photo de sortie.</p>
|
||||
}
|
||||
</div>
|
||||
@if (line.absence) {
|
||||
<p>Motif d’absence : {{ line.absence.reason }}</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,493 +2,519 @@ import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
ConditionPhoto,
|
||||
ConditionObservation,
|
||||
ConditionReportComparison,
|
||||
ConditionReportComparisonLine,
|
||||
ConditionReportService,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
ConditionZone,
|
||||
InventoryCondition,
|
||||
MeterReading,
|
||||
AccessMeans,
|
||||
PropertyConditionReportSupplement,
|
||||
PropertyConditionReport,
|
||||
SaveConditionReportRequest,
|
||||
ConditionPhoto,
|
||||
ConditionObservation,
|
||||
ConditionReportComparison,
|
||||
ConditionReportComparisonLine,
|
||||
ConditionReportService,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
ConditionZone,
|
||||
InventoryCondition,
|
||||
MeterReading,
|
||||
AccessMeans,
|
||||
PropertyConditionReportSupplement,
|
||||
PropertyConditionReport,
|
||||
SaveConditionReportRequest,
|
||||
} from '../../services/condition-report.service';
|
||||
import { FileDownloadService } from '../../services/file-download.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { ConditionReportEditor } from './condition-report-editor';
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-page',
|
||||
imports: [ConditionReportEditor, RouterLink],
|
||||
templateUrl: './condition-report-page.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
selector: 'app-condition-report-page',
|
||||
imports: [ConditionReportEditor, RouterLink],
|
||||
templateUrl: './condition-report-page.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly reports = inject(ConditionReportService);
|
||||
private readonly downloads = inject(FileDownloadService);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly type = (this.route.snapshot.paramMap.get('type') === 'EXIT'
|
||||
? 'EXIT'
|
||||
: 'ENTRY') as ConditionReportType;
|
||||
readonly report = signal<PropertyConditionReport | null>(null);
|
||||
readonly dirty = signal(false);
|
||||
readonly entryPhotos = signal<ConditionPhoto[]>([]);
|
||||
readonly comparison = signal<ConditionReportComparison | null>(null);
|
||||
readonly comparisonSections = computed(() => {
|
||||
const comparison = this.comparison();
|
||||
if (!comparison) return [];
|
||||
return [
|
||||
comparisonSection('Observations', comparison.observations, describeObservation),
|
||||
comparisonSection('Zones', comparison.zones, describeZone),
|
||||
comparisonSection('Inventaire', comparison.inventory, describeInventory),
|
||||
comparisonSection('Compteurs', comparison.meters, describeMeter),
|
||||
comparisonSection('Moyens d’accès', comparison.accessMeans, describeAccess),
|
||||
comparisonSection('Compléments acceptés', comparison.supplements, describeSupplement),
|
||||
];
|
||||
});
|
||||
readonly loading = signal(true);
|
||||
readonly saving = signal(false);
|
||||
readonly revisionPending = signal(false);
|
||||
readonly finalizing = signal(false);
|
||||
readonly finalizationAttested = signal(false);
|
||||
readonly allPartiesSigned = signal(false);
|
||||
readonly supplementPending = signal(false);
|
||||
readonly comparisonPending = signal(false);
|
||||
readonly busyAttestationPartyId = signal('');
|
||||
readonly error = signal('');
|
||||
readonly success = signal('');
|
||||
readonly signedScan = signal<File | null>(null);
|
||||
readonly supplementRequestedOn = signal('');
|
||||
readonly supplementAcceptedAdditions = signal('');
|
||||
readonly supplementType = signal<'GENERAL' | 'HEATING'>('GENERAL');
|
||||
readonly heatingPeriodStartedOn = signal('');
|
||||
readonly supplementReason = signal('');
|
||||
readonly lateWarningConfirmed = signal(false);
|
||||
readonly supplementSignedPdf = signal<File | null>(null);
|
||||
readonly supplementPhotoZoneId = signal('');
|
||||
readonly supplementPhotos = signal<{ file: File; zoneId: string }[]>([]);
|
||||
readonly supplementDeadline = computed(() => {
|
||||
const report = this.report();
|
||||
if (!report) return '';
|
||||
return this.supplementType() === 'HEATING'
|
||||
? addCalendarMonths(this.heatingPeriodStartedOn(), 1)
|
||||
: addCalendarDays(report.occurredOn, 10);
|
||||
});
|
||||
readonly supplementIsLate = computed(() =>
|
||||
Boolean(
|
||||
this.supplementRequestedOn() &&
|
||||
this.supplementDeadline() &&
|
||||
this.supplementRequestedOn() > this.supplementDeadline(),
|
||||
),
|
||||
);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly reports = inject(ConditionReportService);
|
||||
private readonly downloads = inject(FileDownloadService);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly type = (this.route.snapshot.paramMap.get('type') === 'EXIT'
|
||||
? 'EXIT'
|
||||
: 'ENTRY') as ConditionReportType;
|
||||
readonly report = signal<PropertyConditionReport | null>(null);
|
||||
readonly dirty = signal(false);
|
||||
readonly entryPhotos = signal<ConditionPhoto[]>([]);
|
||||
readonly comparison = signal<ConditionReportComparison | null>(null);
|
||||
readonly comparisonSections = computed(() => {
|
||||
const comparison = this.comparison();
|
||||
if (!comparison) return [];
|
||||
return [
|
||||
comparisonSection('Observations', comparison.observations, describeObservation),
|
||||
comparisonSection('Zones', comparison.zones, describeZone),
|
||||
comparisonSection('Inventaire', comparison.inventory, describeInventory),
|
||||
comparisonSection('Compteurs', comparison.meters, describeMeter),
|
||||
comparisonSection('Moyens d’accès', comparison.accessMeans, describeAccess),
|
||||
comparisonSection('Compléments acceptés', comparison.supplements, describeSupplement),
|
||||
];
|
||||
});
|
||||
readonly loading = signal(true);
|
||||
readonly saving = signal(false);
|
||||
readonly revisionPending = signal(false);
|
||||
readonly finalizing = signal(false);
|
||||
readonly finalizationAttested = signal(false);
|
||||
readonly allPartiesSigned = signal(false);
|
||||
readonly supplementPending = signal(false);
|
||||
readonly comparisonPending = signal(false);
|
||||
readonly busyAttestationPartyId = signal('');
|
||||
readonly error = signal('');
|
||||
readonly success = signal('');
|
||||
readonly signedScan = signal<File | null>(null);
|
||||
readonly supplementRequestedOn = signal('');
|
||||
readonly supplementAcceptedAdditions = signal('');
|
||||
readonly supplementType = signal<'GENERAL' | 'HEATING'>('GENERAL');
|
||||
readonly heatingPeriodStartedOn = signal('');
|
||||
readonly supplementReason = signal('');
|
||||
readonly lateWarningConfirmed = signal(false);
|
||||
readonly supplementSignedPdf = signal<File | null>(null);
|
||||
readonly supplementPhotoZoneId = signal('');
|
||||
readonly supplementPhotos = signal<{ file: File; zoneId: string }[]>([]);
|
||||
readonly supplementDeadline = computed(() => {
|
||||
const report = this.report();
|
||||
if (!report) return '';
|
||||
return this.supplementType() === 'HEATING'
|
||||
? addCalendarMonths(this.heatingPeriodStartedOn(), 1)
|
||||
: addCalendarDays(report.occurredOn, 10);
|
||||
});
|
||||
readonly supplementIsLate = computed(() =>
|
||||
Boolean(
|
||||
this.supplementRequestedOn() &&
|
||||
this.supplementDeadline() &&
|
||||
this.supplementRequestedOn() > this.supplementDeadline(),
|
||||
),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.clearNotices();
|
||||
this.reports
|
||||
.getReport(this.leaseId, this.type)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (report) => {
|
||||
this.report.set(report);
|
||||
this.dirty.set(false);
|
||||
if (this.type === 'EXIT') {
|
||||
this.entryPhotos.set([
|
||||
...(report.entrySnapshot?.photos ?? []),
|
||||
...(report.entrySnapshot?.supplements.flatMap((supplement) => supplement.photos) ??
|
||||
[]),
|
||||
]);
|
||||
}
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de charger cet état des lieux.')),
|
||||
});
|
||||
}
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.clearNotices();
|
||||
this.reports
|
||||
.getReport(this.leaseId, this.type)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (report) => {
|
||||
this.report.set(report);
|
||||
this.dirty.set(false);
|
||||
if (this.type === 'EXIT') {
|
||||
this.entryPhotos.set([
|
||||
...(report.entrySnapshot?.photos ?? []),
|
||||
...(report.entrySnapshot?.supplements.flatMap(
|
||||
(supplement) => supplement.photos,
|
||||
) ?? []),
|
||||
]);
|
||||
}
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible de charger cet état des lieux.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
save(request: SaveConditionReportRequest): void {
|
||||
if (this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.clearNotices();
|
||||
this.reports
|
||||
.saveReport(this.leaseId, this.type, request)
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.report.set(saved);
|
||||
this.dirty.set(false);
|
||||
this.success.set('Brouillon enregistré.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’enregistrer le brouillon.')),
|
||||
});
|
||||
}
|
||||
save(request: SaveConditionReportRequest): void {
|
||||
if (this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.clearNotices();
|
||||
this.reports
|
||||
.saveReport(this.leaseId, this.type, request)
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.report.set(saved);
|
||||
this.dirty.set(false);
|
||||
this.success.set('Brouillon enregistré.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible d’enregistrer le brouillon.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
createRevision(): void {
|
||||
const report = this.report();
|
||||
if (!report || this.revisionPending()) return;
|
||||
this.clearNotices();
|
||||
if (this.dirty()) {
|
||||
this.error.set('Enregistrez le brouillon avant de générer sa révision.');
|
||||
return;
|
||||
}
|
||||
this.revisionPending.set(true);
|
||||
this.reports
|
||||
.createRevision(this.leaseId, this.type, report.version)
|
||||
.pipe(rxFinalize(() => this.revisionPending.set(false)))
|
||||
.subscribe({
|
||||
next: (revision) => {
|
||||
this.downloads.download(
|
||||
`condition-report-revision-${this.leaseId}-${this.type}`,
|
||||
this.reports.downloadRevision(this.leaseId, this.type, revision.number),
|
||||
`etat-des-lieux-${this.type.toLowerCase()}-revision-${revision.number}.pdf`,
|
||||
'Impossible de télécharger la révision PDF.',
|
||||
);
|
||||
this.load();
|
||||
this.success.set(`Révision ${revision.number} générée.`);
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de générer la révision PDF.')),
|
||||
});
|
||||
}
|
||||
createRevision(): void {
|
||||
const report = this.report();
|
||||
if (!report || this.revisionPending()) return;
|
||||
this.clearNotices();
|
||||
if (this.dirty()) {
|
||||
this.error.set('Enregistrez le brouillon avant de générer sa révision.');
|
||||
return;
|
||||
}
|
||||
this.revisionPending.set(true);
|
||||
this.reports
|
||||
.createRevision(this.leaseId, this.type, report.version)
|
||||
.pipe(rxFinalize(() => this.revisionPending.set(false)))
|
||||
.subscribe({
|
||||
next: (revision) => {
|
||||
this.downloads.download(
|
||||
`condition-report-revision-${this.leaseId}-${this.type}`,
|
||||
this.reports.downloadRevision(this.leaseId, this.type, revision.number),
|
||||
`etat-des-lieux-${this.type.toLowerCase()}-revision-${revision.number}.pdf`,
|
||||
'Impossible de télécharger la révision PDF.',
|
||||
);
|
||||
this.load();
|
||||
this.success.set(`Révision ${revision.number} générée.`);
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible de générer la révision PDF.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
selectSignedScan(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (!file || (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
this.signedScan.set(null);
|
||||
this.error.set('Sélectionnez le scan signé au format PDF.');
|
||||
return;
|
||||
}
|
||||
this.signedScan.set(file);
|
||||
}
|
||||
selectSignedScan(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (
|
||||
!file ||
|
||||
(file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf'))
|
||||
) {
|
||||
this.signedScan.set(null);
|
||||
this.error.set('Sélectionnez le scan signé au format PDF.');
|
||||
return;
|
||||
}
|
||||
this.signedScan.set(file);
|
||||
}
|
||||
|
||||
setFinalizationAttested(event: Event): void {
|
||||
this.finalizationAttested.set(this.checked(event));
|
||||
}
|
||||
setFinalizationAttested(event: Event): void {
|
||||
this.finalizationAttested.set(this.checked(event));
|
||||
}
|
||||
|
||||
setAllPartiesSigned(event: Event): void {
|
||||
this.allPartiesSigned.set(this.checked(event));
|
||||
}
|
||||
setAllPartiesSigned(event: Event): void {
|
||||
this.allPartiesSigned.set(this.checked(event));
|
||||
}
|
||||
|
||||
setSupplementType(event: Event): void {
|
||||
this.supplementType.set(this.value(event) === 'HEATING' ? 'HEATING' : 'GENERAL');
|
||||
}
|
||||
setSupplementType(event: Event): void {
|
||||
this.supplementType.set(this.value(event) === 'HEATING' ? 'HEATING' : 'GENERAL');
|
||||
}
|
||||
|
||||
setSupplementRequestedOn(event: Event): void {
|
||||
this.supplementRequestedOn.set(this.value(event));
|
||||
}
|
||||
setSupplementRequestedOn(event: Event): void {
|
||||
this.supplementRequestedOn.set(this.value(event));
|
||||
}
|
||||
|
||||
setSupplementAcceptedAdditions(event: Event): void {
|
||||
this.supplementAcceptedAdditions.set(this.value(event));
|
||||
}
|
||||
setSupplementAcceptedAdditions(event: Event): void {
|
||||
this.supplementAcceptedAdditions.set(this.value(event));
|
||||
}
|
||||
|
||||
setHeatingPeriodStartedOn(event: Event): void {
|
||||
this.heatingPeriodStartedOn.set(this.value(event));
|
||||
}
|
||||
setHeatingPeriodStartedOn(event: Event): void {
|
||||
this.heatingPeriodStartedOn.set(this.value(event));
|
||||
}
|
||||
|
||||
setSupplementReason(event: Event): void {
|
||||
this.supplementReason.set(this.value(event));
|
||||
}
|
||||
setSupplementReason(event: Event): void {
|
||||
this.supplementReason.set(this.value(event));
|
||||
}
|
||||
|
||||
setLateWarningConfirmed(event: Event): void {
|
||||
this.lateWarningConfirmed.set(this.checked(event));
|
||||
}
|
||||
setLateWarningConfirmed(event: Event): void {
|
||||
this.lateWarningConfirmed.set(this.checked(event));
|
||||
}
|
||||
|
||||
setSupplementPhotoZoneId(event: Event): void {
|
||||
this.supplementPhotoZoneId.set(this.value(event));
|
||||
}
|
||||
setSupplementPhotoZoneId(event: Event): void {
|
||||
this.supplementPhotoZoneId.set(this.value(event));
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
const report = this.report();
|
||||
const scan = this.signedScan();
|
||||
if (this.finalizing()) return;
|
||||
this.clearNotices();
|
||||
if (!report || !scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
|
||||
if (!scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
|
||||
this.error.set('Téléversez le scan PDF et confirmez les deux attestations.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.dirty()) {
|
||||
this.error.set('Enregistrez le brouillon avant sa finalisation.');
|
||||
return;
|
||||
}
|
||||
this.finalizing.set(true);
|
||||
this.reports
|
||||
.finalize(this.leaseId, this.type, {
|
||||
signedReport: scan,
|
||||
version: report.version,
|
||||
allPartiesSigned: true,
|
||||
})
|
||||
.pipe(rxFinalize(() => this.finalizing.set(false)))
|
||||
.subscribe({
|
||||
next: (finalized) => {
|
||||
this.report.set(finalized);
|
||||
this.success.set('État des lieux finalisé.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de finaliser cet état des lieux.')),
|
||||
});
|
||||
}
|
||||
finalize(): void {
|
||||
const report = this.report();
|
||||
const scan = this.signedScan();
|
||||
if (this.finalizing()) return;
|
||||
this.clearNotices();
|
||||
if (!report || !scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
|
||||
if (!scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
|
||||
this.error.set('Téléversez le scan PDF et confirmez les deux attestations.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.dirty()) {
|
||||
this.error.set('Enregistrez le brouillon avant sa finalisation.');
|
||||
return;
|
||||
}
|
||||
this.finalizing.set(true);
|
||||
this.reports
|
||||
.finalize(this.leaseId, this.type, {
|
||||
signedReport: scan,
|
||||
version: report.version,
|
||||
allPartiesSigned: true,
|
||||
})
|
||||
.pipe(rxFinalize(() => this.finalizing.set(false)))
|
||||
.subscribe({
|
||||
next: (finalized) => {
|
||||
this.report.set(finalized);
|
||||
this.success.set('État des lieux finalisé.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible de finaliser cet état des lieux.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
downloadBundle(): void {
|
||||
this.downloads.download(
|
||||
`condition-report-bundle-${this.leaseId}-${this.type}`,
|
||||
this.reports.downloadBundle(this.leaseId, this.type),
|
||||
`etat-des-lieux-${this.type.toLowerCase()}.zip`,
|
||||
'Impossible de télécharger le dossier ZIP.',
|
||||
);
|
||||
}
|
||||
downloadBundle(): void {
|
||||
this.downloads.download(
|
||||
`condition-report-bundle-${this.leaseId}-${this.type}`,
|
||||
this.reports.downloadBundle(this.leaseId, this.type),
|
||||
`etat-des-lieux-${this.type.toLowerCase()}.zip`,
|
||||
'Impossible de télécharger le dossier ZIP.',
|
||||
);
|
||||
}
|
||||
|
||||
loadComparison(): void {
|
||||
if (this.comparisonPending()) return;
|
||||
this.clearNotices();
|
||||
this.comparisonPending.set(true);
|
||||
this.reports
|
||||
.getComparison(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.comparisonPending.set(false)))
|
||||
.subscribe({
|
||||
next: (comparison) => this.comparison.set(comparison),
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de charger la comparaison.')),
|
||||
});
|
||||
}
|
||||
loadComparison(): void {
|
||||
if (this.comparisonPending()) return;
|
||||
this.clearNotices();
|
||||
this.comparisonPending.set(true);
|
||||
this.reports
|
||||
.getComparison(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.comparisonPending.set(false)))
|
||||
.subscribe({
|
||||
next: (comparison) => this.comparison.set(comparison),
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible de charger la comparaison.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
attestDelivery(partyId: string): void {
|
||||
const report = this.report();
|
||||
if (!report || this.busyAttestationPartyId()) return;
|
||||
this.clearNotices();
|
||||
this.busyAttestationPartyId.set(partyId);
|
||||
this.reports
|
||||
.attestDelivery(this.leaseId, this.type, report.version, partyId)
|
||||
.pipe(rxFinalize(() => this.busyAttestationPartyId.set('')))
|
||||
.subscribe({
|
||||
next: (saved) => this.report.set(saved),
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’attester la remise.')),
|
||||
});
|
||||
}
|
||||
attestDelivery(partyId: string): void {
|
||||
const report = this.report();
|
||||
if (!report || this.busyAttestationPartyId()) return;
|
||||
this.clearNotices();
|
||||
this.busyAttestationPartyId.set(partyId);
|
||||
this.reports
|
||||
.attestDelivery(this.leaseId, this.type, report.version, partyId)
|
||||
.pipe(rxFinalize(() => this.busyAttestationPartyId.set('')))
|
||||
.subscribe({
|
||||
next: (saved) => this.report.set(saved),
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’attester la remise.')),
|
||||
});
|
||||
}
|
||||
|
||||
selectSupplementSignedPdf(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (!file || (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
this.supplementSignedPdf.set(null);
|
||||
this.error.set('Sélectionnez le complément signé au format PDF.');
|
||||
return;
|
||||
}
|
||||
this.supplementSignedPdf.set(file);
|
||||
}
|
||||
selectSupplementSignedPdf(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (
|
||||
!file ||
|
||||
(file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf'))
|
||||
) {
|
||||
this.supplementSignedPdf.set(null);
|
||||
this.error.set('Sélectionnez le complément signé au format PDF.');
|
||||
return;
|
||||
}
|
||||
this.supplementSignedPdf.set(file);
|
||||
}
|
||||
|
||||
selectSupplementPhotos(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const zoneId = this.supplementPhotoZoneId();
|
||||
if (input?.files?.length && !zoneId) {
|
||||
this.error.set('Choisissez la zone des photos du complément.');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
this.supplementPhotos.update((photos) => [
|
||||
...photos,
|
||||
...(input?.files ? Array.from(input.files, (file) => ({ file, zoneId })) : []),
|
||||
]);
|
||||
if (input) input.value = '';
|
||||
}
|
||||
selectSupplementPhotos(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const zoneId = this.supplementPhotoZoneId();
|
||||
if (input?.files?.length && !zoneId) {
|
||||
this.error.set('Choisissez la zone des photos du complément.');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
this.supplementPhotos.update((photos) => [
|
||||
...photos,
|
||||
...(input?.files ? Array.from(input.files, (file) => ({ file, zoneId })) : []),
|
||||
]);
|
||||
if (input) input.value = '';
|
||||
}
|
||||
|
||||
removeSupplementPhoto(index: number): void {
|
||||
this.supplementPhotos.update((photos) => photos.filter((_, item) => item !== index));
|
||||
}
|
||||
removeSupplementPhoto(index: number): void {
|
||||
this.supplementPhotos.update((photos) => photos.filter((_, item) => item !== index));
|
||||
}
|
||||
|
||||
appendSupplement(): void {
|
||||
if (this.supplementPending()) return;
|
||||
this.clearNotices();
|
||||
const signedPdf = this.supplementSignedPdf();
|
||||
const requestedOn = this.supplementRequestedOn().trim();
|
||||
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
|
||||
const heatingPeriodStartedOn = this.heatingPeriodStartedOn().trim();
|
||||
if (
|
||||
!signedPdf ||
|
||||
!requestedOn ||
|
||||
!acceptedAdditions ||
|
||||
(this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) ||
|
||||
(this.supplementIsLate() && (!this.lateWarningConfirmed() || !this.supplementReason().trim()))
|
||||
) {
|
||||
if (!signedPdf || !requestedOn || !acceptedAdditions) {
|
||||
this.error.set('Renseignez la date, les ajouts acceptés et le PDF signé du complément.');
|
||||
} else if (this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) {
|
||||
this.error.set('Renseignez le début réel de la période de chauffe.');
|
||||
} else if (this.supplementIsLate()) {
|
||||
this.error.set('Confirmez l’avertissement de délai et motivez l’ajout tardif.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.supplementPending.set(true);
|
||||
this.reports
|
||||
.appendSupplement(this.leaseId, {
|
||||
signedPdf,
|
||||
photos: this.supplementPhotos(),
|
||||
requestedOn,
|
||||
acceptedAdditions,
|
||||
supplementType: this.supplementType(),
|
||||
heatingPeriodStartedOn:
|
||||
this.supplementType() === 'HEATING' ? heatingPeriodStartedOn : undefined,
|
||||
lateWarningConfirmed: this.lateWarningConfirmed(),
|
||||
reason: this.supplementReason().trim() || undefined,
|
||||
})
|
||||
.pipe(rxFinalize(() => this.supplementPending.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.report.set(saved);
|
||||
this.supplementSignedPdf.set(null);
|
||||
this.supplementPhotos.set([]);
|
||||
this.lateWarningConfirmed.set(false);
|
||||
this.success.set('Complément ajouté sans modifier l’état des lieux finalisé.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’ajouter ce complément.')),
|
||||
});
|
||||
}
|
||||
appendSupplement(): void {
|
||||
if (this.supplementPending()) return;
|
||||
this.clearNotices();
|
||||
const signedPdf = this.supplementSignedPdf();
|
||||
const requestedOn = this.supplementRequestedOn().trim();
|
||||
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
|
||||
const heatingPeriodStartedOn = this.heatingPeriodStartedOn().trim();
|
||||
if (
|
||||
!signedPdf ||
|
||||
!requestedOn ||
|
||||
!acceptedAdditions ||
|
||||
(this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) ||
|
||||
(this.supplementIsLate() &&
|
||||
(!this.lateWarningConfirmed() || !this.supplementReason().trim()))
|
||||
) {
|
||||
if (!signedPdf || !requestedOn || !acceptedAdditions) {
|
||||
this.error.set(
|
||||
'Renseignez la date, les ajouts acceptés et le PDF signé du complément.',
|
||||
);
|
||||
} else if (this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) {
|
||||
this.error.set('Renseignez le début réel de la période de chauffe.');
|
||||
} else if (this.supplementIsLate()) {
|
||||
this.error.set('Confirmez l’avertissement de délai et motivez l’ajout tardif.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.supplementPending.set(true);
|
||||
this.reports
|
||||
.appendSupplement(this.leaseId, {
|
||||
signedPdf,
|
||||
photos: this.supplementPhotos(),
|
||||
requestedOn,
|
||||
acceptedAdditions,
|
||||
supplementType: this.supplementType(),
|
||||
heatingPeriodStartedOn:
|
||||
this.supplementType() === 'HEATING' ? heatingPeriodStartedOn : undefined,
|
||||
lateWarningConfirmed: this.lateWarningConfirmed(),
|
||||
reason: this.supplementReason().trim() || undefined,
|
||||
})
|
||||
.pipe(rxFinalize(() => this.supplementPending.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.report.set(saved);
|
||||
this.supplementSignedPdf.set(null);
|
||||
this.supplementPhotos.set([]);
|
||||
this.lateWarningConfirmed.set(false);
|
||||
this.success.set('Complément ajouté sans modifier l’état des lieux finalisé.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’ajouter ce complément.')),
|
||||
});
|
||||
}
|
||||
|
||||
hasAttested(partyId: string): boolean {
|
||||
return this.report()?.deliveryAttestations.some((item) => item.partyId === partyId) ?? false;
|
||||
}
|
||||
hasAttested(partyId: string): boolean {
|
||||
return (
|
||||
this.report()?.deliveryAttestations.some((item) => item.partyId === partyId) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
changeReport(report: PropertyConditionReport): void {
|
||||
this.report.set(report);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
changeReport(report: PropertyConditionReport): void {
|
||||
this.report.set(report);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
|
||||
photoUrl(type: ConditionReportType, reference: string): string {
|
||||
return this.reports.photoUrl(this.leaseId, type, reference);
|
||||
}
|
||||
photoUrl(type: ConditionReportType, reference: string): string {
|
||||
return this.reports.photoUrl(this.leaseId, type, reference);
|
||||
}
|
||||
|
||||
revisionDownloadPending(): boolean {
|
||||
return this.downloads.isPending(`condition-report-revision-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
revisionDownloadPending(): boolean {
|
||||
return this.downloads.isPending(`condition-report-revision-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
revisionDownloadError(): string {
|
||||
return this.downloads.error(`condition-report-revision-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
revisionDownloadError(): string {
|
||||
return this.downloads.error(`condition-report-revision-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
bundleDownloadPending(): boolean {
|
||||
return this.downloads.isPending(`condition-report-bundle-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
bundleDownloadPending(): boolean {
|
||||
return this.downloads.isPending(`condition-report-bundle-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
bundleDownloadError(): string {
|
||||
return this.downloads.error(`condition-report-bundle-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
bundleDownloadError(): string {
|
||||
return this.downloads.error(`condition-report-bundle-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
private clearNotices(): void {
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
}
|
||||
private clearNotices(): void {
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
}
|
||||
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
|
||||
private checked(event: Event): boolean {
|
||||
return event.target instanceof HTMLInputElement && event.target.checked;
|
||||
}
|
||||
private checked(event: Event): boolean {
|
||||
return event.target instanceof HTMLInputElement && event.target.checked;
|
||||
}
|
||||
}
|
||||
|
||||
interface ComparisonDisplayLine {
|
||||
reference: string;
|
||||
entry: string;
|
||||
exit: string;
|
||||
changed: boolean;
|
||||
absence: string;
|
||||
reference: string;
|
||||
entry: string;
|
||||
exit: string;
|
||||
changed: boolean;
|
||||
absence: string;
|
||||
}
|
||||
|
||||
function comparisonSection<T>(
|
||||
title: string,
|
||||
lines: ConditionReportComparisonLine<T>[],
|
||||
describe: (value: T) => string,
|
||||
title: string,
|
||||
lines: ConditionReportComparisonLine<T>[],
|
||||
describe: (value: T) => string,
|
||||
): { title: string; lines: ComparisonDisplayLine[] } {
|
||||
return {
|
||||
title,
|
||||
lines: lines.map((line) => ({
|
||||
reference: line.reference,
|
||||
entry: line.entry ? describe(line.entry) : '—',
|
||||
exit: line.exit ? describe(line.exit) : '—',
|
||||
changed: line.changed,
|
||||
absence: line.absence?.reason ?? '—',
|
||||
})),
|
||||
};
|
||||
return {
|
||||
title,
|
||||
lines: lines.map((line) => ({
|
||||
reference: line.reference,
|
||||
entry: line.entry ? describe(line.entry) : '—',
|
||||
exit: line.exit ? describe(line.exit) : '—',
|
||||
changed: line.changed,
|
||||
absence: line.absence?.reason ?? '—',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const stateLabels: Record<ConditionState, string> = {
|
||||
NEUF: 'Neuf',
|
||||
BON_ETAT: 'Bon état',
|
||||
ETAT_USAGE: 'État d’usage',
|
||||
DEGRADE: 'Dégradé',
|
||||
ABSENT: 'Absent',
|
||||
NON_APPLICABLE: 'Non applicable',
|
||||
NEUF: 'Neuf',
|
||||
BON_ETAT: 'Bon état',
|
||||
ETAT_USAGE: 'État d’usage',
|
||||
DEGRADE: 'Dégradé',
|
||||
ABSENT: 'Absent',
|
||||
NON_APPLICABLE: 'Non applicable',
|
||||
};
|
||||
|
||||
function describeObservation(value: ConditionObservation): string {
|
||||
return [stateLabels[value.state], value.observation].filter(Boolean).join(' — ');
|
||||
return [stateLabels[value.state], value.observation].filter(Boolean).join(' — ');
|
||||
}
|
||||
|
||||
function describeZone(value: ConditionZone): string {
|
||||
const equipment = value.equipment === 'NONE' ? '' : `équipement ${value.equipment.toLowerCase()}`;
|
||||
return [value.label, equipment].filter(Boolean).join(' — ');
|
||||
const equipment =
|
||||
value.equipment === 'NONE' ? '' : `équipement ${value.equipment.toLowerCase()}`;
|
||||
return [value.label, equipment].filter(Boolean).join(' — ');
|
||||
}
|
||||
|
||||
function describeInventory(value: InventoryCondition): string {
|
||||
const split = value.splits
|
||||
.map((item) => `${item.quantity} ${stateLabels[item.state].toLowerCase()}`)
|
||||
.join(', ');
|
||||
return `${value.designation} (${split || 'aucune quantité constatée'} ; ${value.contractualQuantity} au bail)`;
|
||||
const split = value.splits
|
||||
.map((item) => `${item.quantity} ${stateLabels[item.state].toLowerCase()}`)
|
||||
.join(', ');
|
||||
return `${value.designation} (${split || 'aucune quantité constatée'} ; ${value.contractualQuantity} au bail)`;
|
||||
}
|
||||
|
||||
function describeMeter(value: MeterReading): string {
|
||||
const reading = value.state === 'INACCESSIBLE' ? 'inaccessible' : `${value.index} ${value.unit}`;
|
||||
return `${value.label || value.type} : ${reading}${value.observation ? ` — ${value.observation}` : ''}`;
|
||||
const reading =
|
||||
value.state === 'INACCESSIBLE' ? 'inaccessible' : `${value.index} ${value.unit}`;
|
||||
return `${value.label || value.type} : ${reading}${value.observation ? ` — ${value.observation}` : ''}`;
|
||||
}
|
||||
|
||||
function describeAccess(value: AccessMeans): string {
|
||||
return `${value.type} — ${value.destination} : ${value.quantity}${value.observation ? ` — ${value.observation}` : ''}`;
|
||||
return `${value.type} — ${value.destination} : ${value.quantity}${value.observation ? ` — ${value.observation}` : ''}`;
|
||||
}
|
||||
|
||||
function describeSupplement(value: PropertyConditionReportSupplement): string {
|
||||
const nature =
|
||||
value.type === 'HEATING'
|
||||
? `chauffage depuis le ${value.heatingPeriodStartedOn}`
|
||||
: 'constat général';
|
||||
return `${value.requestedOn} — ${nature} — ${value.acceptedAdditions}`;
|
||||
const nature =
|
||||
value.type === 'HEATING'
|
||||
? `chauffage depuis le ${value.heatingPeriodStartedOn}`
|
||||
: 'constat général';
|
||||
return `${value.requestedOn} — ${nature} — ${value.acceptedAdditions}`;
|
||||
}
|
||||
|
||||
function addCalendarDays(value: string, days: number): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
if (!value) return '';
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addCalendarMonths(value: string, months: number): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
const day = date.getUTCDate();
|
||||
date.setUTCDate(1);
|
||||
date.setUTCMonth(date.getUTCMonth() + months);
|
||||
const lastDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
date.setUTCDate(Math.min(day, lastDay));
|
||||
return date.toISOString().slice(0, 10);
|
||||
if (!value) return '';
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
const day = date.getUTCDate();
|
||||
date.setUTCDate(1);
|
||||
date.setUTCMonth(date.getUTCMonth() + months);
|
||||
const lastDay = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
|
||||
).getUTCDate();
|
||||
date.setUTCDate(Math.min(day, lastDay));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.confirmation-dialog h2 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.confirmation-dialog p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.confirmation-dialog .confirm-action.mat-mdc-unelevated-button {
|
||||
--mdc-filled-button-container-color: var(--danger);
|
||||
--mdc-filled-button-label-text-color: var(--accent-contrast);
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
--mdc-filled-button-container-color: var(--danger);
|
||||
--mdc-filled-button-label-text-color: var(--accent-contrast);
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
<div class="confirmation-dialog">
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content>
|
||||
<p>{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="cancel()">Annuler</button>
|
||||
<button mat-flat-button type="button" class="confirm-action" (click)="accept()">
|
||||
{{ data.confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content>
|
||||
<p>{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="cancel()">Annuler</button>
|
||||
<button mat-flat-button type="button" class="confirm-action" (click)="accept()">
|
||||
{{ data.confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -3,42 +3,42 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { ConfirmationDialog, ConfirmationDialogData } from './confirmation-dialog';
|
||||
|
||||
describe('ConfirmationDialog', () => {
|
||||
const data: ConfirmationDialogData = {
|
||||
title: 'Supprimer cet appartement ?',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
};
|
||||
const dialogRef = { close: vi.fn() };
|
||||
const data: ConfirmationDialogData = {
|
||||
title: 'Supprimer cet appartement ?',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
};
|
||||
const dialogRef = { close: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the destructive action and lets the user cancel', () => {
|
||||
const fixture = TestBed.createComponent(ConfirmationDialog);
|
||||
fixture.detectChanges();
|
||||
it('shows the destructive action and lets the user cancel', () => {
|
||||
const fixture = TestBed.createComponent(ConfirmationDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('[mat-dialog-title]').textContent).toContain(
|
||||
data.title,
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(data.message);
|
||||
expect(fixture.nativeElement.textContent).toContain(data.confirmLabel);
|
||||
expect(fixture.nativeElement.querySelector('[mat-dialog-title]').textContent).toContain(
|
||||
data.title,
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(data.message);
|
||||
expect(fixture.nativeElement.textContent).toContain(data.confirmLabel);
|
||||
|
||||
fixture.nativeElement.querySelector('button:first-of-type').click();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(false);
|
||||
});
|
||||
fixture.nativeElement.querySelector('button:first-of-type').click();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('returns true when the destructive action is selected', () => {
|
||||
const fixture = TestBed.createComponent(ConfirmationDialog);
|
||||
fixture.detectChanges();
|
||||
it('returns true when the destructive action is selected', () => {
|
||||
const fixture = TestBed.createComponent(ConfirmationDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.confirm-action').click();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(true);
|
||||
});
|
||||
fixture.nativeElement.querySelector('.confirm-action').click();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -3,26 +3,26 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
export interface ConfirmationDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirmation-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule],
|
||||
templateUrl: './confirmation-dialog.html',
|
||||
styleUrl: './confirmation-dialog.css',
|
||||
selector: 'app-confirmation-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule],
|
||||
templateUrl: './confirmation-dialog.html',
|
||||
styleUrl: './confirmation-dialog.css',
|
||||
})
|
||||
export class ConfirmationDialog {
|
||||
readonly data = inject<ConfirmationDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<ConfirmationDialog>);
|
||||
readonly data = inject<ConfirmationDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<ConfirmationDialog>);
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
cancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
accept(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
accept(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +1,170 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.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;
|
||||
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;
|
||||
gap: 7px;
|
||||
margin-top: 18px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.status-pill span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.feature-card h2 {
|
||||
margin-bottom: 9px;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.035em;
|
||||
margin-bottom: 9px;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.feature-card p:not(.eyebrow) {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.feature-card-primary {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
border-color: transparent;
|
||||
background: var(--surface-accent);
|
||||
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;
|
||||
--mdc-text-button-label-text-color: var(--accent-strong);
|
||||
width: fit-content;
|
||||
padding-inline: 0;
|
||||
font-weight: 750;
|
||||
}
|
||||
.count-skeleton {
|
||||
width: min(260px, 80%);
|
||||
margin: 0 0 12px;
|
||||
width: min(260px, 80%);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.count-error p {
|
||||
margin-bottom: 4px;
|
||||
color: var(--danger);
|
||||
margin-bottom: 4px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.feature-card-primary mat-progress-bar {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.feature-icon {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
}
|
||||
.feature-icon fa-icon {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.feature-icon fa-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
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;
|
||||
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;
|
||||
min-width: 0;
|
||||
}
|
||||
.user-details h3 {
|
||||
margin-bottom: 3px;
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 3px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.user-email {
|
||||
overflow: hidden;
|
||||
margin-bottom: 11px;
|
||||
font-size: 0.84rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
margin-bottom: 11px;
|
||||
font-size: 0.84rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.role-chip {
|
||||
padding: 5px 9px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
.api-demo-section {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
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;
|
||||
--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);
|
||||
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;
|
||||
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;
|
||||
color: var(--accent-strong);
|
||||
font-size: 0.83rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.feature-card-primary,
|
||||
.api-demo-section {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,91 @@
|
||||
<section class="page-section dashboard-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Bonjour {{ session.username() }}</p>
|
||||
<h1>Tableau de bord</h1>
|
||||
<p>Retrouvez rapidement les actions utiles à votre gestion locative.</p>
|
||||
</div>
|
||||
<span class="status-pill"><span></span>Espace sécurisé</span>
|
||||
</header>
|
||||
<div class="dashboard-grid">
|
||||
<article
|
||||
class="feature-card feature-card-primary"
|
||||
[attr.aria-busy]="workspace.apartmentLoadState().loading"
|
||||
>
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<fa-icon [icon]="faTableCellsLarge" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Votre portefeuille</p>
|
||||
@if (!workspace.apartmentLoadState().loaded && workspace.apartmentLoadState().loading) {
|
||||
<span
|
||||
class="skeleton-line skeleton-line-title count-skeleton"
|
||||
aria-label="Chargement du nombre d’appartements"
|
||||
></span>
|
||||
} @else if (
|
||||
!workspace.apartmentLoadState().loaded && workspace.apartmentLoadState().error
|
||||
) {
|
||||
<div class="count-error" role="alert">
|
||||
<p>{{ workspace.apartmentLoadState().error }}</p>
|
||||
<button mat-button type="button" (click)="workspace.loadApartments()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<h2>
|
||||
{{ workspace.apartments().length }} appartement{{
|
||||
workspace.apartments().length > 1 ? 's' : ''
|
||||
}}
|
||||
</h2>
|
||||
@if (workspace.apartmentLoadState().loading) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des appartements" />
|
||||
}
|
||||
@if (workspace.apartmentLoadState().error) {
|
||||
<div class="count-error" role="alert">
|
||||
<p>{{ workspace.apartmentLoadState().error }}</p>
|
||||
<button mat-button type="button" (click)="workspace.loadApartments()">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<p>Centralisez les informations essentielles de vos biens.</p>
|
||||
</div>
|
||||
<button mat-button type="button" (click)="showApartments()">Gérer mes appartements</button>
|
||||
</article>
|
||||
<article class="feature-card user-profile">
|
||||
<div class="large-avatar">{{ userInitial() }}</div>
|
||||
<div class="user-details">
|
||||
<p class="eyebrow">Compte actif</p>
|
||||
<h3>{{ session.username() }}</h3>
|
||||
<p class="user-email">{{ session.email() }}</p>
|
||||
<span class="role-chip">Bailleur connecté</span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="feature-card api-demo-section">
|
||||
<div>
|
||||
<p class="eyebrow">État du service</p>
|
||||
<h2>Connexion au serveur</h2>
|
||||
<p>Vérifiez que votre espace communique correctement avec le service sécurisé.</p>
|
||||
</div>
|
||||
<div class="api-actions">
|
||||
<button mat-stroked-button type="button" (click)="checkConnection()">
|
||||
Tester la connexion
|
||||
</button>
|
||||
</div>
|
||||
@if (helloMessage()) {
|
||||
<div class="api-response-wrapper">
|
||||
<span>Réponse du serveur</span><code class="api-response-code">{{ helloMessage() }}</code>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</div>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Bonjour {{ session.username() }}</p>
|
||||
<h1>Tableau de bord</h1>
|
||||
<p>Retrouvez rapidement les actions utiles à votre gestion locative.</p>
|
||||
</div>
|
||||
<span class="status-pill"><span></span>Espace sécurisé</span>
|
||||
</header>
|
||||
<div class="dashboard-grid">
|
||||
<article
|
||||
class="feature-card feature-card-primary"
|
||||
[attr.aria-busy]="workspace.apartmentLoadState().loading"
|
||||
>
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<fa-icon [icon]="faTableCellsLarge" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Votre portefeuille</p>
|
||||
@if (
|
||||
!workspace.apartmentLoadState().loaded && workspace.apartmentLoadState().loading
|
||||
) {
|
||||
<span
|
||||
class="skeleton-line skeleton-line-title count-skeleton"
|
||||
aria-label="Chargement du nombre d’appartements"
|
||||
></span>
|
||||
} @else if (
|
||||
!workspace.apartmentLoadState().loaded && workspace.apartmentLoadState().error
|
||||
) {
|
||||
<div class="count-error" role="alert">
|
||||
<p>{{ workspace.apartmentLoadState().error }}</p>
|
||||
<button mat-button type="button" (click)="workspace.loadApartments()">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<h2>
|
||||
{{ workspace.apartments().length }} appartement{{
|
||||
workspace.apartments().length > 1 ? 's' : ''
|
||||
}}
|
||||
</h2>
|
||||
@if (workspace.apartmentLoadState().loading) {
|
||||
<mat-progress-bar
|
||||
mode="indeterminate"
|
||||
aria-label="Actualisation des appartements"
|
||||
/>
|
||||
}
|
||||
@if (workspace.apartmentLoadState().error) {
|
||||
<div class="count-error" role="alert">
|
||||
<p>{{ workspace.apartmentLoadState().error }}</p>
|
||||
<button mat-button type="button" (click)="workspace.loadApartments()">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<p>Centralisez les informations essentielles de vos biens.</p>
|
||||
</div>
|
||||
<button mat-button type="button" (click)="showApartments()">
|
||||
Gérer mes appartements
|
||||
</button>
|
||||
</article>
|
||||
<article class="feature-card user-profile">
|
||||
<div class="large-avatar">{{ userInitial() }}</div>
|
||||
<div class="user-details">
|
||||
<p class="eyebrow">Compte actif</p>
|
||||
<h3>{{ session.username() }}</h3>
|
||||
<p class="user-email">{{ session.email() }}</p>
|
||||
<span class="role-chip">Bailleur connecté</span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="feature-card api-demo-section">
|
||||
<div>
|
||||
<p class="eyebrow">État du service</p>
|
||||
<h2>Connexion au serveur</h2>
|
||||
<p>Vérifiez que votre espace communique correctement avec le service sécurisé.</p>
|
||||
</div>
|
||||
<div class="api-actions">
|
||||
<button mat-stroked-button type="button" (click)="checkConnection()">
|
||||
Tester la connexion
|
||||
</button>
|
||||
</div>
|
||||
@if (helloMessage()) {
|
||||
<div class="api-response-wrapper">
|
||||
<span>Réponse du serveur</span
|
||||
><code class="api-response-code">{{ helloMessage() }}</code>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -9,60 +9,60 @@ import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { DashboardPage } from './dashboard-page';
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
const http = { get: vi.fn() };
|
||||
const router = { navigateByUrl: vi.fn() };
|
||||
const session = { username: vi.fn(() => 'camille'), email: vi.fn(() => 'camille@example.com') };
|
||||
const workspace = {
|
||||
apartments: signal([]),
|
||||
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
loadApartments: vi.fn(),
|
||||
};
|
||||
const http = { get: vi.fn() };
|
||||
const router = { navigateByUrl: vi.fn() };
|
||||
const session = { username: vi.fn(() => 'camille'), email: vi.fn(() => 'camille@example.com') };
|
||||
const workspace = {
|
||||
apartments: signal([]),
|
||||
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
loadApartments: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: HttpClient, useValue: http },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: SessionService, useValue: session },
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: HttpClient, useValue: http },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: SessionService, useValue: session },
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('skeletonizes only the apartment count while it loads', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
fixture.detectChanges();
|
||||
it('skeletonizes only the apartment count while it loads', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.count-skeleton')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Tableau de bord');
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('.count-skeleton')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Tableau de bord');
|
||||
});
|
||||
|
||||
it('loads the workspace and displays the user initial', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
it('loads the workspace and displays the user initial', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
|
||||
expect(workspace.loadApartments).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.userInitial()).toBe('C');
|
||||
});
|
||||
expect(workspace.loadApartments).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.userInitial()).toBe('C');
|
||||
});
|
||||
|
||||
it('shows the backend response or connection error', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
const component = fixture.componentInstance;
|
||||
http.get.mockReturnValueOnce(of('Hello'));
|
||||
component.checkConnection();
|
||||
http.get.mockReturnValueOnce(throwError(() => ({ statusText: 'Offline' })));
|
||||
component.checkConnection();
|
||||
it('shows the backend response or connection error', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
const component = fixture.componentInstance;
|
||||
http.get.mockReturnValueOnce(of('Hello'));
|
||||
component.checkConnection();
|
||||
http.get.mockReturnValueOnce(throwError(() => ({ statusText: 'Offline' })));
|
||||
component.checkConnection();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/hello', { responseType: 'text' });
|
||||
expect(component.helloMessage()).toBe('Failed to connect to backend: Offline');
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/api/hello', { responseType: 'text' });
|
||||
expect(component.helloMessage()).toBe('Failed to connect to backend: Offline');
|
||||
});
|
||||
|
||||
it('navigates to apartments', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
it('navigates to apartments', () => {
|
||||
const fixture = TestBed.createComponent(DashboardPage);
|
||||
|
||||
fixture.componentInstance.showApartments();
|
||||
fixture.componentInstance.showApartments();
|
||||
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/apartments');
|
||||
});
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/apartments');
|
||||
});
|
||||
});
|
||||
@@ -9,32 +9,33 @@ import { SessionService } from '../../services/session.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-page',
|
||||
imports: [FontAwesomeModule, MatButtonModule, MatProgressBarModule],
|
||||
templateUrl: './dashboard-page.html',
|
||||
styleUrl: './dashboard-page.css',
|
||||
selector: 'app-dashboard-page',
|
||||
imports: [FontAwesomeModule, MatButtonModule, MatProgressBarModule],
|
||||
templateUrl: './dashboard-page.html',
|
||||
styleUrl: './dashboard-page.css',
|
||||
})
|
||||
export class DashboardPage {
|
||||
readonly faTableCellsLarge = faTableCellsLarge;
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
readonly session = inject(SessionService);
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
|
||||
readonly helloMessage = signal('');
|
||||
readonly faTableCellsLarge = faTableCellsLarge;
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
readonly session = inject(SessionService);
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
|
||||
readonly helloMessage = signal('');
|
||||
|
||||
constructor() {
|
||||
this.workspace.loadApartments();
|
||||
}
|
||||
constructor() {
|
||||
this.workspace.loadApartments();
|
||||
}
|
||||
|
||||
checkConnection(): void {
|
||||
this.http.get('/api/hello', { responseType: 'text' }).subscribe({
|
||||
next: (message) => this.helloMessage.set(message),
|
||||
error: (error) => this.helloMessage.set(`Failed to connect to backend: ${error.statusText}`),
|
||||
});
|
||||
}
|
||||
checkConnection(): void {
|
||||
this.http.get('/api/hello', { responseType: 'text' }).subscribe({
|
||||
next: (message) => this.helloMessage.set(message),
|
||||
error: (error) =>
|
||||
this.helloMessage.set(`Failed to connect to backend: ${error.statusText}`),
|
||||
});
|
||||
}
|
||||
|
||||
showApartments(): void {
|
||||
this.router.navigateByUrl('/apartments');
|
||||
}
|
||||
showApartments(): void {
|
||||
this.router.navigateByUrl('/apartments');
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.section-heading p,
|
||||
details p,
|
||||
.uploaded-deed span,
|
||||
.selected-file {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.guarantee-card {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.guarantee-card h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
details {
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.file-action,
|
||||
.uploaded-deed {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.file-action span {
|
||||
font-weight: 700;
|
||||
font-weight: 700;
|
||||
}
|
||||
.selected-file,
|
||||
.operation-error {
|
||||
margin: 8px 0;
|
||||
font-size: 0.8rem;
|
||||
margin: 8px 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
mat-checkbox {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.uploaded-deed {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,75 +1,75 @@
|
||||
<section aria-labelledby="guarantees-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="guarantees-heading">Actes de cautionnement</h2>
|
||||
<p>Un acte signé est demandé pour chaque paire garant–locataire.</p>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<h2 id="guarantees-heading">Actes de cautionnement</h2>
|
||||
<p>Un acte signé est demandé pour chaque paire garant–locataire.</p>
|
||||
</div>
|
||||
|
||||
@for (requirement of requirements(); track key(requirement)) {
|
||||
<article class="guarantee-card" [attr.aria-busy]="uploadingKeys().has(key(requirement))">
|
||||
<h3>{{ requirement.guarantorName }} garantit {{ requirement.tenantName }}</h3>
|
||||
<details>
|
||||
<summary>ⓘ Aide pour préparer l'acte</summary>
|
||||
<p>
|
||||
Utilisez si besoin le
|
||||
<a [href]="simulatorUrl()" target="_blank" rel="noopener noreferrer"
|
||||
>simulateur officiel Service-Public.fr</a
|
||||
>. L'application conserve le PDF transmis sans vérifier les signatures.
|
||||
</p>
|
||||
</details>
|
||||
@for (requirement of requirements(); track key(requirement)) {
|
||||
<article class="guarantee-card" [attr.aria-busy]="uploadingKeys().has(key(requirement))">
|
||||
<h3>{{ requirement.guarantorName }} garantit {{ requirement.tenantName }}</h3>
|
||||
<details>
|
||||
<summary>ⓘ Aide pour préparer l'acte</summary>
|
||||
<p>
|
||||
Utilisez si besoin le
|
||||
<a [href]="simulatorUrl()" target="_blank" rel="noopener noreferrer"
|
||||
>simulateur officiel Service-Public.fr</a
|
||||
>. L'application conserve le PDF transmis sans vérifier les signatures.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@if (requirement.deed; as deed) {
|
||||
<p class="uploaded-deed">
|
||||
<strong>{{ deed.fileName }}</strong>
|
||||
<span>Acte téléversé le {{ deed.uploadedAt }}</span>
|
||||
</p>
|
||||
} @else {
|
||||
<label class="file-action">
|
||||
<span>Acte signé au format PDF</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
[disabled]="uploadingKeys().has(key(requirement))"
|
||||
[attr.aria-label]="
|
||||
'Acte de cautionnement de ' +
|
||||
requirement.guarantorName +
|
||||
' pour ' +
|
||||
requirement.tenantName
|
||||
"
|
||||
(change)="selectFile($event, requirement)"
|
||||
/>
|
||||
</label>
|
||||
@if (selectedFiles()[key(requirement)]; as file) {
|
||||
<p class="selected-file">{{ file.name }}</p>
|
||||
}
|
||||
<mat-checkbox
|
||||
[checked]="attestedKeys().has(key(requirement))"
|
||||
[disabled]="uploadingKeys().has(key(requirement))"
|
||||
(change)="setAttestation(requirement, $event.checked)"
|
||||
>
|
||||
J'atteste transmettre l'acte signé correspondant à ce garant et ce locataire.
|
||||
</mat-checkbox>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!selectedFiles()[key(requirement)] ||
|
||||
!attestedKeys().has(key(requirement)) ||
|
||||
uploadingKeys().has(key(requirement))
|
||||
"
|
||||
(click)="upload(requirement)"
|
||||
>
|
||||
@if (uploadingKeys().has(key(requirement))) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléversement…
|
||||
} @else {
|
||||
Téléverser l'acte
|
||||
}
|
||||
</button>
|
||||
@if (errors()[key(requirement)]) {
|
||||
<p class="operation-error" role="alert">{{ errors()[key(requirement)] }}</p>
|
||||
}
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p>Aucun acte de cautionnement n'est requis pour ce bail.</p>
|
||||
}
|
||||
@if (requirement.deed; as deed) {
|
||||
<p class="uploaded-deed">
|
||||
<strong>{{ deed.fileName }}</strong>
|
||||
<span>Acte téléversé le {{ deed.uploadedAt }}</span>
|
||||
</p>
|
||||
} @else {
|
||||
<label class="file-action">
|
||||
<span>Acte signé au format PDF</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
[disabled]="uploadingKeys().has(key(requirement))"
|
||||
[attr.aria-label]="
|
||||
'Acte de cautionnement de ' +
|
||||
requirement.guarantorName +
|
||||
' pour ' +
|
||||
requirement.tenantName
|
||||
"
|
||||
(change)="selectFile($event, requirement)"
|
||||
/>
|
||||
</label>
|
||||
@if (selectedFiles()[key(requirement)]; as file) {
|
||||
<p class="selected-file">{{ file.name }}</p>
|
||||
}
|
||||
<mat-checkbox
|
||||
[checked]="attestedKeys().has(key(requirement))"
|
||||
[disabled]="uploadingKeys().has(key(requirement))"
|
||||
(change)="setAttestation(requirement, $event.checked)"
|
||||
>
|
||||
J'atteste transmettre l'acte signé correspondant à ce garant et ce locataire.
|
||||
</mat-checkbox>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!selectedFiles()[key(requirement)] ||
|
||||
!attestedKeys().has(key(requirement)) ||
|
||||
uploadingKeys().has(key(requirement))
|
||||
"
|
||||
(click)="upload(requirement)"
|
||||
>
|
||||
@if (uploadingKeys().has(key(requirement))) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléversement…
|
||||
} @else {
|
||||
Téléverser l'acte
|
||||
}
|
||||
</button>
|
||||
@if (errors()[key(requirement)]) {
|
||||
<p class="operation-error" role="alert">{{ errors()[key(requirement)] }}</p>
|
||||
}
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p>Aucun acte de cautionnement n'est requis pour ce bail.</p>
|
||||
}
|
||||
</section>
|
||||
@@ -2,108 +2,115 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
GuaranteeDeed,
|
||||
GuaranteeRequirement,
|
||||
LeaseFinalizationService,
|
||||
GuaranteeDeed,
|
||||
GuaranteeRequirement,
|
||||
LeaseFinalizationService,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { GuaranteeDeedList } from './guarantee-deed-list';
|
||||
|
||||
describe('GuaranteeDeedList', () => {
|
||||
const requirement: GuaranteeRequirement = {
|
||||
guarantorId: 'guarantor-1',
|
||||
guarantorName: 'Alex Durand',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: 'Camille Martin',
|
||||
deed: null,
|
||||
};
|
||||
const deed: GuaranteeDeed = {
|
||||
guarantorId: 'guarantor-1',
|
||||
tenantId: 'tenant-1',
|
||||
fileName: 'cautionnement.pdf',
|
||||
sha256: 'sha256',
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
};
|
||||
const uploadGuaranteeDeed = vi.fn();
|
||||
const requirement: GuaranteeRequirement = {
|
||||
guarantorId: 'guarantor-1',
|
||||
guarantorName: 'Alex Durand',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: 'Camille Martin',
|
||||
deed: null,
|
||||
};
|
||||
const deed: GuaranteeDeed = {
|
||||
guarantorId: 'guarantor-1',
|
||||
tenantId: 'tenant-1',
|
||||
fileName: 'cautionnement.pdf',
|
||||
sha256: 'sha256',
|
||||
authorId: 'owner-1',
|
||||
uploadedAt: '2026-07-19T10:00:00Z',
|
||||
};
|
||||
const uploadGuaranteeDeed = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: LeaseFinalizationService, useValue: { uploadGuaranteeDeed } }],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: LeaseFinalizationService, useValue: { uploadGuaranteeDeed } }],
|
||||
});
|
||||
});
|
||||
|
||||
function createList(requirements = [requirement]) {
|
||||
const fixture = TestBed.createComponent(GuaranteeDeedList);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('requirements', requirements);
|
||||
fixture.componentRef.setInput('simulatorUrl', 'https://www.service-public.fr/simulateur');
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
function createList(requirements = [requirement]) {
|
||||
const fixture = TestBed.createComponent(GuaranteeDeedList);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('requirements', requirements);
|
||||
fixture.componentRef.setInput('simulatorUrl', 'https://www.service-public.fr/simulateur');
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('shows each pair and an accessible official simulator link', () => {
|
||||
const fixture = createList();
|
||||
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('details a');
|
||||
it('shows each pair and an accessible official simulator link', () => {
|
||||
const fixture = createList();
|
||||
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('details a');
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Alex Durand garantit Camille Martin');
|
||||
expect(link.href).toBe('https://www.service-public.fr/simulateur');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toContain('noopener');
|
||||
expect(fixture.nativeElement.textContent).toContain('sans vérifier les signatures');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Alex Durand garantit Camille Martin');
|
||||
expect(link.href).toBe('https://www.service-public.fr/simulateur');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toContain('noopener');
|
||||
expect(fixture.nativeElement.textContent).toContain('sans vérifier les signatures');
|
||||
});
|
||||
|
||||
it('requires both a PDF and the attestation, with local pending feedback', () => {
|
||||
const response = new Subject<GuaranteeDeed>();
|
||||
uploadGuaranteeDeed.mockReturnValue(response);
|
||||
const fixture = createList();
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['deed'], 'cautionnement.pdf', { type: 'application/pdf' });
|
||||
const uploaded = vi.fn();
|
||||
component.deedUploaded.subscribe(uploaded);
|
||||
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
|
||||
expect(button.disabled).toBe(true);
|
||||
it('requires both a PDF and the attestation, with local pending feedback', () => {
|
||||
const response = new Subject<GuaranteeDeed>();
|
||||
uploadGuaranteeDeed.mockReturnValue(response);
|
||||
const fixture = createList();
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['deed'], 'cautionnement.pdf', { type: 'application/pdf' });
|
||||
const uploaded = vi.fn();
|
||||
component.deedUploaded.subscribe(uploaded);
|
||||
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
|
||||
expect(button.disabled).toBe(true);
|
||||
|
||||
component.selectedFiles.set({ 'guarantor-1:tenant-1': file });
|
||||
component.setAttestation(requirement, true);
|
||||
fixture.detectChanges();
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
component.selectedFiles.set({ 'guarantor-1:tenant-1': file });
|
||||
component.setAttestation(requirement, true);
|
||||
fixture.detectChanges();
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(uploadGuaranteeDeed).toHaveBeenCalledWith('lease-1', file, 'guarantor-1', 'tenant-1');
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléversement…');
|
||||
expect(uploadGuaranteeDeed).toHaveBeenCalledWith(
|
||||
'lease-1',
|
||||
file,
|
||||
'guarantor-1',
|
||||
'tenant-1',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléversement…');
|
||||
|
||||
response.next(deed);
|
||||
response.complete();
|
||||
expect(uploaded).toHaveBeenCalledWith({
|
||||
guarantorId: 'guarantor-1',
|
||||
tenantId: 'tenant-1',
|
||||
deed,
|
||||
});
|
||||
});
|
||||
response.next(deed);
|
||||
response.complete();
|
||||
expect(uploaded).toHaveBeenCalledWith({
|
||||
guarantorId: 'guarantor-1',
|
||||
tenantId: 'tenant-1',
|
||||
deed,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an upload failure local to its pair', () => {
|
||||
uploadGuaranteeDeed.mockReturnValue(throwError(() => ({ error: { message: 'Acte refusé' } })));
|
||||
const fixture = createList();
|
||||
const component = fixture.componentInstance;
|
||||
component.selectedFiles.set({
|
||||
'guarantor-1:tenant-1': new File(['deed'], 'cautionnement.pdf', {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
});
|
||||
component.setAttestation(requirement, true);
|
||||
it('keeps an upload failure local to its pair', () => {
|
||||
uploadGuaranteeDeed.mockReturnValue(
|
||||
throwError(() => ({ error: { message: 'Acte refusé' } })),
|
||||
);
|
||||
const fixture = createList();
|
||||
const component = fixture.componentInstance;
|
||||
component.selectedFiles.set({
|
||||
'guarantor-1:tenant-1': new File(['deed'], 'cautionnement.pdf', {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
});
|
||||
component.setAttestation(requirement, true);
|
||||
|
||||
component.upload(requirement);
|
||||
fixture.detectChanges();
|
||||
component.upload(requirement);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Acte refusé');
|
||||
expect(fixture.nativeElement.querySelector('[role=alert]')).not.toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Acte refusé');
|
||||
expect(fixture.nativeElement.querySelector('[role=alert]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders an already uploaded deed without another upload form', () => {
|
||||
const fixture = createList([{ ...requirement, deed }]);
|
||||
it('renders an already uploaded deed without another upload form', () => {
|
||||
const fixture = createList([{ ...requirement, deed }]);
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('cautionnement.pdf');
|
||||
expect(fixture.nativeElement.querySelector('input[type=file]')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('cautionnement.pdf');
|
||||
expect(fixture.nativeElement.querySelector('input[type=file]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,86 +4,94 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { finalize } from 'rxjs';
|
||||
import {
|
||||
GuaranteeDeed,
|
||||
GuaranteeRequirement,
|
||||
LeaseFinalizationService,
|
||||
GuaranteeDeed,
|
||||
GuaranteeRequirement,
|
||||
LeaseFinalizationService,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { withoutRecordKey, withoutSetValue } from '../../services/immutable-state';
|
||||
|
||||
export interface GuaranteeDeedUploaded {
|
||||
guarantorId: string;
|
||||
tenantId: string;
|
||||
deed: GuaranteeDeed;
|
||||
guarantorId: string;
|
||||
tenantId: string;
|
||||
deed: GuaranteeDeed;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantee-deed-list',
|
||||
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
|
||||
templateUrl: './guarantee-deed-list.html',
|
||||
styleUrl: './guarantee-deed-list.css',
|
||||
selector: 'app-guarantee-deed-list',
|
||||
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
|
||||
templateUrl: './guarantee-deed-list.html',
|
||||
styleUrl: './guarantee-deed-list.css',
|
||||
})
|
||||
export class GuaranteeDeedList {
|
||||
private readonly finalizationService = inject(LeaseFinalizationService);
|
||||
private readonly finalizationService = inject(LeaseFinalizationService);
|
||||
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly requirements = input.required<GuaranteeRequirement[]>();
|
||||
readonly simulatorUrl = input.required<string>();
|
||||
readonly deedUploaded = output<GuaranteeDeedUploaded>();
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly requirements = input.required<GuaranteeRequirement[]>();
|
||||
readonly simulatorUrl = input.required<string>();
|
||||
readonly deedUploaded = output<GuaranteeDeedUploaded>();
|
||||
|
||||
readonly selectedFiles = signal<Record<string, File | undefined>>({});
|
||||
readonly attestedKeys = signal<ReadonlySet<string>>(new Set());
|
||||
readonly uploadingKeys = signal<ReadonlySet<string>>(new Set());
|
||||
readonly errors = signal<Record<string, string>>({});
|
||||
readonly selectedFiles = signal<Record<string, File | undefined>>({});
|
||||
readonly attestedKeys = signal<ReadonlySet<string>>(new Set());
|
||||
readonly uploadingKeys = signal<ReadonlySet<string>>(new Set());
|
||||
readonly errors = signal<Record<string, string>>({});
|
||||
|
||||
key(requirement: GuaranteeRequirement): string {
|
||||
return `${requirement.guarantorId}:${requirement.tenantId}`;
|
||||
}
|
||||
key(requirement: GuaranteeRequirement): string {
|
||||
return `${requirement.guarantorId}:${requirement.tenantId}`;
|
||||
}
|
||||
|
||||
selectFile(event: Event, requirement: GuaranteeRequirement): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0);
|
||||
const key = this.key(requirement);
|
||||
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
this.errors.update((errors) => ({ ...errors, [key]: 'Sélectionnez un fichier PDF.' }));
|
||||
} else {
|
||||
this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.selectedFiles.update((files) => ({ ...files, [key]: file ?? undefined }));
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
selectFile(event: Event, requirement: GuaranteeRequirement): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0);
|
||||
const key = this.key(requirement);
|
||||
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
this.errors.update((errors) => ({ ...errors, [key]: 'Sélectionnez un fichier PDF.' }));
|
||||
} else {
|
||||
this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.selectedFiles.update((files) => ({ ...files, [key]: file ?? undefined }));
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
setAttestation(requirement: GuaranteeRequirement, checked: boolean): void {
|
||||
const key = this.key(requirement);
|
||||
this.attestedKeys.update((keys) => {
|
||||
const next = new Set(keys);
|
||||
if (checked) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setAttestation(requirement: GuaranteeRequirement, checked: boolean): void {
|
||||
const key = this.key(requirement);
|
||||
this.attestedKeys.update((keys) => {
|
||||
const next = new Set(keys);
|
||||
if (checked) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
upload(requirement: GuaranteeRequirement): void {
|
||||
const key = this.key(requirement);
|
||||
const file = this.selectedFiles()[key];
|
||||
if (!file || !this.attestedKeys().has(key) || this.uploadingKeys().has(key)) return;
|
||||
this.uploadingKeys.update((keys) => new Set(keys).add(key));
|
||||
this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.finalizationService
|
||||
.uploadGuaranteeDeed(this.leaseId(), file, requirement.guarantorId, requirement.tenantId)
|
||||
.pipe(finalize(() => this.uploadingKeys.update((keys) => withoutSetValue(keys, key))))
|
||||
.subscribe({
|
||||
next: (deed) =>
|
||||
this.deedUploaded.emit({
|
||||
guarantorId: requirement.guarantorId,
|
||||
tenantId: requirement.tenantId,
|
||||
deed,
|
||||
}),
|
||||
error: (error) =>
|
||||
this.errors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(error, 'Impossible de téléverser cet acte de cautionnement.'),
|
||||
})),
|
||||
});
|
||||
}
|
||||
upload(requirement: GuaranteeRequirement): void {
|
||||
const key = this.key(requirement);
|
||||
const file = this.selectedFiles()[key];
|
||||
if (!file || !this.attestedKeys().has(key) || this.uploadingKeys().has(key)) return;
|
||||
this.uploadingKeys.update((keys) => new Set(keys).add(key));
|
||||
this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.finalizationService
|
||||
.uploadGuaranteeDeed(
|
||||
this.leaseId(),
|
||||
file,
|
||||
requirement.guarantorId,
|
||||
requirement.tenantId,
|
||||
)
|
||||
.pipe(finalize(() => this.uploadingKeys.update((keys) => withoutSetValue(keys, key))))
|
||||
.subscribe({
|
||||
next: (deed) =>
|
||||
this.deedUploaded.emit({
|
||||
guarantorId: requirement.guarantorId,
|
||||
tenantId: requirement.tenantId,
|
||||
deed,
|
||||
}),
|
||||
error: (error) =>
|
||||
this.errors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(
|
||||
error,
|
||||
'Impossible de téléverser cet acte de cautionnement.',
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.add-guarantor.mat-mdc-outlined-button {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.add-guarantor.mat-mdc-outlined-button {
|
||||
width: auto;
|
||||
}
|
||||
.add-guarantor.mat-mdc-outlined-button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,35 @@
|
||||
<form
|
||||
class="guarantor-form"
|
||||
[formRoot]="guarantorForm"
|
||||
(ngSubmit)="save()"
|
||||
>
|
||||
<div class="form-grid guarantor-fields">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="guarantorFirstName"
|
||||
[formField]="guarantorForm.firstName"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="guarantorLastName"
|
||||
[formField]="guarantorForm.lastName"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="email"
|
||||
id="guarantorEmail"
|
||||
[formField]="guarantorForm.email"
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="tel"
|
||||
id="guarantorPhone"
|
||||
[formField]="guarantorForm.phone"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="guarantorAddress"
|
||||
[formField]="guarantorForm.address"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<button mat-stroked-button type="submit" class="add-guarantor">Ajouter le garant</button>
|
||||
<form class="guarantor-form" [formRoot]="guarantorForm" (ngSubmit)="save()">
|
||||
<div class="form-grid guarantor-fields">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom du garant</mat-label
|
||||
><input matInput id="guarantorFirstName" [formField]="guarantorForm.firstName"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom du garant</mat-label
|
||||
><input matInput id="guarantorLastName" [formField]="guarantorForm.lastName"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="email"
|
||||
id="guarantorEmail"
|
||||
[formField]="guarantorForm.email"
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="tel"
|
||||
id="guarantorPhone"
|
||||
[formField]="guarantorForm.phone"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse du garant</mat-label
|
||||
><input matInput id="guarantorAddress" [formField]="guarantorForm.address"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<button mat-stroked-button type="submit" class="add-guarantor">Ajouter le garant</button>
|
||||
</form>
|
||||
@@ -3,39 +3,39 @@ import { vi } from 'vitest';
|
||||
import { GuarantorForm } from './guarantor-form';
|
||||
|
||||
describe('GuarantorForm', () => {
|
||||
it('marks an invalid form as touched without submitting it', () => {
|
||||
const fixture = TestBed.createComponent(GuarantorForm);
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitted.subscribe(submitted);
|
||||
it('marks an invalid form as touched without submitting it', () => {
|
||||
const fixture = TestBed.createComponent(GuarantorForm);
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitted.subscribe(submitted);
|
||||
|
||||
fixture.componentInstance.save();
|
||||
fixture.componentInstance.save();
|
||||
|
||||
expect(fixture.componentInstance.guarantorForm().touched()).toBe(true);
|
||||
expect(submitted).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(fixture.componentInstance.guarantorForm().touched()).toBe(true);
|
||||
expect(submitted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits trimmed data and resets the form after saving', () => {
|
||||
const fixture = TestBed.createComponent(GuarantorForm);
|
||||
const component = fixture.componentInstance;
|
||||
const submitted = vi.fn();
|
||||
component.submitted.subscribe(submitted);
|
||||
component.guarantorModel.set({
|
||||
firstName: ' Alex ',
|
||||
lastName: ' Durand ',
|
||||
email: ' alex@example.com ',
|
||||
phone: ' 0600000000 ',
|
||||
address: ' 1 rue de Paris ',
|
||||
});
|
||||
it('emits trimmed data and resets the form after saving', () => {
|
||||
const fixture = TestBed.createComponent(GuarantorForm);
|
||||
const component = fixture.componentInstance;
|
||||
const submitted = vi.fn();
|
||||
component.submitted.subscribe(submitted);
|
||||
component.guarantorModel.set({
|
||||
firstName: ' Alex ',
|
||||
lastName: ' Durand ',
|
||||
email: ' alex@example.com ',
|
||||
phone: ' 0600000000 ',
|
||||
address: ' 1 rue de Paris ',
|
||||
});
|
||||
|
||||
component.save();
|
||||
component.save();
|
||||
|
||||
expect(submitted).toHaveBeenCalledWith({
|
||||
firstName: 'Alex',
|
||||
lastName: 'Durand',
|
||||
email: 'alex@example.com',
|
||||
phone: '0600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
expect(component.guarantorModel().firstName).toBe('');
|
||||
});
|
||||
expect(submitted).toHaveBeenCalledWith({
|
||||
firstName: 'Alex',
|
||||
lastName: 'Durand',
|
||||
email: 'alex@example.com',
|
||||
phone: '0600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
expect(component.guarantorModel().firstName).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -7,66 +7,66 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { Guarantor } from '../../services/lease.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantor-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
],
|
||||
templateUrl: './guarantor-form.html',
|
||||
styleUrl: './guarantor-form.css',
|
||||
selector: 'app-guarantor-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
],
|
||||
templateUrl: './guarantor-form.html',
|
||||
styleUrl: './guarantor-form.css',
|
||||
})
|
||||
export class GuarantorForm {
|
||||
readonly submitted = output<Guarantor>();
|
||||
readonly submitted = output<Guarantor>();
|
||||
|
||||
readonly guarantorModel = signal<Guarantor>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
readonly guarantorModel = signal<Guarantor>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
|
||||
readonly guarantorForm = form(this.guarantorModel, (f) => {
|
||||
required(f.firstName);
|
||||
required(f.lastName);
|
||||
required(f.email);
|
||||
email(f.email);
|
||||
});
|
||||
readonly guarantorForm = form(this.guarantorModel, (f) => {
|
||||
required(f.firstName);
|
||||
required(f.lastName);
|
||||
required(f.email);
|
||||
email(f.email);
|
||||
});
|
||||
|
||||
save(): void {
|
||||
this.guarantorModel.update((m) => ({
|
||||
firstName: m.firstName.trim(),
|
||||
lastName: m.lastName.trim(),
|
||||
email: m.email.trim(),
|
||||
phone: m.phone.trim(),
|
||||
address: m.address.trim(),
|
||||
}));
|
||||
save(): void {
|
||||
this.guarantorModel.update((m) => ({
|
||||
firstName: m.firstName.trim(),
|
||||
lastName: m.lastName.trim(),
|
||||
email: m.email.trim(),
|
||||
phone: m.phone.trim(),
|
||||
address: m.address.trim(),
|
||||
}));
|
||||
|
||||
this.guarantorForm().markAsTouched();
|
||||
if (this.guarantorForm().invalid()) {
|
||||
return;
|
||||
}
|
||||
this.guarantorForm().markAsTouched();
|
||||
if (this.guarantorForm().invalid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelVal = this.guarantorModel();
|
||||
this.submitted.emit({
|
||||
firstName: modelVal.firstName,
|
||||
lastName: modelVal.lastName,
|
||||
email: modelVal.email,
|
||||
phone: modelVal.phone,
|
||||
address: modelVal.address,
|
||||
});
|
||||
const modelVal = this.guarantorModel();
|
||||
this.submitted.emit({
|
||||
firstName: modelVal.firstName,
|
||||
lastName: modelVal.lastName,
|
||||
email: modelVal.email,
|
||||
phone: modelVal.phone,
|
||||
address: modelVal.address,
|
||||
});
|
||||
|
||||
this.guarantorModel.set({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
this.guarantorForm().reset();
|
||||
}
|
||||
this.guarantorModel.set({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
this.guarantorForm().reset();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
.inventory-line {
|
||||
margin: 1rem 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.inventory-line label {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.inventory-line span {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.inventory-line input,
|
||||
.inventory-line select,
|
||||
.inventory-line textarea {
|
||||
max-width: 32rem;
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,187 +1,190 @@
|
||||
<section class="form-page" [attr.aria-busy]="loading() || saving()">
|
||||
<a routerLink="/leases">← Retour aux baux</a>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Inventaire du mobilier</h1>
|
||||
<p>
|
||||
Déclarez les éléments réglementaires et les autres meubles mis à disposition du locataire.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<a routerLink="/leases">← Retour aux baux</a>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Inventaire du mobilier</h1>
|
||||
<p>
|
||||
Déclarez les éléments réglementaires et les autres meubles mis à disposition du
|
||||
locataire.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<p role="status">Chargement de l’inventaire…</p>
|
||||
} @else {
|
||||
@if (error()) {
|
||||
<p role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (success()) {
|
||||
<p role="status">{{ success() }}</p>
|
||||
}
|
||||
@if (inventory().frozen) {
|
||||
<p role="status">Cet inventaire est figé.</p>
|
||||
}
|
||||
@if (loading()) {
|
||||
<p role="status">Chargement de l’inventaire…</p>
|
||||
} @else {
|
||||
@if (error()) {
|
||||
<p role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (success()) {
|
||||
<p role="status">{{ success() }}</p>
|
||||
}
|
||||
@if (inventory().frozen) {
|
||||
<p role="status">Cet inventaire est figé.</p>
|
||||
}
|
||||
|
||||
<p>
|
||||
@if (inventory().readyForLeaseDocument) {
|
||||
L’inventaire est prêt pour la génération du bail.
|
||||
} @else {
|
||||
Des éléments réglementaires doivent encore être déclarés.
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
@if (inventory().readyForLeaseDocument) {
|
||||
L’inventaire est prêt pour la génération du bail.
|
||||
} @else {
|
||||
Des éléments réglementaires doivent encore être déclarés.
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (inventory().legalWarnings.length) {
|
||||
<section aria-labelledby="inventory-warnings-heading">
|
||||
<h2 id="inventory-warnings-heading">Avertissements juridiques</h2>
|
||||
<ul>
|
||||
@for (warning of inventory().legalWarnings; track warning.code) {
|
||||
<li>
|
||||
{{ warningLabel(warning.category) }} est déclaré absent (règle
|
||||
{{ warning.ruleVersion }}).
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
@if (inventory().legalWarnings.length) {
|
||||
<section aria-labelledby="inventory-warnings-heading">
|
||||
<h2 id="inventory-warnings-heading">Avertissements juridiques</h2>
|
||||
<ul>
|
||||
@for (warning of inventory().legalWarnings; track warning.code) {
|
||||
<li>
|
||||
{{ warningLabel(warning.category) }} est déclaré absent (règle
|
||||
{{ warning.ruleVersion }}).
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<form (submit)="$event.preventDefault(); save()">
|
||||
<fieldset [disabled]="inventory().frozen || saving()">
|
||||
<section aria-labelledby="mandatory-furniture-heading">
|
||||
<h2 id="mandatory-furniture-heading">Catalogue réglementaire</h2>
|
||||
@for (line of inventory().lines; track line.id) {
|
||||
@if (line.category) {
|
||||
<fieldset class="inventory-line">
|
||||
<legend>{{ line.designation || line.category }}</legend>
|
||||
<p>{{ line.category }}</p>
|
||||
<label>
|
||||
<span>Présence</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.presence ?? ''"
|
||||
(change)="updatePresence(line.id, $event)"
|
||||
>
|
||||
<option value="">À déclarer</option>
|
||||
<option value="PRESENT">Présent</option>
|
||||
<option value="ABSENT">Absent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zone</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.privateAreaId"
|
||||
(change)="updateText(line.id, 'privateAreaId', $event)"
|
||||
>
|
||||
<option value="">Choisir un espace</option>
|
||||
@for (area of inventory().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Désignation</span>
|
||||
<input
|
||||
required
|
||||
[value]="line.designation"
|
||||
(input)="updateText(line.id, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Quantité</span>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
[min]="line.presence === 'PRESENT' ? 1 : 0"
|
||||
step="1"
|
||||
[value]="line.quantity"
|
||||
(input)="updateQuantity(line.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
[value]="line.description"
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (line.equipmentKind !== 'NONE') {
|
||||
<p>Une zone dédiée sera créée dans l’état des lieux.</p>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
<form (submit)="$event.preventDefault(); save()">
|
||||
<fieldset [disabled]="inventory().frozen || saving()">
|
||||
<section aria-labelledby="mandatory-furniture-heading">
|
||||
<h2 id="mandatory-furniture-heading">Catalogue réglementaire</h2>
|
||||
@for (line of inventory().lines; track line.id) {
|
||||
@if (line.category) {
|
||||
<fieldset class="inventory-line">
|
||||
<legend>{{ line.designation || line.category }}</legend>
|
||||
<p>{{ line.category }}</p>
|
||||
<label>
|
||||
<span>Présence</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.presence ?? ''"
|
||||
(change)="updatePresence(line.id, $event)"
|
||||
>
|
||||
<option value="">À déclarer</option>
|
||||
<option value="PRESENT">Présent</option>
|
||||
<option value="ABSENT">Absent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zone</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.privateAreaId"
|
||||
(change)="updateText(line.id, 'privateAreaId', $event)"
|
||||
>
|
||||
<option value="">Choisir un espace</option>
|
||||
@for (area of inventory().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Désignation</span>
|
||||
<input
|
||||
required
|
||||
[value]="line.designation"
|
||||
(input)="updateText(line.id, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Quantité</span>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
[min]="line.presence === 'PRESENT' ? 1 : 0"
|
||||
step="1"
|
||||
[value]="line.quantity"
|
||||
(input)="updateQuantity(line.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
[value]="line.description"
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (line.equipmentKind !== 'NONE') {
|
||||
<p>Une zone dédiée sera créée dans l’état des lieux.</p>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="free-furniture-heading">
|
||||
<h2 id="free-furniture-heading">Éléments complémentaires</h2>
|
||||
@for (line of inventory().lines; track line.id) {
|
||||
@if (line.category === null) {
|
||||
<fieldset class="inventory-line">
|
||||
<legend>{{ line.designation || 'Ligne libre' }}</legend>
|
||||
<label>
|
||||
<span>Zone</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.privateAreaId"
|
||||
(change)="updateText(line.id, 'privateAreaId', $event)"
|
||||
>
|
||||
<option value="">Choisir un espace</option>
|
||||
@for (area of inventory().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Désignation</span>
|
||||
<input
|
||||
required
|
||||
[value]="line.designation"
|
||||
(input)="updateText(line.id, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Quantité</span>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
[value]="line.quantity"
|
||||
(input)="updateQuantity(line.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
[value]="line.description"
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Suivi dans l’état des lieux</span>
|
||||
<select
|
||||
[value]="line.equipmentKind"
|
||||
(change)="updateEquipmentKind(line.id, $event)"
|
||||
>
|
||||
<option value="NONE">Aucune zone dédiée</option>
|
||||
<option value="CLIMATISATION">Climatisation</option>
|
||||
<option value="OTHER">Autre équipement</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" (click)="removeLine(line.id)">Supprimer cette ligne</button>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
<button type="button" (click)="addLine()">Ajouter une ligne libre</button>
|
||||
</section>
|
||||
</fieldset>
|
||||
<section aria-labelledby="free-furniture-heading">
|
||||
<h2 id="free-furniture-heading">Éléments complémentaires</h2>
|
||||
@for (line of inventory().lines; track line.id) {
|
||||
@if (line.category === null) {
|
||||
<fieldset class="inventory-line">
|
||||
<legend>{{ line.designation || 'Ligne libre' }}</legend>
|
||||
<label>
|
||||
<span>Zone</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.privateAreaId"
|
||||
(change)="updateText(line.id, 'privateAreaId', $event)"
|
||||
>
|
||||
<option value="">Choisir un espace</option>
|
||||
@for (area of inventory().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Désignation</span>
|
||||
<input
|
||||
required
|
||||
[value]="line.designation"
|
||||
(input)="updateText(line.id, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Quantité</span>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
[value]="line.quantity"
|
||||
(input)="updateQuantity(line.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
[value]="line.description"
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Suivi dans l’état des lieux</span>
|
||||
<select
|
||||
[value]="line.equipmentKind"
|
||||
(change)="updateEquipmentKind(line.id, $event)"
|
||||
>
|
||||
<option value="NONE">Aucune zone dédiée</option>
|
||||
<option value="CLIMATISATION">Climatisation</option>
|
||||
<option value="OTHER">Autre équipement</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" (click)="removeLine(line.id)">
|
||||
Supprimer cette ligne
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
<button type="button" (click)="addLine()">Ajouter une ligne libre</button>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
@if (!inventory().frozen) {
|
||||
<button type="submit" [disabled]="saving()">
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
@if (!inventory().frozen) {
|
||||
<button type="submit" [disabled]="saving()">
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</section>
|
||||
@@ -3,155 +3,159 @@ import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
FurnitureInventory,
|
||||
FurnitureInventoryService,
|
||||
FurnitureInventory,
|
||||
FurnitureInventoryService,
|
||||
} from '../../services/furniture-inventory.service';
|
||||
import { InventoryPage } from './inventory-page';
|
||||
|
||||
const inventory: FurnitureInventory = {
|
||||
id: 'inventory-1',
|
||||
templateVersion: '2025-01',
|
||||
lines: [
|
||||
{
|
||||
id: 'bed-1',
|
||||
category: 'BEDDING',
|
||||
presence: 'PRESENT',
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: 'bedroom-1',
|
||||
designation: 'Lit',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
missingCategories: [],
|
||||
absentCategories: [],
|
||||
legalWarnings: [],
|
||||
readyForLeaseDocument: true,
|
||||
frozen: false,
|
||||
privateAreas: [{ id: 'bedroom-1', label: 'Chambre' }],
|
||||
version: 3,
|
||||
id: 'inventory-1',
|
||||
templateVersion: '2025-01',
|
||||
lines: [
|
||||
{
|
||||
id: 'bed-1',
|
||||
category: 'BEDDING',
|
||||
presence: 'PRESENT',
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: 'bedroom-1',
|
||||
designation: 'Lit',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
missingCategories: [],
|
||||
absentCategories: [],
|
||||
legalWarnings: [],
|
||||
readyForLeaseDocument: true,
|
||||
frozen: false,
|
||||
privateAreas: [{ id: 'bedroom-1', label: 'Chambre' }],
|
||||
version: 3,
|
||||
};
|
||||
|
||||
function eventFor(target: EventTarget): Event {
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('InventoryPage', () => {
|
||||
function createPage(value: FurnitureInventory = inventory) {
|
||||
const service = {
|
||||
getInventory: vi.fn().mockReturnValue(of(value)),
|
||||
saveInventory: vi.fn().mockReturnValue(of({ ...value, version: 4 })),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{ provide: FurnitureInventoryService, useValue: service },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(InventoryPage);
|
||||
fixture.detectChanges();
|
||||
return { fixture, service };
|
||||
}
|
||||
function createPage(value: FurnitureInventory = inventory) {
|
||||
const service = {
|
||||
getInventory: vi.fn().mockReturnValue(of(value)),
|
||||
saveInventory: vi.fn().mockReturnValue(of({ ...value, version: 4 })),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{ provide: FurnitureInventoryService, useValue: service },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(InventoryPage);
|
||||
fixture.detectChanges();
|
||||
return { fixture, service };
|
||||
}
|
||||
|
||||
it('saves the editable lines with the aggregate version', () => {
|
||||
const { fixture, service } = createPage();
|
||||
it('saves the editable lines with the aggregate version', () => {
|
||||
const { fixture, service } = createPage();
|
||||
|
||||
fixture.componentInstance.save();
|
||||
fixture.componentInstance.save();
|
||||
|
||||
expect(service.saveInventory).toHaveBeenCalledWith('lease-1', {
|
||||
version: 3,
|
||||
lines: inventory.lines,
|
||||
});
|
||||
});
|
||||
expect(service.saveInventory).toHaveBeenCalledWith('lease-1', {
|
||||
version: 3,
|
||||
lines: inventory.lines,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks an undeclared legal category with an actionable error', () => {
|
||||
const { fixture, service } = createPage({
|
||||
...inventory,
|
||||
lines: [{ ...inventory.lines[0], presence: null, quantity: 0 }],
|
||||
missingCategories: ['BEDDING'],
|
||||
readyForLeaseDocument: false,
|
||||
});
|
||||
it('blocks an undeclared legal category with an actionable error', () => {
|
||||
const { fixture, service } = createPage({
|
||||
...inventory,
|
||||
lines: [{ ...inventory.lines[0], presence: null, quantity: 0 }],
|
||||
missingCategories: ['BEDDING'],
|
||||
readyForLeaseDocument: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.save();
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.save();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(service.saveInventory).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Déclarez la présence ou l’absence');
|
||||
});
|
||||
expect(service.saveInventory).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Déclarez la présence ou l’absence');
|
||||
});
|
||||
|
||||
it('updates editable facts, normalizes legal presence, and protects frozen inventory', () => {
|
||||
const { fixture } = createPage();
|
||||
const page = fixture.componentInstance;
|
||||
const text = document.createElement('input');
|
||||
text.value = 'Lit double';
|
||||
const quantity = document.createElement('input');
|
||||
quantity.value = '';
|
||||
const presence = document.createElement('select');
|
||||
presence.append(new Option('Présent', 'PRESENT'), new Option('Absent', 'ABSENT'));
|
||||
const equipment = document.createElement('select');
|
||||
equipment.append(new Option('Climatisation', 'CLIMATISATION'));
|
||||
it('updates editable facts, normalizes legal presence, and protects frozen inventory', () => {
|
||||
const { fixture } = createPage();
|
||||
const page = fixture.componentInstance;
|
||||
const text = document.createElement('input');
|
||||
text.value = 'Lit double';
|
||||
const quantity = document.createElement('input');
|
||||
quantity.value = '';
|
||||
const presence = document.createElement('select');
|
||||
presence.append(new Option('Présent', 'PRESENT'), new Option('Absent', 'ABSENT'));
|
||||
const equipment = document.createElement('select');
|
||||
equipment.append(new Option('Climatisation', 'CLIMATISATION'));
|
||||
|
||||
page.updateText('bed-1', 'designation', eventFor(text));
|
||||
page.updateQuantity('bed-1', eventFor(quantity));
|
||||
presence.value = 'ABSENT';
|
||||
page.updatePresence('bed-1', eventFor(presence));
|
||||
presence.value = 'PRESENT';
|
||||
page.updatePresence('bed-1', eventFor(presence));
|
||||
page.addLine();
|
||||
const added = page.inventory().lines.find((line) => line.category === null);
|
||||
if (added) {
|
||||
equipment.value = 'CLIMATISATION';
|
||||
page.updateEquipmentKind(added.id, eventFor(equipment));
|
||||
expect(page.inventory().lines.find((line) => line.id === added.id)?.equipmentKind).toBe(
|
||||
'CLIMATISATION',
|
||||
);
|
||||
page.removeLine(added.id);
|
||||
}
|
||||
page.updateText('bed-1', 'designation', eventFor(text));
|
||||
page.updateQuantity('bed-1', eventFor(quantity));
|
||||
presence.value = 'ABSENT';
|
||||
page.updatePresence('bed-1', eventFor(presence));
|
||||
presence.value = 'PRESENT';
|
||||
page.updatePresence('bed-1', eventFor(presence));
|
||||
page.addLine();
|
||||
const added = page.inventory().lines.find((line) => line.category === null);
|
||||
if (added) {
|
||||
equipment.value = 'CLIMATISATION';
|
||||
page.updateEquipmentKind(added.id, eventFor(equipment));
|
||||
expect(page.inventory().lines.find((line) => line.id === added.id)?.equipmentKind).toBe(
|
||||
'CLIMATISATION',
|
||||
);
|
||||
page.removeLine(added.id);
|
||||
}
|
||||
|
||||
expect(page.inventory().lines[0]).toEqual(
|
||||
expect.objectContaining({ designation: 'Lit double', presence: 'PRESENT', quantity: 1 }),
|
||||
);
|
||||
expect(page.warningLabel('BEDDING')).toBe('Lit double');
|
||||
page.inventory.set({ ...page.inventory(), frozen: true });
|
||||
page.addLine();
|
||||
page.removeLine('bed-1');
|
||||
page.save();
|
||||
expect(page.inventory().lines).toHaveLength(1);
|
||||
expect(page.error()).toContain('figé');
|
||||
});
|
||||
expect(page.inventory().lines[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
designation: 'Lit double',
|
||||
presence: 'PRESENT',
|
||||
quantity: 1,
|
||||
}),
|
||||
);
|
||||
expect(page.warningLabel('BEDDING')).toBe('Lit double');
|
||||
page.inventory.set({ ...page.inventory(), frozen: true });
|
||||
page.addLine();
|
||||
page.removeLine('bed-1');
|
||||
page.save();
|
||||
expect(page.inventory().lines).toHaveLength(1);
|
||||
expect(page.error()).toContain('figé');
|
||||
});
|
||||
|
||||
it('shows load and save failures without replacing the current inventory', () => {
|
||||
const service = {
|
||||
getInventory: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(of(inventory))
|
||||
.mockReturnValue(throwError(() => new Error('offline'))),
|
||||
saveInventory: vi.fn().mockReturnValue(throwError(() => new Error('offline'))),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{ provide: FurnitureInventoryService, useValue: service },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(InventoryPage);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.componentInstance;
|
||||
it('shows load and save failures without replacing the current inventory', () => {
|
||||
const service = {
|
||||
getInventory: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(of(inventory))
|
||||
.mockReturnValue(throwError(() => new Error('offline'))),
|
||||
saveInventory: vi.fn().mockReturnValue(throwError(() => new Error('offline'))),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{ provide: FurnitureInventoryService, useValue: service },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(InventoryPage);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.componentInstance;
|
||||
|
||||
page.save();
|
||||
expect(page.error()).toContain('Impossible d’enregistrer');
|
||||
page.load();
|
||||
expect(page.error()).toContain('Impossible de charger');
|
||||
expect(page.inventory()).toEqual(inventory);
|
||||
});
|
||||
page.save();
|
||||
expect(page.error()).toContain('Impossible d’enregistrer');
|
||||
page.load();
|
||||
expect(page.error()).toContain('Impossible de charger');
|
||||
expect(page.inventory()).toEqual(inventory);
|
||||
});
|
||||
});
|
||||
@@ -2,195 +2,198 @@ import { Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
FurnitureInventory,
|
||||
FurnitureInventoryLine,
|
||||
FurnitureInventoryService,
|
||||
FurniturePresence,
|
||||
FurnitureInventory,
|
||||
FurnitureInventoryLine,
|
||||
FurnitureInventoryService,
|
||||
FurniturePresence,
|
||||
} from '../../services/furniture-inventory.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
|
||||
const emptyInventory: FurnitureInventory = {
|
||||
id: '',
|
||||
templateVersion: '',
|
||||
lines: [],
|
||||
missingCategories: [],
|
||||
absentCategories: [],
|
||||
legalWarnings: [],
|
||||
readyForLeaseDocument: false,
|
||||
frozen: false,
|
||||
privateAreas: [],
|
||||
version: 0,
|
||||
id: '',
|
||||
templateVersion: '',
|
||||
lines: [],
|
||||
missingCategories: [],
|
||||
absentCategories: [],
|
||||
legalWarnings: [],
|
||||
readyForLeaseDocument: false,
|
||||
frozen: false,
|
||||
privateAreas: [],
|
||||
version: 0,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-inventory-page',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './inventory-page.html',
|
||||
styleUrl: './inventory-page.css',
|
||||
selector: 'app-inventory-page',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './inventory-page.html',
|
||||
styleUrl: './inventory-page.css',
|
||||
})
|
||||
export class InventoryPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly inventories = inject(FurnitureInventoryService);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly inventory = signal<FurnitureInventory>(emptyInventory);
|
||||
readonly loading = signal(true);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly success = signal('');
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly inventories = inject(FurnitureInventoryService);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly inventory = signal<FurnitureInventory>(emptyInventory);
|
||||
readonly loading = signal(true);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly success = signal('');
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
this.inventories
|
||||
.getInventory(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (inventory) => this.inventory.set(inventory),
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible de charger l’inventaire du mobilier.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
this.inventories
|
||||
.getInventory(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (inventory) => this.inventory.set(inventory),
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible de charger l’inventaire du mobilier.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
updateText(
|
||||
id: string,
|
||||
field: 'privateAreaId' | 'designation' | 'description',
|
||||
event: Event,
|
||||
): void {
|
||||
const value =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
? event.target.value
|
||||
: '';
|
||||
this.updateLine(id, { [field]: value });
|
||||
}
|
||||
updateText(
|
||||
id: string,
|
||||
field: 'privateAreaId' | 'designation' | 'description',
|
||||
event: Event,
|
||||
): void {
|
||||
const value =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
? event.target.value
|
||||
: '';
|
||||
this.updateLine(id, { [field]: value });
|
||||
}
|
||||
|
||||
updateQuantity(id: string, event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const quantity =
|
||||
!input || input.value === '' || !Number.isFinite(input.valueAsNumber)
|
||||
? -1
|
||||
: input.valueAsNumber;
|
||||
this.updateLine(id, { quantity });
|
||||
}
|
||||
updateQuantity(id: string, event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const quantity =
|
||||
!input || input.value === '' || !Number.isFinite(input.valueAsNumber)
|
||||
? -1
|
||||
: input.valueAsNumber;
|
||||
this.updateLine(id, { quantity });
|
||||
}
|
||||
|
||||
updatePresence(id: string, event: Event): void {
|
||||
const line = this.inventory().lines.find((candidate) => candidate.id === id);
|
||||
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
|
||||
const presence: FurniturePresence | null =
|
||||
value === 'PRESENT' || value === 'ABSENT' ? value : null;
|
||||
if (!line?.category) return;
|
||||
this.updateLine(id, {
|
||||
presence,
|
||||
quantity:
|
||||
presence === 'ABSENT'
|
||||
? 0
|
||||
: presence === 'PRESENT' && line.quantity === 0
|
||||
? 1
|
||||
: line.quantity,
|
||||
});
|
||||
}
|
||||
updatePresence(id: string, event: Event): void {
|
||||
const line = this.inventory().lines.find((candidate) => candidate.id === id);
|
||||
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
|
||||
const presence: FurniturePresence | null =
|
||||
value === 'PRESENT' || value === 'ABSENT' ? value : null;
|
||||
if (!line?.category) return;
|
||||
this.updateLine(id, {
|
||||
presence,
|
||||
quantity:
|
||||
presence === 'ABSENT'
|
||||
? 0
|
||||
: presence === 'PRESENT' && line.quantity === 0
|
||||
? 1
|
||||
: line.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
addLine(): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: [
|
||||
...inventory.lines,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
category: null,
|
||||
presence: null,
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: '',
|
||||
designation: '',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
addLine(): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: [
|
||||
...inventory.lines,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
category: null,
|
||||
presence: null,
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: '',
|
||||
designation: '',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
removeLine(id: string): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: inventory.lines.filter((line) => line.id !== id || line.category !== null),
|
||||
}));
|
||||
}
|
||||
removeLine(id: string): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: inventory.lines.filter((line) => line.id !== id || line.category !== null),
|
||||
}));
|
||||
}
|
||||
|
||||
updateEquipmentKind(id: string, event: Event): void {
|
||||
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
|
||||
if (value === 'NONE' || value === 'CLIMATISATION' || value === 'OTHER') {
|
||||
this.updateLine(id, { equipmentKind: value });
|
||||
}
|
||||
}
|
||||
updateEquipmentKind(id: string, event: Event): void {
|
||||
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
|
||||
if (value === 'NONE' || value === 'CLIMATISATION' || value === 'OTHER') {
|
||||
this.updateLine(id, { equipmentKind: value });
|
||||
}
|
||||
}
|
||||
|
||||
warningLabel(category: string): string {
|
||||
return (
|
||||
this.inventory().lines.find((line) => line.category === category)?.designation ?? category
|
||||
);
|
||||
}
|
||||
warningLabel(category: string): string {
|
||||
return (
|
||||
this.inventory().lines.find((line) => line.category === category)?.designation ??
|
||||
category
|
||||
);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const inventory = this.inventory();
|
||||
if (inventory.frozen) {
|
||||
this.error.set('Cet inventaire est figé et ne peut plus être modifié.');
|
||||
return;
|
||||
}
|
||||
const validationError = this.validationError(inventory.lines);
|
||||
if (validationError) {
|
||||
this.error.set(validationError);
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
this.inventories
|
||||
.saveInventory(this.leaseId, { version: inventory.version, lines: inventory.lines })
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.inventory.set(saved);
|
||||
this.success.set('Inventaire enregistré.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’enregistrer l’inventaire.')),
|
||||
});
|
||||
}
|
||||
save(): void {
|
||||
const inventory = this.inventory();
|
||||
if (inventory.frozen) {
|
||||
this.error.set('Cet inventaire est figé et ne peut plus être modifié.');
|
||||
return;
|
||||
}
|
||||
const validationError = this.validationError(inventory.lines);
|
||||
if (validationError) {
|
||||
this.error.set(validationError);
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
this.inventories
|
||||
.saveInventory(this.leaseId, { version: inventory.version, lines: inventory.lines })
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.inventory.set(saved);
|
||||
this.success.set('Inventaire enregistré.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible d’enregistrer l’inventaire.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private updateLine(id: string, changes: Partial<FurnitureInventoryLine>): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: inventory.lines.map((line) => (line.id === id ? { ...line, ...changes } : line)),
|
||||
}));
|
||||
}
|
||||
private updateLine(id: string, changes: Partial<FurnitureInventoryLine>): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: inventory.lines.map((line) => (line.id === id ? { ...line, ...changes } : line)),
|
||||
}));
|
||||
}
|
||||
|
||||
private validationError(lines: FurnitureInventoryLine[]): string | null {
|
||||
for (const line of lines) {
|
||||
if (!line.privateAreaId.trim()) return 'Renseignez la zone de chaque ligne.';
|
||||
if (!line.designation.trim()) return 'Renseignez la désignation de chaque ligne.';
|
||||
if (!Number.isInteger(line.quantity) || line.quantity < 0) {
|
||||
return 'Saisissez une quantité entière positive ou nulle.';
|
||||
}
|
||||
if (line.category && line.presence === null) {
|
||||
return 'Déclarez la présence ou l’absence de chaque élément réglementaire.';
|
||||
}
|
||||
if (line.category && line.presence === 'PRESENT' && line.quantity === 0) {
|
||||
return 'Un élément réglementaire présent doit avoir une quantité positive.';
|
||||
}
|
||||
if (line.category && line.presence === 'ABSENT' && line.quantity !== 0) {
|
||||
return 'Un élément réglementaire absent doit avoir une quantité nulle.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private validationError(lines: FurnitureInventoryLine[]): string | null {
|
||||
for (const line of lines) {
|
||||
if (!line.privateAreaId.trim()) return 'Renseignez la zone de chaque ligne.';
|
||||
if (!line.designation.trim()) return 'Renseignez la désignation de chaque ligne.';
|
||||
if (!Number.isInteger(line.quantity) || line.quantity < 0) {
|
||||
return 'Saisissez une quantité entière positive ou nulle.';
|
||||
}
|
||||
if (line.category && line.presence === null) {
|
||||
return 'Déclarez la présence ou l’absence de chaque élément réglementaire.';
|
||||
}
|
||||
if (line.category && line.presence === 'PRESENT' && line.quantity === 0) {
|
||||
return 'Un élément réglementaire présent doit avoir une quantité positive.';
|
||||
}
|
||||
if (line.category && line.presence === 'ABSENT' && line.quantity !== 0) {
|
||||
return 'Un élément réglementaire absent doit avoir une quantité nulle.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -1,29 +1,29 @@
|
||||
.profile-dialog-content {
|
||||
display: grid;
|
||||
width: min(36rem, calc(100vw - 3rem));
|
||||
max-width: 100%;
|
||||
gap: var(--space-4, 1rem);
|
||||
padding-inline: var(--space-6, 1.5rem);
|
||||
padding-block-end: var(--space-6, 1.5rem);
|
||||
display: grid;
|
||||
width: min(36rem, calc(100vw - 3rem));
|
||||
max-width: 100%;
|
||||
gap: var(--space-4, 1rem);
|
||||
padding-inline: var(--space-6, 1.5rem);
|
||||
padding-block-end: var(--space-6, 1.5rem);
|
||||
}
|
||||
|
||||
.profile-dialog-content > p,
|
||||
.profile-error p {
|
||||
margin: 0;
|
||||
color: var(--text-muted, #687670);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
color: var(--text-muted, #687670);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-loading,
|
||||
.profile-error {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
min-height: 3rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.profile-error {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
+34
-32
@@ -1,38 +1,40 @@
|
||||
<h2 mat-dialog-title>Complétez votre profil</h2>
|
||||
<div class="profile-dialog-content">
|
||||
<p>Vos coordonnées sont nécessaires pour créer vos baux et vos quittances.</p>
|
||||
<p>Vos coordonnées sont nécessaires pour créer vos baux et vos quittances.</p>
|
||||
|
||||
@if (profileStatus() === 'loading') {
|
||||
<div class="profile-loading" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
|
||||
</div>
|
||||
} @else if (profileStatus() === 'error') {
|
||||
<div class="profile-error">
|
||||
<p role="alert">{{ error() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<app-landlord-profile-form
|
||||
class="profile-completion-form"
|
||||
[profile]="profile()"
|
||||
[saving]="saving()"
|
||||
[saveError]="error()"
|
||||
(saveRequested)="save($event)"
|
||||
>
|
||||
<button
|
||||
profile-form-secondary-action
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="logout()"
|
||||
[disabled]="saving()"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</app-landlord-profile-form>
|
||||
}
|
||||
@if (profileStatus() === 'loading') {
|
||||
<div class="profile-loading" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
|
||||
</div>
|
||||
} @else if (profileStatus() === 'error') {
|
||||
<div class="profile-error">
|
||||
<p role="alert">{{ error() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<app-landlord-profile-form
|
||||
class="profile-completion-form"
|
||||
[profile]="profile()"
|
||||
[saving]="saving()"
|
||||
[saveError]="error()"
|
||||
(saveRequested)="save($event)"
|
||||
>
|
||||
<button
|
||||
profile-form-secondary-action
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="logout()"
|
||||
[disabled]="saving()"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</app-landlord-profile-form>
|
||||
}
|
||||
</div>
|
||||
@if (profileStatus() !== 'ready') {
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="logout()" [disabled]="saving()">Déconnexion</button>
|
||||
</mat-dialog-actions>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="logout()" [disabled]="saving()">
|
||||
Déconnexion
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
}
|
||||
+119
-117
@@ -6,137 +6,139 @@ import { vi } from 'vitest';
|
||||
import { LandlordProfileService } from '../../services/landlord-profile.service';
|
||||
import { SessionService } from '../../services/session.service';
|
||||
import {
|
||||
LandlordProfileCompletionDialog,
|
||||
LandlordProfileCompletionDialogData,
|
||||
LandlordProfileCompletionDialog,
|
||||
LandlordProfileCompletionDialogData,
|
||||
} from './landlord-profile-completion-dialog';
|
||||
|
||||
describe('LandlordProfileCompletionDialog', () => {
|
||||
const profile = { getProfile: vi.fn(), updateProfile: vi.fn() };
|
||||
const dialogRef = { close: vi.fn() };
|
||||
const session = { logout: vi.fn() };
|
||||
const data: LandlordProfileCompletionDialogData = {
|
||||
profile: {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
},
|
||||
};
|
||||
const profile = { getProfile: vi.fn(), updateProfile: vi.fn() };
|
||||
const dialogRef = { close: vi.fn() };
|
||||
const session = { logout: vi.fn() };
|
||||
const data: LandlordProfileCompletionDialogData = {
|
||||
profile: {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
data.profile = {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: LandlordProfileService, useValue: profile },
|
||||
{ provide: SessionService, useValue: session },
|
||||
],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
data.profile = {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: LandlordProfileService, useValue: profile },
|
||||
{ provide: SessionService, useValue: session },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('saves the completed profile before closing the dialog', () => {
|
||||
profile.updateProfile.mockReturnValue(
|
||||
of({ ...data.profile, phone: '+33600000000', address: '1 rue A', complete: true }),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.profile.update((current) => ({
|
||||
...current,
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
}));
|
||||
it('saves the completed profile before closing the dialog', () => {
|
||||
profile.updateProfile.mockReturnValue(
|
||||
of({ ...data.profile, phone: '+33600000000', address: '1 rue A', complete: true }),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.profile.update((current) => ({
|
||||
...current,
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
}));
|
||||
|
||||
component.save(component.profile());
|
||||
component.save(component.profile());
|
||||
|
||||
expect(profile.updateProfile).toHaveBeenCalledWith({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
});
|
||||
expect(dialogRef.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(profile.updateProfile).toHaveBeenCalledWith({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
});
|
||||
expect(dialogRef.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps the dialog open and explains a rejected save', () => {
|
||||
profile.updateProfile.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
error: { message: 'Cette adresse e-mail est déjà utilisée.' },
|
||||
status: 409,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.profile.update((current) => ({
|
||||
...current,
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
}));
|
||||
it('keeps the dialog open and explains a rejected save', () => {
|
||||
profile.updateProfile.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
error: { message: 'Cette adresse e-mail est déjà utilisée.' },
|
||||
status: 409,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.profile.update((current) => ({
|
||||
...current,
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
}));
|
||||
|
||||
component.save(component.profile());
|
||||
fixture.detectChanges();
|
||||
component.save(component.profile());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.');
|
||||
expect(fixture.nativeElement.textContent).toContain('Réessayer');
|
||||
});
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Cette adresse e-mail est déjà utilisée.',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain('Réessayer');
|
||||
});
|
||||
|
||||
it('retries an initial profile load without releasing the dialog', () => {
|
||||
data.profile = null;
|
||||
profile.getProfile.mockReturnValue(
|
||||
of({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
it('retries an initial profile load without releasing the dialog', () => {
|
||||
data.profile = null;
|
||||
profile.getProfile.mockReturnValue(
|
||||
of({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(profile.getProfile).toHaveBeenCalledOnce();
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('input#profile-phone').value).toBe('');
|
||||
});
|
||||
expect(profile.getProfile).toHaveBeenCalledOnce();
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('input#profile-phone').value).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the load error visible when a retry fails', () => {
|
||||
data.profile = null;
|
||||
profile.getProfile.mockReturnValue(throwError(() => new Error('offline')));
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
it('keeps the load error visible when a retry fails', () => {
|
||||
data.profile = null;
|
||||
profile.getProfile.mockReturnValue(throwError(() => new Error('offline')));
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de charger votre profil bailleur. Réessayez.',
|
||||
);
|
||||
});
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de charger votre profil bailleur. Réessayez.',
|
||||
);
|
||||
});
|
||||
});
|
||||
+74
-71
@@ -5,90 +5,93 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form';
|
||||
import {
|
||||
LandlordProfile,
|
||||
LandlordProfileResponse,
|
||||
LandlordProfileService,
|
||||
LandlordProfile,
|
||||
LandlordProfileResponse,
|
||||
LandlordProfileService,
|
||||
} from '../../services/landlord-profile.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { SessionService } from '../../services/session.service';
|
||||
|
||||
export interface LandlordProfileCompletionDialogData {
|
||||
profile: LandlordProfileResponse | null;
|
||||
profile: LandlordProfileResponse | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-landlord-profile-completion-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, MatProgressSpinnerModule, LandlordProfileForm],
|
||||
templateUrl: './landlord-profile-completion-dialog.html',
|
||||
styleUrl: './landlord-profile-completion-dialog.css',
|
||||
selector: 'app-landlord-profile-completion-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, MatProgressSpinnerModule, LandlordProfileForm],
|
||||
templateUrl: './landlord-profile-completion-dialog.html',
|
||||
styleUrl: './landlord-profile-completion-dialog.css',
|
||||
})
|
||||
export class LandlordProfileCompletionDialog {
|
||||
readonly data = inject<LandlordProfileCompletionDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<LandlordProfileCompletionDialog>);
|
||||
private readonly landlordProfile = inject(LandlordProfileService);
|
||||
private readonly session = inject(SessionService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
readonly data = inject<LandlordProfileCompletionDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<LandlordProfileCompletionDialog>);
|
||||
private readonly landlordProfile = inject(LandlordProfileService);
|
||||
private readonly session = inject(SessionService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly profileStatus = signal<'ready' | 'loading' | 'error'>(
|
||||
this.data.profile ? 'ready' : 'error',
|
||||
);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal(
|
||||
this.data.profile ? '' : 'Impossible de charger votre profil bailleur. Réessayez.',
|
||||
);
|
||||
readonly profile = signal<LandlordProfile>(this.profileFields(this.data.profile));
|
||||
readonly profileStatus = signal<'ready' | 'loading' | 'error'>(
|
||||
this.data.profile ? 'ready' : 'error',
|
||||
);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal(
|
||||
this.data.profile ? '' : 'Impossible de charger votre profil bailleur. Réessayez.',
|
||||
);
|
||||
readonly profile = signal<LandlordProfile>(this.profileFields(this.data.profile));
|
||||
|
||||
retry(): void {
|
||||
this.profileStatus.set('loading');
|
||||
this.error.set('');
|
||||
this.landlordProfile
|
||||
.getProfile()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (profile) => {
|
||||
if (profile.complete) {
|
||||
this.dialogRef.close();
|
||||
return;
|
||||
}
|
||||
this.profile.set(this.profileFields(profile));
|
||||
this.profileStatus.set('ready');
|
||||
},
|
||||
error: () => {
|
||||
this.profileStatus.set('error');
|
||||
this.error.set('Impossible de charger votre profil bailleur. Réessayez.');
|
||||
},
|
||||
});
|
||||
}
|
||||
retry(): void {
|
||||
this.profileStatus.set('loading');
|
||||
this.error.set('');
|
||||
this.landlordProfile
|
||||
.getProfile()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (profile) => {
|
||||
if (profile.complete) {
|
||||
this.dialogRef.close();
|
||||
return;
|
||||
}
|
||||
this.profile.set(this.profileFields(profile));
|
||||
this.profileStatus.set('ready');
|
||||
},
|
||||
error: () => {
|
||||
this.profileStatus.set('error');
|
||||
this.error.set('Impossible de charger votre profil bailleur. Réessayez.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(profile: LandlordProfile): void {
|
||||
if (this.saving()) return;
|
||||
this.error.set('');
|
||||
this.saving.set(true);
|
||||
this.landlordProfile
|
||||
.updateProfile(profile)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.dialogRef.close(),
|
||||
error: (error: unknown) => {
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'),
|
||||
);
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
save(profile: LandlordProfile): void {
|
||||
if (this.saving()) return;
|
||||
this.error.set('');
|
||||
this.saving.set(true);
|
||||
this.landlordProfile
|
||||
.updateProfile(profile)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.dialogRef.close(),
|
||||
error: (error: unknown) => {
|
||||
this.error.set(
|
||||
httpErrorMessage(
|
||||
error,
|
||||
'Impossible d’enregistrer votre profil bailleur. Réessayez.',
|
||||
),
|
||||
);
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.session.logout();
|
||||
}
|
||||
logout(): void {
|
||||
this.session.logout();
|
||||
}
|
||||
|
||||
private profileFields(profile: LandlordProfile | null): LandlordProfile {
|
||||
return {
|
||||
firstName: profile?.firstName ?? '',
|
||||
lastName: profile?.lastName ?? '',
|
||||
email: profile?.email ?? '',
|
||||
phone: profile?.phone ?? '',
|
||||
address: profile?.address ?? '',
|
||||
};
|
||||
}
|
||||
private profileFields(profile: LandlordProfile | null): LandlordProfile {
|
||||
return {
|
||||
firstName: profile?.firstName ?? '',
|
||||
lastName: profile?.lastName ?? '',
|
||||
email: profile?.email ?? '',
|
||||
phone: profile?.phone ?? '',
|
||||
address: profile?.address ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,54 @@
|
||||
:host {
|
||||
display: block;
|
||||
container-type: inline-size;
|
||||
display: block;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
:host(.profile-completion-form) {
|
||||
--mat-button-text-horizontal-padding: var(--space-1, 0.25rem);
|
||||
--mat-button-filled-horizontal-padding: var(--space-1, 0.25rem);
|
||||
--mat-button-text-horizontal-padding: var(--space-1, 0.25rem);
|
||||
--mat-button-filled-horizontal-padding: var(--space-1, 0.25rem);
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.profile-error {
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.profile-fields {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
gap: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
gap: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
|
||||
}
|
||||
|
||||
.profile-fields mat-form-field {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:host(.profile-completion-form) .profile-actions {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
}
|
||||
|
||||
@container (min-width: 32rem) {
|
||||
.profile-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.profile-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.profile-fields .full-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.profile-fields .full-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,66 @@
|
||||
<form
|
||||
class="profile-form"
|
||||
[formRoot]="profileForm"
|
||||
(ngSubmit)="submit()"
|
||||
[attr.aria-busy]="saving()"
|
||||
class="profile-form"
|
||||
[formRoot]="profileForm"
|
||||
(ngSubmit)="submit()"
|
||||
[attr.aria-busy]="saving()"
|
||||
>
|
||||
@if (error()) {
|
||||
<p class="profile-error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
<fieldset class="profile-fields" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-firstName"
|
||||
[formField]="profileForm.firstName"
|
||||
autocomplete="given-name"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-lastName"
|
||||
[formField]="profileForm.lastName"
|
||||
autocomplete="family-name"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-email"
|
||||
type="email"
|
||||
[formField]="profileForm.email"
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-phone"
|
||||
type="tel"
|
||||
[formField]="profileForm.phone"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse postale</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-address"
|
||||
[formField]="profileForm.address"
|
||||
autocomplete="street-address"
|
||||
/></mat-form-field>
|
||||
</fieldset>
|
||||
<div class="profile-actions">
|
||||
<ng-content select="[profile-form-secondary-action]" />
|
||||
<button mat-flat-button type="submit" class="primary-action">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else if (error()) {
|
||||
Réessayer
|
||||
} @else {
|
||||
Enregistrer le profil
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="profile-error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
<fieldset class="profile-fields" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-firstName"
|
||||
[formField]="profileForm.firstName"
|
||||
autocomplete="given-name"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-lastName"
|
||||
[formField]="profileForm.lastName"
|
||||
autocomplete="family-name"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-email"
|
||||
type="email"
|
||||
[formField]="profileForm.email"
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-phone"
|
||||
type="tel"
|
||||
[formField]="profileForm.phone"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse postale</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-address"
|
||||
[formField]="profileForm.address"
|
||||
autocomplete="street-address"
|
||||
/></mat-form-field>
|
||||
</fieldset>
|
||||
<div class="profile-actions">
|
||||
<ng-content select="[profile-form-secondary-action]" />
|
||||
<button mat-flat-button type="submit" class="primary-action">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else if (error()) {
|
||||
Réessayer
|
||||
} @else {
|
||||
Enregistrer le profil
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -3,51 +3,51 @@ import { vi } from 'vitest';
|
||||
import { LandlordProfileForm } from './landlord-profile-form';
|
||||
|
||||
describe('LandlordProfileForm', () => {
|
||||
it('explains incomplete profile data without requesting a save', () => {
|
||||
const fixture = TestBed.createComponent(LandlordProfileForm);
|
||||
const component = fixture.componentInstance;
|
||||
const saveRequested = vi.fn();
|
||||
component.saveRequested.subscribe(saveRequested);
|
||||
fixture.componentRef.setInput('profile', {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
it('explains incomplete profile data without requesting a save', () => {
|
||||
const fixture = TestBed.createComponent(LandlordProfileForm);
|
||||
const component = fixture.componentInstance;
|
||||
const saveRequested = vi.fn();
|
||||
component.saveRequested.subscribe(saveRequested);
|
||||
fixture.componentRef.setInput('profile', {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
component.submit();
|
||||
fixture.detectChanges();
|
||||
component.submit();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(saveRequested).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Veuillez renseigner tous les champs obligatoires.',
|
||||
);
|
||||
});
|
||||
expect(saveRequested).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Veuillez renseigner tous les champs obligatoires.',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a complete profile for saving', () => {
|
||||
const fixture = TestBed.createComponent(LandlordProfileForm);
|
||||
const component = fixture.componentInstance;
|
||||
const saveRequested = vi.fn();
|
||||
component.saveRequested.subscribe(saveRequested);
|
||||
fixture.componentRef.setInput('profile', {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
it('emits a complete profile for saving', () => {
|
||||
const fixture = TestBed.createComponent(LandlordProfileForm);
|
||||
const component = fixture.componentInstance;
|
||||
const saveRequested = vi.fn();
|
||||
component.saveRequested.subscribe(saveRequested);
|
||||
fixture.componentRef.setInput('profile', {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
component.submit();
|
||||
component.submit();
|
||||
|
||||
expect(saveRequested).toHaveBeenCalledWith({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
});
|
||||
expect(saveRequested).toHaveBeenCalledWith({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,61 +8,62 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { LandlordProfile } from '../../services/landlord-profile.service';
|
||||
|
||||
const EMPTY_PROFILE: LandlordProfile = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-landlord-profile-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './landlord-profile-form.html',
|
||||
styleUrl: './landlord-profile-form.css',
|
||||
selector: 'app-landlord-profile-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './landlord-profile-form.html',
|
||||
styleUrl: './landlord-profile-form.css',
|
||||
})
|
||||
export class LandlordProfileForm {
|
||||
readonly profile = input<LandlordProfile>(EMPTY_PROFILE);
|
||||
readonly saving = input(false);
|
||||
readonly saveError = input('');
|
||||
readonly saveRequested = output<LandlordProfile>();
|
||||
readonly profile = input<LandlordProfile>(EMPTY_PROFILE);
|
||||
readonly saving = input(false);
|
||||
readonly saveError = input('');
|
||||
readonly saveRequested = output<LandlordProfile>();
|
||||
|
||||
readonly profileModel = signal<LandlordProfile>({ ...this.profile() });
|
||||
readonly profileForm = form(this.profileModel, (f) => {
|
||||
required(f.firstName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.lastName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.email, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.phone, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.address, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
email(f.email, { message: 'Saisissez une adresse e-mail valide.' });
|
||||
});
|
||||
private readonly validationError = signal('');
|
||||
readonly error = computed(() => this.validationError() || this.saveError());
|
||||
readonly profileModel = signal<LandlordProfile>({ ...this.profile() });
|
||||
readonly profileForm = form(this.profileModel, (f) => {
|
||||
required(f.firstName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.lastName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.email, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.phone, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
required(f.address, { message: 'Veuillez renseigner tous les champs obligatoires.' });
|
||||
email(f.email, { message: 'Saisissez une adresse e-mail valide.' });
|
||||
});
|
||||
private readonly validationError = signal('');
|
||||
readonly error = computed(() => this.validationError() || this.saveError());
|
||||
|
||||
constructor() {
|
||||
effect(() => this.profileModel.set({ ...this.profile() }));
|
||||
}
|
||||
constructor() {
|
||||
effect(() => this.profileModel.set({ ...this.profile() }));
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.saving()) return;
|
||||
submit(): void {
|
||||
if (this.saving()) return;
|
||||
|
||||
this.profileForm().markAsTouched();
|
||||
if (this.profileForm().invalid()) {
|
||||
this.validationError.set(
|
||||
this.profileForm().errorSummary()[0]?.message ?? 'Saisissez des informations valides.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.profileForm().markAsTouched();
|
||||
if (this.profileForm().invalid()) {
|
||||
this.validationError.set(
|
||||
this.profileForm().errorSummary()[0]?.message ??
|
||||
'Saisissez des informations valides.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.validationError.set('');
|
||||
this.saveRequested.emit(this.profileModel());
|
||||
}
|
||||
this.validationError.set('');
|
||||
this.saveRequested.emit(this.profileModel());
|
||||
}
|
||||
}
|
||||
@@ -1,171 +1,171 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.lease-card {
|
||||
min-width: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.lease-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lease-topline strong {
|
||||
margin-left: auto;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
margin-left: auto;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--surface-accent);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--surface-accent);
|
||||
}
|
||||
.lease-card h2 {
|
||||
margin: 25px 0 6px;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.035em;
|
||||
margin: 25px 0 6px;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.tenant-name {
|
||||
color: var(--accent);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.revision-metadata {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.revision-manifest {
|
||||
margin-block-start: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.78rem;
|
||||
margin-block-start: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.revision-manifest summary {
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
.annex-list,
|
||||
.revision-warnings ul {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0;
|
||||
padding-inline-start: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0;
|
||||
padding-inline-start: 1.1rem;
|
||||
}
|
||||
.annex-list li {
|
||||
min-width: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.annex-list span {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.annex-hash {
|
||||
font-family: monospace;
|
||||
font-family: monospace;
|
||||
}
|
||||
.revision-warnings {
|
||||
margin-block-start: 0.75rem;
|
||||
color: var(--danger, #b42318);
|
||||
margin-block-start: 0.75rem;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
.terms {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 22px 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.terms dt,
|
||||
.guarantor-summary span {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.terms dd {
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.84rem;
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.open-rent-revision {
|
||||
margin-block-end: 1rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
cursor: pointer;
|
||||
margin-block-end: 1rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.open-rent-revision:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 0.25rem;
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 0.25rem;
|
||||
}
|
||||
.guarantor-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
padding: 13px;
|
||||
border-radius: 13px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
padding: 13px;
|
||||
border-radius: 13px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.guarantor-summary span {
|
||||
width: 100%;
|
||||
font-size: 0.66rem;
|
||||
width: 100%;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.rent-schedule {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rent-schedule h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.rent-due {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
.rent-due + .rent-due {
|
||||
margin-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rent-due dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.rent-due dt {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rent-due dd {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8rem;
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.rent-due-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.card-actions {
|
||||
margin-top: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.card-error {
|
||||
margin: 12px 0 0;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.lease-end-unsupported {
|
||||
margin: 0.875rem 0 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.8rem;
|
||||
margin: 0.875rem 0 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -1,204 +1,215 @@
|
||||
<article class="lease-card" [attr.aria-busy]="deleting() || downloading() || dossierDownloading()">
|
||||
<div class="lease-topline">
|
||||
<span class="status-dot"></span><span>{{ statusLabel() }}</span
|
||||
><strong>{{ lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
|
||||
</div>
|
||||
<h2>{{ apartmentName() }}</h2>
|
||||
<p class="tenant-name">{{ tenantNames() }}</p>
|
||||
@if (revision(); as revision) {
|
||||
<p class="revision-metadata">
|
||||
Révision {{ revision.number }} · modèle {{ revision.templateVersion }}
|
||||
</p>
|
||||
<details class="revision-manifest">
|
||||
<summary>Contenu du dossier ({{ revision.annexes.length }} annexes)</summary>
|
||||
@if (revision.annexes.length) {
|
||||
<ul class="annex-list">
|
||||
@for (annex of revision.annexes; track annex.category + ':' + annex.sourceId) {
|
||||
<li>
|
||||
<strong>{{ annexLabel(annex) }}</strong>
|
||||
<span>{{ annex.fileName }}</span>
|
||||
@if (annex.version) {
|
||||
<span>Version {{ annex.version }}</span>
|
||||
}
|
||||
@if (annex.establishedOn) {
|
||||
<span>Établi le {{ civilDate(annex.establishedOn) }}</span>
|
||||
}
|
||||
@if (annex.expiresOn) {
|
||||
<span>Expire le {{ civilDate(annex.expiresOn) }}</span>
|
||||
}
|
||||
<span class="annex-hash">Empreinte SHA-256 : {{ annex.sha256 }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>Aucune annexe dans cette révision.</p>
|
||||
}
|
||||
@if (revision.warnings.length) {
|
||||
<div class="revision-warnings" role="status">
|
||||
<strong>Avertissements du dossier</strong>
|
||||
<ul>
|
||||
@for (warning of revision.warnings; track warning.code + ':' + warning.ruleVersion) {
|
||||
<li>
|
||||
{{ warningLabel(warning.code) }}
|
||||
<span>Version de règle : {{ warning.ruleVersion }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>{{ lease().leaseType === 'STANDARD' ? 'Période initiale' : 'Période' }}</dt>
|
||||
<dd>
|
||||
{{ civilDate(lease().startDate) }} →
|
||||
{{ civilDate(lease().endDate) }}
|
||||
@if (lease().leaseType === 'STANDARD') {
|
||||
· bail reconductible
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ lease().monthlyCharges.toLocaleString('fr-FR') }} € / mois</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dépôt</dt>
|
||||
<dd>{{ lease().securityDeposit.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (lease().id; as leaseId) {
|
||||
@if (lease().status === 'ACTIVE' && lease().legalTerms.rentRevision.enabled) {
|
||||
<button
|
||||
type="button"
|
||||
class="open-rent-revision"
|
||||
[attr.aria-expanded]="rentRevisionOpen()"
|
||||
(click)="toggleRentRevision()"
|
||||
>
|
||||
{{ rentRevisionOpen() ? 'Masquer la révision du loyer' : 'Réviser le loyer selon l’IRL' }}
|
||||
</button>
|
||||
@if (rentRevisionOpen()) {
|
||||
<app-rent-revision-preview [leaseId]="leaseId" />
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (lease().guarantors.length) {
|
||||
<div class="guarantor-summary">
|
||||
<span>Garant</span>
|
||||
@for (guarantor of lease().guarantors; track $index) {
|
||||
<strong>{{ guarantor.firstName }} {{ guarantor.lastName }}</strong>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (deleteError() || downloadError() || dossierDownloadError()) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ deleteError() || downloadError() || dossierDownloadError() }}
|
||||
</p>
|
||||
}
|
||||
<div class="card-actions" aria-live="polite">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="deleting() || downloading()"
|
||||
(click)="pdfRequested.emit(lease())"
|
||||
>
|
||||
@if (downloading()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
|
||||
} @else {
|
||||
{{ lease().status === 'DRAFT' ? 'Générer / télécharger le bail' : 'Télécharger le bail' }}
|
||||
}
|
||||
</button>
|
||||
@if (lease().id; as leaseId) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="dossier-download"
|
||||
[disabled]="deleting() || dossierDownloading()"
|
||||
(click)="dossierRequested.emit(lease())"
|
||||
>
|
||||
@if (dossierDownloading()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Préparation du dossier…
|
||||
} @else {
|
||||
Télécharger le dossier complet (.zip)
|
||||
}
|
||||
</button>
|
||||
@if (lease().status === 'DRAFT') {
|
||||
<button mat-button type="button" (click)="inventoryRequested.emit(leaseId)">
|
||||
Inventaire du mobilier
|
||||
</button>
|
||||
@if (revision()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'ENTRY' })"
|
||||
>
|
||||
État des lieux d’entrée
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="deleting()"
|
||||
(click)="editRequested.emit(lease())"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="deleting()"
|
||||
(click)="finalizationRequested.emit(leaseId)"
|
||||
>
|
||||
Finaliser le bail
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
[disabled]="deleting() || downloading() || dossierDownloading()"
|
||||
(click)="deleteRequested.emit(leaseId)"
|
||||
>
|
||||
@if (deleting()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
|
||||
} @else {
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
} @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'ENTRY' })"
|
||||
>
|
||||
État des lieux d’entrée
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'EXIT' })"
|
||||
>
|
||||
État des lieux de sortie
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="lease-end-action"
|
||||
(click)="leaseEndRequested.emit(leaseId)"
|
||||
>
|
||||
{{
|
||||
lease().status === 'ACTIVE' && lease().tenantIds.length === 1
|
||||
? 'Gérer la fin du bail'
|
||||
: 'Voir la fin du bail'
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (lease().status === 'ACTIVE' && lease().tenantIds.length > 1) {
|
||||
<p class="lease-end-unsupported" role="note">
|
||||
La fin de bail en colocation n’est pas encore prise en charge. Aucun congé ni aucune clôture
|
||||
ne peut être enregistré ici.
|
||||
</p>
|
||||
}
|
||||
<div class="lease-topline">
|
||||
<span class="status-dot"></span><span>{{ statusLabel() }}</span
|
||||
><strong>{{ lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
|
||||
</div>
|
||||
<h2>{{ apartmentName() }}</h2>
|
||||
<p class="tenant-name">{{ tenantNames() }}</p>
|
||||
@if (revision(); as revision) {
|
||||
<p class="revision-metadata">
|
||||
Révision {{ revision.number }} · modèle {{ revision.templateVersion }}
|
||||
</p>
|
||||
<details class="revision-manifest">
|
||||
<summary>Contenu du dossier ({{ revision.annexes.length }} annexes)</summary>
|
||||
@if (revision.annexes.length) {
|
||||
<ul class="annex-list">
|
||||
@for (annex of revision.annexes; track annex.category + ':' + annex.sourceId) {
|
||||
<li>
|
||||
<strong>{{ annexLabel(annex) }}</strong>
|
||||
<span>{{ annex.fileName }}</span>
|
||||
@if (annex.version) {
|
||||
<span>Version {{ annex.version }}</span>
|
||||
}
|
||||
@if (annex.establishedOn) {
|
||||
<span>Établi le {{ civilDate(annex.establishedOn) }}</span>
|
||||
}
|
||||
@if (annex.expiresOn) {
|
||||
<span>Expire le {{ civilDate(annex.expiresOn) }}</span>
|
||||
}
|
||||
<span class="annex-hash">Empreinte SHA-256 : {{ annex.sha256 }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>Aucune annexe dans cette révision.</p>
|
||||
}
|
||||
@if (revision.warnings.length) {
|
||||
<div class="revision-warnings" role="status">
|
||||
<strong>Avertissements du dossier</strong>
|
||||
<ul>
|
||||
@for (
|
||||
warning of revision.warnings;
|
||||
track warning.code + ':' + warning.ruleVersion
|
||||
) {
|
||||
<li>
|
||||
{{ warningLabel(warning.code) }}
|
||||
<span>Version de règle : {{ warning.ruleVersion }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>{{ lease().leaseType === 'STANDARD' ? 'Période initiale' : 'Période' }}</dt>
|
||||
<dd>
|
||||
{{ civilDate(lease().startDate) }} →
|
||||
{{ civilDate(lease().endDate) }}
|
||||
@if (lease().leaseType === 'STANDARD') {
|
||||
· bail reconductible
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ lease().monthlyCharges.toLocaleString('fr-FR') }} € / mois</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dépôt</dt>
|
||||
<dd>{{ lease().securityDeposit.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (lease().id; as leaseId) {
|
||||
@if (lease().status === 'ACTIVE' && lease().legalTerms.rentRevision.enabled) {
|
||||
<button
|
||||
type="button"
|
||||
class="open-rent-revision"
|
||||
[attr.aria-expanded]="rentRevisionOpen()"
|
||||
(click)="toggleRentRevision()"
|
||||
>
|
||||
{{
|
||||
rentRevisionOpen()
|
||||
? 'Masquer la révision du loyer'
|
||||
: 'Réviser le loyer selon l’IRL'
|
||||
}}
|
||||
</button>
|
||||
@if (rentRevisionOpen()) {
|
||||
<app-rent-revision-preview [leaseId]="leaseId" />
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (lease().guarantors.length) {
|
||||
<div class="guarantor-summary">
|
||||
<span>Garant</span>
|
||||
@for (guarantor of lease().guarantors; track $index) {
|
||||
<strong>{{ guarantor.firstName }} {{ guarantor.lastName }}</strong>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (deleteError() || downloadError() || dossierDownloadError()) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ deleteError() || downloadError() || dossierDownloadError() }}
|
||||
</p>
|
||||
}
|
||||
<div class="card-actions" aria-live="polite">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="deleting() || downloading()"
|
||||
(click)="pdfRequested.emit(lease())"
|
||||
>
|
||||
@if (downloading()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
|
||||
} @else {
|
||||
{{
|
||||
lease().status === 'DRAFT'
|
||||
? 'Générer / télécharger le bail'
|
||||
: 'Télécharger le bail'
|
||||
}}
|
||||
}
|
||||
</button>
|
||||
@if (lease().id; as leaseId) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="dossier-download"
|
||||
[disabled]="deleting() || dossierDownloading()"
|
||||
(click)="dossierRequested.emit(lease())"
|
||||
>
|
||||
@if (dossierDownloading()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Préparation du dossier…
|
||||
} @else {
|
||||
Télécharger le dossier complet (.zip)
|
||||
}
|
||||
</button>
|
||||
@if (lease().status === 'DRAFT') {
|
||||
<button mat-button type="button" (click)="inventoryRequested.emit(leaseId)">
|
||||
Inventaire du mobilier
|
||||
</button>
|
||||
@if (revision()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'ENTRY' })"
|
||||
>
|
||||
État des lieux d’entrée
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="deleting()"
|
||||
(click)="editRequested.emit(lease())"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="deleting()"
|
||||
(click)="finalizationRequested.emit(leaseId)"
|
||||
>
|
||||
Finaliser le bail
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
[disabled]="deleting() || downloading() || dossierDownloading()"
|
||||
(click)="deleteRequested.emit(leaseId)"
|
||||
>
|
||||
@if (deleting()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
|
||||
} @else {
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
} @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'ENTRY' })"
|
||||
>
|
||||
État des lieux d’entrée
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'EXIT' })"
|
||||
>
|
||||
État des lieux de sortie
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="lease-end-action"
|
||||
(click)="leaseEndRequested.emit(leaseId)"
|
||||
>
|
||||
{{
|
||||
lease().status === 'ACTIVE' && lease().tenantIds.length === 1
|
||||
? 'Gérer la fin du bail'
|
||||
: 'Voir la fin du bail'
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (lease().status === 'ACTIVE' && lease().tenantIds.length > 1) {
|
||||
<p class="lease-end-unsupported" role="note">
|
||||
La fin de bail en colocation n’est pas encore prise en charge. Aucun congé ni aucune
|
||||
clôture ne peut être enregistré ici.
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
@@ -8,346 +8,358 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
|
||||
describe('LeaseCard', () => {
|
||||
it('resolves apartment, tenants, and every status label', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', [
|
||||
{
|
||||
id: 'apartment-1',
|
||||
name: 'T2',
|
||||
address: '',
|
||||
purchasePrice: 0,
|
||||
description: '',
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('tenants', [
|
||||
{
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
},
|
||||
{ id: 'tenant-2', firstName: 'Alex', lastName: 'Durand', email: '', phone: '', address: '' },
|
||||
]);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1', 'missing', 'tenant-2'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
monthlyRent: 0,
|
||||
monthlyCharges: 0,
|
||||
securityDeposit: 0,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
const component = fixture.componentInstance;
|
||||
it('resolves apartment, tenants, and every status label', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', [
|
||||
{
|
||||
id: 'apartment-1',
|
||||
name: 'T2',
|
||||
address: '',
|
||||
purchasePrice: 0,
|
||||
description: '',
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('tenants', [
|
||||
{
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
},
|
||||
{
|
||||
id: 'tenant-2',
|
||||
firstName: 'Alex',
|
||||
lastName: 'Durand',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1', 'missing', 'tenant-2'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
monthlyRent: 0,
|
||||
monthlyCharges: 0,
|
||||
securityDeposit: 0,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.apartmentName()).toBe('T2');
|
||||
expect(component.tenantNames()).toBe('Camille Martin, Alex Durand');
|
||||
expect(component.statusLabel()).toBe('Bail en préparation');
|
||||
fixture.componentRef.setInput('lease', { ...component.lease(), status: 'ACTIVE' });
|
||||
expect(component.statusLabel()).toBe('Bail actif');
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...component.lease(),
|
||||
apartmentId: 'missing',
|
||||
status: 'ARCHIVED',
|
||||
});
|
||||
expect(component.apartmentName()).toBe('Bien inconnu');
|
||||
expect(component.statusLabel()).toBe('Bail archivé');
|
||||
});
|
||||
expect(component.apartmentName()).toBe('T2');
|
||||
expect(component.tenantNames()).toBe('Camille Martin, Alex Durand');
|
||||
expect(component.statusLabel()).toBe('Bail en préparation');
|
||||
fixture.componentRef.setInput('lease', { ...component.lease(), status: 'ACTIVE' });
|
||||
expect(component.statusLabel()).toBe('Bail actif');
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...component.lease(),
|
||||
apartmentId: 'missing',
|
||||
status: 'ARCHIVED',
|
||||
});
|
||||
expect(component.apartmentName()).toBe('Bien inconnu');
|
||||
expect(component.statusLabel()).toBe('Bail archivé');
|
||||
});
|
||||
|
||||
it('shows local Bail PDF download progress and failure', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-07-19',
|
||||
endDate: '2027-07-19',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.componentRef.setInput('downloading', true);
|
||||
fixture.componentRef.setInput('revision', {
|
||||
number: 3,
|
||||
templateVersion: '2026.10',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
annexes: [
|
||||
{
|
||||
category: 'NOTICE',
|
||||
documentType: null,
|
||||
sourceId: '',
|
||||
version: 'FR-NOTICE-2023-03',
|
||||
fileName: 'notice-information.pdf',
|
||||
sha256: 'notice-sha256',
|
||||
establishedOn: null,
|
||||
expiresOn: null,
|
||||
sourceUploadedAt: null,
|
||||
},
|
||||
{
|
||||
category: 'APARTMENT_DOCUMENT',
|
||||
documentType: 'DPE',
|
||||
sourceId: 'document-1',
|
||||
version: '',
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'dpe-sha256',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: '2036-07-19',
|
||||
sourceUploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
warningCodes: ['DPE_EXPIRED', 'CO_OWNERSHIP_RULES_EXTRACT_MISSING'],
|
||||
warnings: [
|
||||
{ code: 'DPE_EXPIRED', ruleVersion: '2026-07-01' },
|
||||
{ code: 'CO_OWNERSHIP_RULES_EXTRACT_MISSING', ruleVersion: '2026-07-01' },
|
||||
],
|
||||
});
|
||||
fixture.componentRef.setInput('dossierDownloading', true);
|
||||
fixture.detectChanges();
|
||||
it('shows local Bail PDF download progress and failure', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-07-19',
|
||||
endDate: '2027-07-19',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.componentRef.setInput('downloading', true);
|
||||
fixture.componentRef.setInput('revision', {
|
||||
number: 3,
|
||||
templateVersion: '2026.10',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
annexes: [
|
||||
{
|
||||
category: 'NOTICE',
|
||||
documentType: null,
|
||||
sourceId: '',
|
||||
version: 'FR-NOTICE-2023-03',
|
||||
fileName: 'notice-information.pdf',
|
||||
sha256: 'notice-sha256',
|
||||
establishedOn: null,
|
||||
expiresOn: null,
|
||||
sourceUploadedAt: null,
|
||||
},
|
||||
{
|
||||
category: 'APARTMENT_DOCUMENT',
|
||||
documentType: 'DPE',
|
||||
sourceId: 'document-1',
|
||||
version: '',
|
||||
fileName: 'dpe.pdf',
|
||||
sha256: 'dpe-sha256',
|
||||
establishedOn: '2026-07-19',
|
||||
expiresOn: '2036-07-19',
|
||||
sourceUploadedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
warningCodes: ['DPE_EXPIRED', 'CO_OWNERSHIP_RULES_EXTRACT_MISSING'],
|
||||
warnings: [
|
||||
{ code: 'DPE_EXPIRED', ruleVersion: '2026-07-01' },
|
||||
{ code: 'CO_OWNERSHIP_RULES_EXTRACT_MISSING', ruleVersion: '2026-07-01' },
|
||||
],
|
||||
});
|
||||
fixture.componentRef.setInput('dossierDownloading', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const download = fixture.nativeElement.querySelector('.card-actions button');
|
||||
expect(download.disabled).toBe(true);
|
||||
expect(download.textContent).toContain('Téléchargement…');
|
||||
const download = fixture.nativeElement.querySelector('.card-actions button');
|
||||
expect(download.disabled).toBe(true);
|
||||
expect(download.textContent).toContain('Téléchargement…');
|
||||
|
||||
fixture.componentRef.setInput('downloading', false);
|
||||
fixture.componentRef.setInput('downloadError', 'Impossible de télécharger ce bail.');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de télécharger ce bail.');
|
||||
expect(fixture.nativeElement.textContent).toContain('Révision 3 · modèle 2026.10');
|
||||
expect(fixture.nativeElement.textContent).toContain('Notice d’information');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Diagnostic de performance énergétique (DPE)',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain('dpe-sha256');
|
||||
expect(fixture.nativeElement.textContent).toContain('19 juillet 2026');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('18 juillet 2026');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Le DPE présent dans le dossier est expiré.',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'L’extrait du règlement de copropriété manque au dossier.',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07-01');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('DPE_EXPIRED');
|
||||
const dossier: HTMLButtonElement = fixture.nativeElement.querySelector('.dossier-download');
|
||||
expect(dossier.disabled).toBe(true);
|
||||
expect(dossier.textContent).toContain('Préparation du dossier…');
|
||||
expect(download.textContent).toContain('Télécharger le bail');
|
||||
fixture.componentRef.setInput('downloading', false);
|
||||
fixture.componentRef.setInput('downloadError', 'Impossible de télécharger ce bail.');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de télécharger ce bail.');
|
||||
expect(fixture.nativeElement.textContent).toContain('Révision 3 · modèle 2026.10');
|
||||
expect(fixture.nativeElement.textContent).toContain('Notice d’information');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Diagnostic de performance énergétique (DPE)',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain('dpe-sha256');
|
||||
expect(fixture.nativeElement.textContent).toContain('19 juillet 2026');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('18 juillet 2026');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Le DPE présent dans le dossier est expiré.',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'L’extrait du règlement de copropriété manque au dossier.',
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07-01');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('DPE_EXPIRED');
|
||||
const dossier: HTMLButtonElement = fixture.nativeElement.querySelector('.dossier-download');
|
||||
expect(dossier.disabled).toBe(true);
|
||||
expect(dossier.textContent).toContain('Préparation du dossier…');
|
||||
expect(download.textContent).toContain('Télécharger le bail');
|
||||
|
||||
fixture.componentRef.setInput('downloadError', '');
|
||||
fixture.componentRef.setInput(
|
||||
'dossierDownloadError',
|
||||
'Impossible de télécharger le dossier complet du bail.',
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de télécharger le dossier complet du bail.',
|
||||
);
|
||||
});
|
||||
fixture.componentRef.setInput('downloadError', '');
|
||||
fixture.componentRef.setInput(
|
||||
'dossierDownloadError',
|
||||
'Impossible de télécharger le dossier complet du bail.',
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de télécharger le dossier complet du bail.',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers draft editing and dedicated finalization actions', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2027-01-01',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.finalizationRequested.subscribe(requested);
|
||||
it('offers draft editing and dedicated finalization actions', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2027-01-01',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.finalizationRequested.subscribe(requested);
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Période initiale 1 janvier 2026 → 1 janvier 2027 · bail reconductible');
|
||||
expect(text).toContain('Générer / télécharger le bail');
|
||||
expect(text).toContain('Modifier');
|
||||
expect(text).toContain('Supprimer');
|
||||
expect(text).toContain('Finaliser le bail');
|
||||
expect(text).not.toContain('État des lieux d’entrée');
|
||||
expect(text).not.toContain('Activer');
|
||||
const finalization = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
|
||||
button.textContent.includes('Finaliser le bail'),
|
||||
);
|
||||
finalization.click();
|
||||
expect(requested).toHaveBeenCalledWith('lease-1');
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain(
|
||||
'Période initiale 1 janvier 2026 → 1 janvier 2027 · bail reconductible',
|
||||
);
|
||||
expect(text).toContain('Générer / télécharger le bail');
|
||||
expect(text).toContain('Modifier');
|
||||
expect(text).toContain('Supprimer');
|
||||
expect(text).toContain('Finaliser le bail');
|
||||
expect(text).not.toContain('État des lieux d’entrée');
|
||||
expect(text).not.toContain('Activer');
|
||||
const finalization = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
|
||||
button.textContent.includes('Finaliser le bail'),
|
||||
);
|
||||
finalization.click();
|
||||
expect(requested).toHaveBeenCalledWith('lease-1');
|
||||
|
||||
fixture.componentRef.setInput('revision', {
|
||||
number: 1,
|
||||
templateVersion: 'FR-MEUBLE-2025-01',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-01-01T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const conditionReportRequested = vi.fn();
|
||||
fixture.componentInstance.conditionReportRequested.subscribe(conditionReportRequested);
|
||||
const entryReport = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
|
||||
button.textContent.includes('État des lieux d’entrée'),
|
||||
);
|
||||
entryReport.click();
|
||||
expect(conditionReportRequested).toHaveBeenCalledWith({ leaseId: 'lease-1', type: 'ENTRY' });
|
||||
fixture.componentRef.setInput('revision', {
|
||||
number: 1,
|
||||
templateVersion: 'FR-MEUBLE-2025-01',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-01-01T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const conditionReportRequested = vi.fn();
|
||||
fixture.componentInstance.conditionReportRequested.subscribe(conditionReportRequested);
|
||||
const entryReport = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
|
||||
button.textContent.includes('État des lieux d’entrée'),
|
||||
);
|
||||
entryReport.click();
|
||||
expect(conditionReportRequested).toHaveBeenCalledWith({
|
||||
leaseId: 'lease-1',
|
||||
type: 'ENTRY',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Modifier');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Supprimer');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Finaliser le bail');
|
||||
});
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Modifier');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Supprimer');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Finaliser le bail');
|
||||
});
|
||||
|
||||
it('manages an active single lease and keeps read access for colocation and archives', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2028-12-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.leaseEndRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
it('manages an active single lease and keeps read access for colocation and archives', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2028-12-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.leaseEndRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
|
||||
const action: HTMLButtonElement = fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(action.textContent).toContain('Gérer la fin du bail');
|
||||
action.click();
|
||||
expect(requested).toHaveBeenCalledWith('lease-1');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Archiver');
|
||||
const action: HTMLButtonElement = fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(action.textContent).toContain('Gérer la fin du bail');
|
||||
action.click();
|
||||
expect(requested).toHaveBeenCalledWith('lease-1');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Archiver');
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
tenantIds: ['tenant-1', 'tenant-2'],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
tenantIds: ['tenant-1', 'tenant-2'],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const colocationAction: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(colocationAction.textContent).toContain('Voir la fin du bail');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La fin de bail en colocation n’est pas encore prise en charge',
|
||||
);
|
||||
const colocationAction: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(colocationAction.textContent).toContain('Voir la fin du bail');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La fin de bail en colocation n’est pas encore prise en charge',
|
||||
);
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ARCHIVED',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-action').textContent).toContain(
|
||||
'Voir la fin du bail',
|
||||
);
|
||||
});
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ARCHIVED',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-action').textContent).toContain(
|
||||
'Voir la fin du bail',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers an IRL preview only for an active lease with the revision clause enabled', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: WorkspaceService, useValue: { acceptRentRevisionApplication: vi.fn() } },
|
||||
],
|
||||
});
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
const initialLegalTerms = emptyLeaseLegalTerms();
|
||||
const legalTerms = {
|
||||
...initialLegalTerms,
|
||||
rentRevision: { ...initialLegalTerms.rentRevision, enabled: true },
|
||||
};
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms,
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-07-19',
|
||||
endDate: '2027-07-19',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
it('offers an IRL preview only for an active lease with the revision clause enabled', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: WorkspaceService, useValue: { acceptRentRevisionApplication: vi.fn() } },
|
||||
],
|
||||
});
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
const initialLegalTerms = emptyLeaseLegalTerms();
|
||||
const legalTerms = {
|
||||
...initialLegalTerms,
|
||||
rentRevision: { ...initialLegalTerms.rentRevision, enabled: true },
|
||||
};
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms,
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-07-19',
|
||||
endDate: '2027-07-19',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).toBeNull();
|
||||
http.expectNone('/api/leases/lease-1/rent-revisions');
|
||||
const openRevision: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.open-rent-revision');
|
||||
expect(openRevision.getAttribute('aria-expanded')).toBe('false');
|
||||
openRevision.click();
|
||||
fixture.detectChanges();
|
||||
expect(openRevision.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).not.toBeNull();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions').flush([]);
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush({});
|
||||
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).toBeNull();
|
||||
http.expectNone('/api/leases/lease-1/rent-revisions');
|
||||
const openRevision: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.open-rent-revision');
|
||||
expect(openRevision.getAttribute('aria-expanded')).toBe('false');
|
||||
openRevision.click();
|
||||
fixture.detectChanges();
|
||||
expect(openRevision.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).not.toBeNull();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions').flush([]);
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush({});
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'DRAFT',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).toBeNull();
|
||||
http.verify();
|
||||
});
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'DRAFT',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-rent-revision-preview')).toBeNull();
|
||||
http.verify();
|
||||
});
|
||||
});
|
||||
@@ -10,68 +10,68 @@ import { leaseWarningDescription } from '../../services/lease-warning-label';
|
||||
import { RentRevisionPreviewComponent } from '../rent-revision-preview/rent-revision-preview';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-card',
|
||||
imports: [MatButtonModule, MatProgressSpinnerModule, RentRevisionPreviewComponent],
|
||||
templateUrl: './lease-card.html',
|
||||
styleUrl: './lease-card.css',
|
||||
selector: 'app-lease-card',
|
||||
imports: [MatButtonModule, MatProgressSpinnerModule, RentRevisionPreviewComponent],
|
||||
templateUrl: './lease-card.html',
|
||||
styleUrl: './lease-card.css',
|
||||
})
|
||||
export class LeaseCard {
|
||||
readonly lease = input.required<Lease>();
|
||||
readonly apartments = input.required<Apartment[]>();
|
||||
readonly tenants = input.required<Tenant[]>();
|
||||
readonly deleting = input(false);
|
||||
readonly deleteError = input('');
|
||||
readonly downloading = input(false);
|
||||
readonly downloadError = input('');
|
||||
readonly dossierDownloading = input(false);
|
||||
readonly dossierDownloadError = input('');
|
||||
readonly revision = input<LeaseRevisionMetadata>();
|
||||
readonly rentDues = input<RentDue[]>([]);
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly pdfRequested = output<Lease>();
|
||||
readonly dossierRequested = output<Lease>();
|
||||
readonly editRequested = output<Lease>();
|
||||
readonly finalizationRequested = output<string>();
|
||||
readonly leaseEndRequested = output<string>();
|
||||
readonly inventoryRequested = output<string>();
|
||||
readonly conditionReportRequested = output<{ leaseId: string; type: 'ENTRY' | 'EXIT' }>();
|
||||
readonly rentRevisionOpen = signal(false);
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly lease = input.required<Lease>();
|
||||
readonly apartments = input.required<Apartment[]>();
|
||||
readonly tenants = input.required<Tenant[]>();
|
||||
readonly deleting = input(false);
|
||||
readonly deleteError = input('');
|
||||
readonly downloading = input(false);
|
||||
readonly downloadError = input('');
|
||||
readonly dossierDownloading = input(false);
|
||||
readonly dossierDownloadError = input('');
|
||||
readonly revision = input<LeaseRevisionMetadata>();
|
||||
readonly rentDues = input<RentDue[]>([]);
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly pdfRequested = output<Lease>();
|
||||
readonly dossierRequested = output<Lease>();
|
||||
readonly editRequested = output<Lease>();
|
||||
readonly finalizationRequested = output<string>();
|
||||
readonly leaseEndRequested = output<string>();
|
||||
readonly inventoryRequested = output<string>();
|
||||
readonly conditionReportRequested = output<{ leaseId: string; type: 'ENTRY' | 'EXIT' }>();
|
||||
readonly rentRevisionOpen = signal(false);
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
toggleRentRevision(): void {
|
||||
this.rentRevisionOpen.update((open) => !open);
|
||||
}
|
||||
toggleRentRevision(): void {
|
||||
this.rentRevisionOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
apartmentName(): string {
|
||||
return (
|
||||
this.apartments().find((apartment) => apartment.id === this.lease().apartmentId)?.name ??
|
||||
'Bien inconnu'
|
||||
);
|
||||
}
|
||||
apartmentName(): string {
|
||||
return (
|
||||
this.apartments().find((apartment) => apartment.id === this.lease().apartmentId)
|
||||
?.name ?? 'Bien inconnu'
|
||||
);
|
||||
}
|
||||
|
||||
tenantNames(): string {
|
||||
return this.lease()
|
||||
.tenantIds.map((id) => this.tenants().find((tenant) => tenant.id === id))
|
||||
.filter((tenant): tenant is Tenant => Boolean(tenant))
|
||||
.map((tenant) => `${tenant.firstName} ${tenant.lastName}`)
|
||||
.join(', ');
|
||||
}
|
||||
tenantNames(): string {
|
||||
return this.lease()
|
||||
.tenantIds.map((id) => this.tenants().find((tenant) => tenant.id === id))
|
||||
.filter((tenant): tenant is Tenant => Boolean(tenant))
|
||||
.map((tenant) => `${tenant.firstName} ${tenant.lastName}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
statusLabel(): string {
|
||||
return { DRAFT: 'Bail en préparation', ACTIVE: 'Bail actif', ARCHIVED: 'Bail archivé' }[
|
||||
this.lease().status
|
||||
];
|
||||
}
|
||||
statusLabel(): string {
|
||||
return { DRAFT: 'Bail en préparation', ACTIVE: 'Bail actif', ARCHIVED: 'Bail archivé' }[
|
||||
this.lease().status
|
||||
];
|
||||
}
|
||||
|
||||
annexLabel(annex: LeaseRevisionMetadata['annexes'][number]): string {
|
||||
return annex.category === 'NOTICE'
|
||||
? 'Notice d’information'
|
||||
: annex.documentType
|
||||
? apartmentDocumentTypeLabel(annex.documentType)
|
||||
: 'Document du logement';
|
||||
}
|
||||
annexLabel(annex: LeaseRevisionMetadata['annexes'][number]): string {
|
||||
return annex.category === 'NOTICE'
|
||||
? 'Notice d’information'
|
||||
: annex.documentType
|
||||
? apartmentDocumentTypeLabel(annex.documentType)
|
||||
: 'Document du logement';
|
||||
}
|
||||
|
||||
warningLabel(code: string): string {
|
||||
return leaseWarningDescription(code);
|
||||
}
|
||||
warningLabel(code: string): string {
|
||||
return leaseWarningDescription(code);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,86 @@
|
||||
:host {
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-heading > span {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.section-heading h2,
|
||||
.section-heading p {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.section-heading h2 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.section-heading p,
|
||||
.catalog-clause p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.local-loading,
|
||||
.local-error,
|
||||
.unavailable-clause,
|
||||
.custom-clause {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.local-loading {
|
||||
min-height: 72px;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
min-height: 72px;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.local-error,
|
||||
.unavailable-clause {
|
||||
justify-content: space-between;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.catalog-clause {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.catalog-clause p {
|
||||
margin: 4px 0 0 32px;
|
||||
margin: 4px 0 0 32px;
|
||||
}
|
||||
.parameter-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 14px 0 0 32px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 14px 0 0 32px;
|
||||
}
|
||||
.parameter-grid mat-form-field,
|
||||
.custom-clause mat-form-field {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.custom-clauses,
|
||||
.validation-error,
|
||||
.unavailable-clause {
|
||||
margin-top: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.custom-clause {
|
||||
align-items: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.custom-clause button {
|
||||
margin-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.parameter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.parameter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1,131 +1,174 @@
|
||||
<section class="clause-editor" aria-labelledby="clause-editor-heading">
|
||||
<div class="section-heading">
|
||||
<span>04</span>
|
||||
<div>
|
||||
<h2 id="clause-editor-heading">Clauses particulières</h2>
|
||||
<p>Sélectionnez une clause proposée ou rédigez une clause personnalisée.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<span>04</span>
|
||||
<div>
|
||||
<h2 id="clause-editor-heading">Clauses particulières</h2>
|
||||
<p>Sélectionnez une clause proposée ou rédigez une clause personnalisée.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="local-loading" role="status">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement des clauses…
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<div class="alert-error local-error" role="alert">
|
||||
<span>{{ loadError() }}</span>
|
||||
<button mat-button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="catalog-clauses">
|
||||
@for (definition of definitions(); track definition.id) {
|
||||
<article class="catalog-clause">
|
||||
<mat-checkbox
|
||||
[checked]="isSelected(definition.id)"
|
||||
(change)="toggleCatalog(definition.id, $event.checked)"
|
||||
>
|
||||
{{ definition.label }}
|
||||
</mat-checkbox>
|
||||
<p>{{ definition.description }}</p>
|
||||
@if (loading()) {
|
||||
<div class="local-loading" role="status">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement des clauses…
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<div class="alert-error local-error" role="alert">
|
||||
<span>{{ loadError() }}</span>
|
||||
<button mat-button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="catalog-clauses">
|
||||
@for (definition of definitions(); track definition.id) {
|
||||
<article class="catalog-clause">
|
||||
<mat-checkbox
|
||||
[checked]="isSelected(definition.id)"
|
||||
(change)="toggleCatalog(definition.id, $event.checked)"
|
||||
>
|
||||
{{ definition.label }}
|
||||
</mat-checkbox>
|
||||
<p>{{ definition.description }}</p>
|
||||
|
||||
@if (isSelected(definition.id) && definition.parameters.length) {
|
||||
<div class="parameter-grid">
|
||||
@for (parameter of definition.parameters; track parameter.name) {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ parameter.label }}</mat-label>
|
||||
@switch (parameter.type) {
|
||||
@case ('CHOICE') {
|
||||
<mat-select
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(selectionChange)="
|
||||
setParameter(definition.id, parameter.name, $event.value)
|
||||
"
|
||||
>
|
||||
@for (choice of parameter.choices; track choice.value) {
|
||||
<mat-option [value]="choice.value">{{ choice.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
}
|
||||
@case ('NUMBER') {
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('DATE') {
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('TIME') {
|
||||
<input
|
||||
matInput
|
||||
type="time"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p class="empty-catalog">Aucune clause proposée pour ce type de bail.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (isSelected(definition.id) && definition.parameters.length) {
|
||||
<div class="parameter-grid">
|
||||
@for (parameter of definition.parameters; track parameter.name) {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ parameter.label }}</mat-label>
|
||||
@switch (parameter.type) {
|
||||
@case ('CHOICE') {
|
||||
<mat-select
|
||||
[required]="parameter.required"
|
||||
[value]="
|
||||
parameterValue(definition.id, parameter.name)
|
||||
"
|
||||
(selectionChange)="
|
||||
setParameter(
|
||||
definition.id,
|
||||
parameter.name,
|
||||
$event.value
|
||||
)
|
||||
"
|
||||
>
|
||||
@for (
|
||||
choice of parameter.choices;
|
||||
track choice.value
|
||||
) {
|
||||
<mat-option [value]="choice.value">{{
|
||||
choice.label
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
}
|
||||
@case ('NUMBER') {
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[required]="parameter.required"
|
||||
[value]="
|
||||
parameterValue(definition.id, parameter.name)
|
||||
"
|
||||
(input)="
|
||||
setParameterFromEvent(
|
||||
definition.id,
|
||||
parameter.name,
|
||||
$event
|
||||
)
|
||||
"
|
||||
/>
|
||||
}
|
||||
@case ('DATE') {
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
[required]="parameter.required"
|
||||
[value]="
|
||||
parameterValue(definition.id, parameter.name)
|
||||
"
|
||||
(input)="
|
||||
setParameterFromEvent(
|
||||
definition.id,
|
||||
parameter.name,
|
||||
$event
|
||||
)
|
||||
"
|
||||
/>
|
||||
}
|
||||
@case ('TIME') {
|
||||
<input
|
||||
matInput
|
||||
type="time"
|
||||
[required]="parameter.required"
|
||||
[value]="
|
||||
parameterValue(definition.id, parameter.name)
|
||||
"
|
||||
(input)="
|
||||
setParameterFromEvent(
|
||||
definition.id,
|
||||
parameter.name,
|
||||
$event
|
||||
)
|
||||
"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
[required]="parameter.required"
|
||||
[value]="
|
||||
parameterValue(definition.id, parameter.name)
|
||||
"
|
||||
(input)="
|
||||
setParameterFromEvent(
|
||||
definition.id,
|
||||
parameter.name,
|
||||
$event
|
||||
)
|
||||
"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p class="empty-catalog">Aucune clause proposée pour ce type de bail.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (item of unavailableCatalogClauses(); track item.index) {
|
||||
@if (!loading()) {
|
||||
<div class="unavailable-clause alert-error">
|
||||
<span>La clause « {{ item.clause.catalogId }} » n'est plus disponible.</span>
|
||||
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@for (item of unavailableCatalogClauses(); track item.index) {
|
||||
@if (!loading()) {
|
||||
<div class="unavailable-clause alert-error">
|
||||
<span>La clause « {{ item.clause.catalogId }} » n'est plus disponible.</span>
|
||||
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="custom-clauses">
|
||||
@for (item of customClauses(); track item.index) {
|
||||
<div class="custom-clause">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Clause personnalisée {{ $index + 1 }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
required
|
||||
[value]="item.clause.customText"
|
||||
(input)="updateCustomClauseFromEvent(item.index, $event)"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
|
||||
</div>
|
||||
}
|
||||
<button mat-stroked-button type="button" (click)="addCustomClause()">
|
||||
Ajouter une clause personnalisée
|
||||
</button>
|
||||
</div>
|
||||
<div class="custom-clauses">
|
||||
@for (item of customClauses(); track item.index) {
|
||||
<div class="custom-clause">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Clause personnalisée {{ $index + 1 }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
required
|
||||
[value]="item.clause.customText"
|
||||
(input)="updateCustomClauseFromEvent(item.index, $event)"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
|
||||
</div>
|
||||
}
|
||||
<button mat-stroked-button type="button" (click)="addCustomClause()">
|
||||
Ajouter une clause personnalisée
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showValidationError() && validationMessage()) {
|
||||
<div class="alert-error validation-error" role="alert">{{ validationMessage() }}</div>
|
||||
}
|
||||
@if (showValidationError() && validationMessage()) {
|
||||
<div class="alert-error validation-error" role="alert">{{ validationMessage() }}</div>
|
||||
}
|
||||
</section>
|
||||
@@ -5,123 +5,129 @@ import { LeaseClauseDefinition, LeaseService } from '../../services/lease.servic
|
||||
import { LeaseClauseEditor } from './lease-clause-editor';
|
||||
|
||||
describe('LeaseClauseEditor', () => {
|
||||
const definitions: LeaseClauseDefinition[] = [
|
||||
{
|
||||
id: 'VISITS',
|
||||
label: 'Visites du logement',
|
||||
description: 'Autorise des visites dans les limites légales.',
|
||||
parameters: [
|
||||
{ name: 'note', label: 'Précision', type: 'TEXT', required: false, choices: [] },
|
||||
{ name: 'hours', label: 'Durée', type: 'NUMBER', required: false, choices: [] },
|
||||
{ name: 'date', label: 'Date', type: 'DATE', required: false, choices: [] },
|
||||
{ name: 'startTime', label: 'Heure de début', type: 'TIME', required: true, choices: [] },
|
||||
{
|
||||
name: 'day',
|
||||
label: 'Jour',
|
||||
type: 'CHOICE',
|
||||
required: false,
|
||||
choices: [{ value: 'MONDAY', label: 'Lundi' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const getLeaseClauses = vi.fn();
|
||||
const definitions: LeaseClauseDefinition[] = [
|
||||
{
|
||||
id: 'VISITS',
|
||||
label: 'Visites du logement',
|
||||
description: 'Autorise des visites dans les limites légales.',
|
||||
parameters: [
|
||||
{ name: 'note', label: 'Précision', type: 'TEXT', required: false, choices: [] },
|
||||
{ name: 'hours', label: 'Durée', type: 'NUMBER', required: false, choices: [] },
|
||||
{ name: 'date', label: 'Date', type: 'DATE', required: false, choices: [] },
|
||||
{
|
||||
name: 'startTime',
|
||||
label: 'Heure de début',
|
||||
type: 'TIME',
|
||||
required: true,
|
||||
choices: [],
|
||||
},
|
||||
{
|
||||
name: 'day',
|
||||
label: 'Jour',
|
||||
type: 'CHOICE',
|
||||
required: false,
|
||||
choices: [{ value: 'MONDAY', label: 'Lundi' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const getLeaseClauses = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getLeaseClauses.mockReturnValue(of(definitions));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: LeaseService, useValue: { getLeaseClauses } }],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getLeaseClauses.mockReturnValue(of(definitions));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: LeaseService, useValue: { getLeaseClauses } }],
|
||||
});
|
||||
});
|
||||
|
||||
function createEditor() {
|
||||
const fixture = TestBed.createComponent(LeaseClauseEditor);
|
||||
fixture.componentRef.setInput('context', 'STANDARD');
|
||||
fixture.componentInstance.specialClausesChange.subscribe((clauses) => {
|
||||
fixture.componentRef.setInput('specialClauses', clauses);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
function createEditor() {
|
||||
const fixture = TestBed.createComponent(LeaseClauseEditor);
|
||||
fixture.componentRef.setInput('context', 'STANDARD');
|
||||
fixture.componentInstance.specialClausesChange.subscribe((clauses) => {
|
||||
fixture.componentRef.setInput('specialClauses', clauses);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('shows local loading content until the catalog arrives', () => {
|
||||
const response = new Subject<LeaseClauseDefinition[]>();
|
||||
getLeaseClauses.mockReturnValue(response);
|
||||
const fixture = createEditor();
|
||||
it('shows local loading content until the catalog arrives', () => {
|
||||
const response = new Subject<LeaseClauseDefinition[]>();
|
||||
getLeaseClauses.mockReturnValue(response);
|
||||
const fixture = createEditor();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Chargement des clauses');
|
||||
expect(fixture.nativeElement.querySelector('[role=status]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Chargement des clauses');
|
||||
expect(fixture.nativeElement.querySelector('[role=status]')).not.toBeNull();
|
||||
|
||||
response.next(definitions);
|
||||
response.complete();
|
||||
fixture.detectChanges();
|
||||
response.next(definitions);
|
||||
response.complete();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
|
||||
expect(fixture.nativeElement.querySelector('[role=status]')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
|
||||
expect(fixture.nativeElement.querySelector('[role=status]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a retryable catalog error', () => {
|
||||
getLeaseClauses
|
||||
.mockReturnValueOnce(throwError(() => new Error('network')))
|
||||
.mockReturnValueOnce(of(definitions));
|
||||
const fixture = createEditor();
|
||||
it('shows a retryable catalog error', () => {
|
||||
getLeaseClauses
|
||||
.mockReturnValueOnce(throwError(() => new Error('network')))
|
||||
.mockReturnValueOnce(of(definitions));
|
||||
const fixture = createEditor();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de charger');
|
||||
fixture.nativeElement.querySelector('.local-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de charger');
|
||||
fixture.nativeElement.querySelector('.local-error button').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getLeaseClauses).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
|
||||
});
|
||||
expect(getLeaseClauses).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
|
||||
});
|
||||
|
||||
it('renders typed parameters and requires mandatory values', () => {
|
||||
const fixture = createEditor();
|
||||
const editor = fixture.componentInstance;
|
||||
editor.toggleCatalog('VISITS', true);
|
||||
fixture.detectChanges();
|
||||
it('renders typed parameters and requires mandatory values', () => {
|
||||
const fixture = createEditor();
|
||||
const editor = fixture.componentInstance;
|
||||
editor.toggleCatalog('VISITS', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input[type=text]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=number]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=date]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=time]').required).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('mat-select')).not.toBeNull();
|
||||
expect(editor.validate()).toBe(false);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Heure de début');
|
||||
expect(fixture.nativeElement.querySelector('input[type=text]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=number]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=date]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=time]').required).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('mat-select')).not.toBeNull();
|
||||
expect(editor.validate()).toBe(false);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Heure de début');
|
||||
|
||||
editor.setParameter('VISITS', 'startTime', '18:00');
|
||||
editor.setParameter('VISITS', 'startTime', '18:00');
|
||||
|
||||
expect(editor.validate()).toBe(true);
|
||||
expect(editor.specialClauses()).toEqual([
|
||||
{ catalogId: 'VISITS', parameters: { startTime: '18:00' }, customText: '' },
|
||||
]);
|
||||
});
|
||||
expect(editor.validate()).toBe(true);
|
||||
expect(editor.specialClauses()).toEqual([
|
||||
{ catalogId: 'VISITS', parameters: { startTime: '18:00' }, customText: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds, validates, edits, and removes a custom clause', () => {
|
||||
const fixture = createEditor();
|
||||
const editor = fixture.componentInstance;
|
||||
it('adds, validates, edits, and removes a custom clause', () => {
|
||||
const fixture = createEditor();
|
||||
const editor = fixture.componentInstance;
|
||||
|
||||
editor.addCustomClause();
|
||||
expect(editor.validate()).toBe(false);
|
||||
editor.updateCustomClause(0, ' Interdiction de fumer. ');
|
||||
editor.addCustomClause();
|
||||
expect(editor.validate()).toBe(false);
|
||||
editor.updateCustomClause(0, ' Interdiction de fumer. ');
|
||||
|
||||
expect(editor.validate()).toBe(true);
|
||||
expect(editor.specialClauses()).toEqual([
|
||||
{ catalogId: null, parameters: {}, customText: ' Interdiction de fumer. ' },
|
||||
]);
|
||||
expect(editor.validate()).toBe(true);
|
||||
expect(editor.specialClauses()).toEqual([
|
||||
{ catalogId: null, parameters: {}, customText: ' Interdiction de fumer. ' },
|
||||
]);
|
||||
|
||||
editor.removeClause(0);
|
||||
expect(editor.specialClauses()).toEqual([]);
|
||||
});
|
||||
editor.removeClause(0);
|
||||
expect(editor.specialClauses()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reloads the catalog when the lease context changes', () => {
|
||||
const fixture = createEditor();
|
||||
it('reloads the catalog when the lease context changes', () => {
|
||||
const fixture = createEditor();
|
||||
|
||||
fixture.componentRef.setInput('context', 'COLOCATION');
|
||||
fixture.detectChanges();
|
||||
fixture.componentRef.setInput('context', 'COLOCATION');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getLeaseClauses).toHaveBeenNthCalledWith(1, 'STANDARD');
|
||||
expect(getLeaseClauses).toHaveBeenNthCalledWith(2, 'COLOCATION');
|
||||
});
|
||||
expect(getLeaseClauses).toHaveBeenNthCalledWith(1, 'STANDARD');
|
||||
expect(getLeaseClauses).toHaveBeenNthCalledWith(2, 'COLOCATION');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -16,173 +16,178 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import {
|
||||
LeaseClauseContext,
|
||||
LeaseClauseDefinition,
|
||||
LeaseService,
|
||||
LeaseSpecialClause,
|
||||
LeaseClauseContext,
|
||||
LeaseClauseDefinition,
|
||||
LeaseService,
|
||||
LeaseSpecialClause,
|
||||
} from '../../services/lease.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-clause-editor',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './lease-clause-editor.html',
|
||||
styleUrl: './lease-clause-editor.css',
|
||||
selector: 'app-lease-clause-editor',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './lease-clause-editor.html',
|
||||
styleUrl: './lease-clause-editor.css',
|
||||
})
|
||||
export class LeaseClauseEditor {
|
||||
private readonly leaseService = inject(LeaseService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private requestId = 0;
|
||||
private readonly leaseService = inject(LeaseService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private requestId = 0;
|
||||
|
||||
readonly context = input.required<LeaseClauseContext>();
|
||||
readonly specialClauses = input<LeaseSpecialClause[]>([]);
|
||||
readonly specialClausesChange = output<LeaseSpecialClause[]>();
|
||||
readonly context = input.required<LeaseClauseContext>();
|
||||
readonly specialClauses = input<LeaseSpecialClause[]>([]);
|
||||
readonly specialClausesChange = output<LeaseSpecialClause[]>();
|
||||
|
||||
readonly definitions = signal<LeaseClauseDefinition[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly showValidationError = signal(false);
|
||||
readonly definitions = signal<LeaseClauseDefinition[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly showValidationError = signal(false);
|
||||
|
||||
readonly customClauses = computed(() =>
|
||||
this.specialClauses()
|
||||
.map((clause, index) => ({ clause, index }))
|
||||
.filter(({ clause }) => clause.catalogId === null),
|
||||
);
|
||||
readonly customClauses = computed(() =>
|
||||
this.specialClauses()
|
||||
.map((clause, index) => ({ clause, index }))
|
||||
.filter(({ clause }) => clause.catalogId === null),
|
||||
);
|
||||
|
||||
readonly unavailableCatalogClauses = computed(() => {
|
||||
const availableIds = new Set(this.definitions().map(({ id }) => id));
|
||||
return this.specialClauses()
|
||||
.map((clause, index) => ({ clause, index }))
|
||||
.filter(({ clause }) => clause.catalogId !== null && !availableIds.has(clause.catalogId));
|
||||
});
|
||||
readonly unavailableCatalogClauses = computed(() => {
|
||||
const availableIds = new Set(this.definitions().map(({ id }) => id));
|
||||
return this.specialClauses()
|
||||
.map((clause, index) => ({ clause, index }))
|
||||
.filter(
|
||||
({ clause }) => clause.catalogId !== null && !availableIds.has(clause.catalogId),
|
||||
);
|
||||
});
|
||||
|
||||
readonly validationMessage = computed(() => {
|
||||
const custom = this.customClauses().find(({ clause }) => !clause.customText.trim());
|
||||
if (custom) return 'Renseignez le texte de chaque clause personnalisée ou retirez-la.';
|
||||
readonly validationMessage = computed(() => {
|
||||
const custom = this.customClauses().find(({ clause }) => !clause.customText.trim());
|
||||
if (custom) return 'Renseignez le texte de chaque clause personnalisée ou retirez-la.';
|
||||
|
||||
const selectedCatalog = this.specialClauses().filter(({ catalogId }) => catalogId !== null);
|
||||
if (selectedCatalog.length && this.loading())
|
||||
return 'Attendez la fin du chargement des clauses.';
|
||||
if (selectedCatalog.length && this.loadError()) {
|
||||
return 'Rechargez le catalogue pour vérifier les clauses sélectionnées.';
|
||||
}
|
||||
const selectedCatalog = this.specialClauses().filter(({ catalogId }) => catalogId !== null);
|
||||
if (selectedCatalog.length && this.loading())
|
||||
return 'Attendez la fin du chargement des clauses.';
|
||||
if (selectedCatalog.length && this.loadError()) {
|
||||
return 'Rechargez le catalogue pour vérifier les clauses sélectionnées.';
|
||||
}
|
||||
|
||||
for (const clause of selectedCatalog) {
|
||||
const definition = this.definitions().find(({ id }) => id === clause.catalogId);
|
||||
if (!definition)
|
||||
return `La clause « ${clause.catalogId} » n'est plus disponible. Retirez-la.`;
|
||||
const missing = definition.parameters.find(
|
||||
(parameter) => parameter.required && !clause.parameters[parameter.name]?.trim(),
|
||||
);
|
||||
if (missing) return `Renseignez « ${missing.label} » pour la clause « ${definition.label} ».`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
for (const clause of selectedCatalog) {
|
||||
const definition = this.definitions().find(({ id }) => id === clause.catalogId);
|
||||
if (!definition)
|
||||
return `La clause « ${clause.catalogId} » n'est plus disponible. Retirez-la.`;
|
||||
const missing = definition.parameters.find(
|
||||
(parameter) => parameter.required && !clause.parameters[parameter.name]?.trim(),
|
||||
);
|
||||
if (missing)
|
||||
return `Renseignez « ${missing.label} » pour la clause « ${definition.label} ».`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.context();
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.context();
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.load();
|
||||
}
|
||||
retry(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
validate(): boolean {
|
||||
this.showValidationError.set(true);
|
||||
return !this.validationMessage();
|
||||
}
|
||||
validate(): boolean {
|
||||
this.showValidationError.set(true);
|
||||
return !this.validationMessage();
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.specialClauses().some(({ catalogId }) => catalogId === id);
|
||||
}
|
||||
isSelected(id: string): boolean {
|
||||
return this.specialClauses().some(({ catalogId }) => catalogId === id);
|
||||
}
|
||||
|
||||
toggleCatalog(id: string, selected: boolean): void {
|
||||
const clauses = this.specialClauses().filter(({ catalogId }) => catalogId !== id);
|
||||
this.emit(selected ? [...clauses, { catalogId: id, parameters: {}, customText: '' }] : clauses);
|
||||
}
|
||||
toggleCatalog(id: string, selected: boolean): void {
|
||||
const clauses = this.specialClauses().filter(({ catalogId }) => catalogId !== id);
|
||||
this.emit(
|
||||
selected ? [...clauses, { catalogId: id, parameters: {}, customText: '' }] : clauses,
|
||||
);
|
||||
}
|
||||
|
||||
parameterValue(catalogId: string, parameterName: string): string {
|
||||
return (
|
||||
this.specialClauses().find((clause) => clause.catalogId === catalogId)?.parameters[
|
||||
parameterName
|
||||
] ?? ''
|
||||
);
|
||||
}
|
||||
parameterValue(catalogId: string, parameterName: string): string {
|
||||
return (
|
||||
this.specialClauses().find((clause) => clause.catalogId === catalogId)?.parameters[
|
||||
parameterName
|
||||
] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
setParameter(catalogId: string, parameterName: string, value: string): void {
|
||||
this.emit(
|
||||
this.specialClauses().map((clause) =>
|
||||
clause.catalogId === catalogId
|
||||
? { ...clause, parameters: { ...clause.parameters, [parameterName]: value } }
|
||||
: clause,
|
||||
),
|
||||
);
|
||||
}
|
||||
setParameter(catalogId: string, parameterName: string, value: string): void {
|
||||
this.emit(
|
||||
this.specialClauses().map((clause) =>
|
||||
clause.catalogId === catalogId
|
||||
? { ...clause, parameters: { ...clause.parameters, [parameterName]: value } }
|
||||
: clause,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setParameterFromEvent(catalogId: string, parameterName: string, event: Event): void {
|
||||
this.setParameter(catalogId, parameterName, this.eventValue(event));
|
||||
}
|
||||
setParameterFromEvent(catalogId: string, parameterName: string, event: Event): void {
|
||||
this.setParameter(catalogId, parameterName, this.eventValue(event));
|
||||
}
|
||||
|
||||
addCustomClause(): void {
|
||||
this.emit([...this.specialClauses(), { catalogId: null, parameters: {}, customText: '' }]);
|
||||
}
|
||||
addCustomClause(): void {
|
||||
this.emit([...this.specialClauses(), { catalogId: null, parameters: {}, customText: '' }]);
|
||||
}
|
||||
|
||||
updateCustomClause(index: number, customText: string): void {
|
||||
this.emit(
|
||||
this.specialClauses().map((clause, clauseIndex) =>
|
||||
clauseIndex === index ? { ...clause, customText } : clause,
|
||||
),
|
||||
);
|
||||
}
|
||||
updateCustomClause(index: number, customText: string): void {
|
||||
this.emit(
|
||||
this.specialClauses().map((clause, clauseIndex) =>
|
||||
clauseIndex === index ? { ...clause, customText } : clause,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
updateCustomClauseFromEvent(index: number, event: Event): void {
|
||||
this.updateCustomClause(index, this.eventValue(event));
|
||||
}
|
||||
updateCustomClauseFromEvent(index: number, event: Event): void {
|
||||
this.updateCustomClause(index, this.eventValue(event));
|
||||
}
|
||||
|
||||
removeClause(index: number): void {
|
||||
this.emit(this.specialClauses().filter((_, clauseIndex) => clauseIndex !== index));
|
||||
}
|
||||
removeClause(index: number): void {
|
||||
this.emit(this.specialClauses().filter((_, clauseIndex) => clauseIndex !== index));
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
const requestId = ++this.requestId;
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.definitions.set([]);
|
||||
this.leaseService
|
||||
.getLeaseClauses(this.context())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (definitions) => {
|
||||
if (requestId !== this.requestId) return;
|
||||
this.definitions.set(definitions);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
if (requestId !== this.requestId) return;
|
||||
this.loading.set(false);
|
||||
this.loadError.set('Impossible de charger les clauses proposées.');
|
||||
},
|
||||
});
|
||||
}
|
||||
private load(): void {
|
||||
const requestId = ++this.requestId;
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.definitions.set([]);
|
||||
this.leaseService
|
||||
.getLeaseClauses(this.context())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (definitions) => {
|
||||
if (requestId !== this.requestId) return;
|
||||
this.definitions.set(definitions);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
if (requestId !== this.requestId) return;
|
||||
this.loading.set(false);
|
||||
this.loadError.set('Impossible de charger les clauses proposées.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emit(clauses: LeaseSpecialClause[]): void {
|
||||
this.showValidationError.set(false);
|
||||
this.specialClausesChange.emit(clauses);
|
||||
}
|
||||
private emit(clauses: LeaseSpecialClause[]): void {
|
||||
this.showValidationError.set(false);
|
||||
this.specialClausesChange.emit(clauses);
|
||||
}
|
||||
|
||||
private eventValue(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
private eventValue(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
}
|
||||
@@ -1,90 +1,90 @@
|
||||
:host,
|
||||
.notice-card,
|
||||
header > div {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.notice-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
h3,
|
||||
h4 {
|
||||
margin-block-start: 0;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.notice-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block: 1.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block: 1.25rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
.evidence-list ul {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-inline-start: 1.25rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
.evidence-attestation {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
.evidence-list li {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block-start: 1.25rem;
|
||||
padding-block-start: 1.25rem;
|
||||
border-block-start: 1px solid var(--border, #dfe3e6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block-start: 1.25rem;
|
||||
padding-block-start: 1.25rem;
|
||||
border-block-start: 1px solid var(--border, #dfe3e6);
|
||||
}
|
||||
fieldset,
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
legend {
|
||||
margin-block-end: 0.75rem;
|
||||
font-weight: 700;
|
||||
margin-block-end: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
@@ -1,192 +1,208 @@
|
||||
<article class="notice-card">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">{{ statusLabel() }}</p>
|
||||
<h3>Congé reçu le {{ civilDate(notice().receivedOn) }}</h3>
|
||||
</div>
|
||||
<span>Départ demandé le {{ civilDate(notice().requestedDepartureOn) }}</span>
|
||||
</header>
|
||||
<dl class="notice-facts">
|
||||
<div>
|
||||
<dt>Remise</dt>
|
||||
<dd>
|
||||
{{ deliveryModeLabel(notice().deliveryMode) }}
|
||||
@if (notice().otherDeliveryMode) {
|
||||
— {{ notice().otherDeliveryMode }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Règle figée</dt>
|
||||
<dd>
|
||||
Préavis retenu : {{ notice().noticeMonths }} mois · fin minimale le
|
||||
{{ civilDate(notice().minimumEndOn) }} · Version de règle : {{ notice().ruleVersion }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Audit</dt>
|
||||
<dd>{{ notice().authorId }} · {{ notice().recordedAt }}</dd>
|
||||
</div>
|
||||
@if (notice().withdrawal; as withdrawal) {
|
||||
<div>
|
||||
<dt>Retrait accepté</dt>
|
||||
<dd>
|
||||
{{ civilDate(withdrawal.acceptedOn) }} · {{ withdrawal.reason }} ·
|
||||
{{ withdrawal.authorId }} · {{ withdrawal.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (notice().invalidation; as invalidation) {
|
||||
<div>
|
||||
<dt>Invalidation administrative</dt>
|
||||
<dd>
|
||||
{{ civilDate(invalidation.invalidatedOn) }} · {{ invalidation.reason }} ·
|
||||
{{ invalidation.authorId }} · {{ invalidation.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@if (notice().recordedWithoutEvidenceConfirmed) {
|
||||
<p class="evidence-attestation">
|
||||
Le bailleur a confirmé que ce congé a été enregistré sans justificatif initial.
|
||||
</p>
|
||||
}
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">{{ statusLabel() }}</p>
|
||||
<h3>Congé reçu le {{ civilDate(notice().receivedOn) }}</h3>
|
||||
</div>
|
||||
<span>Départ demandé le {{ civilDate(notice().requestedDepartureOn) }}</span>
|
||||
</header>
|
||||
<dl class="notice-facts">
|
||||
<div>
|
||||
<dt>Remise</dt>
|
||||
<dd>
|
||||
{{ deliveryModeLabel(notice().deliveryMode) }}
|
||||
@if (notice().otherDeliveryMode) {
|
||||
— {{ notice().otherDeliveryMode }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Règle figée</dt>
|
||||
<dd>
|
||||
Préavis retenu : {{ notice().noticeMonths }} mois · fin minimale le
|
||||
{{ civilDate(notice().minimumEndOn) }} · Version de règle :
|
||||
{{ notice().ruleVersion }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Audit</dt>
|
||||
<dd>{{ notice().authorId }} · {{ notice().recordedAt }}</dd>
|
||||
</div>
|
||||
@if (notice().withdrawal; as withdrawal) {
|
||||
<div>
|
||||
<dt>Retrait accepté</dt>
|
||||
<dd>
|
||||
{{ civilDate(withdrawal.acceptedOn) }} · {{ withdrawal.reason }} ·
|
||||
{{ withdrawal.authorId }} · {{ withdrawal.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (notice().invalidation; as invalidation) {
|
||||
<div>
|
||||
<dt>Invalidation administrative</dt>
|
||||
<dd>
|
||||
{{ civilDate(invalidation.invalidatedOn) }} · {{ invalidation.reason }} ·
|
||||
{{ invalidation.authorId }} · {{ invalidation.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@if (notice().recordedWithoutEvidenceConfirmed) {
|
||||
<p class="evidence-attestation">
|
||||
Le bailleur a confirmé que ce congé a été enregistré sans justificatif initial.
|
||||
</p>
|
||||
}
|
||||
|
||||
<section class="evidence-list" aria-label="Versions des preuves">
|
||||
<h4>Preuves conservées</h4>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<ul>
|
||||
@for (evidence of notice().evidenceVersions; track evidence.version) {
|
||||
<li>
|
||||
<span>
|
||||
Version {{ evidence.version }} · {{ evidence.fileName }}
|
||||
@if (evidence.reason) {
|
||||
— {{ evidence.reason }}
|
||||
}
|
||||
</span>
|
||||
<span>Empreinte SHA-256 : {{ evidence.sha256 }}</span>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="evidence-download"
|
||||
[disabled]="endService.evidenceDownloading(leaseId(), notice().id, evidence.version)"
|
||||
(click)="
|
||||
endService.downloadEvidence(
|
||||
leaseId(),
|
||||
notice().id,
|
||||
evidence.version,
|
||||
evidence.fileName
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
endService.evidenceDownloading(leaseId(), notice().id, evidence.version)
|
||||
? 'Téléchargement…'
|
||||
: 'Télécharger'
|
||||
}}
|
||||
</button>
|
||||
@if (
|
||||
endService.evidenceDownloadError(leaseId(), notice().id, evidence.version);
|
||||
as downloadError
|
||||
) {
|
||||
<span class="operation-error" role="alert">{{ downloadError }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>Aucune preuve téléversée.</p>
|
||||
}
|
||||
</section>
|
||||
<section class="evidence-list" aria-label="Versions des preuves">
|
||||
<h4>Preuves conservées</h4>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<ul>
|
||||
@for (evidence of notice().evidenceVersions; track evidence.version) {
|
||||
<li>
|
||||
<span>
|
||||
Version {{ evidence.version }} · {{ evidence.fileName }}
|
||||
@if (evidence.reason) {
|
||||
— {{ evidence.reason }}
|
||||
}
|
||||
</span>
|
||||
<span>Empreinte SHA-256 : {{ evidence.sha256 }}</span>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="evidence-download"
|
||||
[disabled]="
|
||||
endService.evidenceDownloading(
|
||||
leaseId(),
|
||||
notice().id,
|
||||
evidence.version
|
||||
)
|
||||
"
|
||||
(click)="
|
||||
endService.downloadEvidence(
|
||||
leaseId(),
|
||||
notice().id,
|
||||
evidence.version,
|
||||
evidence.fileName
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
endService.evidenceDownloading(
|
||||
leaseId(),
|
||||
notice().id,
|
||||
evidence.version
|
||||
)
|
||||
? 'Téléchargement…'
|
||||
: 'Télécharger'
|
||||
}}
|
||||
</button>
|
||||
@if (
|
||||
endService.evidenceDownloadError(
|
||||
leaseId(),
|
||||
notice().id,
|
||||
evidence.version
|
||||
);
|
||||
as downloadError
|
||||
) {
|
||||
<span class="operation-error" role="alert">{{ downloadError }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>Aucune preuve téléversée.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (lifecycleMutationsEnabled() || evidenceUploadEnabled()) {
|
||||
<div class="notice-actions">
|
||||
@if (evidenceUploadEnabled()) {
|
||||
<form
|
||||
class="evidence-form"
|
||||
[attr.aria-busy]="evidencePending()"
|
||||
(submit)="submitEvidence(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="evidencePending()">
|
||||
<legend>Ajouter une version de preuve</legend>
|
||||
<label>
|
||||
<span>Fichier (tout format)</span>
|
||||
<input type="file" (change)="selectEvidence($event)" />
|
||||
</label>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<label>
|
||||
<span>Raison de la nouvelle version</span>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="500"
|
||||
[value]="evidenceReason()"
|
||||
(input)="updateEvidenceReason($event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
@if (evidenceError() || evidenceOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ evidenceError() || evidenceOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="evidencePending()">
|
||||
{{ evidencePending() ? 'Ajout…' : 'Ajouter la preuve' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
@if (lifecycleMutationsEnabled() || evidenceUploadEnabled()) {
|
||||
<div class="notice-actions">
|
||||
@if (evidenceUploadEnabled()) {
|
||||
<form
|
||||
class="evidence-form"
|
||||
[attr.aria-busy]="evidencePending()"
|
||||
(submit)="submitEvidence(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="evidencePending()">
|
||||
<legend>Ajouter une version de preuve</legend>
|
||||
<label>
|
||||
<span>Fichier (tout format)</span>
|
||||
<input type="file" (change)="selectEvidence($event)" />
|
||||
</label>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<label>
|
||||
<span>Raison de la nouvelle version</span>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="500"
|
||||
[value]="evidenceReason()"
|
||||
(input)="updateEvidenceReason($event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
@if (evidenceError() || evidenceOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ evidenceError() || evidenceOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="evidencePending()">
|
||||
{{ evidencePending() ? 'Ajout…' : 'Ajouter la preuve' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (lifecycleMutationsEnabled() && notice().status === 'ACTIVE') {
|
||||
<form
|
||||
[formRoot]="withdrawalForm"
|
||||
[attr.aria-busy]="withdrawalPending()"
|
||||
(submit)="submitWithdrawal(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="withdrawalPending()">
|
||||
<legend>Accepter un retrait</legend>
|
||||
<label
|
||||
><span>Date d’acceptation</span
|
||||
><input type="date" [formField]="withdrawalForm.acceptedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
|
||||
</label>
|
||||
@if (withdrawalError() || withdrawalOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ withdrawalError() || withdrawalOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
|
||||
{{ withdrawalPending() ? 'Acceptation…' : 'Accepter le retrait' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form
|
||||
[formRoot]="invalidationForm"
|
||||
[attr.aria-busy]="invalidationPending()"
|
||||
(submit)="submitInvalidation(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="invalidationPending()">
|
||||
<legend>Invalider administrativement</legend>
|
||||
<label
|
||||
><span>Date d’invalidation</span
|
||||
><input type="date" [formField]="invalidationForm.invalidatedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span
|
||||
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
|
||||
</label>
|
||||
@if (invalidationError() || invalidationOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ invalidationError() || invalidationOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
|
||||
{{ invalidationPending() ? 'Invalidation…' : 'Invalider ce congé' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (lifecycleMutationsEnabled() && notice().status === 'ACTIVE') {
|
||||
<form
|
||||
[formRoot]="withdrawalForm"
|
||||
[attr.aria-busy]="withdrawalPending()"
|
||||
(submit)="submitWithdrawal(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="withdrawalPending()">
|
||||
<legend>Accepter un retrait</legend>
|
||||
<label
|
||||
><span>Date d’acceptation</span
|
||||
><input type="date" [formField]="withdrawalForm.acceptedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span
|
||||
><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
|
||||
</label>
|
||||
@if (withdrawalError() || withdrawalOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ withdrawalError() || withdrawalOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
|
||||
{{ withdrawalPending() ? 'Acceptation…' : 'Accepter le retrait' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form
|
||||
[formRoot]="invalidationForm"
|
||||
[attr.aria-busy]="invalidationPending()"
|
||||
(submit)="submitInvalidation(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="invalidationPending()">
|
||||
<legend>Invalider administrativement</legend>
|
||||
<label
|
||||
><span>Date d’invalidation</span
|
||||
><input type="date" [formField]="invalidationForm.invalidatedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span
|
||||
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
|
||||
</label>
|
||||
@if (invalidationError() || invalidationOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ invalidationError() || invalidationOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
|
||||
{{ invalidationPending() ? 'Invalidation…' : 'Invalider ce congé' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
@@ -5,107 +5,107 @@ import { LeaseEndService } from '../../services/lease-end.service';
|
||||
import { LeaseEndNoticeCard } from './lease-end-notice-card';
|
||||
|
||||
describe('LeaseEndNoticeCard', () => {
|
||||
const downloadEvidence = vi.fn();
|
||||
const evidenceDownloading = vi.fn().mockReturnValue(false);
|
||||
const evidenceDownloadError = vi.fn().mockReturnValue('');
|
||||
const notice: LeaseEndNotice = {
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [
|
||||
{
|
||||
version: 1,
|
||||
fileName: 'preuve.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
size: 1200,
|
||||
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
reason: null,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
};
|
||||
const downloadEvidence = vi.fn();
|
||||
const evidenceDownloading = vi.fn().mockReturnValue(false);
|
||||
const evidenceDownloadError = vi.fn().mockReturnValue('');
|
||||
const notice: LeaseEndNotice = {
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [
|
||||
{
|
||||
version: 1,
|
||||
fileName: 'preuve.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
size: 1200,
|
||||
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
reason: null,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: { downloadEvidence, evidenceDownloading, evidenceDownloadError },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeCard);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('notice', notice);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
|
||||
fixture.componentRef.setInput('evidenceUploadEnabled', true);
|
||||
fixture.componentRef.setInput('withdrawalPending', false);
|
||||
fixture.componentRef.setInput('invalidationPending', false);
|
||||
fixture.componentRef.setInput('evidencePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
function createFixture() {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: { downloadEvidence, evidenceDownloading, evidenceDownloadError },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeCard);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('notice', notice);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
|
||||
fixture.componentRef.setInput('evidenceUploadEnabled', true);
|
||||
fixture.componentRef.setInput('withdrawalPending', false);
|
||||
fixture.componentRef.setInput('invalidationPending', false);
|
||||
fixture.componentRef.setInput('evidencePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('shows the rule snapshot and asks a reason from evidence version two', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.evidenceRequested.subscribe(requested);
|
||||
const evidence = new File(['new'], 'preuve-2.txt', { type: 'text/plain' });
|
||||
fixture.componentInstance.evidence.set(evidence);
|
||||
it('shows the rule snapshot and asks a reason from evidence version two', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.evidenceRequested.subscribe(requested);
|
||||
const evidence = new File(['new'], 'preuve-2.txt', { type: 'text/plain' });
|
||||
fixture.componentInstance.evidence.set(evidence);
|
||||
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(fixture.componentInstance.evidenceError()).toContain('raison');
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(fixture.componentInstance.evidenceError()).toContain('raison');
|
||||
|
||||
fixture.componentInstance.evidenceReason.set('Version plus lisible');
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: { timeZone: 'Europe/Paris', evidence, reason: 'Version plus lisible' },
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Préavis retenu : 1 mois');
|
||||
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07');
|
||||
expect(fixture.nativeElement.textContent).toContain('enregistré sans justificatif');
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(500);
|
||||
expect(
|
||||
[...fixture.nativeElement.querySelectorAll('textarea')].map(
|
||||
(textarea: HTMLTextAreaElement) => textarea.maxLength,
|
||||
),
|
||||
).toEqual([500, 500]);
|
||||
});
|
||||
fixture.componentInstance.evidenceReason.set('Version plus lisible');
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: { timeZone: 'Europe/Paris', evidence, reason: 'Version plus lisible' },
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Préavis retenu : 1 mois');
|
||||
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07');
|
||||
expect(fixture.nativeElement.textContent).toContain('enregistré sans justificatif');
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(500);
|
||||
expect(
|
||||
[...fixture.nativeElement.querySelectorAll('textarea')].map(
|
||||
(textarea: HTMLTextAreaElement) => textarea.maxLength,
|
||||
),
|
||||
).toEqual([500, 500]);
|
||||
});
|
||||
|
||||
it('emits dated withdrawal and evidence download intents', () => {
|
||||
const fixture = createFixture();
|
||||
const withdrawal = vi.fn();
|
||||
fixture.componentInstance.withdrawalRequested.subscribe(withdrawal);
|
||||
fixture.componentInstance.withdrawalModel.set({
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
});
|
||||
it('emits dated withdrawal and evidence download intents', () => {
|
||||
const fixture = createFixture();
|
||||
const withdrawal = vi.fn();
|
||||
fixture.componentInstance.withdrawalRequested.subscribe(withdrawal);
|
||||
fixture.componentInstance.withdrawalModel.set({
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitWithdrawal();
|
||||
fixture.nativeElement.querySelector('.evidence-download').click();
|
||||
fixture.componentInstance.submitWithdrawal();
|
||||
fixture.nativeElement.querySelector('.evidence-download').click();
|
||||
|
||||
expect(withdrawal).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
},
|
||||
});
|
||||
expect(downloadEvidence).toHaveBeenCalledWith('lease-1', 'notice-1', 1, 'preuve.jpg');
|
||||
});
|
||||
expect(withdrawal).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
},
|
||||
});
|
||||
expect(downloadEvidence).toHaveBeenCalledWith('lease-1', 'notice-1', 1, 'preuve.jpg');
|
||||
});
|
||||
});
|
||||
@@ -4,137 +4,139 @@ import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
import {
|
||||
AcceptWithdrawalRequest,
|
||||
AppendEvidenceRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndNotice,
|
||||
LeaseEndService,
|
||||
NoticeDeliveryMode,
|
||||
AcceptWithdrawalRequest,
|
||||
AppendEvidenceRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndNotice,
|
||||
LeaseEndService,
|
||||
NoticeDeliveryMode,
|
||||
} from '../../services/lease-end.service';
|
||||
|
||||
export interface NoticeAction<T> {
|
||||
noticeId: string;
|
||||
request: T;
|
||||
noticeId: string;
|
||||
request: T;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-notice-card',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-notice-card.html',
|
||||
styleUrl: './lease-end-notice-card.css',
|
||||
selector: 'app-lease-end-notice-card',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-notice-card.html',
|
||||
styleUrl: './lease-end-notice-card.css',
|
||||
})
|
||||
export class LeaseEndNoticeCard {
|
||||
readonly endService = inject(LeaseEndService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly notice = input.required<LeaseEndNotice>();
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly lifecycleMutationsEnabled = input.required<boolean>();
|
||||
readonly evidenceUploadEnabled = input.required<boolean>();
|
||||
readonly withdrawalPending = input(false);
|
||||
readonly invalidationPending = input(false);
|
||||
readonly evidencePending = input(false);
|
||||
readonly withdrawalOperationError = input('');
|
||||
readonly invalidationOperationError = input('');
|
||||
readonly evidenceOperationError = input('');
|
||||
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
|
||||
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
|
||||
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
|
||||
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
|
||||
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
|
||||
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly invalidationForm = form(this.invalidationModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly evidenceReason = signal('');
|
||||
readonly withdrawalError = signal('');
|
||||
readonly invalidationError = signal('');
|
||||
readonly evidenceError = signal('');
|
||||
readonly endService = inject(LeaseEndService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly notice = input.required<LeaseEndNotice>();
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly lifecycleMutationsEnabled = input.required<boolean>();
|
||||
readonly evidenceUploadEnabled = input.required<boolean>();
|
||||
readonly withdrawalPending = input(false);
|
||||
readonly invalidationPending = input(false);
|
||||
readonly evidencePending = input(false);
|
||||
readonly withdrawalOperationError = input('');
|
||||
readonly invalidationOperationError = input('');
|
||||
readonly evidenceOperationError = input('');
|
||||
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
|
||||
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
|
||||
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
|
||||
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
|
||||
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
|
||||
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly invalidationForm = form(this.invalidationModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly evidenceReason = signal('');
|
||||
readonly withdrawalError = signal('');
|
||||
readonly invalidationError = signal('');
|
||||
readonly evidenceError = signal('');
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
statusLabel(): string {
|
||||
return {
|
||||
ACTIVE: 'Congé en cours',
|
||||
WITHDRAWN: 'Retrait accepté',
|
||||
INVALIDATED: 'Congé invalidé',
|
||||
}[this.notice().status];
|
||||
}
|
||||
statusLabel(): string {
|
||||
return {
|
||||
ACTIVE: 'Congé en cours',
|
||||
WITHDRAWN: 'Retrait accepté',
|
||||
INVALIDATED: 'Congé invalidé',
|
||||
}[this.notice().status];
|
||||
}
|
||||
|
||||
deliveryModeLabel(mode: NoticeDeliveryMode): string {
|
||||
return {
|
||||
REGISTERED_LETTER: 'Lettre recommandée avec avis de réception',
|
||||
BAILIFF: 'Acte d’un commissaire de justice',
|
||||
HAND_DELIVERY: 'Remise en main propre contre récépissé',
|
||||
OTHER: 'Autre mode',
|
||||
}[mode];
|
||||
}
|
||||
deliveryModeLabel(mode: NoticeDeliveryMode): string {
|
||||
return {
|
||||
REGISTERED_LETTER: 'Lettre recommandée avec avis de réception',
|
||||
BAILIFF: 'Acte d’un commissaire de justice',
|
||||
HAND_DELIVERY: 'Remise en main propre contre récépissé',
|
||||
OTHER: 'Autre mode',
|
||||
}[mode];
|
||||
}
|
||||
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidence.set(input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null);
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidence.set(
|
||||
input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null,
|
||||
);
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
|
||||
updateEvidenceReason(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidenceReason.set(input instanceof HTMLInputElement ? input.value : '');
|
||||
}
|
||||
updateEvidenceReason(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidenceReason.set(input instanceof HTMLInputElement ? input.value : '');
|
||||
}
|
||||
|
||||
submitWithdrawal(): void {
|
||||
if (this.withdrawalPending()) return;
|
||||
const model = this.withdrawalModel();
|
||||
if (!model.acceptedOn || !model.reason.trim()) {
|
||||
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
|
||||
return;
|
||||
}
|
||||
this.withdrawalError.set('');
|
||||
this.withdrawalRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
acceptedOn: model.acceptedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
submitWithdrawal(): void {
|
||||
if (this.withdrawalPending()) return;
|
||||
const model = this.withdrawalModel();
|
||||
if (!model.acceptedOn || !model.reason.trim()) {
|
||||
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
|
||||
return;
|
||||
}
|
||||
this.withdrawalError.set('');
|
||||
this.withdrawalRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
acceptedOn: model.acceptedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitInvalidation(): void {
|
||||
if (this.invalidationPending()) return;
|
||||
const model = this.invalidationModel();
|
||||
if (!model.invalidatedOn || !model.reason.trim()) {
|
||||
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
|
||||
return;
|
||||
}
|
||||
this.invalidationError.set('');
|
||||
this.invalidationRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
invalidatedOn: model.invalidatedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
submitInvalidation(): void {
|
||||
if (this.invalidationPending()) return;
|
||||
const model = this.invalidationModel();
|
||||
if (!model.invalidatedOn || !model.reason.trim()) {
|
||||
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
|
||||
return;
|
||||
}
|
||||
this.invalidationError.set('');
|
||||
this.invalidationRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
invalidatedOn: model.invalidatedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitEvidence(): void {
|
||||
if (this.evidencePending()) return;
|
||||
const evidence = this.evidence();
|
||||
const reason = this.evidenceReason().trim();
|
||||
if (!evidence) {
|
||||
this.evidenceError.set('Sélectionnez une preuve à conserver.');
|
||||
return;
|
||||
}
|
||||
if (this.notice().evidenceVersions.length > 0 && !reason) {
|
||||
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
|
||||
return;
|
||||
}
|
||||
this.evidenceError.set('');
|
||||
this.evidenceRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
|
||||
});
|
||||
}
|
||||
submitEvidence(): void {
|
||||
if (this.evidencePending()) return;
|
||||
const evidence = this.evidence();
|
||||
const reason = this.evidenceReason().trim();
|
||||
if (!evidence) {
|
||||
this.evidenceError.set('Sélectionnez une preuve à conserver.');
|
||||
return;
|
||||
}
|
||||
if (this.notice().evidenceVersions.length > 0 && !reason) {
|
||||
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
|
||||
return;
|
||||
}
|
||||
this.evidenceError.set('');
|
||||
this.evidenceRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,67 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.notice-form {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.notice-form > p,
|
||||
small {
|
||||
color: var(--text-muted, #5f6368);
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
label:not(.confirmation-field) {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.confirmation-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.confirmation-field input {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.missing-evidence-warning {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
}
|
||||
.missing-evidence-warning p {
|
||||
margin-block-start: 0;
|
||||
color: var(--text-muted, #5f6368);
|
||||
margin-block-start: 0;
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
@@ -1,67 +1,71 @@
|
||||
<section class="notice-form" aria-labelledby="notice-form-title">
|
||||
<h2 id="notice-form-title">Enregistrer un congé</h2>
|
||||
<p>Les dates sont enregistrées comme des dates civiles dans le fuseau {{ timeZone() }}.</p>
|
||||
<form
|
||||
[formRoot]="noticeForm"
|
||||
[attr.aria-busy]="submitting()"
|
||||
(submit)="submit(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="submitting()">
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
<span>Date de réception</span>
|
||||
<input type="date" [formField]="noticeForm.receivedOn" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Départ demandé</span>
|
||||
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Mode de remise</span>
|
||||
<select [formField]="noticeForm.deliveryMode">
|
||||
<option value="">Choisissez un mode</option>
|
||||
@for (mode of deliveryModes; track mode.value) {
|
||||
<option [value]="mode.value">{{ mode.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (model().deliveryMode === 'OTHER') {
|
||||
<label>
|
||||
<span>Mode de remise utilisé</span>
|
||||
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
|
||||
</label>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
|
||||
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
|
||||
</label>
|
||||
}
|
||||
@if (lateRegistration()) {
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
|
||||
<span>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span>
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
<span>Preuve (facultative, tout format)</span>
|
||||
<input class="notice-evidence" type="file" (change)="selectEvidence($event)" />
|
||||
<small>{{ evidence()?.name ?? 'Aucun fichier sélectionné' }}</small>
|
||||
</label>
|
||||
@if (!evidence()) {
|
||||
<div class="missing-evidence-warning">
|
||||
<p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.missingEvidenceConfirmed" />
|
||||
<span>Je confirme enregistrer ce congé sans justificatif.</span>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
@if (validationError() || operationError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() || operationError() }}</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="submitting()">
|
||||
{{ submitting() ? 'Enregistrement…' : 'Enregistrer le congé' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<h2 id="notice-form-title">Enregistrer un congé</h2>
|
||||
<p>Les dates sont enregistrées comme des dates civiles dans le fuseau {{ timeZone() }}.</p>
|
||||
<form
|
||||
[formRoot]="noticeForm"
|
||||
[attr.aria-busy]="submitting()"
|
||||
(submit)="submit(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="submitting()">
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
<span>Date de réception</span>
|
||||
<input type="date" [formField]="noticeForm.receivedOn" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Départ demandé</span>
|
||||
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Mode de remise</span>
|
||||
<select [formField]="noticeForm.deliveryMode">
|
||||
<option value="">Choisissez un mode</option>
|
||||
@for (mode of deliveryModes; track mode.value) {
|
||||
<option [value]="mode.value">{{ mode.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (model().deliveryMode === 'OTHER') {
|
||||
<label>
|
||||
<span>Mode de remise utilisé</span>
|
||||
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
|
||||
</label>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
|
||||
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
|
||||
</label>
|
||||
}
|
||||
@if (lateRegistration()) {
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
|
||||
<span
|
||||
>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span
|
||||
>
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
<span>Preuve (facultative, tout format)</span>
|
||||
<input class="notice-evidence" type="file" (change)="selectEvidence($event)" />
|
||||
<small>{{ evidence()?.name ?? 'Aucun fichier sélectionné' }}</small>
|
||||
</label>
|
||||
@if (!evidence()) {
|
||||
<div class="missing-evidence-warning">
|
||||
<p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.missingEvidenceConfirmed" />
|
||||
<span>Je confirme enregistrer ce congé sans justificatif.</span>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
@if (validationError() || operationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ validationError() || operationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="submitting()">
|
||||
{{ submitting() ? 'Enregistrement…' : 'Enregistrer le congé' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
@@ -3,117 +3,119 @@ import { vi } from 'vitest';
|
||||
import { LeaseEndNoticeForm } from './lease-end-notice-form';
|
||||
|
||||
describe('LeaseEndNoticeForm', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({}));
|
||||
beforeEach(() => TestBed.configureTestingModule({}));
|
||||
|
||||
it('requires explicit confirmations for a late registration and OTHER delivery', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-18',
|
||||
requestedDepartureOn: '2026-08-18',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
it('requires explicit confirmations for a late registration and OTHER delivery', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-18',
|
||||
requestedDepartureOn: '2026-08-18',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
|
||||
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
lateRegistrationConfirmed: true,
|
||||
}));
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('mode de remise non standard');
|
||||
});
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
lateRegistrationConfirmed: true,
|
||||
}));
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain(
|
||||
'mode de remise non standard',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits civil dates, timezone and arbitrary evidence without converting the day', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
missingEvidenceConfirmed: true,
|
||||
}));
|
||||
const evidence = new File(['photo'], 'preuve.jpg', { type: 'image/jpeg' });
|
||||
const input = fixture.nativeElement.querySelector('.notice-evidence');
|
||||
Object.defineProperty(input, 'files', { value: { item: () => evidence } });
|
||||
input.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.model().missingEvidenceConfirmed).toBe(false);
|
||||
expect(fixture.nativeElement.querySelector('.missing-evidence-warning')).toBeNull();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: true,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
it('emits civil dates, timezone and arbitrary evidence without converting the day', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
missingEvidenceConfirmed: true,
|
||||
}));
|
||||
const evidence = new File(['photo'], 'preuve.jpg', { type: 'image/jpeg' });
|
||||
const input = fixture.nativeElement.querySelector('.notice-evidence');
|
||||
Object.defineProperty(input, 'files', { value: { item: () => evidence } });
|
||||
input.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.model().missingEvidenceConfirmed).toBe(false);
|
||||
expect(fixture.nativeElement.querySelector('.missing-evidence-warning')).toBeNull();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: true,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
fixture.componentInstance.submit();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: true,
|
||||
missingEvidenceConfirmed: false,
|
||||
evidence,
|
||||
});
|
||||
expect(input.getAttribute('accept')).toBeNull();
|
||||
});
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: true,
|
||||
missingEvidenceConfirmed: false,
|
||||
evidence,
|
||||
});
|
||||
expect(input.getAttribute('accept')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires and sends an explicit attestation when no evidence is selected', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
otherDeliveryMode: '',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
it('requires and sends an explicit attestation when no evidence is selected', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
otherDeliveryMode: '',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
|
||||
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
missingEvidenceConfirmed: true,
|
||||
}));
|
||||
fixture.componentInstance.submit();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
});
|
||||
});
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
missingEvidenceConfirmed: true,
|
||||
}));
|
||||
fixture.componentInstance.submit();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,90 +5,92 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-notice-form',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-notice-form.html',
|
||||
styleUrl: './lease-end-notice-form.css',
|
||||
selector: 'app-lease-end-notice-form',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-notice-form.html',
|
||||
styleUrl: './lease-end-notice-form.css',
|
||||
})
|
||||
export class LeaseEndNoticeForm {
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly today = input.required<string>();
|
||||
readonly submitting = input.required<boolean>();
|
||||
readonly operationError = input('');
|
||||
readonly noticeRequested = output<RecordNoticeRequest>();
|
||||
readonly model = signal({
|
||||
receivedOn: '',
|
||||
requestedDepartureOn: '',
|
||||
deliveryMode: '' as NoticeDeliveryMode | '',
|
||||
otherDeliveryMode: '',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
readonly noticeForm = form(this.model, (fields) => {
|
||||
maxLength(fields.otherDeliveryMode, 200);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly validationError = signal('');
|
||||
readonly lateRegistration = computed(
|
||||
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
|
||||
);
|
||||
readonly deliveryModes = [
|
||||
{ value: 'REGISTERED_LETTER', label: 'Lettre recommandée avec avis de réception' },
|
||||
{ value: 'BAILIFF', label: 'Acte d’un commissaire de justice' },
|
||||
{ value: 'HAND_DELIVERY', label: 'Remise en main propre contre récépissé' },
|
||||
{ value: 'OTHER', label: 'Autre mode' },
|
||||
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly today = input.required<string>();
|
||||
readonly submitting = input.required<boolean>();
|
||||
readonly operationError = input('');
|
||||
readonly noticeRequested = output<RecordNoticeRequest>();
|
||||
readonly model = signal({
|
||||
receivedOn: '',
|
||||
requestedDepartureOn: '',
|
||||
deliveryMode: '' as NoticeDeliveryMode | '',
|
||||
otherDeliveryMode: '',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
readonly noticeForm = form(this.model, (fields) => {
|
||||
maxLength(fields.otherDeliveryMode, 200);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly validationError = signal('');
|
||||
readonly lateRegistration = computed(
|
||||
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
|
||||
);
|
||||
readonly deliveryModes = [
|
||||
{ value: 'REGISTERED_LETTER', label: 'Lettre recommandée avec avis de réception' },
|
||||
{ value: 'BAILIFF', label: 'Acte d’un commissaire de justice' },
|
||||
{ value: 'HAND_DELIVERY', label: 'Remise en main propre contre récépissé' },
|
||||
{ value: 'OTHER', label: 'Autre mode' },
|
||||
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
|
||||
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||
this.evidence.set(evidence);
|
||||
if (evidence) {
|
||||
this.model.update((model) => ({ ...model, missingEvidenceConfirmed: false }));
|
||||
}
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||
this.evidence.set(evidence);
|
||||
if (evidence) {
|
||||
this.model.update((model) => ({ ...model, missingEvidenceConfirmed: false }));
|
||||
}
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
const model = this.model();
|
||||
if (!model.receivedOn || !model.requestedDepartureOn || !model.deliveryMode) {
|
||||
this.validationError.set('Renseignez les deux dates et le mode de remise du congé.');
|
||||
return;
|
||||
}
|
||||
if (this.lateRegistration() && !model.lateRegistrationConfirmed) {
|
||||
this.validationError.set('Confirmez cet enregistrement tardif avant de continuer.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.otherDeliveryMode.trim()) {
|
||||
this.validationError.set('Décrivez le mode de remise utilisé.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.nonStandardDeliveryConfirmed) {
|
||||
this.validationError.set('Confirmez le mode de remise non standard avant de continuer.');
|
||||
return;
|
||||
}
|
||||
if (!this.evidence() && !model.missingEvidenceConfirmed) {
|
||||
this.validationError.set(
|
||||
'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.validationError.set('');
|
||||
const evidence = this.evidence();
|
||||
this.noticeRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
receivedOn: model.receivedOn,
|
||||
requestedDepartureOn: model.requestedDepartureOn,
|
||||
deliveryMode: model.deliveryMode,
|
||||
...(model.deliveryMode === 'OTHER'
|
||||
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
|
||||
: {}),
|
||||
lateRegistrationConfirmed: this.lateRegistration(),
|
||||
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
|
||||
missingEvidenceConfirmed: evidence === null,
|
||||
...(evidence ? { evidence } : {}),
|
||||
});
|
||||
}
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
const model = this.model();
|
||||
if (!model.receivedOn || !model.requestedDepartureOn || !model.deliveryMode) {
|
||||
this.validationError.set('Renseignez les deux dates et le mode de remise du congé.');
|
||||
return;
|
||||
}
|
||||
if (this.lateRegistration() && !model.lateRegistrationConfirmed) {
|
||||
this.validationError.set('Confirmez cet enregistrement tardif avant de continuer.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.otherDeliveryMode.trim()) {
|
||||
this.validationError.set('Décrivez le mode de remise utilisé.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.nonStandardDeliveryConfirmed) {
|
||||
this.validationError.set(
|
||||
'Confirmez le mode de remise non standard avant de continuer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.evidence() && !model.missingEvidenceConfirmed) {
|
||||
this.validationError.set(
|
||||
'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.validationError.set('');
|
||||
const evidence = this.evidence();
|
||||
this.noticeRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
receivedOn: model.receivedOn,
|
||||
requestedDepartureOn: model.requestedDepartureOn,
|
||||
deliveryMode: model.deliveryMode,
|
||||
...(model.deliveryMode === 'OTHER'
|
||||
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
|
||||
: {}),
|
||||
lateRegistrationConfirmed: this.lateRegistration(),
|
||||
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
|
||||
missingEvidenceConfirmed: evidence === null,
|
||||
...(evidence ? { evidence } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,58 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.lease-end-page {
|
||||
max-width: 62rem;
|
||||
max-width: 62rem;
|
||||
}
|
||||
.lease-end-skeleton {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.lease-end-skeleton span {
|
||||
min-height: 5rem;
|
||||
border-radius: 1rem;
|
||||
min-height: 5rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.phase-summary,
|
||||
.unsupported-state,
|
||||
.timeline,
|
||||
.correction-warning {
|
||||
min-width: 0;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
min-width: 0;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
.phase-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.phase-summary h2,
|
||||
.timeline h2,
|
||||
.notice-history > h2,
|
||||
.unsupported-state h2 {
|
||||
margin-block-start: 0;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.student-summary {
|
||||
border-color: var(--accent, #236b5d);
|
||||
border-color: var(--accent, #236b5d);
|
||||
}
|
||||
.student-final-month {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-accent, #eaf5f1);
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-accent, #eaf5f1);
|
||||
}
|
||||
.student-final-month span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.student-final-month strong {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.unsupported-state,
|
||||
.correction-warning,
|
||||
@@ -60,37 +60,37 @@
|
||||
.notice-history,
|
||||
app-lease-end-notice-form,
|
||||
app-lease-end-resolution-forms {
|
||||
margin-block-start: 1rem;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
.unsupported-state {
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
}
|
||||
.correction-warning {
|
||||
color: var(--danger, #b42318);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
.timeline ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.timeline li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
border-inline-start: 0.2rem solid var(--accent, #236b5d);
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
border-inline-start: 0.2rem solid var(--accent, #236b5d);
|
||||
}
|
||||
.timeline span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.timeline small {
|
||||
color: var(--text-muted, #5f6368);
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-muted, #5f6368);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice-history {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,152 +1,160 @@
|
||||
<section class="form-page lease-end-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Fin du bail</h1>
|
||||
<p>Suivez les faits datés jusqu’à la restitution effective des clés.</p>
|
||||
</div>
|
||||
</header>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Fin du bail</h1>
|
||||
<p>Suivez les faits datés jusqu’à la restitution effective des clés.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="lease-end-skeleton" aria-label="Chargement de la fin du bail">
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (state(); as endState) {
|
||||
<article class="phase-summary" [class.student-summary]="endState.leaseType === 'STUDENT'">
|
||||
<div>
|
||||
<p class="eyebrow">Situation actuelle</p>
|
||||
<h2>{{ phaseLabel(endState.phase) }}</h2>
|
||||
</div>
|
||||
@if (endState.leaseType === 'STUDENT' && endState.fixedEndOn) {
|
||||
<p class="student-final-month">
|
||||
<span>Dernier mois du bail étudiant</span>
|
||||
<strong>{{ civilMonth(endState.fixedEndOn) }}</strong>
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
@if (loading()) {
|
||||
<div class="lease-end-skeleton" aria-label="Chargement de la fin du bail">
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (state(); as endState) {
|
||||
<article class="phase-summary" [class.student-summary]="endState.leaseType === 'STUDENT'">
|
||||
<div>
|
||||
<p class="eyebrow">Situation actuelle</p>
|
||||
<h2>{{ phaseLabel(endState.phase) }}</h2>
|
||||
</div>
|
||||
@if (endState.leaseType === 'STUDENT' && endState.fixedEndOn) {
|
||||
<p class="student-final-month">
|
||||
<span>Dernier mois du bail étudiant</span>
|
||||
<strong>{{ civilMonth(endState.fixedEndOn) }}</strong>
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
|
||||
@if (!endState.supported) {
|
||||
<article class="unsupported-state" role="status">
|
||||
<h2>Gestion non prise en charge</h2>
|
||||
<p>{{ endState.unsupportedReason ?? 'Ce type de bail ne peut pas être modifié ici.' }}</p>
|
||||
<p>
|
||||
Aucun nouveau fait de fin, retrait ou clôture n’est proposé. Les justificatifs des congés
|
||||
historiques peuvent toujours être complétés.
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
@if (endState.correctionRequired) {
|
||||
<article class="correction-warning" role="alert">
|
||||
<p>
|
||||
Une correction financière est requise : contrepasser le paiement concerné, puis
|
||||
enregistrer le bon paiement.
|
||||
</p>
|
||||
<a mat-stroked-button routerLink="/rent-payments">Corriger les paiements</a>
|
||||
</article>
|
||||
}
|
||||
@if (!endState.supported) {
|
||||
<article class="unsupported-state" role="status">
|
||||
<h2>Gestion non prise en charge</h2>
|
||||
<p>
|
||||
{{
|
||||
endState.unsupportedReason ??
|
||||
'Ce type de bail ne peut pas être modifié ici.'
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
Aucun nouveau fait de fin, retrait ou clôture n’est proposé. Les justificatifs
|
||||
des congés historiques peuvent toujours être complétés.
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
@if (endState.correctionRequired) {
|
||||
<article class="correction-warning" role="alert">
|
||||
<p>
|
||||
Une correction financière est requise : contrepasser le paiement concerné, puis
|
||||
enregistrer le bon paiement.
|
||||
</p>
|
||||
<a mat-stroked-button routerLink="/rent-payments">Corriger les paiements</a>
|
||||
</article>
|
||||
}
|
||||
|
||||
<section class="timeline" aria-labelledby="timeline-title">
|
||||
<h2 id="timeline-title">Chronologie</h2>
|
||||
<ol>
|
||||
@if (endState.fixedEndOn) {
|
||||
<li>
|
||||
<span>Terme fixe du bail étudiant</span
|
||||
><strong>{{ civilDate(endState.fixedEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<li>
|
||||
<span>Congé reçu</span><strong>{{ civilDate(notice.receivedOn) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Départ demandé</span><strong>{{ civilDate(notice.requestedDepartureOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (agreement of endState.agreedEarlyEnds; track agreement.recordedAt) {
|
||||
<li>
|
||||
<span>Fin anticipée convenue</span>
|
||||
<strong>{{ civilDate(agreement.endOn) }}</strong>
|
||||
<small>
|
||||
{{ agreement.reason }} · {{ agreement.authorId }} · {{ agreement.recordedAt }}
|
||||
</small>
|
||||
</li>
|
||||
}
|
||||
@if (endState.effectiveEndOn) {
|
||||
<li>
|
||||
<span>Fin effective retenue</span
|
||||
><strong>{{ civilDate(endState.effectiveEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@if (endState.closure) {
|
||||
<li>
|
||||
<span>Clés restituées</span
|
||||
><strong>{{ civilDate(endState.closure.keysReturnedOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@if (endState.closure) {
|
||||
<p>
|
||||
Clôture confirmée par {{ endState.closure.authorId }} · {{ endState.closure.recordedAt }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
<section class="timeline" aria-labelledby="timeline-title">
|
||||
<h2 id="timeline-title">Chronologie</h2>
|
||||
<ol>
|
||||
@if (endState.fixedEndOn) {
|
||||
<li>
|
||||
<span>Terme fixe du bail étudiant</span
|
||||
><strong>{{ civilDate(endState.fixedEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<li>
|
||||
<span>Congé reçu</span><strong>{{ civilDate(notice.receivedOn) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Départ demandé</span
|
||||
><strong>{{ civilDate(notice.requestedDepartureOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (agreement of endState.agreedEarlyEnds; track agreement.recordedAt) {
|
||||
<li>
|
||||
<span>Fin anticipée convenue</span>
|
||||
<strong>{{ civilDate(agreement.endOn) }}</strong>
|
||||
<small>
|
||||
{{ agreement.reason }} · {{ agreement.authorId }} ·
|
||||
{{ agreement.recordedAt }}
|
||||
</small>
|
||||
</li>
|
||||
}
|
||||
@if (endState.effectiveEndOn) {
|
||||
<li>
|
||||
<span>Fin effective retenue</span
|
||||
><strong>{{ civilDate(endState.effectiveEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@if (endState.closure) {
|
||||
<li>
|
||||
<span>Clés restituées</span
|
||||
><strong>{{ civilDate(endState.closure.keysReturnedOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@if (endState.closure) {
|
||||
<p>
|
||||
Clôture confirmée par {{ endState.closure.authorId }} ·
|
||||
{{ endState.closure.recordedAt }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (endState.notices.length) {
|
||||
<section class="notice-history" aria-labelledby="notice-history-title">
|
||||
<h2 id="notice-history-title">Historique des congés</h2>
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<app-lease-end-notice-card
|
||||
[leaseId]="leaseId"
|
||||
[notice]="notice"
|
||||
[timeZone]="timeZone"
|
||||
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
|
||||
[evidenceUploadEnabled]="true"
|
||||
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
|
||||
[invalidationPending]="operationPending('invalidation:' + notice.id)"
|
||||
[evidencePending]="operationPending('evidence:' + notice.id)"
|
||||
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
|
||||
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
|
||||
[evidenceOperationError]="operationError('evidence:' + notice.id)"
|
||||
(withdrawalRequested)="requestWithdrawal($event)"
|
||||
(invalidationRequested)="requestInvalidation($event)"
|
||||
(evidenceRequested)="appendEvidence($event)"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (endState.notices.length) {
|
||||
<section class="notice-history" aria-labelledby="notice-history-title">
|
||||
<h2 id="notice-history-title">Historique des congés</h2>
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<app-lease-end-notice-card
|
||||
[leaseId]="leaseId"
|
||||
[notice]="notice"
|
||||
[timeZone]="timeZone"
|
||||
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
|
||||
[evidenceUploadEnabled]="true"
|
||||
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
|
||||
[invalidationPending]="operationPending('invalidation:' + notice.id)"
|
||||
[evidencePending]="operationPending('evidence:' + notice.id)"
|
||||
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
|
||||
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
|
||||
[evidenceOperationError]="operationError('evidence:' + notice.id)"
|
||||
(withdrawalRequested)="requestWithdrawal($event)"
|
||||
(invalidationRequested)="requestInvalidation($event)"
|
||||
(evidenceRequested)="appendEvidence($event)"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (canRecordNotice()) {
|
||||
<app-lease-end-notice-form
|
||||
[timeZone]="timeZone"
|
||||
[today]="today"
|
||||
[submitting]="operationPending('notice')"
|
||||
[operationError]="operationError('notice')"
|
||||
(noticeRequested)="recordNotice($event)"
|
||||
/>
|
||||
}
|
||||
@if (canRecordAgreement() || canClose()) {
|
||||
<app-lease-end-resolution-forms
|
||||
[timeZone]="timeZone"
|
||||
[showAgreement]="canRecordAgreement()"
|
||||
[showClosure]="canClose()"
|
||||
[keysReturnedOn]="exitReportDate()"
|
||||
[closureEnabled]="closureReady()"
|
||||
[agreementPending]="operationPending('agreement')"
|
||||
[closurePending]="operationPending('closure')"
|
||||
[agreementOperationError]="operationError('agreement')"
|
||||
[closureOperationError]="operationError('closure')"
|
||||
(agreedEndRequested)="recordAgreement($event)"
|
||||
(closureRequested)="closeLease($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@if (canRecordNotice()) {
|
||||
<app-lease-end-notice-form
|
||||
[timeZone]="timeZone"
|
||||
[today]="today"
|
||||
[submitting]="operationPending('notice')"
|
||||
[operationError]="operationError('notice')"
|
||||
(noticeRequested)="recordNotice($event)"
|
||||
/>
|
||||
}
|
||||
@if (canRecordAgreement() || canClose()) {
|
||||
<app-lease-end-resolution-forms
|
||||
[timeZone]="timeZone"
|
||||
[showAgreement]="canRecordAgreement()"
|
||||
[showClosure]="canClose()"
|
||||
[keysReturnedOn]="exitReportDate()"
|
||||
[closureEnabled]="closureReady()"
|
||||
[agreementPending]="operationPending('agreement')"
|
||||
[closurePending]="operationPending('closure')"
|
||||
[agreementOperationError]="operationError('agreement')"
|
||||
[closureOperationError]="operationError('closure')"
|
||||
(agreedEndRequested)="recordAgreement($event)"
|
||||
(closureRequested)="closeLease($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -4,258 +4,261 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
} from '../../services/lease-end.service';
|
||||
import { LeaseEndPage } from './lease-end-page';
|
||||
|
||||
describe('LeaseEndPage', () => {
|
||||
const state: LeaseEndState = {
|
||||
leaseId: 'lease-1',
|
||||
supported: true,
|
||||
unsupportedReason: null,
|
||||
phase: 'NOTICE_PERIOD',
|
||||
leaseType: 'STUDENT',
|
||||
fixedEndOn: '2026-07-31',
|
||||
effectiveEndOn: '2026-07-31',
|
||||
correctionRequired: false,
|
||||
agreedEarlyEnds: [],
|
||||
agreedEarlyEnd: null,
|
||||
closure: null,
|
||||
exitConditionReport: {
|
||||
occurredOn: '2026-07-31',
|
||||
finalized: true,
|
||||
},
|
||||
notices: [
|
||||
{
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const getState = vi.fn();
|
||||
const recordNotice = vi.fn();
|
||||
const acceptWithdrawal = vi.fn();
|
||||
const invalidateNotice = vi.fn();
|
||||
const appendEvidence = vi.fn();
|
||||
const recordAgreedEarlyEnd = vi.fn();
|
||||
const close = vi.fn();
|
||||
const dialog = { open: vi.fn() };
|
||||
const state: LeaseEndState = {
|
||||
leaseId: 'lease-1',
|
||||
supported: true,
|
||||
unsupportedReason: null,
|
||||
phase: 'NOTICE_PERIOD',
|
||||
leaseType: 'STUDENT',
|
||||
fixedEndOn: '2026-07-31',
|
||||
effectiveEndOn: '2026-07-31',
|
||||
correctionRequired: false,
|
||||
agreedEarlyEnds: [],
|
||||
agreedEarlyEnd: null,
|
||||
closure: null,
|
||||
exitConditionReport: {
|
||||
occurredOn: '2026-07-31',
|
||||
finalized: true,
|
||||
},
|
||||
notices: [
|
||||
{
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const getState = vi.fn();
|
||||
const recordNotice = vi.fn();
|
||||
const acceptWithdrawal = vi.fn();
|
||||
const invalidateNotice = vi.fn();
|
||||
const appendEvidence = vi.fn();
|
||||
const recordAgreedEarlyEnd = vi.fn();
|
||||
const close = vi.fn();
|
||||
const dialog = { open: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getState.mockReturnValue(of(state));
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: {
|
||||
getState,
|
||||
recordNotice,
|
||||
acceptWithdrawal,
|
||||
invalidateNotice,
|
||||
appendEvidence,
|
||||
recordAgreedEarlyEnd,
|
||||
close,
|
||||
evidenceDownloading: vi.fn().mockReturnValue(false),
|
||||
evidenceDownloadError: vi.fn().mockReturnValue(''),
|
||||
downloadEvidence: vi.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getState.mockReturnValue(of(state));
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: {
|
||||
getState,
|
||||
recordNotice,
|
||||
acceptWithdrawal,
|
||||
invalidateNotice,
|
||||
appendEvidence,
|
||||
recordAgreedEarlyEnd,
|
||||
close,
|
||||
evidenceDownloading: vi.fn().mockReturnValue(false),
|
||||
evidenceDownloadError: vi.fn().mockReturnValue(''),
|
||||
downloadEvidence: vi.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a skeleton, then a retryable load error', () => {
|
||||
const response = new Subject<LeaseEndState>();
|
||||
getState.mockReturnValueOnce(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledWith('lease-1', fixture.componentInstance.timeZone);
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-skeleton')).not.toBeNull();
|
||||
it('shows a skeleton, then a retryable load error', () => {
|
||||
const response = new Subject<LeaseEndState>();
|
||||
getState.mockReturnValueOnce(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledWith('lease-1', fixture.componentInstance.timeZone);
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-skeleton')).not.toBeNull();
|
||||
|
||||
response.error({ error: { message: 'Bail introuvable' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
|
||||
response.error({ error: { message: 'Bail introuvable' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
|
||||
|
||||
getState.mockReturnValueOnce(of(state));
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
getState.mockReturnValueOnce(of(state));
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders the authoritative phase, civil timeline and student final month', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
it('renders the authoritative phase, civil timeline and student final month', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Préavis en cours');
|
||||
expect(text).toContain('19 juillet 2026');
|
||||
expect(text).toContain('Juillet 2026');
|
||||
expect(text).toContain('enregistré sans justificatif initial');
|
||||
expect(text).not.toContain('18 juillet 2026');
|
||||
});
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Préavis en cours');
|
||||
expect(text).toContain('19 juillet 2026');
|
||||
expect(text).toContain('Juillet 2026');
|
||||
expect(text).toContain('enregistré sans justificatif initial');
|
||||
expect(text).not.toContain('18 juillet 2026');
|
||||
});
|
||||
|
||||
it('disables unsupported lifecycle mutations but keeps historical evidence append-only', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
supported: false,
|
||||
unsupportedReason: 'La fin de bail en colocation doit être gérée locataire par locataire.',
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
it('disables unsupported lifecycle mutations but keeps historical evidence append-only', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
supported: false,
|
||||
unsupportedReason:
|
||||
'La fin de bail en colocation doit être gérée locataire par locataire.',
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('doit être gérée locataire par locataire');
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-resolution-forms')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'doit être gérée locataire par locataire',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-resolution-forms')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
});
|
||||
|
||||
it('keeps archived evidence append-only while hiding lifecycle mutations', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
phase: 'ARCHIVED',
|
||||
closure: {
|
||||
keysReturnedOn: '2026-07-31',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-31T18:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
it('keeps archived evidence append-only while hiding lifecycle mutations', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
phase: 'ARCHIVED',
|
||||
closure: {
|
||||
keysReturnedOn: '2026-07-31',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-31T18:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Clôturer le bail');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Clôturer le bail');
|
||||
});
|
||||
|
||||
it('links a financial correction to the existing payment reversal journey', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, correctionRequired: true }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
it('links a financial correction to the existing payment reversal journey', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, correctionRequired: true }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const correction: HTMLAnchorElement =
|
||||
fixture.nativeElement.querySelector('.correction-warning a');
|
||||
expect(fixture.nativeElement.textContent).toContain('contrepasser le paiement concerné');
|
||||
expect(correction.getAttribute('href')).toBe('/rent-payments');
|
||||
});
|
||||
const correction: HTMLAnchorElement =
|
||||
fixture.nativeElement.querySelector('.correction-warning a');
|
||||
expect(fixture.nativeElement.textContent).toContain('contrepasser le paiement concerné');
|
||||
expect(correction.getAttribute('href')).toBe('/rent-payments');
|
||||
});
|
||||
|
||||
it('keeps an earlier agreement actionable before the effective end without offering a new notice', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
notices: [],
|
||||
agreedEarlyEnds: [
|
||||
{
|
||||
endOn: '2026-08-10',
|
||||
reason: 'Premier accord',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
endOn: '2026-07-31',
|
||||
reason: 'Accord avancé',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-12T10:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
it('keeps an earlier agreement actionable before the effective end without offering a new notice', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
notices: [],
|
||||
agreedEarlyEnds: [
|
||||
{
|
||||
endOn: '2026-08-10',
|
||||
reason: 'Premier accord',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
endOn: '2026-07-31',
|
||||
reason: 'Accord avancé',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-12T10:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Premier accord');
|
||||
expect(fixture.nativeElement.textContent).toContain('Accord avancé');
|
||||
expect(fixture.nativeElement.textContent).toContain('owner-1');
|
||||
expect(fixture.nativeElement.textContent).toContain('Enregistrer l’accord');
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Premier accord');
|
||||
expect(fixture.nativeElement.textContent).toContain('Accord avancé');
|
||||
expect(fixture.nativeElement.textContent).toContain('owner-1');
|
||||
expect(fixture.nativeElement.textContent).toContain('Enregistrer l’accord');
|
||||
});
|
||||
|
||||
it('keeps notice loading and failures local without publishing false success', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
|
||||
const response = new Subject<LeaseEndState>();
|
||||
recordNotice.mockReturnValue(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request: RecordNoticeRequest = {
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
};
|
||||
it('keeps notice loading and failures local without publishing false success', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
|
||||
const response = new Subject<LeaseEndState>();
|
||||
recordNotice.mockReturnValue(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request: RecordNoticeRequest = {
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
};
|
||||
|
||||
fixture.componentInstance.recordNotice(request);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form').textContent).toContain(
|
||||
'Enregistrement…',
|
||||
);
|
||||
fixture.componentInstance.recordNotice(request);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-lease-end-notice-form').textContent,
|
||||
).toContain('Enregistrement…');
|
||||
|
||||
response.error({ error: { message: 'Congé refusé' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Congé refusé');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
|
||||
});
|
||||
response.error({ error: { message: 'Congé refusé' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Congé refusé');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
|
||||
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request = {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
};
|
||||
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
|
||||
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request = {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
};
|
||||
|
||||
fixture.componentInstance.requestWithdrawal({ noticeId: 'notice-1', request });
|
||||
fixture.componentInstance.requestWithdrawal({ noticeId: 'notice-1', request });
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(acceptWithdrawal).toHaveBeenCalledWith('lease-1', 'notice-1', request);
|
||||
});
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(acceptWithdrawal).toHaveBeenCalledWith('lease-1', 'notice-1', request);
|
||||
});
|
||||
|
||||
it('keeps a closure failure actionable', () => {
|
||||
close.mockReturnValue(throwError(() => ({ error: { message: 'Clôture refusée' } })));
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'AWAITING_CLOSURE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
it('keeps a closure failure actionable', () => {
|
||||
close.mockReturnValue(throwError(() => ({ error: { message: 'Clôture refusée' } })));
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'AWAITING_CLOSURE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.closeLease({
|
||||
timeZone: 'Europe/Paris',
|
||||
confirmed: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.closeLease({
|
||||
timeZone: 'Europe/Paris',
|
||||
confirmed: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Clôture refusée');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('AWAITING_CLOSURE');
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Clôture refusée');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('AWAITING_CLOSURE');
|
||||
});
|
||||
});
|
||||
@@ -5,240 +5,242 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Observable, finalize } from 'rxjs';
|
||||
import {
|
||||
browserTimeZone,
|
||||
formatFrenchCivilDate,
|
||||
formatFrenchCivilMonth,
|
||||
todayInTimeZone,
|
||||
browserTimeZone,
|
||||
formatFrenchCivilDate,
|
||||
formatFrenchCivilMonth,
|
||||
todayInTimeZone,
|
||||
} from '../../services/civil-date';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { withoutRecordKey } from '../../services/immutable-state';
|
||||
import {
|
||||
AgreedEarlyEndRequest,
|
||||
AppendEvidenceRequest,
|
||||
CloseLeaseRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndPhase,
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
AcceptWithdrawalRequest,
|
||||
AgreedEarlyEndRequest,
|
||||
AppendEvidenceRequest,
|
||||
CloseLeaseRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndPhase,
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
AcceptWithdrawalRequest,
|
||||
} from '../../services/lease-end.service';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
} from '../confirmation-dialog/confirmation-dialog';
|
||||
import { LeaseEndNoticeForm } from '../lease-end-notice-form/lease-end-notice-form';
|
||||
import { LeaseEndNoticeCard, NoticeAction } from '../lease-end-notice-card/lease-end-notice-card';
|
||||
import { LeaseEndResolutionForms } from '../lease-end-resolution-forms/lease-end-resolution-forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-page',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
RouterLink,
|
||||
LeaseEndNoticeForm,
|
||||
LeaseEndNoticeCard,
|
||||
LeaseEndResolutionForms,
|
||||
],
|
||||
templateUrl: './lease-end-page.html',
|
||||
styleUrl: './lease-end-page.css',
|
||||
selector: 'app-lease-end-page',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
RouterLink,
|
||||
LeaseEndNoticeForm,
|
||||
LeaseEndNoticeCard,
|
||||
LeaseEndResolutionForms,
|
||||
],
|
||||
templateUrl: './lease-end-page.html',
|
||||
styleUrl: './lease-end-page.css',
|
||||
})
|
||||
export class LeaseEndPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly endService = inject(LeaseEndService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly endService = inject(LeaseEndService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly timeZone = browserTimeZone;
|
||||
readonly today = todayInTimeZone(this.timeZone);
|
||||
readonly state = signal<LeaseEndState | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly operationErrors = signal<Record<string, string>>({});
|
||||
readonly lifecycleMutationsEnabled = computed(() => {
|
||||
const state = this.state();
|
||||
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
|
||||
});
|
||||
readonly canRecordNotice = computed(
|
||||
() =>
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
this.state()?.phase === 'ACTIVE' &&
|
||||
!this.state()?.notices.some((notice) => notice.status === 'ACTIVE'),
|
||||
);
|
||||
readonly canRecordAgreement = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'ACTIVE' || state.phase === 'NOTICE_PERIOD')
|
||||
);
|
||||
});
|
||||
readonly canClose = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'AWAITING_CLOSURE' || state.phase === 'UNAUTHORIZED_OCCUPANCY')
|
||||
);
|
||||
});
|
||||
readonly exitReportDate = computed(() => {
|
||||
const report = this.state()?.exitConditionReport;
|
||||
return report?.finalized ? report.occurredOn : null;
|
||||
});
|
||||
readonly closureReady = computed(() => this.canClose() && !!this.exitReportDate());
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly timeZone = browserTimeZone;
|
||||
readonly today = todayInTimeZone(this.timeZone);
|
||||
readonly state = signal<LeaseEndState | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly operationErrors = signal<Record<string, string>>({});
|
||||
readonly lifecycleMutationsEnabled = computed(() => {
|
||||
const state = this.state();
|
||||
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
|
||||
});
|
||||
readonly canRecordNotice = computed(
|
||||
() =>
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
this.state()?.phase === 'ACTIVE' &&
|
||||
!this.state()?.notices.some((notice) => notice.status === 'ACTIVE'),
|
||||
);
|
||||
readonly canRecordAgreement = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'ACTIVE' || state.phase === 'NOTICE_PERIOD')
|
||||
);
|
||||
});
|
||||
readonly canClose = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'AWAITING_CLOSURE' || state.phase === 'UNAUTHORIZED_OCCUPANCY')
|
||||
);
|
||||
});
|
||||
readonly exitReportDate = computed(() => {
|
||||
const report = this.state()?.exitConditionReport;
|
||||
return report?.finalized ? report.occurredOn : null;
|
||||
});
|
||||
readonly closureReady = computed(() => this.canClose() && !!this.exitReportDate());
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly civilMonth = formatFrenchCivilMonth;
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly civilMonth = formatFrenchCivilMonth;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.endService
|
||||
.getState(this.leaseId, this.timeZone)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.loading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.loadError.set(httpErrorMessage(error, 'Impossible de charger la fin de ce bail.')),
|
||||
});
|
||||
}
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.endService
|
||||
.getState(this.leaseId, this.timeZone)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.loading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.loadError.set(
|
||||
httpErrorMessage(error, 'Impossible de charger la fin de ce bail.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
phaseLabel(phase: LeaseEndPhase): string {
|
||||
return {
|
||||
ACTIVE: 'Bail actif',
|
||||
NOTICE_PERIOD: 'Préavis en cours',
|
||||
AWAITING_CLOSURE: 'En attente de clôture',
|
||||
UNAUTHORIZED_OCCUPANCY: 'Occupation sans droit ni titre',
|
||||
ARCHIVED: 'Bail clôturé et archivé',
|
||||
}[phase];
|
||||
}
|
||||
phaseLabel(phase: LeaseEndPhase): string {
|
||||
return {
|
||||
ACTIVE: 'Bail actif',
|
||||
NOTICE_PERIOD: 'Préavis en cours',
|
||||
AWAITING_CLOSURE: 'En attente de clôture',
|
||||
UNAUTHORIZED_OCCUPANCY: 'Occupation sans droit ni titre',
|
||||
ARCHIVED: 'Bail clôturé et archivé',
|
||||
}[phase];
|
||||
}
|
||||
|
||||
recordNotice(request: RecordNoticeRequest): void {
|
||||
this.mutate(
|
||||
'notice',
|
||||
this.endService.recordNotice(this.leaseId, request),
|
||||
'Impossible d’enregistrer ce congé.',
|
||||
);
|
||||
}
|
||||
recordNotice(request: RecordNoticeRequest): void {
|
||||
this.mutate(
|
||||
'notice',
|
||||
this.endService.recordNotice(this.leaseId, request),
|
||||
'Impossible d’enregistrer ce congé.',
|
||||
);
|
||||
}
|
||||
|
||||
requestWithdrawal(action: NoticeAction<AcceptWithdrawalRequest>): void {
|
||||
const key = `withdrawal:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Accepter le retrait de ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Accepter le retrait',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.acceptWithdrawal(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’accepter ce retrait.',
|
||||
),
|
||||
);
|
||||
}
|
||||
requestWithdrawal(action: NoticeAction<AcceptWithdrawalRequest>): void {
|
||||
const key = `withdrawal:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Accepter le retrait de ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Accepter le retrait',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.acceptWithdrawal(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’accepter ce retrait.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
requestInvalidation(action: NoticeAction<InvalidateNoticeRequest>): void {
|
||||
const key = `invalidation:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Invalider administrativement ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Invalider le congé',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.invalidateNotice(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’invalider ce congé.',
|
||||
),
|
||||
);
|
||||
}
|
||||
requestInvalidation(action: NoticeAction<InvalidateNoticeRequest>): void {
|
||||
const key = `invalidation:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Invalider administrativement ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Invalider le congé',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.invalidateNotice(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’invalider ce congé.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
appendEvidence(action: NoticeAction<AppendEvidenceRequest>): void {
|
||||
this.mutate(
|
||||
`evidence:${action.noticeId}`,
|
||||
this.endService.appendEvidence(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’ajouter cette preuve.',
|
||||
);
|
||||
}
|
||||
appendEvidence(action: NoticeAction<AppendEvidenceRequest>): void {
|
||||
this.mutate(
|
||||
`evidence:${action.noticeId}`,
|
||||
this.endService.appendEvidence(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’ajouter cette preuve.',
|
||||
);
|
||||
}
|
||||
|
||||
recordAgreement(request: AgreedEarlyEndRequest): void {
|
||||
this.mutate(
|
||||
'agreement',
|
||||
this.endService.recordAgreedEarlyEnd(this.leaseId, request),
|
||||
'Impossible d’enregistrer cette fin anticipée.',
|
||||
);
|
||||
}
|
||||
recordAgreement(request: AgreedEarlyEndRequest): void {
|
||||
this.mutate(
|
||||
'agreement',
|
||||
this.endService.recordAgreedEarlyEnd(this.leaseId, request),
|
||||
'Impossible d’enregistrer cette fin anticipée.',
|
||||
);
|
||||
}
|
||||
|
||||
closeLease(request: CloseLeaseRequest): void {
|
||||
this.mutate(
|
||||
'closure',
|
||||
this.endService.close(this.leaseId, request),
|
||||
'Impossible de clôturer ce bail.',
|
||||
);
|
||||
}
|
||||
closeLease(request: CloseLeaseRequest): void {
|
||||
this.mutate(
|
||||
'closure',
|
||||
this.endService.close(this.leaseId, request),
|
||||
'Impossible de clôturer ce bail.',
|
||||
);
|
||||
}
|
||||
|
||||
operationPending(key: string): boolean {
|
||||
return this.pendingOperations().has(key) || this.confirmingOperations().has(key);
|
||||
}
|
||||
operationPending(key: string): boolean {
|
||||
return this.pendingOperations().has(key) || this.confirmingOperations().has(key);
|
||||
}
|
||||
|
||||
operationError(key: string): string {
|
||||
return this.operationErrors()[key] ?? '';
|
||||
}
|
||||
operationError(key: string): string {
|
||||
return this.operationErrors()[key] ?? '';
|
||||
}
|
||||
|
||||
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
|
||||
if (this.operationPending(key)) return;
|
||||
this.confirmingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
this.confirmingOperations.update((keys) => withoutKey(keys, key));
|
||||
if (confirmed) action();
|
||||
});
|
||||
}
|
||||
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
|
||||
if (this.operationPending(key)) return;
|
||||
this.confirmingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
this.confirmingOperations.update((keys) => withoutKey(keys, key));
|
||||
if (confirmed) action();
|
||||
});
|
||||
}
|
||||
|
||||
private mutate(key: string, request: Observable<LeaseEndState>, fallback: string): void {
|
||||
if (this.pendingOperations().has(key)) return;
|
||||
this.pendingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
request
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.pendingOperations.update((keys) => withoutKey(keys, key))),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.operationErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(error, fallback),
|
||||
})),
|
||||
});
|
||||
}
|
||||
private mutate(key: string, request: Observable<LeaseEndState>, fallback: string): void {
|
||||
if (this.pendingOperations().has(key)) return;
|
||||
this.pendingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
request
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.pendingOperations.update((keys) => withoutKey(keys, key))),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.operationErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(error, fallback),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function withoutKey(keys: ReadonlySet<string>, key: string): ReadonlySet<string> {
|
||||
const next = new Set(keys);
|
||||
next.delete(key);
|
||||
return next;
|
||||
const next = new Set(keys);
|
||||
next.delete(key);
|
||||
return next;
|
||||
}
|
||||
+32
-32
@@ -1,64 +1,64 @@
|
||||
:host,
|
||||
.resolution-card {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.resolution-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.resolution-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
.closure-card {
|
||||
border-color: var(--accent, #236b5d);
|
||||
border-color: var(--accent, #236b5d);
|
||||
}
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
fieldset,
|
||||
label:not(.confirmation-field) {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.confirmation-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.confirmation-field input {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.report-date {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
.report-date span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
+69
-58
@@ -1,61 +1,72 @@
|
||||
<div class="resolution-grid">
|
||||
@if (showAgreement()) {
|
||||
<section class="resolution-card" aria-labelledby="agreement-title">
|
||||
<h2 id="agreement-title">Fin anticipée convenue</h2>
|
||||
<form
|
||||
[formRoot]="agreementForm"
|
||||
[attr.aria-busy]="agreementPending()"
|
||||
(submit)="submitAgreement(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="agreementPending()">
|
||||
<label>
|
||||
<span>Date de fin convenue</span>
|
||||
<input type="date" [formField]="agreementForm.endOn" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Raison de l’accord</span>
|
||||
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
|
||||
</label>
|
||||
@if (agreementError() || agreementOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ agreementError() || agreementOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
|
||||
{{ agreementPending() ? 'Enregistrement…' : 'Enregistrer l’accord' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
@if (showAgreement()) {
|
||||
<section class="resolution-card" aria-labelledby="agreement-title">
|
||||
<h2 id="agreement-title">Fin anticipée convenue</h2>
|
||||
<form
|
||||
[formRoot]="agreementForm"
|
||||
[attr.aria-busy]="agreementPending()"
|
||||
(submit)="submitAgreement(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="agreementPending()">
|
||||
<label>
|
||||
<span>Date de fin convenue</span>
|
||||
<input type="date" [formField]="agreementForm.endOn" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Raison de l’accord</span>
|
||||
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
|
||||
</label>
|
||||
@if (agreementError() || agreementOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ agreementError() || agreementOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
|
||||
{{ agreementPending() ? 'Enregistrement…' : 'Enregistrer l’accord' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (showClosure()) {
|
||||
<section class="resolution-card closure-card" aria-labelledby="closure-title">
|
||||
<h2 id="closure-title">Clôturer le bail</h2>
|
||||
<form
|
||||
[formRoot]="closureForm"
|
||||
[attr.aria-busy]="closurePending()"
|
||||
(submit)="submitClosure(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="closurePending()">
|
||||
<p class="report-date">
|
||||
<span>Date de restitution des clés, d’après l’état des lieux de sortie</span>
|
||||
<strong>{{ keysReturnedOn() ?? 'État des lieux de sortie finalisé requis' }}</strong>
|
||||
</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="closureForm.confirmed" />
|
||||
<span>Je confirme que les clés ont réellement été restituées à cette date.</span>
|
||||
</label>
|
||||
@if (closureError() || closureOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ closureError() || closureOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="closurePending() || !closureEnabled()">
|
||||
{{ closurePending() ? 'Clôture…' : 'Clôturer le bail' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
@if (showClosure()) {
|
||||
<section class="resolution-card closure-card" aria-labelledby="closure-title">
|
||||
<h2 id="closure-title">Clôturer le bail</h2>
|
||||
<form
|
||||
[formRoot]="closureForm"
|
||||
[attr.aria-busy]="closurePending()"
|
||||
(submit)="submitClosure(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="closurePending()">
|
||||
<p class="report-date">
|
||||
<span
|
||||
>Date de restitution des clés, d’après l’état des lieux de sortie</span
|
||||
>
|
||||
<strong>{{
|
||||
keysReturnedOn() ?? 'État des lieux de sortie finalisé requis'
|
||||
}}</strong>
|
||||
</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="closureForm.confirmed" />
|
||||
<span
|
||||
>Je confirme que les clés ont réellement été restituées à cette
|
||||
date.</span
|
||||
>
|
||||
</label>
|
||||
@if (closureError() || closureOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ closureError() || closureOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="closurePending() || !closureEnabled()"
|
||||
>
|
||||
{{ closurePending() ? 'Clôture…' : 'Clôturer le bail' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
+42
-42
@@ -3,51 +3,51 @@ import { vi } from 'vitest';
|
||||
import { LeaseEndResolutionForms } from './lease-end-resolution-forms';
|
||||
|
||||
describe('LeaseEndResolutionForms', () => {
|
||||
function createFixture() {
|
||||
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('agreementPending', false);
|
||||
fixture.componentRef.setInput('closurePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
function createFixture() {
|
||||
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('agreementPending', false);
|
||||
fixture.componentRef.setInput('closurePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('records a reasoned agreed end, including a retroactive civil date', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.agreedEndRequested.subscribe(requested);
|
||||
fixture.componentInstance.agreementModel.set({
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
it('records a reasoned agreed end, including a retroactive civil date', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.agreedEndRequested.subscribe(requested);
|
||||
fixture.componentInstance.agreementModel.set({
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitAgreement();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('textarea').maxLength).toBe(500);
|
||||
});
|
||||
fixture.componentInstance.submitAgreement();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('textarea').maxLength).toBe(500);
|
||||
});
|
||||
|
||||
it('displays the exit report date and only emits closure after explicit confirmation', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.componentRef.setInput('keysReturnedOn', '2026-07-19');
|
||||
fixture.componentRef.setInput('closureEnabled', true);
|
||||
fixture.detectChanges();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.closureRequested.subscribe(requested);
|
||||
fixture.componentInstance.closureModel.set({ confirmed: false });
|
||||
it('displays the exit report date and only emits closure after explicit confirmation', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.componentRef.setInput('keysReturnedOn', '2026-07-19');
|
||||
fixture.componentRef.setInput('closureEnabled', true);
|
||||
fixture.detectChanges();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.closureRequested.subscribe(requested);
|
||||
fixture.componentInstance.closureModel.set({ confirmed: false });
|
||||
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(fixture.componentInstance.closureError()).toContain('Confirmez');
|
||||
expect(fixture.nativeElement.textContent).toContain('2026-07-19');
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(fixture.componentInstance.closureError()).toContain('Confirmez');
|
||||
expect(fixture.nativeElement.textContent).toContain('2026-07-19');
|
||||
|
||||
fixture.componentInstance.closureModel.update((model) => ({ ...model, confirmed: true }));
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
fixture.componentInstance.closureModel.update((model) => ({ ...model, confirmed: true }));
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
+56
-54
@@ -5,62 +5,64 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-resolution-forms',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-resolution-forms.html',
|
||||
styleUrl: './lease-end-resolution-forms.css',
|
||||
selector: 'app-lease-end-resolution-forms',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-resolution-forms.html',
|
||||
styleUrl: './lease-end-resolution-forms.css',
|
||||
})
|
||||
export class LeaseEndResolutionForms {
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly agreementPending = input(false);
|
||||
readonly closurePending = input(false);
|
||||
readonly agreementOperationError = input('');
|
||||
readonly closureOperationError = input('');
|
||||
readonly showAgreement = input(true);
|
||||
readonly showClosure = input(true);
|
||||
readonly keysReturnedOn = input<string | null>(null);
|
||||
readonly closureEnabled = input(false);
|
||||
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
|
||||
readonly closureRequested = output<CloseLeaseRequest>();
|
||||
readonly agreementModel = signal({ endOn: '', reason: '' });
|
||||
readonly closureModel = signal({ confirmed: false });
|
||||
readonly agreementForm = form(this.agreementModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly closureForm = form(this.closureModel);
|
||||
readonly agreementError = signal('');
|
||||
readonly closureError = signal('');
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly agreementPending = input(false);
|
||||
readonly closurePending = input(false);
|
||||
readonly agreementOperationError = input('');
|
||||
readonly closureOperationError = input('');
|
||||
readonly showAgreement = input(true);
|
||||
readonly showClosure = input(true);
|
||||
readonly keysReturnedOn = input<string | null>(null);
|
||||
readonly closureEnabled = input(false);
|
||||
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
|
||||
readonly closureRequested = output<CloseLeaseRequest>();
|
||||
readonly agreementModel = signal({ endOn: '', reason: '' });
|
||||
readonly closureModel = signal({ confirmed: false });
|
||||
readonly agreementForm = form(this.agreementModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly closureForm = form(this.closureModel);
|
||||
readonly agreementError = signal('');
|
||||
readonly closureError = signal('');
|
||||
|
||||
submitAgreement(): void {
|
||||
if (this.agreementPending()) return;
|
||||
const model = this.agreementModel();
|
||||
if (!model.endOn || !model.reason.trim()) {
|
||||
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
|
||||
return;
|
||||
}
|
||||
this.agreementError.set('');
|
||||
this.agreedEndRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
endOn: model.endOn,
|
||||
reason: model.reason.trim(),
|
||||
});
|
||||
}
|
||||
submitAgreement(): void {
|
||||
if (this.agreementPending()) return;
|
||||
const model = this.agreementModel();
|
||||
if (!model.endOn || !model.reason.trim()) {
|
||||
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
|
||||
return;
|
||||
}
|
||||
this.agreementError.set('');
|
||||
this.agreedEndRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
endOn: model.endOn,
|
||||
reason: model.reason.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
submitClosure(): void {
|
||||
if (this.closurePending()) return;
|
||||
const model = this.closureModel();
|
||||
if (!this.keysReturnedOn() || !this.closureEnabled()) {
|
||||
this.closureError.set('Finalisez l’état des lieux de sortie avant de clôturer le bail.');
|
||||
return;
|
||||
}
|
||||
if (!model.confirmed) {
|
||||
this.closureError.set('Confirmez la restitution réelle des clés avant de clôturer.');
|
||||
return;
|
||||
}
|
||||
this.closureError.set('');
|
||||
this.closureRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
submitClosure(): void {
|
||||
if (this.closurePending()) return;
|
||||
const model = this.closureModel();
|
||||
if (!this.keysReturnedOn() || !this.closureEnabled()) {
|
||||
this.closureError.set(
|
||||
'Finalisez l’état des lieux de sortie avant de clôturer le bail.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!model.confirmed) {
|
||||
this.closureError.set('Confirmez la restitution réelle des clés avant de clôturer.');
|
||||
return;
|
||||
}
|
||||
this.closureError.set('');
|
||||
this.closureRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,49 @@
|
||||
:host {
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.section-heading p,
|
||||
.selected-file,
|
||||
.signature-notice {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.file-action,
|
||||
.date-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.date-field input {
|
||||
max-width: 260px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
max-width: 260px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.selected-file,
|
||||
.signature-notice,
|
||||
.operation-error {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.warnings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
.warnings h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
+176
-165
@@ -1,172 +1,183 @@
|
||||
<section aria-labelledby="signed-lease-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="signed-lease-heading">Bail signé</h2>
|
||||
<p>
|
||||
Document attendu : révision {{ revision().number }} · modèle {{ revision().templateVersion }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<h2 id="signed-lease-heading">Bail signé</h2>
|
||||
<p>
|
||||
Document attendu : révision {{ revision().number }} · modèle
|
||||
{{ revision().templateVersion }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form (submit)="submit(); $event.preventDefault()" [attr.aria-busy]="submitting()">
|
||||
<fieldset [disabled]="submitting()">
|
||||
<label class="file-action">
|
||||
<span>Bail signé au format PDF</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
aria-label="Bail signé au format PDF"
|
||||
(change)="selectSignedLease($event)"
|
||||
/>
|
||||
</label>
|
||||
@if (signedLease(); as file) {
|
||||
<p class="selected-file">{{ file.name }}</p>
|
||||
}
|
||||
<form (submit)="submit(); $event.preventDefault()" [attr.aria-busy]="submitting()">
|
||||
<fieldset [disabled]="submitting()">
|
||||
<label class="file-action">
|
||||
<span>Bail signé au format PDF</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
aria-label="Bail signé au format PDF"
|
||||
(change)="selectSignedLease($event)"
|
||||
/>
|
||||
</label>
|
||||
@if (signedLease(); as file) {
|
||||
<p class="selected-file">{{ file.name }}</p>
|
||||
}
|
||||
|
||||
<label class="date-field" for="lastSignatureDate">
|
||||
<span>Date de la dernière signature</span>
|
||||
<input
|
||||
id="lastSignatureDate"
|
||||
type="date"
|
||||
required
|
||||
[value]="lastSignatureDate()"
|
||||
(input)="setLastSignatureDate($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="date-field" for="lastSignatureDate">
|
||||
<span>Date de la dernière signature</span>
|
||||
<input
|
||||
id="lastSignatureDate"
|
||||
type="date"
|
||||
required
|
||||
[value]="lastSignatureDate()"
|
||||
(input)="setLastSignatureDate($event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<mat-checkbox [checked]="allPartiesSigned()" (change)="allPartiesSigned.set($event.checked)">
|
||||
J'atteste que toutes les parties ont signé le PDF transmis.
|
||||
</mat-checkbox>
|
||||
<mat-checkbox [checked]="revisionMatches()" (change)="revisionMatches.set($event.checked)">
|
||||
J'ai vérifié que le PDF signé correspond à la révision {{ revision().number }} · modèle
|
||||
{{ revision().templateVersion }}.
|
||||
</mat-checkbox>
|
||||
<mat-checkbox
|
||||
[checked]="allPartiesSigned()"
|
||||
(change)="allPartiesSigned.set($event.checked)"
|
||||
>
|
||||
J'atteste que toutes les parties ont signé le PDF transmis.
|
||||
</mat-checkbox>
|
||||
<mat-checkbox
|
||||
[checked]="revisionMatches()"
|
||||
(change)="revisionMatches.set($event.checked)"
|
||||
>
|
||||
J'ai vérifié que le PDF signé correspond à la révision {{ revision().number }} ·
|
||||
modèle {{ revision().templateVersion }}.
|
||||
</mat-checkbox>
|
||||
|
||||
@if (warnings().length) {
|
||||
<div class="warnings" aria-labelledby="warnings-heading">
|
||||
<h3 id="warnings-heading">Avertissements à confirmer</h3>
|
||||
@for (warning of warnings(); track warning.code + ':' + warning.ruleVersion) {
|
||||
<mat-checkbox
|
||||
[checked]="confirmedWarningCodes().has(warning.code)"
|
||||
(change)="setWarning(warning.code, $event.checked)"
|
||||
>
|
||||
{{ warningLabel(warning.code) }}
|
||||
<small>Version de règle : {{ warning.ruleVersion }}</small>
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (warnings().length) {
|
||||
<div class="warnings" aria-labelledby="warnings-heading">
|
||||
<h3 id="warnings-heading">Avertissements à confirmer</h3>
|
||||
@for (warning of warnings(); track warning.code + ':' + warning.ruleVersion) {
|
||||
<mat-checkbox
|
||||
[checked]="confirmedWarningCodes().has(warning.code)"
|
||||
(change)="setWarning(warning.code, $event.checked)"
|
||||
>
|
||||
{{ warningLabel(warning.code) }}
|
||||
<small>Version de règle : {{ warning.ruleVersion }}</small>
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="signature-notice">
|
||||
L'application conserve le document et vos attestations ; elle ne vérifie pas les signatures
|
||||
contenues dans le PDF.
|
||||
</p>
|
||||
@if (!guaranteesComplete()) {
|
||||
<p class="operation-error" role="alert">
|
||||
Téléversez tous les actes de cautionnement requis avant de finaliser.
|
||||
</p>
|
||||
}
|
||||
@if (validationError() || submitError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() || submitError() }}</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="submitting() || !guaranteesComplete()">
|
||||
@if (submitting()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Finalisation…
|
||||
} @else {
|
||||
Finaliser le bail
|
||||
}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<dialog
|
||||
#discrepancyDialog
|
||||
aria-modal="true"
|
||||
aria-labelledby="inventory-discrepancy-title"
|
||||
aria-describedby="inventory-discrepancy-description"
|
||||
>
|
||||
<h3 id="inventory-discrepancy-title">Incohérences d’inventaire à l’entrée</h3>
|
||||
<p id="inventory-discrepancy-description">
|
||||
L’état des lieux d’entrée a-t-il révélé des différences avec l’inventaire imprimé ?
|
||||
</p>
|
||||
@if (discrepancyAnswer() !== true) {
|
||||
<div class="dialog-actions">
|
||||
<button type="button" (click)="answerNo()">Non, finaliser le bail</button>
|
||||
<button type="button" (click)="answerYes()">Oui, déclarer les corrections</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p>Décrivez uniquement les corrections d’inventaire manuscrites et paraphées.</p>
|
||||
@for (correction of inventoryCorrections(); track correction.id; let index = $index) {
|
||||
<fieldset>
|
||||
<legend>Correction {{ index + 1 }}</legend>
|
||||
<label>
|
||||
Élément de l’inventaire
|
||||
<select
|
||||
[value]="correction.inventoryLineId ?? ''"
|
||||
(change)="selectInventoryLine(index, $event)"
|
||||
>
|
||||
<option value="">Ajout ou correction exceptionnelle</option>
|
||||
@for (line of revision().furnitureInventory.lines; track line.id) {
|
||||
<option [value]="line.id">{{ line.designation }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Espace privatif
|
||||
<select
|
||||
required
|
||||
[value]="correction.privateAreaId"
|
||||
(change)="setCorrection(index, 'privateAreaId', $event)"
|
||||
>
|
||||
@for (area of revision().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Désignation
|
||||
<input
|
||||
#firstCorrectionInput
|
||||
required
|
||||
[value]="correction.designation"
|
||||
(input)="setCorrection(index, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="correction.quantity"
|
||||
(input)="setCorrection(index, 'quantity', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description de la correction
|
||||
<textarea
|
||||
required
|
||||
[value]="correction.description"
|
||||
(input)="setCorrection(index, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeCorrection(index)">
|
||||
Supprimer cette correction
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addCorrection()">Ajouter une correction</button>
|
||||
<mat-checkbox
|
||||
[checked]="allInventoryCorrectionsInitialed()"
|
||||
(change)="allInventoryCorrectionsInitialed.set($event.checked)"
|
||||
>
|
||||
Toutes les parties ont paraphé chaque correction manuscrite déclarée.
|
||||
</mat-checkbox>
|
||||
@if (validationError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() }}</p>
|
||||
}
|
||||
<button type="button" (click)="continueWithCorrections()">
|
||||
Finaliser avec ces corrections
|
||||
</button>
|
||||
}
|
||||
<p>Le numéro de révision imprimé ne se corrige jamais à la main.</p>
|
||||
</dialog>
|
||||
<p class="signature-notice">
|
||||
L'application conserve le document et vos attestations ; elle ne vérifie pas les
|
||||
signatures contenues dans le PDF.
|
||||
</p>
|
||||
@if (!guaranteesComplete()) {
|
||||
<p class="operation-error" role="alert">
|
||||
Téléversez tous les actes de cautionnement requis avant de finaliser.
|
||||
</p>
|
||||
}
|
||||
@if (validationError() || submitError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() || submitError() }}</p>
|
||||
}
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="submitting() || !guaranteesComplete()"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Finalisation…
|
||||
} @else {
|
||||
Finaliser le bail
|
||||
}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<dialog
|
||||
#discrepancyDialog
|
||||
aria-modal="true"
|
||||
aria-labelledby="inventory-discrepancy-title"
|
||||
aria-describedby="inventory-discrepancy-description"
|
||||
>
|
||||
<h3 id="inventory-discrepancy-title">Incohérences d’inventaire à l’entrée</h3>
|
||||
<p id="inventory-discrepancy-description">
|
||||
L’état des lieux d’entrée a-t-il révélé des différences avec l’inventaire imprimé ?
|
||||
</p>
|
||||
@if (discrepancyAnswer() !== true) {
|
||||
<div class="dialog-actions">
|
||||
<button type="button" (click)="answerNo()">Non, finaliser le bail</button>
|
||||
<button type="button" (click)="answerYes()">Oui, déclarer les corrections</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p>Décrivez uniquement les corrections d’inventaire manuscrites et paraphées.</p>
|
||||
@for (correction of inventoryCorrections(); track correction.id; let index = $index) {
|
||||
<fieldset>
|
||||
<legend>Correction {{ index + 1 }}</legend>
|
||||
<label>
|
||||
Élément de l’inventaire
|
||||
<select
|
||||
[value]="correction.inventoryLineId ?? ''"
|
||||
(change)="selectInventoryLine(index, $event)"
|
||||
>
|
||||
<option value="">Ajout ou correction exceptionnelle</option>
|
||||
@for (line of revision().furnitureInventory.lines; track line.id) {
|
||||
<option [value]="line.id">{{ line.designation }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Espace privatif
|
||||
<select
|
||||
required
|
||||
[value]="correction.privateAreaId"
|
||||
(change)="setCorrection(index, 'privateAreaId', $event)"
|
||||
>
|
||||
@for (area of revision().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Désignation
|
||||
<input
|
||||
#firstCorrectionInput
|
||||
required
|
||||
[value]="correction.designation"
|
||||
(input)="setCorrection(index, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="correction.quantity"
|
||||
(input)="setCorrection(index, 'quantity', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description de la correction
|
||||
<textarea
|
||||
required
|
||||
[value]="correction.description"
|
||||
(input)="setCorrection(index, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeCorrection(index)">
|
||||
Supprimer cette correction
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addCorrection()">Ajouter une correction</button>
|
||||
<mat-checkbox
|
||||
[checked]="allInventoryCorrectionsInitialed()"
|
||||
(change)="allInventoryCorrectionsInitialed.set($event.checked)"
|
||||
>
|
||||
Toutes les parties ont paraphé chaque correction manuscrite déclarée.
|
||||
</mat-checkbox>
|
||||
@if (validationError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() }}</p>
|
||||
}
|
||||
<button type="button" (click)="continueWithCorrections()">
|
||||
Finaliser avec ces corrections
|
||||
</button>
|
||||
}
|
||||
<p>Le numéro de révision imprimé ne se corrige jamais à la main.</p>
|
||||
</dialog>
|
||||
</section>
|
||||
+246
-238
@@ -4,271 +4,279 @@ import { FinalizationRevision } from '../../services/lease-finalization.service'
|
||||
import { LeaseFinalizationForm } from './lease-finalization-form';
|
||||
|
||||
function eventFor(target: EventTarget): Event {
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('LeaseFinalizationForm', () => {
|
||||
const revision: FinalizationRevision = {
|
||||
number: 4,
|
||||
templateVersion: '2026.10',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
furnitureInventory: {
|
||||
templateVersion: 'FR-MEUBLE-2025-01',
|
||||
lines: [
|
||||
{
|
||||
id: 'line-1',
|
||||
category: null,
|
||||
presence: null,
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
privateAreas: [
|
||||
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'LIVING_ROOM', label: 'Séjour' },
|
||||
],
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
};
|
||||
const revision: FinalizationRevision = {
|
||||
number: 4,
|
||||
templateVersion: '2026.10',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
furnitureInventory: {
|
||||
templateVersion: 'FR-MEUBLE-2025-01',
|
||||
lines: [
|
||||
{
|
||||
id: 'line-1',
|
||||
category: null,
|
||||
presence: null,
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
privateAreas: [
|
||||
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'LIVING_ROOM', label: 'Séjour' },
|
||||
],
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
function createForm(warningCodes: string[] = []) {
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationForm);
|
||||
fixture.componentRef.setInput('revision', revision);
|
||||
fixture.componentRef.setInput('warningCodes', warningCodes);
|
||||
fixture.componentRef.setInput(
|
||||
'warnings',
|
||||
warningCodes.map((code) => ({ code, ruleVersion: '2026-07-01' })),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
function createForm(warningCodes: string[] = []) {
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationForm);
|
||||
fixture.componentRef.setInput('revision', revision);
|
||||
fixture.componentRef.setInput('warningCodes', warningCodes);
|
||||
fixture.componentRef.setInput(
|
||||
'warnings',
|
||||
warningCodes.map((code) => ({ code, ruleVersion: '2026-07-01' })),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('emits the exact civil date string and confirmations', () => {
|
||||
const fixture = createForm([
|
||||
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
|
||||
'IRL_REVISION_WITH_F_OR_G_DPE',
|
||||
'FUTURE_WARNING',
|
||||
]);
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' });
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(file);
|
||||
component.lastSignatureDate.set('2026-07-19');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
component.discrepancyAnswer.set(false);
|
||||
component.setWarning('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', true);
|
||||
component.setWarning('IRL_REVISION_WITH_F_OR_G_DPE', true);
|
||||
component.setWarning('FUTURE_WARNING', true);
|
||||
it('emits the exact civil date string and confirmations', () => {
|
||||
const fixture = createForm([
|
||||
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
|
||||
'IRL_REVISION_WITH_F_OR_G_DPE',
|
||||
'FUTURE_WARNING',
|
||||
]);
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' });
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(file);
|
||||
component.lastSignatureDate.set('2026-07-19');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
component.discrepancyAnswer.set(false);
|
||||
component.setWarning('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', true);
|
||||
component.setWarning('IRL_REVISION_WITH_F_OR_G_DPE', true);
|
||||
component.setWarning('FUTURE_WARNING', true);
|
||||
|
||||
component.submit();
|
||||
component.submit();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
signedLease: file,
|
||||
revisionNumber: 4,
|
||||
lastSignatureDate: '2026-07-19',
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: [
|
||||
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
|
||||
'IRL_REVISION_WITH_F_OR_G_DPE',
|
||||
'FUTURE_WARNING',
|
||||
],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
});
|
||||
});
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
signedLease: file,
|
||||
revisionNumber: 4,
|
||||
lastSignatureDate: '2026-07-19',
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: [
|
||||
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
|
||||
'IRL_REVISION_WITH_F_OR_G_DPE',
|
||||
'FUTURE_WARNING',
|
||||
],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses actionable French warning labels without exposing raw codes', () => {
|
||||
const fixture = createForm([
|
||||
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
|
||||
'IRL_REVISION_WITH_F_OR_G_DPE',
|
||||
'FUTURE_WARNING',
|
||||
]);
|
||||
const text = fixture.nativeElement.textContent;
|
||||
it('uses actionable French warning labels without exposing raw codes', () => {
|
||||
const fixture = createForm([
|
||||
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
|
||||
'IRL_REVISION_WITH_F_OR_G_DPE',
|
||||
'FUTURE_WARNING',
|
||||
]);
|
||||
const text = fixture.nativeElement.textContent;
|
||||
|
||||
expect(text).toContain('dépasse deux mois de loyer hors charges');
|
||||
expect(text).toContain('confirme forcer ce montant');
|
||||
expect(text).toContain('classé F ou G');
|
||||
expect(text).toContain('maintenir la clause de révision IRL');
|
||||
expect(text).toContain('avertissement légal signalé');
|
||||
expect(text).not.toContain('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS');
|
||||
expect(text).not.toContain('FUTURE_WARNING');
|
||||
expect(text).not.toContain('IRL_REVISION_WITH_F_OR_G_DPE');
|
||||
expect(text).toContain('Version de règle : 2026-07-01');
|
||||
});
|
||||
expect(text).toContain('dépasse deux mois de loyer hors charges');
|
||||
expect(text).toContain('confirme forcer ce montant');
|
||||
expect(text).toContain('classé F ou G');
|
||||
expect(text).toContain('maintenir la clause de révision IRL');
|
||||
expect(text).toContain('avertissement légal signalé');
|
||||
expect(text).not.toContain('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS');
|
||||
expect(text).not.toContain('FUTURE_WARNING');
|
||||
expect(text).not.toContain('IRL_REVISION_WITH_F_OR_G_DPE');
|
||||
expect(text).toContain('Version de règle : 2026-07-01');
|
||||
});
|
||||
|
||||
it('explains every document warning precisely in French', () => {
|
||||
const warningCodes = [
|
||||
'DPE_MISSING',
|
||||
'DPE_EXPIRED',
|
||||
'DIAGNOSTIC_EXPIRED',
|
||||
'CO_OWNERSHIP_RULES_EXTRACT_MISSING',
|
||||
'PRIOR_RENTAL_AUTHORIZATION_MISSING',
|
||||
];
|
||||
const fixture = createForm(warningCodes);
|
||||
const text = fixture.nativeElement.textContent;
|
||||
it('explains every document warning precisely in French', () => {
|
||||
const warningCodes = [
|
||||
'DPE_MISSING',
|
||||
'DPE_EXPIRED',
|
||||
'DIAGNOSTIC_EXPIRED',
|
||||
'CO_OWNERSHIP_RULES_EXTRACT_MISSING',
|
||||
'PRIOR_RENTAL_AUTHORIZATION_MISSING',
|
||||
];
|
||||
const fixture = createForm(warningCodes);
|
||||
const text = fixture.nativeElement.textContent;
|
||||
|
||||
expect(text).toContain('DPE est absent du dossier');
|
||||
expect(text).toContain('DPE présent dans le dossier est expiré');
|
||||
expect(text).toContain('diagnostic autre que le DPE est expiré');
|
||||
expect(text).toContain('extrait du règlement de copropriété manque');
|
||||
expect(text).toContain('autorisation préalable de mise en location manque');
|
||||
for (const code of warningCodes) expect(text).not.toContain(code);
|
||||
});
|
||||
expect(text).toContain('DPE est absent du dossier');
|
||||
expect(text).toContain('DPE présent dans le dossier est expiré');
|
||||
expect(text).toContain('diagnostic autre que le DPE est expiré');
|
||||
expect(text).toContain('extrait du règlement de copropriété manque');
|
||||
expect(text).toContain('autorisation préalable de mise en location manque');
|
||||
for (const code of warningCodes) expect(text).not.toContain(code);
|
||||
});
|
||||
|
||||
it('blocks an incomplete submission with an actionable error', () => {
|
||||
const fixture = createForm(['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS']);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.finalizationRequested.subscribe(requested);
|
||||
it('blocks an incomplete submission with an actionable error', () => {
|
||||
const fixture = createForm(['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS']);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.finalizationRequested.subscribe(requested);
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.submit();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(requested).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Sélectionnez le bail signé');
|
||||
});
|
||||
expect(requested).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Sélectionnez le bail signé');
|
||||
});
|
||||
|
||||
it('blocks finalization while a required guarantee deed is missing', () => {
|
||||
const fixture = createForm();
|
||||
fixture.componentRef.setInput('guaranteesComplete', false);
|
||||
fixture.detectChanges();
|
||||
it('blocks finalization while a required guarantee deed is missing', () => {
|
||||
const fixture = createForm();
|
||||
fixture.componentRef.setInput('guaranteesComplete', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléversez tous les actes');
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléversez tous les actes');
|
||||
});
|
||||
|
||||
it('sends an initialed structured inventory correction after a yes answer', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' });
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(file);
|
||||
component.lastSignatureDate.set('2026-07-19');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
component.discrepancyAnswer.set(true);
|
||||
component.inventoryCorrections.set([
|
||||
{
|
||||
id: 'correction-1',
|
||||
inventoryLineId: null,
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 0,
|
||||
description: 'Absente',
|
||||
},
|
||||
]);
|
||||
component.allInventoryCorrectionsInitialed.set(true);
|
||||
it('sends an initialed structured inventory correction after a yes answer', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' });
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(file);
|
||||
component.lastSignatureDate.set('2026-07-19');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
component.discrepancyAnswer.set(true);
|
||||
component.inventoryCorrections.set([
|
||||
{
|
||||
id: 'correction-1',
|
||||
inventoryLineId: null,
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 0,
|
||||
description: 'Absente',
|
||||
},
|
||||
]);
|
||||
component.allInventoryCorrectionsInitialed.set(true);
|
||||
|
||||
component.submit();
|
||||
component.submit();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entryInventoryDiscrepancies: true,
|
||||
inventoryCorrections: [expect.objectContaining({ id: 'correction-1', quantity: 0 })],
|
||||
allInventoryCorrectionsInitialed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(requested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entryInventoryDiscrepancies: true,
|
||||
inventoryCorrections: [
|
||||
expect.objectContaining({ id: 'correction-1', quantity: 0 }),
|
||||
],
|
||||
allInventoryCorrectionsInitialed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('prefills known entry discrepancies with readable inventory choices', () => {
|
||||
const fixture = createForm();
|
||||
fixture.componentRef.setInput('suggestedInventoryCorrections', [
|
||||
{
|
||||
id: 'entry-line-1',
|
||||
inventoryLineId: 'line-1',
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 0,
|
||||
description: 'Chaise manquante',
|
||||
},
|
||||
]);
|
||||
it('prefills known entry discrepancies with readable inventory choices', () => {
|
||||
const fixture = createForm();
|
||||
fixture.componentRef.setInput('suggestedInventoryCorrections', [
|
||||
{
|
||||
id: 'entry-line-1',
|
||||
inventoryLineId: 'line-1',
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 0,
|
||||
description: 'Chaise manquante',
|
||||
},
|
||||
]);
|
||||
|
||||
fixture.componentInstance.answerYes();
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.answerYes();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.inventoryCorrections()).toEqual([
|
||||
expect.objectContaining({ inventoryLineId: 'line-1', quantity: 0 }),
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('Chaise');
|
||||
expect(fixture.nativeElement.textContent).toContain('Séjour');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('ID d’espace privatif');
|
||||
});
|
||||
expect(fixture.componentInstance.inventoryCorrections()).toEqual([
|
||||
expect.objectContaining({ inventoryLineId: 'line-1', quantity: 0 }),
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('Chaise');
|
||||
expect(fixture.nativeElement.textContent).toContain('Séjour');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('ID d’espace privatif');
|
||||
});
|
||||
|
||||
it('accepts only a PDF upload, captures the civil date, and toggles warning confirmations', () => {
|
||||
const fixture = createForm(['DPE_MISSING']);
|
||||
const component = fixture.componentInstance;
|
||||
const input = document.createElement('input');
|
||||
const invalid = new File(['text'], 'notes.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(input, 'files', { value: { item: () => invalid }, configurable: true });
|
||||
it('accepts only a PDF upload, captures the civil date, and toggles warning confirmations', () => {
|
||||
const fixture = createForm(['DPE_MISSING']);
|
||||
const component = fixture.componentInstance;
|
||||
const input = document.createElement('input');
|
||||
const invalid = new File(['text'], 'notes.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: { item: () => invalid },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
expect(component.signedLease()).toBeNull();
|
||||
expect(component.validationError()).toContain('format PDF');
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
expect(component.signedLease()).toBeNull();
|
||||
expect(component.validationError()).toContain('format PDF');
|
||||
|
||||
const pdf = new File(['pdf'], 'bail.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(input, 'files', { value: { item: () => pdf }, configurable: true });
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
const date = document.createElement('input');
|
||||
date.value = '2026-07-20';
|
||||
component.setLastSignatureDate(eventFor(date));
|
||||
component.setWarning('DPE_MISSING', true);
|
||||
component.setWarning('DPE_MISSING', false);
|
||||
const pdf = new File(['pdf'], 'bail.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(input, 'files', { value: { item: () => pdf }, configurable: true });
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
const date = document.createElement('input');
|
||||
date.value = '2026-07-20';
|
||||
component.setLastSignatureDate(eventFor(date));
|
||||
component.setWarning('DPE_MISSING', true);
|
||||
component.setWarning('DPE_MISSING', false);
|
||||
|
||||
expect(component.signedLease()).toBe(pdf);
|
||||
expect(component.lastSignatureDate()).toBe('2026-07-20');
|
||||
expect(component.confirmedWarningCodes().size).toBe(0);
|
||||
expect(component.warningLabel('DPE_MISSING')).toContain('DPE');
|
||||
});
|
||||
expect(component.signedLease()).toBe(pdf);
|
||||
expect(component.lastSignatureDate()).toBe('2026-07-20');
|
||||
expect(component.confirmedWarningCodes().size).toBe(0);
|
||||
expect(component.warningLabel('DPE_MISSING')).toContain('DPE');
|
||||
});
|
||||
|
||||
it('edits a handwritten inventory correction from the revision snapshot', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(new File(['pdf'], 'bail.pdf', { type: 'application/pdf' }));
|
||||
component.lastSignatureDate.set('2026-07-20');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
const dialog = fixture.nativeElement.querySelector('dialog');
|
||||
Object.defineProperty(dialog, 'close', { value: vi.fn() });
|
||||
component.answerYes();
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Chaise', 'line-1'));
|
||||
select.value = 'line-1';
|
||||
component.selectInventoryLine(0, eventFor(select));
|
||||
const description = document.createElement('textarea');
|
||||
description.value = 'Pied rayé';
|
||||
component.setCorrection(0, 'description', eventFor(description));
|
||||
component.setCorrection(0, 'inventoryLineId', eventFor(document.createElement('input')));
|
||||
it('edits a handwritten inventory correction from the revision snapshot', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(new File(['pdf'], 'bail.pdf', { type: 'application/pdf' }));
|
||||
component.lastSignatureDate.set('2026-07-20');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
const dialog = fixture.nativeElement.querySelector('dialog');
|
||||
Object.defineProperty(dialog, 'close', { value: vi.fn() });
|
||||
component.answerYes();
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Chaise', 'line-1'));
|
||||
select.value = 'line-1';
|
||||
component.selectInventoryLine(0, eventFor(select));
|
||||
const description = document.createElement('textarea');
|
||||
description.value = 'Pied rayé';
|
||||
component.setCorrection(0, 'description', eventFor(description));
|
||||
component.setCorrection(0, 'inventoryLineId', eventFor(document.createElement('input')));
|
||||
|
||||
component.continueWithCorrections();
|
||||
expect(component.validationError()).toContain('correction');
|
||||
component.selectInventoryLine(0, eventFor(select));
|
||||
component.allInventoryCorrectionsInitialed.set(true);
|
||||
component.continueWithCorrections();
|
||||
component.continueWithCorrections();
|
||||
expect(component.validationError()).toContain('correction');
|
||||
component.selectInventoryLine(0, eventFor(select));
|
||||
component.allInventoryCorrectionsInitialed.set(true);
|
||||
component.continueWithCorrections();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entryInventoryDiscrepancies: true,
|
||||
inventoryCorrections: [
|
||||
expect.objectContaining({ inventoryLineId: 'line-1', description: 'Pied rayé' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
component.removeCorrection(0);
|
||||
expect(component.inventoryCorrections()).toEqual([]);
|
||||
});
|
||||
expect(requested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entryInventoryDiscrepancies: true,
|
||||
inventoryCorrections: [
|
||||
expect.objectContaining({
|
||||
inventoryLineId: 'line-1',
|
||||
description: 'Pied rayé',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
component.removeCorrection(0);
|
||||
expect(component.inventoryCorrections()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,231 +3,231 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import {
|
||||
FinalizationRevision,
|
||||
FinalizeLeaseRequest,
|
||||
InventoryCorrection,
|
||||
FinalizationRevision,
|
||||
FinalizeLeaseRequest,
|
||||
InventoryCorrection,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { LeaseRevisionWarning } from '../../services/lease.service';
|
||||
import { leaseWarningConfirmation } from '../../services/lease-warning-label';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-finalization-form',
|
||||
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
|
||||
templateUrl: './lease-finalization-form.html',
|
||||
styleUrl: './lease-finalization-form.css',
|
||||
selector: 'app-lease-finalization-form',
|
||||
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
|
||||
templateUrl: './lease-finalization-form.html',
|
||||
styleUrl: './lease-finalization-form.css',
|
||||
})
|
||||
export class LeaseFinalizationForm {
|
||||
readonly revision = input.required<FinalizationRevision>();
|
||||
readonly suggestedInventoryCorrections = input<InventoryCorrection[]>([]);
|
||||
readonly warningCodes = input<string[]>([]);
|
||||
readonly warnings = input<LeaseRevisionWarning[]>([]);
|
||||
readonly guaranteesComplete = input(true);
|
||||
readonly submitting = input(false);
|
||||
readonly submitError = input('');
|
||||
readonly finalizationRequested = output<FinalizeLeaseRequest>();
|
||||
readonly revision = input.required<FinalizationRevision>();
|
||||
readonly suggestedInventoryCorrections = input<InventoryCorrection[]>([]);
|
||||
readonly warningCodes = input<string[]>([]);
|
||||
readonly warnings = input<LeaseRevisionWarning[]>([]);
|
||||
readonly guaranteesComplete = input(true);
|
||||
readonly submitting = input(false);
|
||||
readonly submitError = input('');
|
||||
readonly finalizationRequested = output<FinalizeLeaseRequest>();
|
||||
|
||||
readonly signedLease = signal<File | null>(null);
|
||||
readonly lastSignatureDate = signal('');
|
||||
readonly allPartiesSigned = signal(false);
|
||||
readonly revisionMatches = signal(false);
|
||||
readonly confirmedWarningCodes = signal<ReadonlySet<string>>(new Set());
|
||||
readonly validationError = signal('');
|
||||
readonly discrepancyAnswer = signal<boolean | null>(null);
|
||||
readonly inventoryCorrections = signal<InventoryCorrection[]>([]);
|
||||
readonly allInventoryCorrectionsInitialed = signal(false);
|
||||
readonly discrepancyDialog = viewChild<ElementRef<HTMLDialogElement>>('discrepancyDialog');
|
||||
readonly firstCorrectionInput = viewChild<ElementRef<HTMLInputElement>>('firstCorrectionInput');
|
||||
readonly signedLease = signal<File | null>(null);
|
||||
readonly lastSignatureDate = signal('');
|
||||
readonly allPartiesSigned = signal(false);
|
||||
readonly revisionMatches = signal(false);
|
||||
readonly confirmedWarningCodes = signal<ReadonlySet<string>>(new Set());
|
||||
readonly validationError = signal('');
|
||||
readonly discrepancyAnswer = signal<boolean | null>(null);
|
||||
readonly inventoryCorrections = signal<InventoryCorrection[]>([]);
|
||||
readonly allInventoryCorrectionsInitialed = signal(false);
|
||||
readonly discrepancyDialog = viewChild<ElementRef<HTMLDialogElement>>('discrepancyDialog');
|
||||
readonly firstCorrectionInput = viewChild<ElementRef<HTMLInputElement>>('firstCorrectionInput');
|
||||
|
||||
selectSignedLease(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0) ?? null;
|
||||
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
this.signedLease.set(null);
|
||||
this.validationError.set('Sélectionnez le bail signé au format PDF.');
|
||||
} else {
|
||||
this.signedLease.set(file);
|
||||
this.validationError.set('');
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
selectSignedLease(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0) ?? null;
|
||||
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
this.signedLease.set(null);
|
||||
this.validationError.set('Sélectionnez le bail signé au format PDF.');
|
||||
} else {
|
||||
this.signedLease.set(file);
|
||||
this.validationError.set('');
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
setLastSignatureDate(event: Event): void {
|
||||
const target = event.target;
|
||||
this.lastSignatureDate.set(target instanceof HTMLInputElement ? target.value : '');
|
||||
}
|
||||
setLastSignatureDate(event: Event): void {
|
||||
const target = event.target;
|
||||
this.lastSignatureDate.set(target instanceof HTMLInputElement ? target.value : '');
|
||||
}
|
||||
|
||||
setWarning(code: string, checked: boolean): void {
|
||||
this.confirmedWarningCodes.update((codes) => {
|
||||
const next = new Set(codes);
|
||||
if (checked) next.add(code);
|
||||
else next.delete(code);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setWarning(code: string, checked: boolean): void {
|
||||
this.confirmedWarningCodes.update((codes) => {
|
||||
const next = new Set(codes);
|
||||
if (checked) next.add(code);
|
||||
else next.delete(code);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
warningLabel(code: string): string {
|
||||
return leaseWarningConfirmation(code);
|
||||
}
|
||||
warningLabel(code: string): string {
|
||||
return leaseWarningConfirmation(code);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
if (this.discrepancyAnswer() === null) {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.showModal();
|
||||
return;
|
||||
}
|
||||
const error = this.errorMessage();
|
||||
this.validationError.set(error);
|
||||
const signedLease = this.signedLease();
|
||||
if (error || !signedLease) return;
|
||||
this.finalizationRequested.emit({
|
||||
signedLease,
|
||||
revisionNumber: this.revision().number,
|
||||
lastSignatureDate: this.lastSignatureDate(),
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: this.warningCodes(),
|
||||
entryInventoryDiscrepancies: this.discrepancyAnswer() === true,
|
||||
inventoryCorrections: this.inventoryCorrections(),
|
||||
allInventoryCorrectionsInitialed: this.allInventoryCorrectionsInitialed(),
|
||||
});
|
||||
}
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
if (this.discrepancyAnswer() === null) {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.showModal();
|
||||
return;
|
||||
}
|
||||
const error = this.errorMessage();
|
||||
this.validationError.set(error);
|
||||
const signedLease = this.signedLease();
|
||||
if (error || !signedLease) return;
|
||||
this.finalizationRequested.emit({
|
||||
signedLease,
|
||||
revisionNumber: this.revision().number,
|
||||
lastSignatureDate: this.lastSignatureDate(),
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: this.warningCodes(),
|
||||
entryInventoryDiscrepancies: this.discrepancyAnswer() === true,
|
||||
inventoryCorrections: this.inventoryCorrections(),
|
||||
allInventoryCorrectionsInitialed: this.allInventoryCorrectionsInitialed(),
|
||||
});
|
||||
}
|
||||
|
||||
answerNo(): void {
|
||||
this.discrepancyAnswer.set(false);
|
||||
this.inventoryCorrections.set([]);
|
||||
this.allInventoryCorrectionsInitialed.set(false);
|
||||
this.discrepancyDialog()?.nativeElement.close();
|
||||
this.submit();
|
||||
}
|
||||
answerNo(): void {
|
||||
this.discrepancyAnswer.set(false);
|
||||
this.inventoryCorrections.set([]);
|
||||
this.allInventoryCorrectionsInitialed.set(false);
|
||||
this.discrepancyDialog()?.nativeElement.close();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
answerYes(): void {
|
||||
this.discrepancyAnswer.set(true);
|
||||
if (!this.inventoryCorrections().length) {
|
||||
const suggestions = this.suggestedInventoryCorrections();
|
||||
if (suggestions.length) {
|
||||
this.inventoryCorrections.set(suggestions.map((correction) => ({ ...correction })));
|
||||
} else {
|
||||
this.addCorrection();
|
||||
}
|
||||
}
|
||||
queueMicrotask(() => this.firstCorrectionInput()?.nativeElement.focus());
|
||||
}
|
||||
answerYes(): void {
|
||||
this.discrepancyAnswer.set(true);
|
||||
if (!this.inventoryCorrections().length) {
|
||||
const suggestions = this.suggestedInventoryCorrections();
|
||||
if (suggestions.length) {
|
||||
this.inventoryCorrections.set(suggestions.map((correction) => ({ ...correction })));
|
||||
} else {
|
||||
this.addCorrection();
|
||||
}
|
||||
}
|
||||
queueMicrotask(() => this.firstCorrectionInput()?.nativeElement.focus());
|
||||
}
|
||||
|
||||
addCorrection(): void {
|
||||
this.inventoryCorrections.update((corrections) => [
|
||||
...corrections,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
inventoryLineId: null,
|
||||
privateAreaId: this.revision().privateAreas[0]?.id ?? '',
|
||||
designation: '',
|
||||
quantity: 0,
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
addCorrection(): void {
|
||||
this.inventoryCorrections.update((corrections) => [
|
||||
...corrections,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
inventoryLineId: null,
|
||||
privateAreaId: this.revision().privateAreas[0]?.id ?? '',
|
||||
designation: '',
|
||||
quantity: 0,
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
removeCorrection(index: number): void {
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.filter((_, item) => item !== index),
|
||||
);
|
||||
}
|
||||
removeCorrection(index: number): void {
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.filter((_, item) => item !== index),
|
||||
);
|
||||
}
|
||||
|
||||
setCorrection(index: number, field: keyof InventoryCorrection, event: Event): void {
|
||||
const value =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
? event.target.value
|
||||
: '';
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.map((correction, item) =>
|
||||
item === index
|
||||
? {
|
||||
...correction,
|
||||
[field]:
|
||||
field === 'quantity'
|
||||
? Number(value)
|
||||
: field === 'inventoryLineId'
|
||||
? value || null
|
||||
: value,
|
||||
}
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
}
|
||||
setCorrection(index: number, field: keyof InventoryCorrection, event: Event): void {
|
||||
const value =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
? event.target.value
|
||||
: '';
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.map((correction, item) =>
|
||||
item === index
|
||||
? {
|
||||
...correction,
|
||||
[field]:
|
||||
field === 'quantity'
|
||||
? Number(value)
|
||||
: field === 'inventoryLineId'
|
||||
? value || null
|
||||
: value,
|
||||
}
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
selectInventoryLine(index: number, event: Event): void {
|
||||
const target = event.target;
|
||||
const lineId = target instanceof HTMLSelectElement ? target.value : '';
|
||||
const line = this.revision().furnitureInventory.lines.find(
|
||||
(candidate) => candidate.id === lineId,
|
||||
);
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.map((correction, item) =>
|
||||
item === index
|
||||
? {
|
||||
...correction,
|
||||
inventoryLineId: line?.id ?? null,
|
||||
privateAreaId: line?.privateAreaId ?? correction.privateAreaId,
|
||||
designation: line?.designation ?? correction.designation,
|
||||
quantity: line?.quantity ?? correction.quantity,
|
||||
}
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
}
|
||||
selectInventoryLine(index: number, event: Event): void {
|
||||
const target = event.target;
|
||||
const lineId = target instanceof HTMLSelectElement ? target.value : '';
|
||||
const line = this.revision().furnitureInventory.lines.find(
|
||||
(candidate) => candidate.id === lineId,
|
||||
);
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.map((correction, item) =>
|
||||
item === index
|
||||
? {
|
||||
...correction,
|
||||
inventoryLineId: line?.id ?? null,
|
||||
privateAreaId: line?.privateAreaId ?? correction.privateAreaId,
|
||||
designation: line?.designation ?? correction.designation,
|
||||
quantity: line?.quantity ?? correction.quantity,
|
||||
}
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
continueWithCorrections(): void {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.close();
|
||||
this.submit();
|
||||
}
|
||||
continueWithCorrections(): void {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.close();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
private errorMessage(): string {
|
||||
if (!this.guaranteesComplete()) {
|
||||
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
|
||||
}
|
||||
if (!this.signedLease()) return 'Sélectionnez le bail signé au format PDF.';
|
||||
if (!this.lastSignatureDate()) return 'Renseignez la date de la dernière signature.';
|
||||
if (!this.allPartiesSigned()) {
|
||||
return 'Attestez que toutes les parties ont signé le document transmis.';
|
||||
}
|
||||
if (!this.revisionMatches()) {
|
||||
return `Confirmez que le document signé correspond à la révision ${this.revision().number}.`;
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.inventoryCorrections().length) {
|
||||
return 'Déclarez au moins une correction d’inventaire.';
|
||||
}
|
||||
if (
|
||||
this.discrepancyAnswer() &&
|
||||
this.inventoryCorrections().some(
|
||||
(correction) =>
|
||||
!correction.id ||
|
||||
!correction.privateAreaId.trim() ||
|
||||
!correction.designation.trim() ||
|
||||
!correction.description.trim() ||
|
||||
!Number.isInteger(correction.quantity) ||
|
||||
correction.quantity < 0,
|
||||
)
|
||||
) {
|
||||
return 'Complétez chaque correction d’inventaire avec son espace, sa désignation, sa quantité et sa description.';
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.allInventoryCorrectionsInitialed()) {
|
||||
return 'Attestez que chaque correction manuscrite d’inventaire a été paraphée.';
|
||||
}
|
||||
const missingWarning = this.warningCodes().find(
|
||||
(code) => !this.confirmedWarningCodes().has(code),
|
||||
);
|
||||
return missingWarning ? 'Confirmez chaque avertissement légal avant de finaliser.' : '';
|
||||
}
|
||||
private errorMessage(): string {
|
||||
if (!this.guaranteesComplete()) {
|
||||
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
|
||||
}
|
||||
if (!this.signedLease()) return 'Sélectionnez le bail signé au format PDF.';
|
||||
if (!this.lastSignatureDate()) return 'Renseignez la date de la dernière signature.';
|
||||
if (!this.allPartiesSigned()) {
|
||||
return 'Attestez que toutes les parties ont signé le document transmis.';
|
||||
}
|
||||
if (!this.revisionMatches()) {
|
||||
return `Confirmez que le document signé correspond à la révision ${this.revision().number}.`;
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.inventoryCorrections().length) {
|
||||
return 'Déclarez au moins une correction d’inventaire.';
|
||||
}
|
||||
if (
|
||||
this.discrepancyAnswer() &&
|
||||
this.inventoryCorrections().some(
|
||||
(correction) =>
|
||||
!correction.id ||
|
||||
!correction.privateAreaId.trim() ||
|
||||
!correction.designation.trim() ||
|
||||
!correction.description.trim() ||
|
||||
!Number.isInteger(correction.quantity) ||
|
||||
correction.quantity < 0,
|
||||
)
|
||||
) {
|
||||
return 'Complétez chaque correction d’inventaire avec son espace, sa désignation, sa quantité et sa description.';
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.allInventoryCorrectionsInitialed()) {
|
||||
return 'Attestez que chaque correction manuscrite d’inventaire a été paraphée.';
|
||||
}
|
||||
const missingWarning = this.warningCodes().find(
|
||||
(code) => !this.confirmedWarningCodes().has(code),
|
||||
);
|
||||
return missingWarning ? 'Confirmez chaque avertissement légal avant de finaliser.' : '';
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,52 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.finalization-page {
|
||||
max-width: 900px;
|
||||
max-width: 900px;
|
||||
}
|
||||
.finalization-skeleton {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.finalization-skeleton span {
|
||||
height: 90px;
|
||||
border-radius: 14px;
|
||||
height: 90px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.revision-summary,
|
||||
.missing-revision,
|
||||
.finalized-summary {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.revision-summary {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 28px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.revision-summary span,
|
||||
.finalized-summary dt,
|
||||
.finalized-summary > p:last-child {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.finalized-summary dl {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 22px 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.finalized-summary dt {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.finalized-summary dd {
|
||||
margin: 3px 0 0;
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
.hash {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: monospace;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: monospace;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.finalized-summary dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.finalized-summary dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,96 @@
|
||||
<section class="form-page finalization-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Finalisation du bail</h1>
|
||||
<p>Téléversez les documents signés et confirmez leur correspondance.</p>
|
||||
</div>
|
||||
</header>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Finalisation du bail</h1>
|
||||
<p>Téléversez les documents signés et confirmez leur correspondance.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="finalization-skeleton" aria-label="Chargement de la finalisation">
|
||||
<span class="skeleton-line"></span><span class="skeleton-line"></span
|
||||
><span class="skeleton-line"></span>
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (state(); as finalizationState) {
|
||||
@if (finalizationState.finalization; as finalization) {
|
||||
<article class="finalized-summary">
|
||||
<p class="eyebrow">Finalisé</p>
|
||||
<h2>Bail finalisé et désormais immuable</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Révision</dt>
|
||||
<dd>{{ finalization.revisionNumber }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Modèle</dt>
|
||||
<dd>{{ finalization.templateVersion }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dernière signature</dt>
|
||||
<dd>{{ finalization.lastSignatureDate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Finalisé le</dt>
|
||||
<dd>{{ finalization.finalizedAt }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fichier</dt>
|
||||
<dd>{{ finalization.signedFileName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Auteur</dt>
|
||||
<dd>{{ finalization.authorId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Empreinte SHA-256</dt>
|
||||
<dd class="hash">{{ finalization.signedSha256 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p>Ces informations et le document signé ne peuvent plus être modifiés.</p>
|
||||
</article>
|
||||
} @else if (!finalizationState.revision) {
|
||||
<article class="missing-revision">
|
||||
<h2>Générez d'abord le bail</h2>
|
||||
<p>
|
||||
Une révision PDF doit être générée depuis la carte du bail avant de téléverser sa version
|
||||
signée.
|
||||
</p>
|
||||
<a mat-flat-button routerLink="/leases">Revenir aux baux</a>
|
||||
</article>
|
||||
} @else {
|
||||
<article class="revision-summary">
|
||||
<span>Document à signer</span>
|
||||
<strong>
|
||||
Révision {{ finalizationState.revision.number }} · modèle
|
||||
{{ finalizationState.revision.templateVersion }}
|
||||
</strong>
|
||||
</article>
|
||||
@if (loading()) {
|
||||
<div class="finalization-skeleton" aria-label="Chargement de la finalisation">
|
||||
<span class="skeleton-line"></span><span class="skeleton-line"></span
|
||||
><span class="skeleton-line"></span>
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (state(); as finalizationState) {
|
||||
@if (finalizationState.finalization; as finalization) {
|
||||
<article class="finalized-summary">
|
||||
<p class="eyebrow">Finalisé</p>
|
||||
<h2>Bail finalisé et désormais immuable</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Révision</dt>
|
||||
<dd>{{ finalization.revisionNumber }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Modèle</dt>
|
||||
<dd>{{ finalization.templateVersion }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dernière signature</dt>
|
||||
<dd>{{ finalization.lastSignatureDate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Finalisé le</dt>
|
||||
<dd>{{ finalization.finalizedAt }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fichier</dt>
|
||||
<dd>{{ finalization.signedFileName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Auteur</dt>
|
||||
<dd>{{ finalization.authorId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Empreinte SHA-256</dt>
|
||||
<dd class="hash">{{ finalization.signedSha256 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p>Ces informations et le document signé ne peuvent plus être modifiés.</p>
|
||||
</article>
|
||||
} @else if (!finalizationState.revision) {
|
||||
<article class="missing-revision">
|
||||
<h2>Générez d'abord le bail</h2>
|
||||
<p>
|
||||
Une révision PDF doit être générée depuis la carte du bail avant de téléverser
|
||||
sa version signée.
|
||||
</p>
|
||||
<a mat-flat-button routerLink="/leases">Revenir aux baux</a>
|
||||
</article>
|
||||
} @else {
|
||||
<article class="revision-summary">
|
||||
<span>Document à signer</span>
|
||||
<strong>
|
||||
Révision {{ finalizationState.revision.number }} · modèle
|
||||
{{ finalizationState.revision.templateVersion }}
|
||||
</strong>
|
||||
</article>
|
||||
|
||||
<app-guarantee-deed-list
|
||||
[leaseId]="leaseId"
|
||||
[requirements]="finalizationState.guaranteeRequirements"
|
||||
[simulatorUrl]="finalizationState.guaranteeSimulatorUrl"
|
||||
(deedUploaded)="acceptDeed($event)"
|
||||
/>
|
||||
<app-guarantee-deed-list
|
||||
[leaseId]="leaseId"
|
||||
[requirements]="finalizationState.guaranteeRequirements"
|
||||
[simulatorUrl]="finalizationState.guaranteeSimulatorUrl"
|
||||
(deedUploaded)="acceptDeed($event)"
|
||||
/>
|
||||
|
||||
<app-lease-finalization-form
|
||||
[revision]="finalizationState.revision"
|
||||
[suggestedInventoryCorrections]="finalizationState.suggestedInventoryCorrections"
|
||||
[warningCodes]="finalizationState.warningCodes"
|
||||
[warnings]="finalizationState.warnings"
|
||||
[guaranteesComplete]="guaranteesComplete()"
|
||||
[submitting]="submitting() || confirming()"
|
||||
[submitError]="submitError()"
|
||||
(finalizationRequested)="requestFinalization($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
<app-lease-finalization-form
|
||||
[revision]="finalizationState.revision"
|
||||
[suggestedInventoryCorrections]="finalizationState.suggestedInventoryCorrections"
|
||||
[warningCodes]="finalizationState.warningCodes"
|
||||
[warnings]="finalizationState.warnings"
|
||||
[guaranteesComplete]="guaranteesComplete()"
|
||||
[submitting]="submitting() || confirming()"
|
||||
[submitError]="submitError()"
|
||||
(finalizationRequested)="requestFinalization($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
+164
-164
@@ -4,189 +4,189 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
FinalizeLeaseRequest,
|
||||
FinalizationMetadata,
|
||||
LeaseFinalizationService,
|
||||
LeaseFinalizationState,
|
||||
FinalizeLeaseRequest,
|
||||
FinalizationMetadata,
|
||||
LeaseFinalizationService,
|
||||
LeaseFinalizationState,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { LeaseFinalizationPage } from './lease-finalization-page';
|
||||
|
||||
describe('LeaseFinalizationPage', () => {
|
||||
const revision = {
|
||||
number: 4,
|
||||
templateVersion: '2026.10',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
furnitureInventory: { templateVersion: 'FR-MEUBLE-2025-01', lines: [] },
|
||||
privateAreas: [],
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
};
|
||||
const state: LeaseFinalizationState = {
|
||||
revision,
|
||||
guaranteeSimulatorUrl: 'https://www.service-public.fr/simulateur',
|
||||
warningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
warnings: [{ code: 'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', ruleVersion: '2026-07-01' }],
|
||||
guaranteeRequirements: [],
|
||||
suggestedInventoryCorrections: [],
|
||||
finalization: null,
|
||||
};
|
||||
const finalization: FinalizationMetadata = {
|
||||
revisionNumber: 4,
|
||||
templateVersion: '2026.10',
|
||||
lastSignatureDate: '2026-07-19',
|
||||
signedFileName: 'bail-signe.pdf',
|
||||
signedSha256: 'signed-sha256',
|
||||
authorId: 'owner-1',
|
||||
finalizedAt: '2026-07-19T10:30:00Z',
|
||||
overrides: [],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
const lease = {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
leaseType: 'STANDARD' as const,
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2027-06-30',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE' as const,
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE' as const,
|
||||
};
|
||||
const getState = vi.fn();
|
||||
const uploadGuaranteeDeed = vi.fn();
|
||||
const finalize = vi.fn();
|
||||
const workspace = { acceptLeaseFinalization: vi.fn() };
|
||||
const dialog = { open: vi.fn() };
|
||||
const revision = {
|
||||
number: 4,
|
||||
templateVersion: '2026.10',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
furnitureInventory: { templateVersion: 'FR-MEUBLE-2025-01', lines: [] },
|
||||
privateAreas: [],
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
};
|
||||
const state: LeaseFinalizationState = {
|
||||
revision,
|
||||
guaranteeSimulatorUrl: 'https://www.service-public.fr/simulateur',
|
||||
warningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
warnings: [{ code: 'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', ruleVersion: '2026-07-01' }],
|
||||
guaranteeRequirements: [],
|
||||
suggestedInventoryCorrections: [],
|
||||
finalization: null,
|
||||
};
|
||||
const finalization: FinalizationMetadata = {
|
||||
revisionNumber: 4,
|
||||
templateVersion: '2026.10',
|
||||
lastSignatureDate: '2026-07-19',
|
||||
signedFileName: 'bail-signe.pdf',
|
||||
signedSha256: 'signed-sha256',
|
||||
authorId: 'owner-1',
|
||||
finalizedAt: '2026-07-19T10:30:00Z',
|
||||
overrides: [],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
const lease = {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
leaseType: 'STANDARD' as const,
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2027-06-30',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE' as const,
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE' as const,
|
||||
};
|
||||
const getState = vi.fn();
|
||||
const uploadGuaranteeDeed = vi.fn();
|
||||
const finalize = vi.fn();
|
||||
const workspace = { acceptLeaseFinalization: vi.fn() };
|
||||
const dialog = { open: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getState.mockReturnValue(of(state));
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{
|
||||
provide: LeaseFinalizationService,
|
||||
useValue: { getState, uploadGuaranteeDeed, finalize },
|
||||
},
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getState.mockReturnValue(of(state));
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{
|
||||
provide: LeaseFinalizationService,
|
||||
useValue: { getState, uploadGuaranteeDeed, finalize },
|
||||
},
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a skeleton, then a retryable load error', () => {
|
||||
const response = new Subject<LeaseFinalizationState>();
|
||||
getState.mockReturnValueOnce(response);
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.finalization-skeleton')).not.toBeNull();
|
||||
it('shows a skeleton, then a retryable load error', () => {
|
||||
const response = new Subject<LeaseFinalizationState>();
|
||||
getState.mockReturnValueOnce(response);
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.finalization-skeleton')).not.toBeNull();
|
||||
|
||||
response.error({ error: { message: 'Bail introuvable' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
|
||||
response.error({ error: { message: 'Bail introuvable' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
|
||||
|
||||
getState.mockReturnValueOnce(of(state));
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.nativeElement.textContent).toContain('Révision 4 · modèle 2026.10');
|
||||
});
|
||||
getState.mockReturnValueOnce(of(state));
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.nativeElement.textContent).toContain('Révision 4 · modèle 2026.10');
|
||||
});
|
||||
|
||||
it('instructs the landlord to generate a revision when none exists', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, revision: null }));
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
it('instructs the landlord to generate a revision when none exists', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, revision: null }));
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain("Générez d'abord le bail");
|
||||
expect(fixture.nativeElement.querySelector('app-lease-finalization-form')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain("Générez d'abord le bail");
|
||||
expect(fixture.nativeElement.querySelector('app-lease-finalization-form')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks for final confirmation, prevents duplicates, and publishes success', () => {
|
||||
const response = new Subject<{
|
||||
lease: typeof lease;
|
||||
rentDues: [];
|
||||
finalization: FinalizationMetadata;
|
||||
}>();
|
||||
finalize.mockReturnValue(response);
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
const request: FinalizeLeaseRequest = {
|
||||
signedLease: new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' }),
|
||||
revisionNumber: 4,
|
||||
lastSignatureDate: '2026-07-19',
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
it('asks for final confirmation, prevents duplicates, and publishes success', () => {
|
||||
const response = new Subject<{
|
||||
lease: typeof lease;
|
||||
rentDues: [];
|
||||
finalization: FinalizationMetadata;
|
||||
}>();
|
||||
finalize.mockReturnValue(response);
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
const request: FinalizeLeaseRequest = {
|
||||
signedLease: new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' }),
|
||||
revisionNumber: 4,
|
||||
lastSignatureDate: '2026-07-19',
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(finalize).toHaveBeenCalledWith('lease-1', request);
|
||||
expect(fixture.componentInstance.submitting()).toBe(true);
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(finalize).toHaveBeenCalledWith('lease-1', request);
|
||||
expect(fixture.componentInstance.submitting()).toBe(true);
|
||||
|
||||
response.next({ lease, rentDues: [], finalization });
|
||||
response.complete();
|
||||
fixture.detectChanges();
|
||||
response.next({ lease, rentDues: [], finalization });
|
||||
response.complete();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(workspace.acceptLeaseFinalization).toHaveBeenCalledWith(lease, []);
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail finalisé et désormais immuable');
|
||||
expect(fixture.nativeElement.querySelector('app-lease-finalization-form')).toBeNull();
|
||||
});
|
||||
expect(workspace.acceptLeaseFinalization).toHaveBeenCalledWith(lease, []);
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail finalisé et désormais immuable');
|
||||
expect(fixture.nativeElement.querySelector('app-lease-finalization-form')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders an immutable summary when the lease was already finalized', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, finalization }));
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
it('renders an immutable summary when the lease was already finalized', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, finalization }));
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('bail-signe.pdf');
|
||||
expect(fixture.nativeElement.textContent).toContain('signed-sha256');
|
||||
expect(fixture.nativeElement.querySelector('input')).toBeNull();
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('bail-signe.pdf');
|
||||
expect(fixture.nativeElement.textContent).toContain('signed-sha256');
|
||||
expect(fixture.nativeElement.querySelector('input')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a finalization failure actionable without showing success', () => {
|
||||
finalize.mockReturnValue(throwError(() => ({ error: { message: 'PDF signé refusé' } })));
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
const request: FinalizeLeaseRequest = {
|
||||
signedLease: new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' }),
|
||||
revisionNumber: 4,
|
||||
lastSignatureDate: '2026-07-19',
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
it('keeps a finalization failure actionable without showing success', () => {
|
||||
finalize.mockReturnValue(throwError(() => ({ error: { message: 'PDF signé refusé' } })));
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
const request: FinalizeLeaseRequest = {
|
||||
signedLease: new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' }),
|
||||
revisionNumber: 4,
|
||||
lastSignatureDate: '2026-07-19',
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('PDF signé refusé');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('désormais immuable');
|
||||
expect(fixture.componentInstance.submitting()).toBe(false);
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('PDF signé refusé');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('désormais immuable');
|
||||
expect(fixture.componentInstance.submitting()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,119 +4,124 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
} from '../confirmation-dialog/confirmation-dialog';
|
||||
import {
|
||||
FinalizeLeaseRequest,
|
||||
LeaseFinalizationService,
|
||||
LeaseFinalizationState,
|
||||
FinalizeLeaseRequest,
|
||||
LeaseFinalizationService,
|
||||
LeaseFinalizationState,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import {
|
||||
GuaranteeDeedList,
|
||||
GuaranteeDeedUploaded,
|
||||
GuaranteeDeedList,
|
||||
GuaranteeDeedUploaded,
|
||||
} from '../guarantee-deed-list/guarantee-deed-list';
|
||||
import { LeaseFinalizationForm } from '../lease-finalization-form/lease-finalization-form';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-finalization-page',
|
||||
imports: [MatButtonModule, RouterLink, GuaranteeDeedList, LeaseFinalizationForm],
|
||||
templateUrl: './lease-finalization-page.html',
|
||||
styleUrl: './lease-finalization-page.css',
|
||||
selector: 'app-lease-finalization-page',
|
||||
imports: [MatButtonModule, RouterLink, GuaranteeDeedList, LeaseFinalizationForm],
|
||||
templateUrl: './lease-finalization-page.html',
|
||||
styleUrl: './lease-finalization-page.css',
|
||||
})
|
||||
export class LeaseFinalizationPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly finalizationService = inject(LeaseFinalizationService);
|
||||
private readonly workspace = inject(WorkspaceService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly finalizationService = inject(LeaseFinalizationService);
|
||||
private readonly workspace = inject(WorkspaceService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly state = signal<LeaseFinalizationState | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly submitting = signal(false);
|
||||
readonly confirming = signal(false);
|
||||
readonly submitError = signal('');
|
||||
readonly guaranteesComplete = computed(
|
||||
() => this.state()?.guaranteeRequirements.every(({ deed }) => deed !== null) ?? false,
|
||||
);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly state = signal<LeaseFinalizationState | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly submitting = signal(false);
|
||||
readonly confirming = signal(false);
|
||||
readonly submitError = signal('');
|
||||
readonly guaranteesComplete = computed(
|
||||
() => this.state()?.guaranteeRequirements.every(({ deed }) => deed !== null) ?? false,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.finalizationService
|
||||
.getState(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error) =>
|
||||
this.loadError.set(
|
||||
httpErrorMessage(error, 'Impossible de charger la finalisation de ce bail.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.finalizationService
|
||||
.getState(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error) =>
|
||||
this.loadError.set(
|
||||
httpErrorMessage(
|
||||
error,
|
||||
'Impossible de charger la finalisation de ce bail.',
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
acceptDeed(event: GuaranteeDeedUploaded): void {
|
||||
this.state.update((state) =>
|
||||
state
|
||||
? {
|
||||
...state,
|
||||
guaranteeRequirements: state.guaranteeRequirements.map((requirement) =>
|
||||
requirement.guarantorId === event.guarantorId &&
|
||||
requirement.tenantId === event.tenantId
|
||||
? { ...requirement, deed: event.deed }
|
||||
: requirement,
|
||||
),
|
||||
}
|
||||
: state,
|
||||
);
|
||||
}
|
||||
acceptDeed(event: GuaranteeDeedUploaded): void {
|
||||
this.state.update((state) =>
|
||||
state
|
||||
? {
|
||||
...state,
|
||||
guaranteeRequirements: state.guaranteeRequirements.map((requirement) =>
|
||||
requirement.guarantorId === event.guarantorId &&
|
||||
requirement.tenantId === event.tenantId
|
||||
? { ...requirement, deed: event.deed }
|
||||
: requirement,
|
||||
),
|
||||
}
|
||||
: state,
|
||||
);
|
||||
}
|
||||
|
||||
requestFinalization(request: FinalizeLeaseRequest): void {
|
||||
if (this.submitting() || this.confirming()) return;
|
||||
this.confirming.set(true);
|
||||
const data: ConfirmationDialogData = {
|
||||
title: 'Finaliser définitivement ce bail ?',
|
||||
message:
|
||||
'Le bail signé, les attestations et leurs empreintes seront conservés sans possibilité de modification.',
|
||||
confirmLabel: 'Finaliser le bail',
|
||||
};
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
this.confirming.set(false);
|
||||
if (confirmed) this.finalize(request);
|
||||
});
|
||||
}
|
||||
requestFinalization(request: FinalizeLeaseRequest): void {
|
||||
if (this.submitting() || this.confirming()) return;
|
||||
this.confirming.set(true);
|
||||
const data: ConfirmationDialogData = {
|
||||
title: 'Finaliser définitivement ce bail ?',
|
||||
message:
|
||||
'Le bail signé, les attestations et leurs empreintes seront conservés sans possibilité de modification.',
|
||||
confirmLabel: 'Finaliser le bail',
|
||||
};
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
this.confirming.set(false);
|
||||
if (confirmed) this.finalize(request);
|
||||
});
|
||||
}
|
||||
|
||||
private finalize(request: FinalizeLeaseRequest): void {
|
||||
if (this.submitting()) return;
|
||||
this.submitting.set(true);
|
||||
this.submitError.set('');
|
||||
this.finalizationService
|
||||
.finalize(this.leaseId, request)
|
||||
.pipe(rxFinalize(() => this.submitting.set(false)))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.workspace.acceptLeaseFinalization(result.lease, result.rentDues);
|
||||
this.state.update((state) =>
|
||||
state ? { ...state, finalization: result.finalization } : state,
|
||||
);
|
||||
},
|
||||
error: (error) =>
|
||||
this.submitError.set(httpErrorMessage(error, 'Impossible de finaliser ce bail.')),
|
||||
});
|
||||
}
|
||||
private finalize(request: FinalizeLeaseRequest): void {
|
||||
if (this.submitting()) return;
|
||||
this.submitting.set(true);
|
||||
this.submitError.set('');
|
||||
this.finalizationService
|
||||
.finalize(this.leaseId, request)
|
||||
.pipe(rxFinalize(() => this.submitting.set(false)))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.workspace.acceptLeaseFinalization(result.lease, result.rentDues);
|
||||
this.state.update((state) =>
|
||||
state ? { ...state, finalization: result.finalization } : state,
|
||||
);
|
||||
},
|
||||
error: (error) =>
|
||||
this.submitError.set(
|
||||
httpErrorMessage(error, 'Impossible de finaliser ce bail.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,84 @@
|
||||
:host {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.section-heading p {
|
||||
margin-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.guarantor-section {
|
||||
margin-top: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.lease-duration {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
min-height: 56px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
min-height: 56px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lease-duration > span,
|
||||
.lease-duration small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.lease-duration output {
|
||||
margin: 2px 0;
|
||||
font-weight: 700;
|
||||
margin: 2px 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-section + .form-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-heading > span {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.section-heading h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.section-heading p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.guarantor-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 7px 5px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 7px 5px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.guarantor-chip .mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 28px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.three-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.three-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.form-page {
|
||||
max-width: 980px;
|
||||
}
|
||||
.form-page {
|
||||
max-width: 980px;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +1,201 @@
|
||||
<section class="form-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="back-action"
|
||||
[disabled]="saving()"
|
||||
(click)="cancelled.emit()"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<p class="eyebrow">{{ lease() ? 'Brouillon' : 'Nouveau bail' }}</p>
|
||||
<h1>{{ lease() ? 'Modifier le brouillon' : 'Créer un brouillon de bail' }}</h1>
|
||||
<p>Associez le bien, les locataires et les éventuels garants.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
id="lease-details"
|
||||
class="lease-form"
|
||||
[formRoot]="leaseForm"
|
||||
(ngSubmit)="save()"
|
||||
[attr.aria-busy]="saving()"
|
||||
>
|
||||
@if (saveError()) {
|
||||
<div class="alert-error" role="alert">{{ saveError() }}</div>
|
||||
}
|
||||
<p class="visually-hidden" role="status" aria-live="polite">
|
||||
{{ saving() ? 'Enregistrement en cours.' : '' }}
|
||||
</p>
|
||||
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<section class="form-section">
|
||||
<div class="section-heading">
|
||||
<span>01</span>
|
||||
<div>
|
||||
<h2>Parties du bail</h2>
|
||||
<p>Choisissez le logement et les locataires.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Appartement</mat-label
|
||||
><mat-select id="apartmentId" [formField]="leaseForm.apartmentId"
|
||||
><mat-option value="">Sélectionnez un appartement</mat-option>
|
||||
@for (apartment of apartments(); track apartment.id) {
|
||||
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
|
||||
}
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Locataires</mat-label
|
||||
><mat-select id="tenantIds" [formField]="leaseForm.tenantIds" multiple>
|
||||
@for (tenant of tenants(); track tenant.id) {
|
||||
<mat-option [value]="tenant.id"
|
||||
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section">
|
||||
<div class="section-heading">
|
||||
<span>02</span>
|
||||
<div>
|
||||
<h2>Conditions</h2>
|
||||
<p>Choisissez le type, la date d'entrée et les montants mensuels.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid three-columns">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Type de bail</mat-label
|
||||
><mat-select id="leaseType" [formField]="leaseForm.leaseType">
|
||||
<mat-option value="STANDARD">Location meublée classique</mat-option>
|
||||
<mat-option value="STUDENT">Location meublée étudiante</mat-option>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Date d'entrée</mat-label
|
||||
><input matInput type="date" id="startDate" [formField]="leaseForm.startDate" />
|
||||
@if (leaseForm.startDate().touched() && leaseForm.startDate().invalid()) {
|
||||
<mat-error>{{ leaseForm.startDate().errors()[0]?.message }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="lease-duration" aria-live="polite">
|
||||
<span id="lease-end-date-label">Date de fin calculée</span>
|
||||
<output aria-labelledby="lease-end-date-label">
|
||||
{{ leaseDuration().endDateLabel }}
|
||||
</output>
|
||||
<small>{{ leaseDuration().renewalLabel }}</small>
|
||||
</div>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Loyer mensuel</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="monthlyRent"
|
||||
[formField]="leaseForm.monthlyRent"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Charges mensuelles</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="monthlyCharges"
|
||||
[formField]="leaseForm.monthlyCharges"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Dépôt de garantie</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="securityDeposit"
|
||||
[formField]="leaseForm.securityDeposit"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Mode de récupération des charges</mat-label
|
||||
><mat-select id="chargeMethod" [formField]="leaseForm.chargeMethod">
|
||||
<mat-option value="FLAT_RATE">Forfait de charges</mat-option>
|
||||
<mat-option value="PROVISION">Provision avec régularisation annuelle</mat-option>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Jour d’échéance</mat-label
|
||||
><input matInput type="number" id="dueDay" [formField]="leaseForm.dueDay" /><mat-hint
|
||||
>Entre le 1er et le 28 du mois</mat-hint
|
||||
></mat-form-field
|
||||
>
|
||||
@if (leaseModel().chargeMethod === 'FLAT_RATE') {
|
||||
<mat-checkbox id="indexFlatRate" [formField]="leaseForm.indexFlatRate">
|
||||
Indexer le forfait de charges avec le loyer
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<app-lease-legal-terms
|
||||
[legalTermsForm]="leaseForm.legalTerms"
|
||||
[legalTerms]="leaseModel().legalTerms"
|
||||
[tenantCount]="leaseModel().tenantIds.length"
|
||||
[energyRating]="selectedEnergyRating()"
|
||||
/>
|
||||
<app-lease-clause-editor
|
||||
[context]="clauseContext()"
|
||||
[specialClauses]="specialClauses()"
|
||||
(specialClausesChange)="specialClauses.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<section class="guarantor-section">
|
||||
<div class="section-heading">
|
||||
<span>05</span>
|
||||
<div>
|
||||
<h2>Garants</h2>
|
||||
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
|
||||
</div>
|
||||
</div>
|
||||
@for (guarantor of guarantors(); track $index) {
|
||||
<div class="guarantor-chip">
|
||||
<span>{{ guarantor.firstName }} {{ guarantor.lastName }}</span
|
||||
><button mat-button type="button" (click)="removeGuarantor($index)">Retirer</button>
|
||||
</div>
|
||||
}
|
||||
<app-guarantor-form (submitted)="addGuarantor($event)" />
|
||||
</section>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" [disabled]="saving()" (click)="cancelled.emit()">
|
||||
Annuler</button
|
||||
><button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
form="lease-details"
|
||||
class="primary-action"
|
||||
[disabled]="saving()"
|
||||
>
|
||||
<span>
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else {
|
||||
{{ lease() ? 'Enregistrer les modifications' : 'Enregistrer le brouillon' }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="back-action"
|
||||
[disabled]="saving()"
|
||||
(click)="cancelled.emit()"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<p class="eyebrow">{{ lease() ? 'Brouillon' : 'Nouveau bail' }}</p>
|
||||
<h1>{{ lease() ? 'Modifier le brouillon' : 'Créer un brouillon de bail' }}</h1>
|
||||
<p>Associez le bien, les locataires et les éventuels garants.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
id="lease-details"
|
||||
class="lease-form"
|
||||
[formRoot]="leaseForm"
|
||||
(ngSubmit)="save()"
|
||||
[attr.aria-busy]="saving()"
|
||||
>
|
||||
@if (saveError()) {
|
||||
<div class="alert-error" role="alert">{{ saveError() }}</div>
|
||||
}
|
||||
<p class="visually-hidden" role="status" aria-live="polite">
|
||||
{{ saving() ? 'Enregistrement en cours.' : '' }}
|
||||
</p>
|
||||
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<section class="form-section">
|
||||
<div class="section-heading">
|
||||
<span>01</span>
|
||||
<div>
|
||||
<h2>Parties du bail</h2>
|
||||
<p>Choisissez le logement et les locataires.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Appartement</mat-label
|
||||
><mat-select id="apartmentId" [formField]="leaseForm.apartmentId"
|
||||
><mat-option value="">Sélectionnez un appartement</mat-option>
|
||||
@for (apartment of apartments(); track apartment.id) {
|
||||
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
|
||||
}
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Locataires</mat-label
|
||||
><mat-select id="tenantIds" [formField]="leaseForm.tenantIds" multiple>
|
||||
@for (tenant of tenants(); track tenant.id) {
|
||||
<mat-option [value]="tenant.id"
|
||||
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section">
|
||||
<div class="section-heading">
|
||||
<span>02</span>
|
||||
<div>
|
||||
<h2>Conditions</h2>
|
||||
<p>Choisissez le type, la date d'entrée et les montants mensuels.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid three-columns">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Type de bail</mat-label
|
||||
><mat-select id="leaseType" [formField]="leaseForm.leaseType">
|
||||
<mat-option value="STANDARD">Location meublée classique</mat-option>
|
||||
<mat-option value="STUDENT">Location meublée étudiante</mat-option>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Date d'entrée</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="date"
|
||||
id="startDate"
|
||||
[formField]="leaseForm.startDate"
|
||||
/>
|
||||
@if (leaseForm.startDate().touched() && leaseForm.startDate().invalid()) {
|
||||
<mat-error>{{ leaseForm.startDate().errors()[0]?.message }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="lease-duration" aria-live="polite">
|
||||
<span id="lease-end-date-label">Date de fin calculée</span>
|
||||
<output aria-labelledby="lease-end-date-label">
|
||||
{{ leaseDuration().endDateLabel }}
|
||||
</output>
|
||||
<small>{{ leaseDuration().renewalLabel }}</small>
|
||||
</div>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Loyer mensuel</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="monthlyRent"
|
||||
[formField]="leaseForm.monthlyRent"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Charges mensuelles</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="monthlyCharges"
|
||||
[formField]="leaseForm.monthlyCharges"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Dépôt de garantie</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="securityDeposit"
|
||||
[formField]="leaseForm.securityDeposit"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Mode de récupération des charges</mat-label
|
||||
><mat-select id="chargeMethod" [formField]="leaseForm.chargeMethod">
|
||||
<mat-option value="FLAT_RATE">Forfait de charges</mat-option>
|
||||
<mat-option value="PROVISION"
|
||||
>Provision avec régularisation annuelle</mat-option
|
||||
>
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Jour d’échéance</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="dueDay"
|
||||
[formField]="leaseForm.dueDay"
|
||||
/><mat-hint>Entre le 1er et le 28 du mois</mat-hint></mat-form-field
|
||||
>
|
||||
@if (leaseModel().chargeMethod === 'FLAT_RATE') {
|
||||
<mat-checkbox id="indexFlatRate" [formField]="leaseForm.indexFlatRate">
|
||||
Indexer le forfait de charges avec le loyer
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<app-lease-legal-terms
|
||||
[legalTermsForm]="leaseForm.legalTerms"
|
||||
[legalTerms]="leaseModel().legalTerms"
|
||||
[tenantCount]="leaseModel().tenantIds.length"
|
||||
[energyRating]="selectedEnergyRating()"
|
||||
/>
|
||||
<app-lease-clause-editor
|
||||
[context]="clauseContext()"
|
||||
[specialClauses]="specialClauses()"
|
||||
(specialClausesChange)="specialClauses.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<section class="guarantor-section">
|
||||
<div class="section-heading">
|
||||
<span>05</span>
|
||||
<div>
|
||||
<h2>Garants</h2>
|
||||
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
|
||||
</div>
|
||||
</div>
|
||||
@for (guarantor of guarantors(); track $index) {
|
||||
<div class="guarantor-chip">
|
||||
<span>{{ guarantor.firstName }} {{ guarantor.lastName }}</span
|
||||
><button mat-button type="button" (click)="removeGuarantor($index)">
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<app-guarantor-form (submitted)="addGuarantor($event)" />
|
||||
</section>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" [disabled]="saving()" (click)="cancelled.emit()">
|
||||
Annuler</button
|
||||
><button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
form="lease-details"
|
||||
class="primary-action"
|
||||
[disabled]="saving()"
|
||||
>
|
||||
<span>
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else {
|
||||
{{ lease() ? 'Enregistrer les modifications' : 'Enregistrer le brouillon' }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user