feat(rental): add inventory and condition reports (#7)

This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:16 +02:00
1 parent 92d8a3e351
commit e4be5cd05f
193 files changed
+9697 -483

No files matched your search

+58
View File
@@ -217,6 +217,64 @@ test('follows operating-system changes for the System theme', async ({ page }) =
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});
test('offers the native guided photo control for an entry condition report', async ({ page }) => {
await mockUser(page, true);
await page.route(
(url) => url.pathname === '/api/leases/lease-1/condition-reports/ENTRY',
async (route) => {
await route.fulfill({
contentType: 'application/json',
json: {
id: 'report-1',
type: 'ENTRY',
status: 'DRAFT',
occurredOn: '2026-07-22',
timeZone: 'Europe/Paris',
participants: [],
forwardingAddresses: [],
zones: [
{
id: 'kitchen',
privateAreaId: 'area-kitchen',
label: 'Cuisine',
equipment: 'NONE',
},
],
observations: [{ subjectId: 'kitchen', state: 'BON_ETAT', observation: '' }],
meterReadings: [],
accessMeans: [],
inventoryConditions: [],
photos: [],
zonePhotoAbsences: [],
entryPhotoDispositions: [],
supplements: [],
entrySnapshot: null,
revisions: [],
currentRevision: null,
finalization: null,
deliveryAttestations: [],
leaseRevisionNumber: 1,
leaseRevisionFingerprint: 'lease-source',
version: 0,
},
});
},
);
await page.goto('/leases/lease-1/condition-reports/ENTRY');
const kitchen = page.getByRole('article').filter({
has: page.getByRole('heading', { name: 'Cuisine' }),
});
const photo = kitchen.getByLabel('Prendre ou choisir une photo');
await expect(photo).toHaveAttribute(
'accept',
'image/jpeg,image/png,image/webp,image/heic,image/heif',
);
await expect(photo).toHaveAttribute('capture', 'environment');
await expect(page.getByRole('heading', { name: 'Parcours photos guidé' })).toBeVisible();
});
test('creates a tenant with an attachment and a lease with a guarantor', async ({ page }) => {
const apartments = [
{
+2
View File
@@ -9,6 +9,8 @@ describe('routes', () => {
'tenants',
'leases/:leaseId/finalize',
'leases/:leaseId/end',
'leases/:leaseId/condition-reports/:type',
'leases/:leaseId/inventory',
'leases',
'rent-payments',
'settings',
+4
View File
@@ -7,6 +7,8 @@ import { LeaseEndPage } from './components/lease-end-page/lease-end-page';
import { RentPaymentsPage } from './components/rent-payments-page/rent-payments-page';
import { SettingsPage } from './components/settings-page/settings-page';
import { TenantsPage } from './components/tenants-page/tenants-page';
import { ConditionReportPage } from './components/condition-report-page/condition-report-page';
import { InventoryPage } from './components/inventory-page/inventory-page';
export const routes: Routes = [
{ path: 'dashboard', component: DashboardPage },
@@ -14,6 +16,8 @@ export const routes: Routes = [
{ 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 },
@@ -78,6 +78,10 @@
</section>
<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
@@ -10,8 +10,6 @@ describe('ApartmentForm', () => {
ownershipRegime: 'Copropriété',
constructionPeriod: '1990_2005' as const,
habitableSurface: 42,
roomCount: 2,
otherPrivateAreas: 'Balcon',
equipment: 'Cuisine équipée',
heating: 'Individuel électrique',
hotWater: 'Ballon électrique',
@@ -33,6 +31,7 @@ describe('ApartmentForm', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: 'Cinquième étage',
privateAreas: [],
legalProfile: completeLegalProfile,
});
fixture.detectChanges();
@@ -65,6 +64,7 @@ describe('ApartmentForm', () => {
address: ' ',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
});
@@ -84,6 +84,7 @@ describe('ApartmentForm', () => {
address: ' 45 rue des Entrepreneurs ',
purchasePrice: 250000,
description: ' Cinquième étage ',
privateAreas: [],
legalProfile: {
...completeLegalProfile,
taxIdentifier: ' 751150001234 ',
@@ -95,10 +96,12 @@ describe('ApartmentForm', () => {
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',
@@ -132,6 +135,7 @@ describe('ApartmentForm', () => {
address: '45 rue des Entrepreneurs',
purchasePrice: 265000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
});
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
@@ -157,6 +161,7 @@ describe('ApartmentForm', () => {
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: { ...emptyApartmentLegalProfile(), habitableSurface: -1 },
});
@@ -190,6 +195,7 @@ describe('ApartmentForm', () => {
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: {
...emptyApartmentLegalProfile(),
estimatedAnnualEnergyCost: 850,
@@ -217,6 +223,7 @@ describe('ApartmentForm', () => {
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: {
...emptyApartmentLegalProfile(),
estimatedAnnualEnergyCostReferenceYear: 2021,
@@ -9,8 +9,10 @@ import {
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).';
@@ -26,6 +28,7 @@ const requiredFieldsError =
MatInputModule,
MatProgressSpinnerModule,
ApartmentLegalProfileForm,
PrivateAreaEditor,
],
templateUrl: './apartment-form.html',
styleUrl: './apartment-form.css',
@@ -43,6 +46,7 @@ export class ApartmentForm {
purchasePrice: 0,
description: '',
legalProfile: emptyApartmentLegalProfile(),
privateAreas: [],
});
readonly apartmentForm = form(this.apartmentModel, (f) => {
@@ -53,9 +57,6 @@ export class ApartmentForm {
min(f.legalProfile.habitableSurface, 0, {
message: 'La surface habitable doit être positive ou nulle.',
});
min(f.legalProfile.roomCount, 0, {
message: 'Le nombre de pièces doit être positif ou nul.',
});
min(f.legalProfile.estimatedAnnualEnergyCost, 0, {
message: 'Le coût annuel énergétique doit être positif ou nul.',
});
@@ -97,9 +98,14 @@ export class ApartmentForm {
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 }));
}
private resetForm(apartment: Apartment | null): void {
this.apartmentModel.set({
id: apartment?.id,
@@ -108,6 +114,7 @@ export class ApartmentForm {
purchasePrice: apartment?.purchasePrice ?? 0,
description: apartment?.description ?? '',
legalProfile: apartment?.legalProfile ?? emptyApartmentLegalProfile(),
privateAreas: apartment?.privateAreas ?? [],
});
this.error.set('');
}
@@ -122,7 +129,6 @@ export class ApartmentForm {
: profile.estimatedAnnualEnergyCostReferenceYear,
taxIdentifier: profile.taxIdentifier.trim(),
ownershipRegime: profile.ownershipRegime.trim(),
otherPrivateAreas: profile.otherPrivateAreas.trim(),
equipment: profile.equipment.trim(),
heating: profile.heating.trim(),
hotWater: profile.hotWater.trim(),
@@ -68,15 +68,6 @@
[formField]="legalProfileForm().habitableSurface"
/><span matTextSuffix>m²</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Nombre de pièces principales</mat-label
><input
matInput
type="number"
step="1"
id="roomCount"
[formField]="legalProfileForm().roomCount"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Classe énergétique</mat-label
><mat-select id="energyRating" [formField]="legalProfileForm().energyRating">
@@ -113,16 +104,6 @@
[formField]="legalProfileForm().destination"
placeholder="Usage d'habitation"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Autres parties privatives</mat-label
><textarea
matInput
id="otherPrivateAreas"
[formField]="legalProfileForm().otherPrivateAreas"
rows="2"
placeholder="Balcon, terrasse, jardin…"
></textarea>
</mat-form-field>
</div>
</section>
@@ -81,6 +81,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
},
]);
@@ -103,6 +104,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
},
]);
@@ -124,6 +126,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
};
const fixture = TestBed.createComponent(ApartmentsPage);
@@ -143,6 +146,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
};
const fixture = TestBed.createComponent(ApartmentsPage);
@@ -165,6 +169,7 @@ describe('ApartmentsPage', () => {
address: '45 rue des Entrepreneurs, 75015 Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
};
const fixture = TestBed.createComponent(ApartmentsPage);
@@ -0,0 +1,121 @@
<section>
<h2>Date et fuseau</h2>
<label>
Date civile
<input
type="date"
required
[value]="report().occurredOn"
(input)="setOccurredOn($any($event.target).value)"
/>
</label>
<label>
Fuseau IANA du navigateur
<input required [value]="report().timeZone" (input)="setTimeZone($any($event.target).value)" />
</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', $any($event.target).value)"
>
<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', $any($event.target).value)"
/>
</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', $any($event.target).value)"
>
@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', $any($event.target).value)"
/>
</label>
<label>
Équipement
<select
[value]="zone.equipment"
(change)="updateZone(zone.id, 'equipment', $any($event.target).value)"
>
<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', $any($event.target).value)"
>
@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', $any($event.target).value)
"
></textarea>
</label>
</fieldset>
}
</section>
@@ -0,0 +1,112 @@
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',
];
@Component({
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;
setOccurredOn(occurredOn: string): void {
this.update((report) => ({ ...report, occurredOn }));
}
setTimeZone(timeZone: string): void {
this.update((report) => ({ ...report, timeZone }));
}
updateParticipant(
index: number,
field: 'attendance' | 'representativeName',
value: string,
): void {
this.update((report) => ({
...report,
participants: report.participants.map((participant, item) =>
item === index ? { ...participant, [field]: value } : 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: '' }],
}));
}
updateZone(id: string, field: 'privateAreaId' | 'label' | 'equipment', value: string): void {
this.update((report) => ({
...report,
zones: report.zones.map((zone) => (zone.id === id ? { ...zone, [field]: value } : 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),
}));
}
canRemoveZone(id: string): boolean {
return (
!id.startsWith('inventory:') &&
![':floor', ':walls', ':ceiling'].some((suffix) => id.endsWith(suffix))
);
}
updateObservation(id: string, field: 'state' | 'observation', value: string): void {
this.update((report) => ({
...report,
observations: report.observations.map((observation) =>
observation.subjectId === id ? { ...observation, [field]: value } : observation,
),
}));
}
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 }));
}
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()));
}
}
@@ -0,0 +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>
}
</form>
@@ -0,0 +1,168 @@
import { Component, input, output } from '@angular/core';
import {
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',
})
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>();
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));
}
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() ||
!Number.isFinite(meter.index) ||
meter.index < 0 ||
(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' &&
this.entryPhotos().some((photo) => {
const disposition = report.entryPhotoDispositions.find(
(item) => item.entryPhotoId === photo.reference,
);
return !disposition?.exitPhotoId && !disposition?.absenceReason?.trim();
})
)
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
return '';
}
private needsObservation(state: ConditionState, observation: string): boolean {
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state) && !observation.trim();
}
}
@@ -0,0 +1,105 @@
<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, $any($event.target).value)"
/>
</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',
$any($event.target).value
)
"
>
<option value="">Aucune</option>
@for (exitPhoto of report().photos; 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',
$any($event.target).value
)
"
/>
</label>
</article>
}
</section>
}
@@ -0,0 +1,151 @@
import { Component, OnDestroy, inject, input, output, signal } from '@angular/core';
import { finalize as rxFinalize } from 'rxjs';
import {
ConditionPhoto,
ConditionReportService,
ConditionReportType,
EntryPhotoDisposition,
PropertyConditionReport,
} from '../../services/condition-report.service';
import { httpErrorMessage } from '../../services/http-error-message';
interface PendingPhoto {
file: File;
previewUrl: string;
}
@Component({
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>();
ngOnDestroy(): void {
Object.values(this.pendingPhotos()).forEach(({ previewUrl }) =>
URL.revokeObjectURL(previewUrl),
);
}
setZoneAbsence(zoneId: string, reason: string): void {
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,
value: string,
): void {
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) },
}));
}
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.successOccurred.emit('Photo ajoutée.');
this.reloadRequested.emit();
},
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.')),
});
}
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,
}
);
}
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()));
}
}
@@ -0,0 +1,208 @@
<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', $any($event.target).value)"
>
<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', $any($event.target).value)"
/>
</label>
<label>
Unité
<input
required
[value]="meter.unit"
(input)="updateMeter(meter.id, 'unit', $any($event.target).value)"
/>
</label>
<label>
Index
<input
type="number"
min="0"
step="any"
required
[value]="meter.index"
(input)="updateMeter(meter.id, 'index', $any($event.target).value)"
/>
</label>
<label>
Référence photo
<input
[value]="meter.photoId"
(input)="updateMeter(meter.id, 'photoId', $any($event.target).value)"
/>
</label>
<label>
Observation
<textarea
[value]="meter.observation"
(input)="updateMeter(meter.id, 'observation', $any($event.target).value)"
></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', $any($event.target).value)"
>
<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', $any($event.target).value)"
/>
</label>
<label>
Quantité
<input
type="number"
min="0"
step="1"
required
[value]="access.quantity"
(input)="updateAccessMean(access.id, 'quantity', $any($event.target).value)"
/>
</label>
<label>
Observation
<textarea
[value]="access.observation"
(input)="updateAccessMean(access.id, 'observation', $any($event.target).value)"
></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', $any($event.target).value)
"
>
@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',
$any($event.target).value
)
"
/>
</label>
<label>
Observation
@if (needsObservation(split.state)) {
<span>(obligatoire)</span>
}
<textarea
[required]="needsObservation(split.state)"
[value]="split.observation"
(input)="
updateInventorySplit(
conditionIndex,
splitIndex,
'observation',
$any($event.target).value
)
"
></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, $any($event.target).value)"
></textarea>
</label>
}
}
</section>
}
@@ -0,0 +1,176 @@
import { Component, input, output } from '@angular/core';
import {
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',
];
@Component({
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;
addMeter(): void {
this.update((report) => ({
...report,
meterReadings: [
...report.meterReadings,
{
id: crypto.randomUUID(),
type: 'ELECTRICITE',
index: 0,
unit: 'kWh',
label: '',
photoId: '',
observation: '',
},
],
}));
}
updateMeter(id: string, field: keyof MeterReading, value: string): void {
this.update((report) => ({
...report,
meterReadings: report.meterReadings.map((meter) =>
meter.id === id ? { ...meter, [field]: field === 'index' ? Number(value) : value } : meter,
),
}));
}
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: '' },
],
}));
}
updateAccessMean(id: string, field: keyof AccessMeans, value: string): void {
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),
}));
}
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,
value: string,
): void {
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),
}));
}
updateForwardingAddress(tenantPartyId: string, address: string): void {
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 ?? ''
);
}
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 updateInventoryCondition(
index: number,
change: (condition: InventoryCondition) => InventoryCondition,
): void {
this.update((report) => ({
...report,
inventoryConditions: report.inventoryConditions.map((condition, item) =>
item === index ? change(condition) : condition,
),
}));
}
}
@@ -0,0 +1,25 @@
section {
margin: 20px 0;
}
label {
display: grid;
gap: 5px;
margin: 10px 0;
}
article {
border: 1px solid var(--border);
padding: 12px;
margin: 10px 0;
}
button {
margin: 6px;
}
img {
max-width: min(100%, 480px);
height: auto;
}
.photo-comparison {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
@@ -0,0 +1,241 @@
<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>
@if (loading()) {
<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 (error()) {
<p role="alert" class="operation-error">{{ error() }}</p>
}
@if (success()) {
<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()"
/>
<section>
<h2>Révision et finalisation</h2>
@if (value.status === 'DRAFT') {
<button type="button" [disabled]="revisionPending() || dirty()" (click)="createRevision()">
{{ revisionPending() ? 'Génération…' : 'Générer et télécharger la révision PDF' }}
</button>
<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)="finalizationAttested.set($any($event.target).checked)"
/>
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)="allPartiesSigned.set($any($event.target).checked)"
/>
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" (click)="downloadBundle()">Télécharger le dossier ZIP</button>
<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>
Date de demande du complément
<input
type="date"
required
[value]="supplementRequestedOn()"
(input)="supplementRequestedOn.set($any($event.target).value)"
/>
</label>
<label>
Ajouts acceptés
<textarea
required
[value]="supplementAcceptedAdditions()"
(input)="supplementAcceptedAdditions.set($any($event.target).value)"
></textarea>
</label>
<label>
Début de la période de chauffe (facultatif)
<input
type="date"
[value]="heatingPeriodStartedOn()"
(input)="heatingPeriodStartedOn.set($any($event.target).value)"
/>
</label>
<label>
Motif du dépassement de délai
<textarea
[value]="supplementReason()"
(input)="supplementReason.set($any($event.target).value)"
></textarea>
</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>
<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 | json }}</td>
<td>{{ line.exit | json }}</td>
<td>{{ line.changed ? 'Oui' : 'Non' }}</td>
<td>{{ line.absence | json }}</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>
@@ -0,0 +1,154 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { vi } from 'vitest';
import {
ConditionReportComparison,
ConditionReportService,
PropertyConditionReport,
} from '../../services/condition-report.service';
import { FileDownloadService } from '../../services/file-download.service';
import { ConditionReportEditor } from './condition-report-editor';
import { ConditionReportPage } from './condition-report-page';
const report: PropertyConditionReport = {
id: 'report-1',
type: 'ENTRY',
status: 'DRAFT',
occurredOn: '2026-07-22',
timeZone: 'Europe/Paris',
participants: [],
forwardingAddresses: [],
zones: [{ id: 'kitchen', privateAreaId: '', label: 'Cuisine', equipment: 'NONE' }],
observations: [],
meterReadings: [],
accessMeans: [],
inventoryConditions: [],
photos: [],
zonePhotoAbsences: [],
entryPhotoDispositions: [],
supplements: [],
entrySnapshot: null,
revisions: [],
currentRevision: null,
finalization: null,
deliveryAttestations: [],
leaseRevisionNumber: 3,
leaseRevisionFingerprint: 'lease-revision-fingerprint',
version: 1,
};
describe('ConditionReportEditor', () => {
it('uses a rear-camera input and explains an invalid degraded observation before saving', () => {
const service = {
getReport: vi.fn().mockReturnValue(of(report)),
saveReport: vi.fn(),
uploadPhoto: vi.fn(),
deletePhoto: vi.fn(),
createRevision: vi.fn(),
downloadRevision: vi.fn(),
finalize: vi.fn(),
downloadBundle: vi.fn(),
getComparison: vi.fn(),
appendSupplement: vi.fn(),
attestDelivery: vi.fn(),
};
TestBed.configureTestingModule({
providers: [{ provide: ConditionReportService, useValue: service }],
});
const fixture = TestBed.createComponent(ConditionReportEditor);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('type', 'ENTRY');
fixture.componentRef.setInput('report', report);
fixture.componentRef.setInput('entryPhotos', []);
fixture.detectChanges();
const editor = fixture.componentInstance;
let error = '';
editor.errorOccurred.subscribe((message) => (error = message));
fixture.componentRef.setInput('report', {
...report,
observations: [{ subjectId: 'kitchen', state: 'DEGRADE', observation: '' }],
});
editor.save();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('input[type=file][capture]').getAttribute('capture'),
).toBe('environment');
expect(error).toContain('observation');
expect(service.saveReport).not.toHaveBeenCalled();
});
});
describe('ConditionReportPage', () => {
it('refreshes the revision version, requires attestation, and renders every comparison section', () => {
const refreshed = { ...report, version: 2 };
const comparison: ConditionReportComparison = {
observations: [],
zones: [],
inventory: [],
meters: [],
accessMeans: [],
supplements: [],
photos: [{ reference: 'photo-1', entry: null, exit: null, changed: true }],
};
const service = {
getReport: vi.fn().mockReturnValueOnce(of(report)).mockReturnValue(of(refreshed)),
createRevision: vi
.fn()
.mockReturnValue(
of({ number: 1, sha256: 'sha', sourceFingerprint: 'source', obsolete: false }),
),
downloadRevision: vi.fn().mockReturnValue(of(new Blob(['pdf']))),
finalize: vi.fn().mockReturnValue(of({ ...refreshed, status: 'FINALIZED' as const })),
getComparison: vi.fn().mockReturnValue(of(comparison)),
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
};
const downloads = { download: vi.fn() };
TestBed.configureTestingModule({
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ leaseId: 'lease-1', type: 'ENTRY' }) },
},
},
{ provide: ConditionReportService, useValue: service },
{ provide: FileDownloadService, useValue: downloads },
],
});
const fixture = TestBed.createComponent(ConditionReportPage);
fixture.detectChanges();
const page = fixture.componentInstance;
page.dirty.set(true);
page.createRevision();
expect(service.createRevision).not.toHaveBeenCalled();
page.dirty.set(false);
page.createRevision();
expect(service.getReport).toHaveBeenCalledTimes(2);
expect(page.report()?.version).toBe(2);
page.signedScan.set(new File(['pdf'], 'signed.pdf', { type: 'application/pdf' }));
page.finalize();
expect(service.finalize).not.toHaveBeenCalled();
page.finalizationAttested.set(true);
page.finalize();
expect(service.finalize).not.toHaveBeenCalled();
page.allPartiesSigned.set(true);
page.finalize();
expect(service.finalize).toHaveBeenCalledWith('lease-1', 'ENTRY', {
signedReport: expect.any(File),
version: 2,
allPartiesSigned: true,
});
page.loadComparison();
expect(page.comparisonSections().map((section) => section.title)).toEqual([
'Observations',
'Zones',
'Inventaire',
'Compteurs',
'Moyens d’accès',
'Compléments acceptés',
]);
});
});
@@ -0,0 +1,280 @@
import { JsonPipe } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { finalize as rxFinalize } from 'rxjs';
import {
ConditionPhoto,
ConditionReportComparison,
ConditionReportService,
ConditionReportType,
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, JsonPipe, 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 [
{ title: 'Observations', lines: comparison.observations },
{ title: 'Zones', lines: comparison.zones },
{ title: 'Inventaire', lines: comparison.inventory },
{ title: 'Compteurs', lines: comparison.meters },
{ title: 'Moyens d’accès', lines: comparison.accessMeans },
{ title: 'Compléments acceptés', lines: comparison.supplements },
];
});
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 heatingPeriodStartedOn = signal('');
readonly supplementReason = signal('');
readonly supplementSignedPdf = signal<File | null>(null);
readonly supplementPhotos = signal<File[]>([]);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.error.set('');
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 ?? []);
},
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.error.set('');
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;
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.success.set(`Révision ${revision.number} générée.`);
this.load();
},
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);
}
finalize(): void {
const report = this.report();
const scan = this.signedScan();
if (
!report ||
!scan ||
!this.finalizationAttested() ||
!this.allPartiesSigned() ||
this.finalizing()
) {
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.',
);
}
loadComparison(): void {
if (this.comparisonPending()) return;
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.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);
}
selectSupplementPhotos(event: Event): void {
const input = event.target instanceof HTMLInputElement ? event.target : null;
this.supplementPhotos.set(input?.files ? Array.from(input.files) : []);
if (input) input.value = '';
}
appendSupplement(): void {
const signedPdf = this.supplementSignedPdf();
const requestedOn = this.supplementRequestedOn().trim();
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
if (!signedPdf || !requestedOn || !acceptedAdditions || this.supplementPending()) {
if (!signedPdf || !requestedOn || !acceptedAdditions) {
this.error.set('Renseignez la date, les ajouts acceptés et le PDF signé du complément.');
}
return;
}
this.supplementPending.set(true);
this.reports
.appendSupplement(this.leaseId, {
signedPdf,
photos: this.supplementPhotos(),
requestedOn,
acceptedAdditions,
heatingPeriodStartedOn: this.heatingPeriodStartedOn().trim() || undefined,
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.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;
}
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);
}
}
@@ -0,0 +1,16 @@
.inventory-line {
margin: 1rem 0;
}
.inventory-line label {
display: block;
margin: 0.5rem 0;
}
.inventory-line span {
display: block;
}
.inventory-line input,
.inventory-line select,
.inventory-line textarea {
max-width: 32rem;
width: 100%;
}
@@ -0,0 +1,173 @@
<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>
@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>
@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>
</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>
<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>
}
</section>
@@ -0,0 +1,80 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { vi } from 'vitest';
import {
ConditionReportService,
FurnitureInventory,
} from '../../services/condition-report.service';
import { InventoryPage } from './inventory-page';
const inventory: FurnitureInventory = {
id: 'inventory-1',
templateVersion: '2025-01',
lines: [
{
id: 'bed-1',
category: 'BEDDING',
presence: 'PRESENT',
privateAreaId: 'bedroom-1',
designation: 'Lit',
quantity: 1,
description: '',
},
],
missingCategories: [],
absentCategories: [],
legalWarnings: [],
readyForLeaseDocument: true,
frozen: false,
privateAreas: [{ id: 'bedroom-1', label: 'Chambre' }],
version: 3,
};
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: ConditionReportService, 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();
fixture.componentInstance.save();
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,
});
fixture.componentInstance.save();
fixture.detectChanges();
expect(service.saveInventory).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Déclarez la présence ou l’absence');
});
});
@@ -0,0 +1,188 @@
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { finalize as rxFinalize } from 'rxjs';
import {
ConditionReportService,
FurnitureInventory,
FurnitureInventoryLine,
FurniturePresence,
} from '../../services/condition-report.service';
import { httpErrorMessage } from '../../services/http-error-message';
const emptyInventory: FurnitureInventory = {
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',
})
export class InventoryPage {
private readonly route = inject(ActivatedRoute);
private readonly inventories = inject(ConditionReportService);
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();
}
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 });
}
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,
});
}
addLine(): void {
if (this.inventory().frozen || this.saving()) return;
this.inventory.update((inventory) => ({
...inventory,
lines: [
...inventory.lines,
{
id: crypto.randomUUID(),
category: null,
presence: null,
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),
}));
}
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.')),
});
}
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;
}
}
@@ -124,6 +124,18 @@
}
</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"
@@ -154,6 +166,20 @@
}
</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"
@@ -199,6 +199,7 @@ describe('LeaseCard', () => {
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'),
@@ -206,6 +207,26 @@ describe('LeaseCard', () => {
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('lease', {
...fixture.componentInstance.lease(),
status: 'ACTIVE',
@@ -33,6 +33,8 @@ export class LeaseCard {
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;
@@ -138,6 +138,8 @@
[timeZone]="timeZone"
[showAgreement]="canRecordAgreement()"
[showClosure]="canClose()"
[keysReturnedOn]="exitReportDate()"
[closureEnabled]="closureReady()"
[agreementPending]="operationPending('agreement')"
[closurePending]="operationPending('closure')"
[agreementOperationError]="operationError('agreement')"
@@ -23,6 +23,10 @@ describe('LeaseEndPage', () => {
agreedEarlyEnds: [],
agreedEarlyEnd: null,
closure: null,
exitConditionReport: {
occurredOn: '2026-07-31',
finalized: true,
},
notices: [
{
id: 'notice-1',
@@ -247,7 +251,6 @@ describe('LeaseEndPage', () => {
fixture.componentInstance.closeLease({
timeZone: 'Europe/Paris',
keysReturnedOn: '2026-08-19',
confirmed: true,
});
fixture.detectChanges();
@@ -84,6 +84,11 @@ export class LeaseEndPage {
(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;
@@ -46,6 +46,14 @@ textarea {
.confirmation-field input {
flex: 0 0 auto;
}
.report-date {
display: grid;
gap: 0.25rem;
margin: 0;
}
.report-date span {
color: var(--text-muted, #5f6368);
}
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent, #236b5d);
@@ -38,10 +38,10 @@
(submit)="submitClosure(); $event.preventDefault()"
>
<fieldset [disabled]="closurePending()">
<label>
<span>Date réelle de restitution des clés</span>
<input type="date" [formField]="closureForm.keysReturnedOn" />
</label>
<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>
@@ -51,7 +51,7 @@
{{ closureError() || closureOperationError() }}
</p>
}
<button mat-flat-button type="submit" [disabled]="closurePending()">
<button mat-flat-button type="submit" [disabled]="closurePending() || !closureEnabled()">
{{ closurePending() ? 'Clôture…' : 'Clôturer le bail' }}
</button>
</fieldset>
@@ -30,23 +30,23 @@ describe('LeaseEndResolutionForms', () => {
expect(fixture.nativeElement.querySelector('textarea').maxLength).toBe(500);
});
it('only emits closure after explicit confirmation and keeps the civil day', () => {
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({
keysReturnedOn: '2026-07-19',
confirmed: false,
});
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.closureModel.update((model) => ({ ...model, confirmed: true }));
fixture.componentInstance.submitClosure();
expect(requested).toHaveBeenCalledWith({
timeZone: 'America/Los_Angeles',
keysReturnedOn: '2026-07-19',
confirmed: true,
});
});
@@ -18,10 +18,12 @@ export class LeaseEndResolutionForms {
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({ keysReturnedOn: '', confirmed: false });
readonly closureModel = signal({ confirmed: false });
readonly agreementForm = form(this.agreementModel, (fields) => {
maxLength(fields.reason, 500);
});
@@ -47,8 +49,8 @@ export class LeaseEndResolutionForms {
submitClosure(): void {
if (this.closurePending()) return;
const model = this.closureModel();
if (!model.keysReturnedOn) {
this.closureError.set('Renseignez la date réelle de restitution des clés.');
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) {
@@ -58,7 +60,6 @@ export class LeaseEndResolutionForms {
this.closureError.set('');
this.closureRequested.emit({
timeZone: this.timeZone(),
keysReturnedOn: model.keysReturnedOn,
confirmed: true,
});
}
@@ -76,4 +76,97 @@
</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>
@@ -1,16 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseRevisionMetadata } from '../../services/lease.service';
import { FinalizationRevision } from '../../services/lease-finalization.service';
import { LeaseFinalizationForm } from './lease-finalization-form';
describe('LeaseFinalizationForm', () => {
const revision: LeaseRevisionMetadata = {
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,
privateAreaId: 'area-1',
designation: 'Chaise',
quantity: 1,
description: '',
},
],
},
privateAreas: [
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'LIVING_ROOM', label: 'Séjour' },
],
annexes: [],
warningCodes: [],
warnings: [],
@@ -42,6 +59,7 @@ describe('LeaseFinalizationForm', () => {
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);
@@ -59,6 +77,9 @@ describe('LeaseFinalizationForm', () => {
'IRL_REVISION_WITH_F_OR_G_DPE',
'FUTURE_WARNING',
],
entryInventoryDiscrepancies: false,
inventoryCorrections: [],
allInventoryCorrectionsInitialed: false,
});
});
@@ -120,4 +141,62 @@ describe('LeaseFinalizationForm', () => {
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);
component.submit();
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',
},
]);
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');
});
});
@@ -1,9 +1,13 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, ElementRef, input, output, signal, viewChild } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FinalizeLeaseRequest } from '../../services/lease-finalization.service';
import { LeaseRevisionMetadata, LeaseRevisionWarning } from '../../services/lease.service';
import {
FinalizationRevision,
FinalizeLeaseRequest,
InventoryCorrection,
} from '../../services/lease-finalization.service';
import { LeaseRevisionWarning } from '../../services/lease.service';
import { leaseWarningConfirmation } from '../../services/lease-warning-label';
@Component({
@@ -13,7 +17,8 @@ import { leaseWarningConfirmation } from '../../services/lease-warning-label';
styleUrl: './lease-finalization-form.css',
})
export class LeaseFinalizationForm {
readonly revision = input.required<LeaseRevisionMetadata>();
readonly revision = input.required<FinalizationRevision>();
readonly suggestedInventoryCorrections = input<InventoryCorrection[]>([]);
readonly warningCodes = input<string[]>([]);
readonly warnings = input<LeaseRevisionWarning[]>([]);
readonly guaranteesComplete = input(true);
@@ -27,6 +32,11 @@ export class LeaseFinalizationForm {
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;
@@ -61,6 +71,15 @@ export class LeaseFinalizationForm {
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();
@@ -72,9 +91,108 @@ export class LeaseFinalizationForm {
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();
}
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: '',
},
]);
}
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,
),
);
}
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();
}
private errorMessage(): string {
if (!this.guaranteesComplete()) {
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
@@ -87,6 +205,26 @@ export class LeaseFinalizationForm {
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),
);
@@ -83,6 +83,7 @@
<app-lease-finalization-form
[revision]="finalizationState.revision"
[suggestedInventoryCorrections]="finalizationState.suggestedInventoryCorrections"
[warningCodes]="finalizationState.warningCodes"
[warnings]="finalizationState.warnings"
[guaranteesComplete]="guaranteesComplete()"
@@ -20,6 +20,8 @@ describe('LeaseFinalizationPage', () => {
sha256: 'sha256',
generatedAt: '2026-07-19T10:00:00Z',
authorId: 'owner-1',
furnitureInventory: { templateVersion: 'FR-MEUBLE-2025-01', lines: [] },
privateAreas: [],
annexes: [],
warningCodes: [],
warnings: [],
@@ -30,6 +32,7 @@ describe('LeaseFinalizationPage', () => {
warningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
warnings: [{ code: 'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', ruleVersion: '2026-07-01' }],
guaranteeRequirements: [],
suggestedInventoryCorrections: [],
finalization: null,
};
const finalization: FinalizationMetadata = {
@@ -41,6 +44,9 @@ describe('LeaseFinalizationPage', () => {
authorId: 'owner-1',
finalizedAt: '2026-07-19T10:30:00Z',
overrides: [],
entryInventoryDiscrepancies: false,
inventoryCorrections: [],
allInventoryCorrectionsInitialed: false,
};
const lease = {
id: 'lease-1',
@@ -129,6 +135,9 @@ describe('LeaseFinalizationPage', () => {
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
entryInventoryDiscrepancies: false,
inventoryCorrections: [],
allInventoryCorrectionsInitialed: false,
};
fixture.componentInstance.requestFinalization(request);
@@ -168,6 +177,9 @@ describe('LeaseFinalizationPage', () => {
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
entryInventoryDiscrepancies: false,
inventoryCorrections: [],
allInventoryCorrectionsInitialed: false,
};
fixture.componentInstance.requestFinalization(request);
@@ -73,6 +73,8 @@
(editRequested)="openForm($event)"
(finalizationRequested)="openFinalization($event)"
(leaseEndRequested)="openLeaseEnd($event)"
(inventoryRequested)="openInventory($event)"
(conditionReportRequested)="openConditionReport($event)"
(deleteRequested)="workspace.deleteLease($event)"
/>
}
@@ -81,6 +81,14 @@ export class LeasesPage {
void this.router.navigate(['/leases', leaseId, 'end']);
}
openInventory(leaseId: string): void {
void this.router.navigate(['/leases', leaseId, 'inventory']);
}
openConditionReport(event: { leaseId: string; type: 'ENTRY' | 'EXIT' }): void {
void this.router.navigate(['/leases', event.leaseId, 'condition-reports', event.type]);
}
retryLoad(): void {
if (!this.workspace.apartmentLoadState().loaded || this.workspace.apartmentLoadState().error)
this.workspace.loadApartments();
@@ -0,0 +1,17 @@
.private-areas {
display: grid;
gap: 12px;
}
.private-areas fieldset {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.private-areas label {
display: grid;
gap: 4px;
}
.private-areas input,
.private-areas select {
min-height: 40px;
}
@@ -0,0 +1,38 @@
<section class="form-section" aria-labelledby="private-areas-heading">
<div class="section-heading">
<h2 id="private-areas-heading">Espaces privatifs</h2>
<p>
Le nombre réglementaire de pièces principales est calculé depuis ces espaces :
{{ mainRoomCount() }}.
</p>
</div>
<div class="private-areas">
@for (area of areas(); track area.id) {
<fieldset>
<legend>{{ area.label || 'Espace privatif' }}</legend>
<label>
<span>Famille</span>
<select [value]="area.family" (change)="updateArea(area.id, 'family', $event)">
@for (family of families; track family.value) {
<option [value]="family.value">{{ family.label }}</option>
}
</select>
</label>
<label>
<span>Sous-type</span>
<select [value]="area.subtype" (change)="updateArea(area.id, 'subtype', $event)">
@for (subtype of subtypesFor(area.family); track subtype.value) {
<option [value]="subtype.value">{{ subtype.label }}</option>
}
</select>
</label>
<label>
<span>Libellé</span>
<input [value]="area.label" (input)="updateArea(area.id, 'label', $event)" />
</label>
<button type="button" (click)="removeArea(area.id)">Supprimer</button>
</fieldset>
}
</div>
<button type="button" (click)="addArea()">Ajouter un espace privatif</button>
</section>
@@ -0,0 +1,20 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { PrivateAreaEditor } from './private-area-editor';
describe('PrivateAreaEditor', () => {
it('adds a stable private area and derives the main-room count from its family', () => {
const fixture = TestBed.createComponent(PrivateAreaEditor);
fixture.componentRef.setInput('areas', []);
const changed = vi.fn();
fixture.componentInstance.areasChanged.subscribe(changed);
fixture.componentInstance.addArea();
expect(changed).toHaveBeenCalledWith([
expect.objectContaining({ family: 'PIECE_PRINCIPALE', subtype: 'OTHER' }),
]);
fixture.componentRef.setInput('areas', changed.mock.calls[0][0]);
expect(fixture.componentInstance.mainRoomCount()).toBe(1);
});
});
@@ -0,0 +1,86 @@
import { Component, computed, input, output } from '@angular/core';
import {
PrivateArea,
PrivateAreaFamily,
PrivateAreaSubtype,
} from '../../services/apartment.service';
const families: readonly { value: PrivateAreaFamily; label: string }[] = [
{ value: 'PIECE_PRINCIPALE', label: 'Pièce principale' },
{ value: 'PIECE_DE_SERVICE', label: 'Pièce de service' },
{ value: 'ANNEXE', label: 'Annexe' },
];
const subtypes: readonly { value: PrivateAreaSubtype; label: string }[] = [
{ value: 'LIVING_ROOM', label: 'Séjour' },
{ value: 'DINING_ROOM', label: 'Salle à manger' },
{ value: 'BEDROOM', label: 'Chambre' },
{ value: 'KITCHEN', label: 'Cuisine' },
{ value: 'BATHROOM', label: 'Salle de bains' },
{ value: 'TOILET', label: 'Toilettes' },
{ value: 'HALLWAY', label: 'Couloir' },
{ value: 'LAUNDRY_ROOM', label: 'Buanderie' },
{ value: 'BALCONY', label: 'Balcon' },
{ value: 'TERRACE', label: 'Terrasse' },
{ value: 'CELLAR', label: 'Cave' },
{ value: 'PARKING', label: 'Stationnement' },
{ value: 'OTHER', label: 'Autre' },
];
const subtypeFamilies: Record<PrivateAreaFamily, readonly PrivateAreaSubtype[]> = {
PIECE_PRINCIPALE: ['LIVING_ROOM', 'DINING_ROOM', 'BEDROOM', 'OTHER'],
PIECE_DE_SERVICE: ['KITCHEN', 'BATHROOM', 'TOILET', 'HALLWAY', 'LAUNDRY_ROOM', 'OTHER'],
ANNEXE: ['BALCONY', 'TERRACE', 'CELLAR', 'PARKING', 'OTHER'],
};
@Component({
selector: 'app-private-area-editor',
templateUrl: './private-area-editor.html',
styleUrl: './private-area-editor.css',
})
export class PrivateAreaEditor {
readonly areas = input.required<PrivateArea[]>();
readonly areasChanged = output<PrivateArea[]>();
readonly families = families;
readonly mainRoomCount = computed(
() => this.areas().filter(({ family }) => family === 'PIECE_PRINCIPALE').length,
);
addArea(): void {
this.areasChanged.emit([
...this.areas(),
{
id: crypto.randomUUID(),
family: 'PIECE_PRINCIPALE',
subtype: 'OTHER',
label: '',
},
]);
}
updateArea(id: string, field: keyof Omit<PrivateArea, 'id'>, event: Event): void {
const value =
event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement
? event.target.value
: '';
this.areasChanged.emit(
this.areas().map((area) =>
area.id === id
? {
...area,
[field]: value,
...(field === 'family' ? { subtype: 'OTHER' as const } : {}),
}
: area,
),
);
}
subtypesFor(family: PrivateAreaFamily): typeof subtypes {
return subtypes.filter(({ value }) => subtypeFamilies[family].includes(value));
}
removeArea(id: string): void {
this.areasChanged.emit(this.areas().filter((area) => area.id !== id));
}
}
@@ -30,6 +30,7 @@ describe('ApartmentService', () => {
address: 'Addr 1',
purchasePrice: 100000,
description: 'Desc 1',
privateAreas: [],
legalProfile,
},
];
@@ -50,6 +51,7 @@ describe('ApartmentService', () => {
address: 'Addr 1',
purchasePrice: 100000,
description: 'Desc 1',
privateAreas: [],
legalProfile,
};
let result: Apartment | undefined;
@@ -68,6 +70,7 @@ describe('ApartmentService', () => {
address: 'Addr 1',
purchasePrice: 100000,
description: 'Desc 1',
privateAreas: [],
legalProfile,
};
let result: Apartment | undefined;
@@ -81,12 +84,43 @@ describe('ApartmentService', () => {
expect(result?.id).toBe('2');
});
it('sends stable private areas and never a separately stored room count', () => {
const apartment: Apartment = {
name: 'T2 République',
address: '12 rue de la République, Lyon',
purchasePrice: 185000,
description: '',
legalProfile,
privateAreas: [
{
id: 'living-room',
family: 'PIECE_PRINCIPALE',
subtype: 'LIVING_ROOM',
label: 'Séjour',
},
{ id: 'balcony', family: 'ANNEXE', subtype: 'BALCONY', label: 'Balcon' },
],
};
service.createApartment(apartment).subscribe();
const request = http.expectOne('/api/apartments');
expect(request.request.body).toEqual(
expect.objectContaining({
privateAreas: apartment.privateAreas,
}),
);
expect(request.request.body.legalProfile.roomCount).toBeUndefined();
request.flush({ id: 'apartment-1', ...apartment, mainRoomCount: 1 });
});
it('should call PUT /api/apartments/:id', () => {
const apartment: Apartment = {
name: 'Updated Apt',
address: 'Addr 1',
purchasePrice: 100000,
description: 'Desc 1',
privateAreas: [],
legalProfile,
};
let result: Apartment | undefined;
+24 -4
View File
@@ -6,6 +6,21 @@ export type BuildingType = 'APARTMENT_BUILDING' | 'INDIVIDUAL_HOUSE';
export type ConstructionPeriod =
'BEFORE_1949' | '1949_1974' | '1975_1989' | '1990_2005' | '2006_2012' | 'AFTER_2012';
export type EnergyRating = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G';
export type PrivateAreaFamily = 'PIECE_PRINCIPALE' | 'PIECE_DE_SERVICE' | 'ANNEXE';
export type PrivateAreaSubtype =
| 'LIVING_ROOM'
| 'DINING_ROOM'
| 'BEDROOM'
| 'KITCHEN'
| 'BATHROOM'
| 'TOILET'
| 'HALLWAY'
| 'LAUNDRY_ROOM'
| 'BALCONY'
| 'TERRACE'
| 'CELLAR'
| 'PARKING'
| 'OTHER';
export type ApartmentDocumentType =
| 'DPE'
| 'LEAD'
@@ -41,8 +56,6 @@ export interface ApartmentLegalProfile {
ownershipRegime: string;
constructionPeriod: ConstructionPeriod | null;
habitableSurface: number | null;
roomCount: number;
otherPrivateAreas: string;
equipment: string;
heating: string;
hotWater: string;
@@ -56,6 +69,13 @@ export interface ApartmentLegalProfile {
departmentCode: string;
}
export interface PrivateArea {
id: string;
family: PrivateAreaFamily;
subtype: PrivateAreaSubtype;
label: string;
}
export interface Apartment {
id?: string;
name: string;
@@ -63,6 +83,8 @@ export interface Apartment {
purchasePrice: number;
description: string;
legalProfile: ApartmentLegalProfile;
privateAreas: PrivateArea[];
mainRoomCount?: number;
}
export function emptyApartmentLegalProfile(): ApartmentLegalProfile {
@@ -72,8 +94,6 @@ export function emptyApartmentLegalProfile(): ApartmentLegalProfile {
ownershipRegime: '',
constructionPeriod: null,
habitableSurface: null,
roomCount: 0,
otherPrivateAreas: '',
equipment: '',
heating: '',
hotWater: '',
@@ -0,0 +1,119 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ConditionReportComparison, ConditionReportService } from './condition-report.service';
describe('ConditionReportService', () => {
let service: ConditionReportService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(ConditionReportService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('uses the versioned condition-report contract and browser time zone', () => {
service.getReport('lease-1', 'ENTRY', 'Europe/Paris').subscribe();
http.expectOne('/api/leases/lease-1/condition-reports/ENTRY?timeZone=Europe/Paris').flush({});
service.saveReport('lease-1', 'ENTRY', { version: 4 } as never).subscribe();
const save = http.expectOne('/api/leases/lease-1/condition-reports/ENTRY');
expect(save.request.method).toBe('PUT');
expect(save.request.body.version).toBe(4);
save.flush({});
const photo = new File(['image'], 'cuisine.webp', { type: 'image/webp' });
service.uploadPhoto('lease-1', 'ENTRY', photo, 'cuisine').subscribe();
const upload = http.expectOne('/api/leases/lease-1/condition-reports/ENTRY/photos');
expect((upload.request.body as FormData).get('zoneId')).toBe('cuisine');
upload.flush({ version: 5 });
service.deletePhoto('lease-1', 'ENTRY', 'photo-1', 5).subscribe();
const deletion = http.expectOne(
'/api/leases/lease-1/condition-reports/ENTRY/photos/photo-1?version=5',
);
expect(deletion.request.method).toBe('DELETE');
deletion.flush(null);
expect(service.photoUrl('lease 1', 'ENTRY', 'photo/1')).toBe(
'/api/leases/lease%201/condition-reports/ENTRY/photos/photo%2F1',
);
});
it('sends finalization evidence and revision version without a duplicate submit contract', () => {
const scan = new File(['pdf'], 'edl.pdf', { type: 'application/pdf' });
service
.finalize('lease-1', 'EXIT', { signedReport: scan, version: 8, allPartiesSigned: true })
.subscribe();
const request = http.expectOne('/api/leases/lease-1/condition-reports/EXIT/finalization');
const formData = request.request.body as FormData;
expect(formData.get('file')).toBe(scan);
expect(formData.get('version')).toBe('8');
expect(formData.get('attestation')).toBe('true');
expect(formData.get('allPartiesSigned')).toBe('true');
request.flush({});
});
it('sends the accepted supplement facts, including the optional heating period and late reason', () => {
const signedPdf = new File(['pdf'], 'complement.pdf', { type: 'application/pdf' });
const photo = new File(['photo'], 'cuisine.webp', { type: 'image/webp' });
service
.appendSupplement('lease-1', {
signedPdf,
photos: [photo],
requestedOn: '2026-07-20',
acceptedAdditions: 'Rayure sur le mur de la cuisine.',
heatingPeriodStartedOn: '2026-07-01',
reason: 'Demande reçue après le délai ordinaire.',
})
.subscribe();
const request = http.expectOne('/api/leases/lease-1/condition-reports/ENTRY/supplements');
const formData = request.request.body as FormData;
expect(formData.get('signedPdf')).toBe(signedPdf);
expect(formData.getAll('photos')).toEqual([photo]);
expect(formData.get('requestedOn')).toBe('2026-07-20');
expect(formData.get('acceptedAdditions')).toBe('Rayure sur le mur de la cuisine.');
expect(formData.get('heatingPeriodStartedOn')).toBe('2026-07-01');
expect(formData.get('reason')).toBe('Demande reçue après le délai ordinaire.');
request.flush({});
});
it('reads every deterministic comparison section and its changed flag', () => {
const comparison: ConditionReportComparison = {
observations: [{ reference: 'kitchen:walls', entry: null, exit: null, changed: false }],
zones: [],
inventory: [],
meters: [],
accessMeans: [],
supplements: [],
photos: [
{
reference: 'photo-1',
entry: null,
exit: null,
absence: {
reason: 'Photo indisponible',
auditedBy: 'landlord-1',
auditedAt: '2026-07-22T10:00:00Z',
},
changed: true,
},
],
};
service.getComparison('lease-1').subscribe((received) => {
expect(received.observations[0]?.reference).toBe('kitchen:walls');
expect(received.photos[0]?.changed).toBe(true);
expect(received.photos[0]?.absence?.reason).toBe('Photo indisponible');
});
const request = http.expectOne('/api/leases/lease-1/condition-report-comparison');
expect(request.request.method).toBe('GET');
request.flush(comparison);
});
});
@@ -0,0 +1,405 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export type ConditionReportType = 'ENTRY' | 'EXIT';
export type ConditionReportStatus = 'DRAFT' | 'OBSOLETE' | 'FINALIZED';
export type ConditionState =
'NEUF' | 'BON_ETAT' | 'ETAT_USAGE' | 'DEGRADE' | 'ABSENT' | 'NON_APPLICABLE';
export type ReportPartyRole = 'LANDLORD' | 'TENANT';
export type ParticipantAttendance = 'PRESENT' | 'REPRESENTED';
export type ZoneEquipment = 'NONE' | 'CLIMATISATION' | 'OTHER';
export type MeterType = 'ELECTRICITE' | 'GAZ' | 'EAU_FROIDE' | 'EAU_CHAUDE' | 'OTHER';
export type AccessMeansType = 'CLE' | 'BADGE' | 'TELECOMMANDE' | 'OTHER';
export type FurniturePresence = 'PRESENT' | 'ABSENT';
export type FurnitureCategory =
| 'BEDDING'
| 'SHUTTERS'
| 'HOTPLATES'
| 'OVEN_OR_MICROWAVE'
| 'REFRIGERATOR_WITH_FREEZER'
| 'DISHES'
| 'UTENSILS'
| 'TABLE_AND_SEATING'
| 'STORAGE'
| 'LIGHTING'
| 'CLEANING';
export interface ReportParticipant {
partyId: string;
role: ReportPartyRole;
name: string;
attendance: ParticipantAttendance;
representativeName: string;
}
export interface ForwardingAddress {
tenantPartyId: string;
address: string;
}
export interface ConditionZone {
id: string;
privateAreaId: string;
label: string;
equipment: ZoneEquipment;
}
export interface ConditionObservation {
subjectId: string;
state: ConditionState;
observation: string;
}
export interface MeterReading {
id: string;
type: MeterType;
index: number;
unit: string;
label: string;
photoId: string;
observation: string;
}
export interface AccessMeans {
id: string;
type: AccessMeansType;
destination: string;
quantity: number;
observation: string;
}
export interface InventoryQuantitySplit {
inventoryLineId: string;
state: ConditionState;
quantity: number;
observation: string;
}
export interface InventoryCondition {
inventoryLineId: string;
privateAreaId: string;
privateAreaLabel: string;
designation: string;
contractualQuantity: number;
splits: InventoryQuantitySplit[];
}
export interface ConditionPhoto {
reference: string;
zoneId: string;
sha256: string;
width: number;
height: number;
uploadedBy: string;
uploadedAt: string;
}
export interface ZonePhotoAbsence {
zoneId: string;
absence: { reason: string; author: string; recordedAt: string };
}
export interface EntryPhotoDisposition {
entryPhotoId: string;
exitPhotoId?: string;
absenceReason?: string;
}
export interface PropertyConditionReportSupplement {
id: string;
signedPdf: { reference: string; sha256: string };
photos: { reference: string; sha256: string }[];
uploadedBy: string;
uploadedAt: string;
requestedOn: string;
acceptedAdditions: string;
audit: unknown;
}
export interface PropertyConditionReportDeliveryAttestation {
partyId: string;
attestedBy: string;
attestedAt: string;
}
export interface PropertyConditionReportRevision {
number: number;
sha256: string;
sourceFingerprint: string;
obsolete: boolean;
}
export interface EntryReportSnapshot {
reportId: string;
participants: ReportParticipant[];
zones: ConditionZone[];
observations: ConditionObservation[];
meterReadings: MeterReading[];
accessMeans: AccessMeans[];
inventoryConditions: InventoryCondition[];
photos: ConditionPhoto[];
supplements: PropertyConditionReportSupplement[];
}
export interface PropertyConditionReportFinalization {
revisionNumber: number;
signedSha256: string;
authorId: string;
finalizedAt: string;
}
export interface PropertyConditionReport {
id: string;
type: ConditionReportType;
status: ConditionReportStatus;
occurredOn: string;
timeZone: string;
participants: ReportParticipant[];
forwardingAddresses: ForwardingAddress[];
zones: ConditionZone[];
observations: ConditionObservation[];
meterReadings: MeterReading[];
accessMeans: AccessMeans[];
inventoryConditions: InventoryCondition[];
photos: ConditionPhoto[];
zonePhotoAbsences: ZonePhotoAbsence[];
entryPhotoDispositions: EntryPhotoDisposition[];
supplements: PropertyConditionReportSupplement[];
entrySnapshot: EntryReportSnapshot | null;
revisions: PropertyConditionReportRevision[];
currentRevision: PropertyConditionReportRevision | null;
finalization: PropertyConditionReportFinalization | null;
deliveryAttestations: PropertyConditionReportDeliveryAttestation[];
leaseRevisionNumber: number | null;
leaseRevisionFingerprint: string | null;
version: number;
}
export interface SaveConditionReportRequest {
version: number;
occurredOn: string;
timeZone: string;
participants: ReportParticipant[];
forwardingAddresses: ForwardingAddress[];
zones: ConditionZone[];
observations: ConditionObservation[];
meterReadings: MeterReading[];
accessMeans: AccessMeans[];
inventoryConditions: InventoryCondition[];
zonePhotoAbsences: { zoneId: string; reason: string }[];
entryPhotoDispositions: EntryPhotoDisposition[];
}
export interface FurnitureInventoryLine {
id: string;
category: FurnitureCategory | null;
presence: FurniturePresence | null;
privateAreaId: string;
designation: string;
quantity: number;
description: string;
}
export interface FurnitureInventoryWarning {
code: string;
ruleVersion: string;
category: FurnitureCategory;
}
export interface FurnitureInventory {
id: string;
templateVersion: string;
lines: FurnitureInventoryLine[];
missingCategories: FurnitureCategory[];
absentCategories: FurnitureCategory[];
legalWarnings: FurnitureInventoryWarning[];
readyForLeaseDocument: boolean;
frozen: boolean;
privateAreas: { id: string; label: string }[];
version: number;
}
export interface SaveFurnitureInventoryRequest {
version: number;
lines: FurnitureInventoryLine[];
}
export interface ReportFinalizationRequest {
signedReport: File;
version: number;
allPartiesSigned: boolean;
}
export interface AppendConditionReportSupplementRequest {
signedPdf: File;
photos: File[];
requestedOn: string;
acceptedAdditions: string;
heatingPeriodStartedOn?: string;
reason?: string;
}
export interface ConditionReportComparisonLine<T> {
reference: string;
entry: T | null;
exit: T | null;
changed: boolean;
absence?: { reason: string; auditedBy: string; auditedAt: string } | null;
}
export interface ConditionReportComparison {
observations: ConditionReportComparisonLine<ConditionObservation>[];
zones: ConditionReportComparisonLine<ConditionZone>[];
inventory: ConditionReportComparisonLine<InventoryCondition>[];
meters: ConditionReportComparisonLine<MeterReading>[];
accessMeans: ConditionReportComparisonLine<AccessMeans>[];
supplements: ConditionReportComparisonLine<PropertyConditionReportSupplement>[];
photos: ConditionReportComparisonLine<ConditionPhoto>[];
}
@Injectable({ providedIn: 'root' })
export class ConditionReportService {
private readonly http = inject(HttpClient);
getInventory(leaseId: string): Observable<FurnitureInventory> {
return this.http.get<FurnitureInventory>(`/api/leases/${leaseId}/inventory`);
}
saveInventory(
leaseId: string,
request: SaveFurnitureInventoryRequest,
): Observable<FurnitureInventory> {
return this.http.put<FurnitureInventory>(`/api/leases/${leaseId}/inventory`, request);
}
getReport(
leaseId: string,
type: ConditionReportType,
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
): Observable<PropertyConditionReport> {
return this.http.get<PropertyConditionReport>(
`/api/leases/${leaseId}/condition-reports/${type}`,
{ params: new HttpParams().set('timeZone', timeZone) },
);
}
saveReport(
leaseId: string,
type: ConditionReportType,
report: SaveConditionReportRequest,
): Observable<PropertyConditionReport> {
return this.http.put<PropertyConditionReport>(
`/api/leases/${leaseId}/condition-reports/${type}`,
report,
);
}
uploadPhoto(
leaseId: string,
type: ConditionReportType,
file: File,
zoneId: string,
): Observable<PropertyConditionReport> {
const formData = new FormData();
formData.append('file', file);
formData.append('zoneId', zoneId);
return this.http.post<PropertyConditionReport>(
`/api/leases/${leaseId}/condition-reports/${type}/photos`,
formData,
);
}
deletePhoto(
leaseId: string,
type: ConditionReportType,
reference: string,
version: number,
): Observable<void> {
return this.http.delete<void>(
`/api/leases/${leaseId}/condition-reports/${type}/photos/${reference}`,
{ params: new HttpParams().set('version', version) },
);
}
photoUrl(leaseId: string, type: ConditionReportType, reference: string): string {
return `/api/leases/${encodeURIComponent(leaseId)}/condition-reports/${type}/photos/${encodeURIComponent(reference)}`;
}
createRevision(
leaseId: string,
type: ConditionReportType,
version: number,
): Observable<PropertyConditionReportRevision> {
return this.http.post<PropertyConditionReportRevision>(
`/api/leases/${leaseId}/condition-reports/${type}/revisions?version=${version}`,
{},
);
}
downloadRevision(leaseId: string, type: ConditionReportType, number: number): Observable<Blob> {
return this.http.get(`/api/leases/${leaseId}/condition-reports/${type}/revisions/${number}`, {
responseType: 'blob',
});
}
finalize(
leaseId: string,
type: ConditionReportType,
request: ReportFinalizationRequest,
): Observable<PropertyConditionReport> {
const formData = new FormData();
formData.append('file', request.signedReport);
formData.append('version', String(request.version));
formData.append('attestation', 'true');
formData.append('allPartiesSigned', String(request.allPartiesSigned));
return this.http.post<PropertyConditionReport>(
`/api/leases/${leaseId}/condition-reports/${type}/finalization`,
formData,
);
}
attestDelivery(
leaseId: string,
type: ConditionReportType,
version: number,
partyId: string,
): Observable<PropertyConditionReport> {
return this.http.post<PropertyConditionReport>(
`/api/leases/${leaseId}/condition-reports/${type}/delivery-attestations`,
null,
{ params: new HttpParams().set('version', version).set('partyId', partyId) },
);
}
appendSupplement(
leaseId: string,
request: AppendConditionReportSupplementRequest,
): Observable<PropertyConditionReport> {
const formData = new FormData();
formData.append('signedPdf', request.signedPdf);
request.photos.forEach((photo) => formData.append('photos', photo));
formData.append('requestedOn', request.requestedOn);
formData.append('acceptedAdditions', request.acceptedAdditions);
if (request.heatingPeriodStartedOn) {
formData.append('heatingPeriodStartedOn', request.heatingPeriodStartedOn);
}
if (request.reason?.trim()) formData.append('reason', request.reason.trim());
return this.http.post<PropertyConditionReport>(
`/api/leases/${leaseId}/condition-reports/ENTRY/supplements`,
formData,
);
}
getComparison(leaseId: string): Observable<ConditionReportComparison> {
return this.http.get<ConditionReportComparison>(
`/api/leases/${leaseId}/condition-report-comparison`,
);
}
downloadBundle(leaseId: string, type: ConditionReportType): Observable<Blob> {
return this.http.get(`/api/leases/${leaseId}/condition-reports/${type}/bundle`, {
responseType: 'blob',
});
}
}
@@ -158,7 +158,7 @@ describe('LeaseEndService', () => {
);
});
it('records an agreed early end and a confirmed closure', () => {
it('records an agreed early end and confirms closure without reentering the key-return date', () => {
const service = TestBed.inject(LeaseEndService);
service
@@ -171,7 +171,6 @@ describe('LeaseEndService', () => {
service
.close('lease-1', {
timeZone: 'Europe/London',
keysReturnedOn: '2026-08-01',
confirmed: true,
})
.subscribe();
@@ -183,7 +182,6 @@ describe('LeaseEndService', () => {
});
expect(http.post).toHaveBeenNthCalledWith(2, '/api/leases/lease-1/end/closure', {
timeZone: 'Europe/London',
keysReturnedOn: '2026-08-01',
confirmed: true,
});
});
@@ -51,6 +51,10 @@ export interface LeaseEndState {
fixedEndOn: string | null;
effectiveEndOn: string | null;
correctionRequired: boolean;
exitConditionReport: {
occurredOn: string;
finalized: boolean;
} | null;
notices: LeaseEndNotice[];
agreedEarlyEnds: LeaseEndAgreement[];
agreedEarlyEnd: LeaseEndAgreement | null;
@@ -106,7 +110,6 @@ export interface AgreedEarlyEndRequest {
export interface CloseLeaseRequest {
timeZone: string;
keysReturnedOn: string;
confirmed: true;
}
@@ -37,7 +37,7 @@ describe('LeaseFinalizationService', () => {
expect(formData.get('attestation')).toBe('true');
});
it('keeps the civil signature date string and repeated warnings unchanged', () => {
it('sends the signed PDF and a native JSON metadata part', async () => {
const file = new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' });
TestBed.inject(LeaseFinalizationService)
.finalize('lease-1', {
@@ -47,16 +47,46 @@ describe('LeaseFinalizationService', () => {
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: ['DPE_OVERRIDE', 'RENT_OVERRIDE'],
entryInventoryDiscrepancies: true,
inventoryCorrections: [
{
id: 'correction-1',
inventoryLineId: null,
privateAreaId: 'area-1',
designation: 'Chaise',
quantity: 0,
description: 'Absente',
},
],
allInventoryCorrectionsInitialed: true,
})
.subscribe();
const formData = http.post.mock.calls[0][1] as FormData;
expect(http.post.mock.calls[0][0]).toBe('/api/leases/lease-1/finalization');
expect(formData.get('signedLease')).toBe(file);
expect(formData.get('revisionNumber')).toBe('4');
expect(formData.get('lastSignatureDate')).toBe('2026-07-19');
expect(formData.get('allPartiesSigned')).toBe('true');
expect(formData.get('revisionMatches')).toBe('true');
expect(formData.getAll('confirmedWarningCodes')).toEqual(['DPE_OVERRIDE', 'RENT_OVERRIDE']);
const metadata = formData.get('metadata');
expect(metadata).toBeInstanceOf(Blob);
await expect((metadata as Blob).text()).resolves.toBe(
JSON.stringify({
revisionNumber: 4,
lastSignatureDate: '2026-07-19',
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: ['DPE_OVERRIDE', 'RENT_OVERRIDE'],
entryInventoryDiscrepancies: true,
inventoryCorrections: [
{
id: 'correction-1',
inventoryLineId: null,
privateAreaId: 'area-1',
designation: 'Chaise',
quantity: 0,
description: 'Absente',
},
],
allInventoryCorrectionsInitialed: true,
}),
);
});
});
@@ -29,6 +29,9 @@ export interface FinalizationMetadata {
authorId: string;
finalizedAt: string;
overrides: FinalizationOverride[];
entryInventoryDiscrepancies: boolean;
inventoryCorrections: InventoryCorrection[];
allInventoryCorrectionsInitialed: boolean;
}
export interface FinalizationOverride {
@@ -39,14 +42,40 @@ export interface FinalizationOverride {
}
export interface LeaseFinalizationState {
revision: LeaseRevisionMetadata | null;
revision: FinalizationRevision | null;
guaranteeSimulatorUrl: string;
warningCodes: string[];
warnings: LeaseRevisionWarning[];
guaranteeRequirements: GuaranteeRequirement[];
suggestedInventoryCorrections: InventoryCorrection[];
finalization: FinalizationMetadata | null;
}
export interface FinalizationRevision extends LeaseRevisionMetadata {
furnitureInventory: {
templateVersion: string;
lines: FinalizationInventoryLine[];
};
privateAreas: FinalizationPrivateArea[];
}
export interface FinalizationInventoryLine {
id: string;
category: string | null;
presence: 'PRESENT' | 'ABSENT' | null;
privateAreaId: string;
designation: string;
quantity: number;
description: string;
}
export interface FinalizationPrivateArea {
id: string;
family: string;
subtype: string;
label: string;
}
export interface FinalizeLeaseRequest {
signedLease: File;
revisionNumber: number;
@@ -54,6 +83,18 @@ export interface FinalizeLeaseRequest {
allPartiesSigned: boolean;
revisionMatches: boolean;
confirmedWarningCodes: string[];
entryInventoryDiscrepancies: boolean;
inventoryCorrections: InventoryCorrection[];
allInventoryCorrectionsInitialed: boolean;
}
export interface InventoryCorrection {
id: string;
inventoryLineId: string | null;
privateAreaId: string;
designation: string;
quantity: number;
description: string;
}
export interface LeaseFinalizationResult {
@@ -87,11 +128,12 @@ export class LeaseFinalizationService {
finalize(leaseId: string, request: FinalizeLeaseRequest): Observable<LeaseFinalizationResult> {
const formData = new FormData();
formData.append('signedLease', request.signedLease);
formData.append('revisionNumber', String(request.revisionNumber));
formData.append('lastSignatureDate', request.lastSignatureDate);
formData.append('allPartiesSigned', String(request.allPartiesSigned));
formData.append('revisionMatches', String(request.revisionMatches));
request.confirmedWarningCodes.forEach((code) => formData.append('confirmedWarningCodes', code));
formData.append(
'metadata',
new Blob([JSON.stringify({ ...request, signedLease: undefined })], {
type: 'application/json',
}),
);
return this.http.post<LeaseFinalizationResult>(`/api/leases/${leaseId}/finalization`, formData);
}
}
@@ -0,0 +1,10 @@
import { leaseWarningConfirmation } from './lease-warning-label';
describe('leaseWarningConfirmation', () => {
it('names the absent furniture category being confirmed', () => {
expect(leaseWarningConfirmation('FURNITURE_CATEGORY_ABSENT_BEDDING')).toContain('literie');
expect(leaseWarningConfirmation('FURNITURE_CATEGORY_ABSENT_CLEANING')).toContain(
'matériel d’entretien',
);
});
});
@@ -11,7 +11,23 @@ const descriptions: Record<string, string> = {
'L’autorisation préalable de mise en location manque au dossier.',
};
const furnitureCategories: Record<string, string> = {
BEDDING: 'la literie',
SHUTTERS: 'le dispositif d’occultation',
HOTPLATES: 'les plaques de cuisson',
OVEN_OR_MICROWAVE: 'le four ou micro-ondes',
REFRIGERATOR_WITH_FREEZER: 'le réfrigérateur avec compartiment congélation',
DISHES: 'la vaisselle',
UTENSILS: 'les ustensiles de cuisine',
TABLE_AND_SEATING: 'la table et les sièges',
STORAGE: 'les rangements',
LIGHTING: 'les luminaires',
CLEANING: 'le matériel d’entretien',
};
export function leaseWarningDescription(code: string): string {
const furniture = furnitureCategories[code.replace('FURNITURE_CATEGORY_ABSENT_', '')];
if (furniture) return `L’inventaire déclare absent : ${furniture}.`;
return descriptions[code] ?? 'Un avertissement légal a été signalé par l’application.';
}
@@ -22,7 +38,7 @@ export function leaseWarningConfirmation(code: string): string {
if (code === 'IRL_REVISION_WITH_F_OR_G_DPE') {
return `${leaseWarningDescription(code)} Je confirme maintenir la clause de révision IRL malgré l’alerte.`;
}
if (descriptions[code]) {
if (descriptions[code] || code.startsWith('FURNITURE_CATEGORY_ABSENT_')) {
return `${leaseWarningDescription(code)} Je confirme finaliser le bail malgré cet avertissement.`;
}
return 'Je confirme poursuivre malgré un avertissement légal signalé par l’application.';
@@ -17,6 +17,7 @@ describe('WorkspaceService', () => {
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
};
const tenant = {