fix(rental): harden inventory and condition reports (#7)
This commit is contained in:
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()),
|
||||
)
|
||||
)
|
||||
|
||||
+27
-11
@@ -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>
|
||||
|
||||
+10
-1
@@ -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);
|
||||
}
|
||||
@@ -105,6 +105,9 @@
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (line.equipmentKind !== 'NONE') {
|
||||
<p>Une zone dédiée sera créée dans l’état des lieux.</p>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
@@ -155,6 +158,17 @@
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Suivi dans l’état des lieux</span>
|
||||
<select
|
||||
[value]="line.equipmentKind"
|
||||
(change)="updateEquipmentKind(line.id, $event)"
|
||||
>
|
||||
<option value="NONE">Aucune zone dédiée</option>
|
||||
<option value="CLIMATISATION">Climatisation</option>
|
||||
<option value="OTHER">Autre équipement</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" (click)="removeLine(line.id)">Supprimer cette ligne</button>
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
ConditionReportService,
|
||||
@@ -16,6 +16,7 @@ const inventory: FurnitureInventory = {
|
||||
id: 'bed-1',
|
||||
category: 'BEDDING',
|
||||
presence: 'PRESENT',
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: 'bedroom-1',
|
||||
designation: 'Lit',
|
||||
quantity: 1,
|
||||
@@ -31,6 +32,12 @@ const inventory: FurnitureInventory = {
|
||||
version: 3,
|
||||
};
|
||||
|
||||
function eventFor(target: EventTarget): Event {
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('InventoryPage', () => {
|
||||
function createPage(value: FurnitureInventory = inventory) {
|
||||
const service = {
|
||||
@@ -77,4 +84,74 @@ describe('InventoryPage', () => {
|
||||
expect(service.saveInventory).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Déclarez la présence ou l’absence');
|
||||
});
|
||||
|
||||
it('updates editable facts, normalizes legal presence, and protects frozen inventory', () => {
|
||||
const { fixture } = createPage();
|
||||
const page = fixture.componentInstance;
|
||||
const text = document.createElement('input');
|
||||
text.value = 'Lit double';
|
||||
const quantity = document.createElement('input');
|
||||
quantity.value = '';
|
||||
const presence = document.createElement('select');
|
||||
presence.append(new Option('Présent', 'PRESENT'), new Option('Absent', 'ABSENT'));
|
||||
const equipment = document.createElement('select');
|
||||
equipment.append(new Option('Climatisation', 'CLIMATISATION'));
|
||||
|
||||
page.updateText('bed-1', 'designation', eventFor(text));
|
||||
page.updateQuantity('bed-1', eventFor(quantity));
|
||||
presence.value = 'ABSENT';
|
||||
page.updatePresence('bed-1', eventFor(presence));
|
||||
presence.value = 'PRESENT';
|
||||
page.updatePresence('bed-1', eventFor(presence));
|
||||
page.addLine();
|
||||
const added = page.inventory().lines.find((line) => line.category === null);
|
||||
if (added) {
|
||||
equipment.value = 'CLIMATISATION';
|
||||
page.updateEquipmentKind(added.id, eventFor(equipment));
|
||||
expect(page.inventory().lines.find((line) => line.id === added.id)?.equipmentKind).toBe(
|
||||
'CLIMATISATION',
|
||||
);
|
||||
page.removeLine(added.id);
|
||||
}
|
||||
|
||||
expect(page.inventory().lines[0]).toEqual(
|
||||
expect.objectContaining({ designation: 'Lit double', presence: 'PRESENT', quantity: 1 }),
|
||||
);
|
||||
expect(page.warningLabel('BEDDING')).toBe('Lit double');
|
||||
page.inventory.set({ ...page.inventory(), frozen: true });
|
||||
page.addLine();
|
||||
page.removeLine('bed-1');
|
||||
page.save();
|
||||
expect(page.inventory().lines).toHaveLength(1);
|
||||
expect(page.error()).toContain('figé');
|
||||
});
|
||||
|
||||
it('shows load and save failures without replacing the current inventory', () => {
|
||||
const service = {
|
||||
getInventory: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(of(inventory))
|
||||
.mockReturnValue(throwError(() => new Error('offline'))),
|
||||
saveInventory: vi.fn().mockReturnValue(throwError(() => new Error('offline'))),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{ provide: ConditionReportService, useValue: service },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(InventoryPage);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.componentInstance;
|
||||
|
||||
page.save();
|
||||
expect(page.error()).toContain('Impossible d’enregistrer');
|
||||
page.load();
|
||||
expect(page.error()).toContain('Impossible de charger');
|
||||
expect(page.inventory()).toEqual(inventory);
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,7 @@ export class InventoryPage {
|
||||
id: crypto.randomUUID(),
|
||||
category: null,
|
||||
presence: null,
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: '',
|
||||
designation: '',
|
||||
quantity: 1,
|
||||
@@ -125,6 +126,13 @@ export class InventoryPage {
|
||||
}));
|
||||
}
|
||||
|
||||
updateEquipmentKind(id: string, event: Event): void {
|
||||
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
|
||||
if (value === 'NONE' || value === 'CLIMATISATION' || value === 'OTHER') {
|
||||
this.updateLine(id, { equipmentKind: value });
|
||||
}
|
||||
}
|
||||
|
||||
warningLabel(category: string): string {
|
||||
return (
|
||||
this.inventory().lines.find((line) => line.category === category)?.designation ?? category
|
||||
|
||||
@@ -3,6 +3,12 @@ import { vi } from 'vitest';
|
||||
import { FinalizationRevision } from '../../services/lease-finalization.service';
|
||||
import { LeaseFinalizationForm } from './lease-finalization-form';
|
||||
|
||||
function eventFor(target: EventTarget): Event {
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('LeaseFinalizationForm', () => {
|
||||
const revision: FinalizationRevision = {
|
||||
number: 4,
|
||||
@@ -18,6 +24,7 @@ describe('LeaseFinalizationForm', () => {
|
||||
id: 'line-1',
|
||||
category: null,
|
||||
presence: null,
|
||||
equipmentKind: 'NONE',
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 1,
|
||||
@@ -199,4 +206,69 @@ describe('LeaseFinalizationForm', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('Séjour');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('ID d’espace privatif');
|
||||
});
|
||||
|
||||
it('accepts only a PDF upload, captures the civil date, and toggles warning confirmations', () => {
|
||||
const fixture = createForm(['DPE_MISSING']);
|
||||
const component = fixture.componentInstance;
|
||||
const input = document.createElement('input');
|
||||
const invalid = new File(['text'], 'notes.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(input, 'files', { value: { item: () => invalid }, configurable: true });
|
||||
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
expect(component.signedLease()).toBeNull();
|
||||
expect(component.validationError()).toContain('format PDF');
|
||||
|
||||
const pdf = new File(['pdf'], 'bail.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(input, 'files', { value: { item: () => pdf }, configurable: true });
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
const date = document.createElement('input');
|
||||
date.value = '2026-07-20';
|
||||
component.setLastSignatureDate(eventFor(date));
|
||||
component.setWarning('DPE_MISSING', true);
|
||||
component.setWarning('DPE_MISSING', false);
|
||||
|
||||
expect(component.signedLease()).toBe(pdf);
|
||||
expect(component.lastSignatureDate()).toBe('2026-07-20');
|
||||
expect(component.confirmedWarningCodes().size).toBe(0);
|
||||
expect(component.warningLabel('DPE_MISSING')).toContain('DPE');
|
||||
});
|
||||
|
||||
it('edits a handwritten inventory correction from the revision snapshot', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(new File(['pdf'], 'bail.pdf', { type: 'application/pdf' }));
|
||||
component.lastSignatureDate.set('2026-07-20');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
const dialog = fixture.nativeElement.querySelector('dialog');
|
||||
Object.defineProperty(dialog, 'close', { value: vi.fn() });
|
||||
component.answerYes();
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Chaise', 'line-1'));
|
||||
select.value = 'line-1';
|
||||
component.selectInventoryLine(0, eventFor(select));
|
||||
const description = document.createElement('textarea');
|
||||
description.value = 'Pied rayé';
|
||||
component.setCorrection(0, 'description', eventFor(description));
|
||||
component.setCorrection(0, 'inventoryLineId', eventFor(document.createElement('input')));
|
||||
|
||||
component.continueWithCorrections();
|
||||
expect(component.validationError()).toContain('correction');
|
||||
component.selectInventoryLine(0, eventFor(select));
|
||||
component.allInventoryCorrectionsInitialed.set(true);
|
||||
component.continueWithCorrections();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entryInventoryDiscrepancies: true,
|
||||
inventoryCorrections: [
|
||||
expect.objectContaining({ inventoryLineId: 'line-1', description: 'Pied rayé' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
component.removeCorrection(0);
|
||||
expect(component.inventoryCorrections()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,12 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { PrivateAreaEditor } from './private-area-editor';
|
||||
|
||||
function eventFor(target: EventTarget): Event {
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('PrivateAreaEditor', () => {
|
||||
it('adds a stable private area and derives the main-room count from its family', () => {
|
||||
const fixture = TestBed.createComponent(PrivateAreaEditor);
|
||||
@@ -17,4 +23,32 @@ describe('PrivateAreaEditor', () => {
|
||||
fixture.componentRef.setInput('areas', changed.mock.calls[0][0]);
|
||||
expect(fixture.componentInstance.mainRoomCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('filters subtypes and resets the subtype when the area family changes', () => {
|
||||
const fixture = TestBed.createComponent(PrivateAreaEditor);
|
||||
fixture.componentRef.setInput('areas', [
|
||||
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'BEDROOM', label: 'Chambre' },
|
||||
]);
|
||||
const changed = vi.fn();
|
||||
fixture.componentInstance.areasChanged.subscribe(changed);
|
||||
const family = document.createElement('select');
|
||||
family.append(new Option('Annexe', 'ANNEXE'));
|
||||
family.value = 'ANNEXE';
|
||||
const label = document.createElement('input');
|
||||
label.value = 'Cave';
|
||||
|
||||
fixture.componentInstance.updateArea('area-1', 'family', eventFor(family));
|
||||
fixture.componentRef.setInput('areas', changed.mock.calls[0][0]);
|
||||
fixture.componentInstance.updateArea('area-1', 'label', eventFor(label));
|
||||
fixture.componentInstance.removeArea('area-1');
|
||||
|
||||
expect(changed.mock.calls[0][0][0]).toEqual(
|
||||
expect.objectContaining({ family: 'ANNEXE', subtype: 'OTHER' }),
|
||||
);
|
||||
expect(fixture.componentInstance.subtypesFor('ANNEXE').map((item) => item.value)).toContain(
|
||||
'CELLAR',
|
||||
);
|
||||
expect(changed.mock.calls[1][0][0]).toEqual(expect.objectContaining({ label: 'Cave' }));
|
||||
expect(changed.mock.calls[2][0]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -65,10 +65,12 @@ describe('ConditionReportService', () => {
|
||||
service
|
||||
.appendSupplement('lease-1', {
|
||||
signedPdf,
|
||||
photos: [photo],
|
||||
photos: [{ file: photo, zoneId: 'cuisine' }],
|
||||
requestedOn: '2026-07-20',
|
||||
acceptedAdditions: 'Rayure sur le mur de la cuisine.',
|
||||
supplementType: 'HEATING',
|
||||
heatingPeriodStartedOn: '2026-07-01',
|
||||
lateWarningConfirmed: true,
|
||||
reason: 'Demande reçue après le délai ordinaire.',
|
||||
})
|
||||
.subscribe();
|
||||
@@ -77,9 +79,12 @@ describe('ConditionReportService', () => {
|
||||
const formData = request.request.body as FormData;
|
||||
expect(formData.get('signedPdf')).toBe(signedPdf);
|
||||
expect(formData.getAll('photos')).toEqual([photo]);
|
||||
expect(formData.getAll('photoZoneIds')).toEqual(['cuisine']);
|
||||
expect(formData.get('requestedOn')).toBe('2026-07-20');
|
||||
expect(formData.get('acceptedAdditions')).toBe('Rayure sur le mur de la cuisine.');
|
||||
expect(formData.get('supplementType')).toBe('HEATING');
|
||||
expect(formData.get('heatingPeriodStartedOn')).toBe('2026-07-01');
|
||||
expect(formData.get('lateWarningConfirmed')).toBe('true');
|
||||
expect(formData.get('reason')).toBe('Demande reçue après le délai ordinaire.');
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 MeterReadingState = 'RECORDED' | 'INACCESSIBLE';
|
||||
export type AccessMeansType = 'CLE' | 'BADGE' | 'TELECOMMANDE' | 'OTHER';
|
||||
export type FurniturePresence = 'PRESENT' | 'ABSENT';
|
||||
export type FurnitureCategory =
|
||||
@@ -54,7 +55,8 @@ export interface ConditionObservation {
|
||||
export interface MeterReading {
|
||||
id: string;
|
||||
type: MeterType;
|
||||
index: number;
|
||||
state: MeterReadingState;
|
||||
index: number | null;
|
||||
unit: string;
|
||||
label: string;
|
||||
photoId: string;
|
||||
@@ -108,15 +110,28 @@ export interface EntryPhotoDisposition {
|
||||
|
||||
export interface PropertyConditionReportSupplement {
|
||||
id: string;
|
||||
type: 'GENERAL' | 'HEATING';
|
||||
signedPdf: { reference: string; sha256: string };
|
||||
photos: { reference: string; sha256: string }[];
|
||||
photos: ConditionPhoto[];
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
requestedOn: string;
|
||||
heatingPeriodStartedOn: string | null;
|
||||
acceptedAdditions: string;
|
||||
audit: unknown;
|
||||
audit: SupplementAudit;
|
||||
}
|
||||
|
||||
export type SupplementAudit =
|
||||
| { status: 'ON_TIME'; deadlineOn: string }
|
||||
| {
|
||||
status: 'LATE';
|
||||
deadlineOn: string;
|
||||
ruleVersion: string;
|
||||
reason: string;
|
||||
auditedBy: string;
|
||||
auditedAt: string;
|
||||
};
|
||||
|
||||
export interface PropertyConditionReportDeliveryAttestation {
|
||||
partyId: string;
|
||||
attestedBy: string;
|
||||
@@ -195,6 +210,7 @@ export interface FurnitureInventoryLine {
|
||||
id: string;
|
||||
category: FurnitureCategory | null;
|
||||
presence: FurniturePresence | null;
|
||||
equipmentKind: 'NONE' | 'CLIMATISATION' | 'OTHER';
|
||||
privateAreaId: string;
|
||||
designation: string;
|
||||
quantity: number;
|
||||
@@ -233,10 +249,12 @@ export interface ReportFinalizationRequest {
|
||||
|
||||
export interface AppendConditionReportSupplementRequest {
|
||||
signedPdf: File;
|
||||
photos: File[];
|
||||
photos: { file: File; zoneId: string }[];
|
||||
requestedOn: string;
|
||||
acceptedAdditions: string;
|
||||
supplementType: 'GENERAL' | 'HEATING';
|
||||
heatingPeriodStartedOn?: string;
|
||||
lateWarningConfirmed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -378,9 +396,14 @@ export class ConditionReportService {
|
||||
): Observable<PropertyConditionReport> {
|
||||
const formData = new FormData();
|
||||
formData.append('signedPdf', request.signedPdf);
|
||||
request.photos.forEach((photo) => formData.append('photos', photo));
|
||||
request.photos.forEach((photo) => {
|
||||
formData.append('photos', photo.file);
|
||||
formData.append('photoZoneIds', photo.zoneId);
|
||||
});
|
||||
formData.append('requestedOn', request.requestedOn);
|
||||
formData.append('acceptedAdditions', request.acceptedAdditions);
|
||||
formData.append('supplementType', request.supplementType);
|
||||
formData.append('lateWarningConfirmed', String(request.lateWarningConfirmed));
|
||||
if (request.heatingPeriodStartedOn) {
|
||||
formData.append('heatingPeriodStartedOn', request.heatingPeriodStartedOn);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface FinalizationInventoryLine {
|
||||
id: string;
|
||||
category: string | null;
|
||||
presence: 'PRESENT' | 'ABSENT' | null;
|
||||
equipmentKind: 'NONE' | 'CLIMATISATION' | 'OTHER';
|
||||
privateAreaId: string;
|
||||
designation: string;
|
||||
quantity: number;
|
||||
|
||||
Reference in new issue
Block a user