fix(rental): harden 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 e4be5cd05f
commit b030ac579a
55 files changed
+1969 -217

No files matched your search

@@ -95,8 +95,9 @@ export class ConditionReportEditor {
(meter) =>
!meter.id.trim() ||
!meter.unit.trim() ||
!Number.isFinite(meter.index) ||
meter.index < 0 ||
(meter.state === 'RECORDED' &&
(!Number.isFinite(meter.index) || meter.index === null || meter.index < 0)) ||
(meter.state === 'INACCESSIBLE' && (meter.index !== null || !meter.observation.trim())) ||
(meter.type === 'OTHER' && !meter.label.trim()),
)
)
@@ -24,6 +24,16 @@
(input)="updateMeter(meter.id, 'label', $any($event.target).value)"
/>
</label>
<label>
Relevé
<select
[value]="meter.state"
(change)="updateMeter(meter.id, 'state', $any($event.target).value)"
>
<option value="RECORDED">Index relevé</option>
<option value="INACCESSIBLE">Compteur inaccessible</option>
</select>
</label>
<label>
Unité
<input
@@ -32,17 +42,19 @@
(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>
@if (meter.state === 'RECORDED') {
<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
@@ -52,7 +64,11 @@
</label>
<label>
Observation
@if (meter.state === 'INACCESSIBLE') {
<span>(obligatoire)</span>
}
<textarea
[required]="meter.state === 'INACCESSIBLE'"
[value]="meter.observation"
(input)="updateMeter(meter.id, 'observation', $any($event.target).value)"
></textarea>
@@ -37,6 +37,7 @@ export class ConditionReportInventorySection {
{
id: crypto.randomUUID(),
type: 'ELECTRICITE',
state: 'RECORDED',
index: 0,
unit: 'kWh',
label: '',
@@ -51,7 +52,15 @@ export class ConditionReportInventorySection {
this.update((report) => ({
...report,
meterReadings: report.meterReadings.map((meter) =>
meter.id === id ? { ...meter, [field]: field === 'index' ? Number(value) : value } : meter,
meter.id !== id
? meter
: field === 'state'
? {
...meter,
state: value === 'INACCESSIBLE' ? 'INACCESSIBLE' : 'RECORDED',
index: value === 'INACCESSIBLE' ? null : (meter.index ?? 0),
}
: { ...meter, [field]: field === 'index' ? Number(value) : value },
),
}));
}
@@ -110,6 +110,16 @@
}
}
@if (type === 'ENTRY') {
<label>
Nature du complément
<select
[value]="supplementType()"
(change)="supplementType.set($any($event.target).value)"
>
<option value="GENERAL">Constat général (délai indicatif de 10 jours)</option>
<option value="HEATING">Équipement de chauffage (premier mois de chauffe)</option>
</select>
</label>
<label>
Date de demande du complément
<input
@@ -127,20 +137,50 @@
(input)="supplementAcceptedAdditions.set($any($event.target).value)"
></textarea>
</label>
@if (supplementType() === 'HEATING') {
<label>
Début réel de la période de chauffe
<input
type="date"
required
[value]="heatingPeriodStartedOn()"
(input)="heatingPeriodStartedOn.set($any($event.target).value)"
/>
</label>
}
@if (supplementIsLate()) {
<p role="alert">
La demande dépasse le délai indicatif (échéance : {{ supplementDeadline() }}). L’ajout
reste possible avec une confirmation auditée.
</p>
<label>
Motif du dépassement de délai
<textarea
required
[value]="supplementReason()"
(input)="supplementReason.set($any($event.target).value)"
></textarea>
</label>
<label>
<input
type="checkbox"
[checked]="lateWarningConfirmed()"
(change)="lateWarningConfirmed.set($any($event.target).checked)"
/>
Je confirme l’ajout malgré le dépassement du délai indicatif.
</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>
Zone des prochaines photos
<select
[value]="supplementPhotoZoneId()"
(change)="supplementPhotoZoneId.set($any($event.target).value)"
>
<option value="">Choisir une zone</option>
@for (zone of value.zones; track zone.id) {
<option [value]="zone.id">{{ zone.label }}</option>
}
</select>
</label>
<label>
Photos du complément (facultatives)
@@ -152,6 +192,12 @@
(change)="selectSupplementPhotos($event)"
/>
</label>
@for (photo of supplementPhotos(); track $index; let index = $index) {
<p>
{{ photo.file.name }} — {{ photo.zoneId }}
<button type="button" (click)="removeSupplementPhoto(index)">Retirer</button>
</p>
}
<label>
Complément signé PDF (obligatoire)
<input
@@ -200,10 +246,10 @@
@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.entry }}</td>
<td>{{ line.exit }}</td>
<td>{{ line.changed ? 'Oui' : 'Non' }}</td>
<td>{{ line.absence | json }}</td>
<td>{{ line.absence }}</td>
</tr>
}
</tbody>
@@ -1,14 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import {
ConditionPhoto,
ConditionReportComparison,
ConditionReportService,
PropertyConditionReportSupplement,
PropertyConditionReport,
} from '../../services/condition-report.service';
import { FileDownloadService } from '../../services/file-download.service';
import { ConditionReportDetailsSection } from './condition-report-details-section';
import { ConditionReportEditor } from './condition-report-editor';
import { ConditionReportEvidenceSection } from './condition-report-evidence-section';
import { ConditionReportInventorySection } from './condition-report-inventory-section';
import { ConditionReportPage } from './condition-report-page';
const report: PropertyConditionReport = {
@@ -38,6 +43,35 @@ const report: PropertyConditionReport = {
version: 1,
};
const supplementPhoto: ConditionPhoto = {
reference: 'supplement-photo-1',
zoneId: 'kitchen',
sha256: 'supplement-photo-sha',
width: 1200,
height: 800,
uploadedBy: 'owner',
uploadedAt: '2026-07-22T00:00:00Z',
};
const supplement: PropertyConditionReportSupplement = {
id: 'supplement-1',
type: 'GENERAL',
signedPdf: { reference: 'supplement-pdf', sha256: 'supplement-pdf-sha' },
photos: [supplementPhoto],
uploadedBy: 'owner',
uploadedAt: '2026-07-22T00:00:00Z',
requestedOn: '2026-07-22',
heatingPeriodStartedOn: null,
acceptedAdditions: 'Rayure acceptée',
audit: { status: 'ON_TIME', deadlineOn: '2026-07-30' },
};
function eventFor(target: EventTarget): Event {
const event = new Event('change');
Object.defineProperty(event, 'target', { value: target });
return event;
}
describe('ConditionReportEditor', () => {
it('uses a rear-camera input and explains an invalid degraded observation before saving', () => {
const service = {
@@ -78,19 +112,517 @@ describe('ConditionReportEditor', () => {
expect(error).toContain('observation');
expect(service.saveReport).not.toHaveBeenCalled();
});
it('covers each draft validation boundary before emitting the save request', () => {
const service = { photoUrl: vi.fn().mockReturnValue('/photo.webp') };
TestBed.configureTestingModule({
providers: [{ provide: ConditionReportService, useValue: service }],
});
const valid: PropertyConditionReport = {
...report,
participants: [
{
partyId: 'tenant-1',
role: 'TENANT',
name: 'Camille Martin',
attendance: 'PRESENT',
representativeName: '',
},
],
observations: [{ subjectId: 'kitchen', state: 'BON_ETAT', observation: '' }],
photos: [
{
reference: 'photo-1',
zoneId: 'kitchen',
sha256: 'sha',
width: 1,
height: 1,
uploadedBy: 'owner',
uploadedAt: '2026-07-22T00:00:00Z',
},
],
};
const fixture = TestBed.createComponent(ConditionReportEditor);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('type', 'ENTRY');
fixture.componentRef.setInput('report', valid);
fixture.componentRef.setInput('entryPhotos', []);
fixture.detectChanges();
const editor = fixture.componentInstance;
const errors: string[] = [];
const requests = vi.fn();
const changes = vi.fn();
editor.errorOccurred.subscribe((error) => errors.push(error));
editor.saveRequested.subscribe(requests);
editor.reportChanged.subscribe(changes);
const invalidReports: [PropertyConditionReport, string][] = [
[{ ...valid, occurredOn: '' }, 'date'],
[
{
...valid,
participants: [{ ...valid.participants[0]!, attendance: 'REPRESENTED' }],
},
'mandataire',
],
[{ ...valid, zones: [{ ...valid.zones[0]!, label: '' }] }, 'libellé'],
[
{
...valid,
observations: [{ subjectId: 'kitchen', state: 'DEGRADE', observation: '' }],
},
'observation',
],
[
{
...valid,
meterReadings: [
{
id: 'meter-1',
type: 'OTHER',
state: 'RECORDED',
index: -1,
unit: '',
label: '',
photoId: '',
observation: '',
},
],
},
'compteurs',
],
[
{
...valid,
meterReadings: [
{
id: 'meter-1',
type: 'EAU_FROIDE',
state: 'RECORDED',
index: null,
unit: 'm3',
label: '',
photoId: '',
observation: '',
},
],
},
'compteurs',
],
[
{
...valid,
meterReadings: [
{
id: 'meter-1',
type: 'EAU_FROIDE',
state: 'INACCESSIBLE',
index: 0,
unit: 'm3',
label: '',
photoId: '',
observation: 'Local fermé',
},
],
},
'compteurs',
],
[
{
...valid,
meterReadings: [
{
id: 'meter-1',
type: 'EAU_FROIDE',
state: 'INACCESSIBLE',
index: null,
unit: 'm3',
label: '',
photoId: '',
observation: '',
},
],
},
'compteurs',
],
[
{
...valid,
meterReadings: [
{
id: 'meter-1',
type: 'OTHER',
state: 'RECORDED',
index: 1,
unit: 'u',
label: '',
photoId: '',
observation: '',
},
],
},
'compteurs',
],
[
{
...valid,
accessMeans: [
{ id: 'key-1', type: 'CLE', destination: '', quantity: -1, observation: '' },
],
},
'moyens d’accès',
],
[
{
...valid,
inventoryConditions: [
{
inventoryLineId: 'chair-1',
privateAreaId: 'kitchen',
privateAreaLabel: 'Cuisine',
designation: 'Chaise',
contractualQuantity: 1,
splits: [
{ inventoryLineId: 'chair-1', state: 'DEGRADE', quantity: 0, observation: '' },
],
},
],
},
'Répartissez',
],
[{ ...valid, photos: [] }, 'photo'],
];
for (const [invalid, fragment] of invalidReports) {
fixture.componentRef.setInput('report', invalid);
editor.save();
expect(errors.at(-1)).toContain(fragment);
}
fixture.componentRef.setInput('report', {
...valid,
meterReadings: [
{
id: 'meter-1',
type: 'EAU_FROIDE',
state: 'INACCESSIBLE',
index: null,
unit: 'm3',
label: '',
photoId: '',
observation: 'Local fermé',
},
],
});
editor.save();
fixture.componentRef.setInput('type', 'EXIT');
fixture.componentRef.setInput('entryPhotos', valid.photos);
fixture.componentRef.setInput('report', valid);
editor.save();
expect(errors.at(-1)).toContain('adresse de correspondance');
fixture.componentRef.setInput('report', {
...valid,
forwardingAddresses: [{ tenantPartyId: 'tenant-1', address: '12 rue A' }],
});
editor.save();
expect(errors.at(-1)).toContain('photo d’entrée');
fixture.componentRef.setInput('type', 'ENTRY');
fixture.componentRef.setInput('entryPhotos', []);
fixture.componentRef.setInput('report', valid);
editor.save();
fixture.componentRef.setInput('saving', true);
editor.changeReport(valid);
fixture.componentRef.setInput('saving', false);
fixture.componentRef.setInput('finalizing', true);
editor.changeReport(valid);
expect(requests).toHaveBeenCalledWith(
expect.objectContaining({ version: 1, zones: valid.zones }),
);
expect(changes).not.toHaveBeenCalled();
});
});
describe('Condition-report sections', () => {
it('updates the report details, participants, zones and observations', () => {
const value: PropertyConditionReport = {
...report,
participants: [
{
partyId: 'tenant-1',
role: 'TENANT',
name: 'Camille Martin',
attendance: 'PRESENT',
representativeName: '',
},
],
observations: [{ subjectId: 'kitchen', state: 'BON_ETAT', observation: '' }],
};
const fixture = TestBed.createComponent(ConditionReportDetailsSection);
fixture.componentRef.setInput('report', value);
fixture.detectChanges();
const section = fixture.componentInstance;
let current = value;
section.reportChanged.subscribe((next) => {
current = next;
fixture.componentRef.setInput('report', next);
});
section.setOccurredOn('2026-07-23');
section.setTimeZone('Europe/Paris');
section.updateParticipant(0, 'attendance', 'REPRESENTED');
section.updateParticipant(0, 'representativeName', 'Alex Martin');
section.updateZone('kitchen', 'label', 'Cuisine équipée');
section.updateObservation('kitchen', 'state', 'DEGRADE');
section.updateObservation('kitchen', 'observation', 'Rayure');
section.addZone();
const added = current.zones.find((zone) => zone.id !== 'kitchen');
expect(current.occurredOn).toBe('2026-07-23');
expect(current.participants[0]).toEqual(
expect.objectContaining({ representativeName: 'Alex Martin' }),
);
expect(current.observations).toContainEqual(
expect.objectContaining({ subjectId: 'kitchen', state: 'DEGRADE', observation: 'Rayure' }),
);
expect(added?.privateAreaId).toBe('');
expect(section.canRemoveZone('inventory:chair')).toBe(false);
expect(section.canRemoveZone('kitchen:walls')).toBe(false);
expect(section.canRemoveZone('custom')).toBe(true);
expect(section.needsObservation('ABSENT')).toBe(true);
expect(section.needsObservation('BON_ETAT')).toBe(false);
expect(section.privateAreas()).toEqual([]);
expect(section.observationLabel('missing')).toBe('Zone');
if (added) section.removeZone(added.id);
expect(current.zones).toHaveLength(1);
});
it('updates meter, access, inventory and forwarding facts', () => {
const value: PropertyConditionReport = {
...report,
participants: [
{
partyId: 'tenant-1',
role: 'TENANT',
name: 'Camille Martin',
attendance: 'PRESENT',
representativeName: '',
},
],
meterReadings: [
{
id: 'meter-1',
type: 'ELECTRICITE',
state: 'RECORDED',
index: 1,
unit: 'kWh',
label: '',
photoId: '',
observation: '',
},
],
accessMeans: [
{ id: 'key-1', type: 'CLE', destination: 'Porte', quantity: 1, observation: '' },
],
inventoryConditions: [
{
inventoryLineId: 'chair-1',
privateAreaId: 'kitchen',
privateAreaLabel: 'Cuisine',
designation: 'Chaise',
contractualQuantity: 2,
splits: [{ inventoryLineId: 'chair-1', state: 'BON_ETAT', quantity: 2, observation: '' }],
},
],
};
const fixture = TestBed.createComponent(ConditionReportInventorySection);
fixture.componentRef.setInput('report', value);
fixture.componentRef.setInput('type', 'EXIT');
fixture.detectChanges();
const section = fixture.componentInstance;
let current = value;
section.reportChanged.subscribe((next) => {
current = next;
fixture.componentRef.setInput('report', next);
});
section.updateMeter('meter-1', 'state', 'INACCESSIBLE');
expect(current.meterReadings[0]?.index).toBeNull();
section.updateMeter('meter-1', 'state', 'RECORDED');
section.updateMeter('meter-1', 'index', '12.5');
section.addMeter();
section.removeMeter('meter-1');
section.updateAccessMean('key-1', 'quantity', '2');
section.addAccessMean();
section.removeAccessMean('key-1');
section.addInventorySplit(0);
section.updateInventorySplit(0, 1, 'quantity', '0');
section.updateInventorySplit(0, 1, 'state', 'ABSENT');
section.removeInventorySplit(0, 1);
section.updateForwardingAddress('tenant-1', '12 rue A');
expect(current.meterReadings).toHaveLength(1);
expect(current.meterReadings[0]?.index).toBe(0);
expect(current.accessMeans).toHaveLength(1);
expect(current.accessMeans[0]?.type).toBe('CLE');
expect(current.inventoryConditions[0]?.splits).toHaveLength(1);
expect(section.forwardingAddress('tenant-1')).toBe('12 rue A');
expect(section.forwardingAddress('missing')).toBe('');
expect(section.needsObservation('NON_APPLICABLE')).toBe(true);
expect(section.needsObservation('NEUF')).toBe(false);
});
it('keeps pending evidence local and reports upload and deletion outcomes', () => {
const service = {
uploadPhoto: vi.fn().mockReturnValue(of(void 0)),
deletePhoto: vi.fn().mockReturnValue(of(void 0)),
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
};
TestBed.configureTestingModule({
providers: [{ provide: ConditionReportService, useValue: service }],
});
const fixture = TestBed.createComponent(ConditionReportEvidenceSection);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('type', 'EXIT');
fixture.componentRef.setInput('report', report);
fixture.componentRef.setInput('entryPhotos', []);
fixture.detectChanges();
const section = fixture.componentInstance;
let current = report;
section.reportChanged.subscribe((next) => {
current = next;
fixture.componentRef.setInput('report', next);
});
const success = vi.fn();
const reload = vi.fn();
section.successOccurred.subscribe(success);
section.reloadRequested.subscribe(reload);
const createUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:photo');
const revokeUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
const input = document.createElement('input');
const file = new File(['photo'], 'cuisine.webp', { type: 'image/webp' });
Object.defineProperty(input, 'files', { value: { item: () => file } });
section.setZoneAbsence('kitchen', 'Accès impossible');
section.setZoneAbsence('kitchen', '');
section.setEntryPhotoDisposition('entry-photo', 'exitPhotoId', 'exit-photo');
section.setEntryPhotoDisposition('entry-photo', 'absenceReason', '');
section.selectPhoto('kitchen', { target: input } as unknown as Event);
section.uploadPhoto('kitchen');
section.removePhoto({
reference: 'photo-1',
zoneId: 'kitchen',
sha256: 'sha',
width: 1,
height: 1,
uploadedBy: 'owner',
uploadedAt: '2026-07-22T00:00:00Z',
});
expect(current.zonePhotoAbsences).toEqual([]);
expect(section.disposition('entry-photo')).toEqual({
entryPhotoId: 'entry-photo',
exitPhotoId: 'exit-photo',
});
expect(service.uploadPhoto).toHaveBeenCalledWith('lease-1', 'EXIT', file, 'kitchen');
expect(service.deletePhoto).toHaveBeenCalledWith('lease-1', 'EXIT', 'photo-1', 1);
expect(section.photoUrl('ENTRY', 'entry-photo')).toBe('/photo.webp');
expect(success).toHaveBeenCalledWith('Photo ajoutée.');
expect(reload).toHaveBeenCalledTimes(2);
expect(createUrl).toHaveBeenCalled();
expect(revokeUrl).toHaveBeenCalled();
createUrl.mockRestore();
revokeUrl.mockRestore();
});
});
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 }],
observations: [
{
reference: 'kitchen:walls',
entry: { subjectId: 'kitchen:walls', state: 'DEGRADE', observation: 'Rayure' },
exit: { subjectId: 'kitchen:walls', state: 'BON_ETAT', observation: '' },
changed: true,
},
],
zones: [
{
reference: 'kitchen',
entry: { id: 'kitchen', privateAreaId: 'area-1', label: 'Cuisine', equipment: 'OTHER' },
exit: { id: 'kitchen', privateAreaId: 'area-1', label: 'Cuisine', equipment: 'NONE' },
changed: true,
},
],
inventory: [
{
reference: 'chair',
entry: {
inventoryLineId: 'chair',
privateAreaId: 'area-1',
privateAreaLabel: 'Cuisine',
designation: 'Chaise',
contractualQuantity: 1,
splits: [
{ inventoryLineId: 'chair', state: 'ETAT_USAGE', quantity: 1, observation: '' },
],
},
exit: null,
changed: true,
absence: {
reason: 'Non constaté',
auditedBy: 'owner',
auditedAt: '2026-07-23T00:00:00Z',
},
},
],
meters: [
{
reference: 'electricity',
entry: {
id: 'electricity',
type: 'ELECTRICITE',
state: 'INACCESSIBLE',
index: null,
unit: 'kWh',
label: '',
photoId: '',
observation: 'Local fermé',
},
exit: null,
changed: true,
},
],
accessMeans: [
{
reference: 'key',
entry: {
id: 'key',
type: 'CLE',
destination: 'Porte',
quantity: 2,
observation: 'Une clé usée',
},
exit: null,
changed: true,
},
],
supplements: [{ reference: 'supplement-1', entry: supplement, exit: null, changed: true }],
photos: [
{
reference: 'supplement-photo-1',
entry: supplementPhoto,
exit: null,
changed: true,
absence: {
reason: 'Photo impossible',
auditedBy: 'owner',
auditedAt: '2026-07-23T00:00:00Z',
},
},
],
};
const service = {
getReport: vi.fn().mockReturnValueOnce(of(report)).mockReturnValue(of(refreshed)),
@@ -151,4 +683,166 @@ describe('ConditionReportPage', () => {
'Compléments acceptés',
]);
});
it('handles save, finalization, comparison, delivery and supplements through their local states', () => {
const exitReport: PropertyConditionReport = {
...report,
type: 'EXIT',
entrySnapshot: {
reportId: 'entry-1',
participants: [],
zones: [],
observations: [],
meterReadings: [],
accessMeans: [],
inventoryConditions: [],
photos: [],
supplements: [supplement],
},
deliveryAttestations: [
{ partyId: 'tenant-1', attestedBy: 'owner', attestedAt: '2026-07-22T00:00:00Z' },
],
};
const comparison: ConditionReportComparison = {
observations: [],
zones: [],
inventory: [],
meters: [],
accessMeans: [],
supplements: [],
photos: [],
};
const service = {
getReport: vi.fn().mockReturnValue(of(exitReport)),
saveReport: vi.fn().mockReturnValue(of({ ...exitReport, version: 2 })),
createRevision: vi.fn(),
downloadRevision: vi.fn(),
finalize: vi.fn().mockReturnValue(of({ ...exitReport, status: 'FINALIZED' as const })),
downloadBundle: vi.fn().mockReturnValue(of(new Blob(['zip']))),
getComparison: vi.fn().mockReturnValue(of(comparison)),
attestDelivery: vi.fn().mockReturnValue(of(exitReport)),
appendSupplement: vi.fn().mockReturnValue(of(exitReport)),
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
};
const downloads = { download: vi.fn() };
TestBed.configureTestingModule({
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ leaseId: 'lease-1', type: 'EXIT' }) },
},
},
{ provide: ConditionReportService, useValue: service },
{ provide: FileDownloadService, useValue: downloads },
],
});
const fixture = TestBed.createComponent(ConditionReportPage);
fixture.detectChanges();
const page = fixture.componentInstance;
const scan = new File(['pdf'], 'signed.pdf', { type: 'application/pdf' });
expect(page.entryPhotos()).toEqual([supplementPhoto]);
page.supplementType.set('HEATING');
page.heatingPeriodStartedOn.set('2026-01-31');
expect(page.supplementDeadline()).toBe('2026-02-28');
page.supplementType.set('GENERAL');
page.save({ version: 1 } as never);
page.selectSignedScan(eventFor(document.createElement('input')));
page.signedScan.set(scan);
page.finalizationAttested.set(true);
page.allPartiesSigned.set(true);
page.dirty.set(true);
page.finalize();
page.dirty.set(false);
page.finalize();
page.downloadBundle();
page.loadComparison();
page.attestDelivery('tenant-2');
page.changeReport({ ...exitReport, occurredOn: '2026-07-23' });
page.appendSupplement();
expect(page.error()).toContain('date, les ajouts acceptés');
page.supplementSignedPdf.set(scan);
page.supplementRequestedOn.set('2026-07-22');
page.supplementAcceptedAdditions.set('Rayure déclarée');
page.supplementType.set('HEATING');
page.heatingPeriodStartedOn.set('');
page.appendSupplement();
expect(page.error()).toContain('période de chauffe');
page.heatingPeriodStartedOn.set('2026-06-01');
page.appendSupplement();
expect(page.error()).toContain('avertissement de délai');
page.lateWarningConfirmed.set(true);
page.appendSupplement();
expect(page.error()).toContain('motivez');
page.supplementReason.set('Demande reçue tardivement');
const photoInput = document.createElement('input');
const photo = new File(['photo'], 'cuisine.webp', { type: 'image/webp' });
Object.defineProperty(photoInput, 'files', {
value: { 0: photo, length: 1, item: () => photo },
});
page.selectSupplementPhotos(eventFor(photoInput));
expect(page.error()).toContain('zone');
page.supplementPhotoZoneId.set('kitchen');
page.selectSupplementPhotos(eventFor(photoInput));
page.removeSupplementPhoto(0);
page.selectSupplementPhotos(eventFor(photoInput));
page.appendSupplement();
expect(service.saveReport).toHaveBeenCalled();
expect(service.finalize).toHaveBeenCalledWith(
'lease-1',
'EXIT',
expect.objectContaining({ version: 2 }),
);
expect(downloads.download).toHaveBeenCalled();
expect(page.comparison()).toEqual(comparison);
expect(service.attestDelivery).toHaveBeenCalledWith('lease-1', 'EXIT', 1, 'tenant-2');
expect(page.hasAttested('tenant-1')).toBe(true);
expect(service.appendSupplement).toHaveBeenCalledWith(
'lease-1',
expect.objectContaining({
signedPdf: scan,
photos: [{ file: photo, zoneId: 'kitchen' }],
requestedOn: '2026-07-22',
supplementType: 'HEATING',
heatingPeriodStartedOn: '2026-06-01',
lateWarningConfirmed: true,
reason: 'Demande reçue tardivement',
}),
);
expect(page.photoUrl('ENTRY', 'photo-1')).toBe('/photo.webp');
page.report.set(null);
expect(page.hasAttested('tenant-1')).toBe(false);
});
it('keeps asynchronous failures truthful', () => {
const service = {
getReport: vi.fn().mockReturnValue(throwError(() => new Error('offline'))),
getComparison: vi.fn().mockReturnValue(throwError(() => new Error('offline'))),
};
TestBed.configureTestingModule({
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ leaseId: 'lease-1', type: 'ENTRY' }) },
},
},
{ provide: ConditionReportService, useValue: service },
{ provide: FileDownloadService, useValue: { download: vi.fn() } },
],
});
const fixture = TestBed.createComponent(ConditionReportPage);
fixture.detectChanges();
const page = fixture.componentInstance;
page.loadComparison();
expect(page.loading()).toBe(false);
expect(page.comparisonPending()).toBe(false);
expect(page.error()).toContain('Impossible de charger la comparaison');
});
});
@@ -1,12 +1,19 @@
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,
ConditionObservation,
ConditionReportComparison,
ConditionReportComparisonLine,
ConditionReportService,
ConditionReportType,
ConditionState,
ConditionZone,
InventoryCondition,
MeterReading,
AccessMeans,
PropertyConditionReportSupplement,
PropertyConditionReport,
SaveConditionReportRequest,
} from '../../services/condition-report.service';
@@ -16,7 +23,7 @@ import { ConditionReportEditor } from './condition-report-editor';
@Component({
selector: 'app-condition-report-page',
imports: [ConditionReportEditor, JsonPipe, RouterLink],
imports: [ConditionReportEditor, RouterLink],
templateUrl: './condition-report-page.html',
styleUrl: './condition-report-page.css',
})
@@ -36,12 +43,12 @@ export class ConditionReportPage {
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 },
comparisonSection('Observations', comparison.observations, describeObservation),
comparisonSection('Zones', comparison.zones, describeZone),
comparisonSection('Inventaire', comparison.inventory, describeInventory),
comparisonSection('Compteurs', comparison.meters, describeMeter),
comparisonSection('Moyens d’accès', comparison.accessMeans, describeAccess),
comparisonSection('Compléments acceptés', comparison.supplements, describeSupplement),
];
});
readonly loading = signal(true);
@@ -58,10 +65,27 @@ export class ConditionReportPage {
readonly signedScan = signal<File | null>(null);
readonly supplementRequestedOn = signal('');
readonly supplementAcceptedAdditions = signal('');
readonly supplementType = signal<'GENERAL' | 'HEATING'>('GENERAL');
readonly heatingPeriodStartedOn = signal('');
readonly supplementReason = signal('');
readonly lateWarningConfirmed = signal(false);
readonly supplementSignedPdf = signal<File | null>(null);
readonly supplementPhotos = signal<File[]>([]);
readonly supplementPhotoZoneId = signal('');
readonly supplementPhotos = signal<{ file: File; zoneId: string }[]>([]);
readonly supplementDeadline = computed(() => {
const report = this.report();
if (!report) return '';
return this.supplementType() === 'HEATING'
? addCalendarMonths(this.heatingPeriodStartedOn(), 1)
: addCalendarDays(report.occurredOn, 10);
});
readonly supplementIsLate = computed(() =>
Boolean(
this.supplementRequestedOn() &&
this.supplementDeadline() &&
this.supplementRequestedOn() > this.supplementDeadline(),
),
);
constructor() {
this.load();
@@ -77,7 +101,13 @@ export class ConditionReportPage {
next: (report) => {
this.report.set(report);
this.dirty.set(false);
if (this.type === 'EXIT') this.entryPhotos.set(report.entrySnapshot?.photos ?? []);
if (this.type === 'EXIT') {
this.entryPhotos.set([
...(report.entrySnapshot?.photos ?? []),
...(report.entrySnapshot?.supplements.flatMap((supplement) => supplement.photos) ??
[]),
]);
}
},
error: (error) =>
this.error.set(httpErrorMessage(error, 'Impossible de charger cet état des lieux.')),
@@ -228,17 +258,43 @@ export class ConditionReportPage {
selectSupplementPhotos(event: Event): void {
const input = event.target instanceof HTMLInputElement ? event.target : null;
this.supplementPhotos.set(input?.files ? Array.from(input.files) : []);
const zoneId = this.supplementPhotoZoneId();
if (input?.files?.length && !zoneId) {
this.error.set('Choisissez la zone des photos du complément.');
input.value = '';
return;
}
this.supplementPhotos.update((photos) => [
...photos,
...(input?.files ? Array.from(input.files, (file) => ({ file, zoneId })) : []),
]);
if (input) input.value = '';
}
removeSupplementPhoto(index: number): void {
this.supplementPhotos.update((photos) => photos.filter((_, item) => item !== index));
}
appendSupplement(): void {
const signedPdf = this.supplementSignedPdf();
const requestedOn = this.supplementRequestedOn().trim();
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
if (!signedPdf || !requestedOn || !acceptedAdditions || this.supplementPending()) {
const heatingPeriodStartedOn = this.heatingPeriodStartedOn().trim();
if (
!signedPdf ||
!requestedOn ||
!acceptedAdditions ||
(this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) ||
(this.supplementIsLate() &&
(!this.lateWarningConfirmed() || !this.supplementReason().trim())) ||
this.supplementPending()
) {
if (!signedPdf || !requestedOn || !acceptedAdditions) {
this.error.set('Renseignez la date, les ajouts acceptés et le PDF signé du complément.');
} else if (this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) {
this.error.set('Renseignez le début réel de la période de chauffe.');
} else if (this.supplementIsLate()) {
this.error.set('Confirmez l’avertissement de délai et motivez l’ajout tardif.');
}
return;
}
@@ -249,7 +305,10 @@ export class ConditionReportPage {
photos: this.supplementPhotos(),
requestedOn,
acceptedAdditions,
heatingPeriodStartedOn: this.heatingPeriodStartedOn().trim() || undefined,
supplementType: this.supplementType(),
heatingPeriodStartedOn:
this.supplementType() === 'HEATING' ? heatingPeriodStartedOn : undefined,
lateWarningConfirmed: this.lateWarningConfirmed(),
reason: this.supplementReason().trim() || undefined,
})
.pipe(rxFinalize(() => this.supplementPending.set(false)))
@@ -258,6 +317,7 @@ export class ConditionReportPage {
this.report.set(saved);
this.supplementSignedPdf.set(null);
this.supplementPhotos.set([]);
this.lateWarningConfirmed.set(false);
this.success.set('Complément ajouté sans modifier l’état des lieux finalisé.');
},
error: (error) =>
@@ -278,3 +338,88 @@ export class ConditionReportPage {
return this.reports.photoUrl(this.leaseId, type, reference);
}
}
interface ComparisonDisplayLine {
reference: string;
entry: string;
exit: string;
changed: boolean;
absence: string;
}
function comparisonSection<T>(
title: string,
lines: ConditionReportComparisonLine<T>[],
describe: (value: T) => string,
): { title: string; lines: ComparisonDisplayLine[] } {
return {
title,
lines: lines.map((line) => ({
reference: line.reference,
entry: line.entry ? describe(line.entry) : '—',
exit: line.exit ? describe(line.exit) : '—',
changed: line.changed,
absence: line.absence?.reason ?? '—',
})),
};
}
const stateLabels: Record<ConditionState, string> = {
NEUF: 'Neuf',
BON_ETAT: 'Bon état',
ETAT_USAGE: 'État d’usage',
DEGRADE: 'Dégradé',
ABSENT: 'Absent',
NON_APPLICABLE: 'Non applicable',
};
function describeObservation(value: ConditionObservation): string {
return [stateLabels[value.state], value.observation].filter(Boolean).join(' — ');
}
function describeZone(value: ConditionZone): string {
const equipment = value.equipment === 'NONE' ? '' : `équipement ${value.equipment.toLowerCase()}`;
return [value.label, equipment].filter(Boolean).join(' — ');
}
function describeInventory(value: InventoryCondition): string {
const split = value.splits
.map((item) => `${item.quantity} ${stateLabels[item.state].toLowerCase()}`)
.join(', ');
return `${value.designation} (${split || 'aucune quantité constatée'} ; ${value.contractualQuantity} au bail)`;
}
function describeMeter(value: MeterReading): string {
const reading = value.state === 'INACCESSIBLE' ? 'inaccessible' : `${value.index} ${value.unit}`;
return `${value.label || value.type} : ${reading}${value.observation ? ` — ${value.observation}` : ''}`;
}
function describeAccess(value: AccessMeans): string {
return `${value.type} — ${value.destination} : ${value.quantity}${value.observation ? ` — ${value.observation}` : ''}`;
}
function describeSupplement(value: PropertyConditionReportSupplement): string {
const nature =
value.type === 'HEATING'
? `chauffage depuis le ${value.heatingPeriodStartedOn}`
: 'constat général';
return `${value.requestedOn} — ${nature} — ${value.acceptedAdditions}`;
}
function addCalendarDays(value: string, days: number): string {
if (!value) return '';
const date = new Date(`${value}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function addCalendarMonths(value: string, months: number): string {
if (!value) return '';
const date = new Date(`${value}T00:00:00Z`);
const day = date.getUTCDate();
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + months);
const lastDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
date.setUTCDate(Math.min(day, lastDay));
return date.toISOString().slice(0, 10);
}