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:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 5d2d85aa8c
commit 878da155bd
564 files changed
+61467 -47179

No files matched your search

+17 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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>
}
+16 -16
View File
@@ -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',
'',
'**',
]);
});
});
+12 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
});
}
}
@@ -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}`;
}
}
@@ -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);
}
@@ -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>
@@ -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é.');
});
});
@@ -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(),
};
}
}
@@ -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;
}
@@ -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>
@@ -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(),
);
}
}
+20 -20
View File
@@ -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;
}
@@ -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>
@@ -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();
}
}
@@ -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>
}
@@ -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
: '';
}
}
@@ -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>
}
@@ -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;
}
}
@@ -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;
}
@@ -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>
}
@@ -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.',
);
});
});
@@ -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;
}
@@ -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);
}
@@ -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>
@@ -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,
});
});
});
@@ -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;
}
@@ -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>
@@ -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>
@@ -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