feat(rental): add inventory and condition reports (#7)
This commit is contained in:
1 parent
92d8a3e351
commit
e4be5cd05f
193 files changed
+9697
-483
No files matched your search
+121
@@ -0,0 +1,121 @@
|
||||
<section>
|
||||
<h2>Date et fuseau</h2>
|
||||
<label>
|
||||
Date civile
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
[value]="report().occurredOn"
|
||||
(input)="setOccurredOn($any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Fuseau IANA du navigateur
|
||||
<input required [value]="report().timeZone" (input)="setTimeZone($any($event.target).value)" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Parties et mandats</h2>
|
||||
@for (participant of report().participants; track participant.partyId; let index = $index) {
|
||||
<fieldset>
|
||||
<legend>Partie {{ index + 1 }}</legend>
|
||||
<p>
|
||||
{{ participant.name }} — {{ participant.role === 'LANDLORD' ? 'Bailleur' : 'Locataire' }}
|
||||
</p>
|
||||
<label>
|
||||
Présence
|
||||
<select
|
||||
[value]="participant.attendance"
|
||||
(change)="updateParticipant(index, 'attendance', $any($event.target).value)"
|
||||
>
|
||||
<option value="PRESENT">Présent</option>
|
||||
<option value="REPRESENTED">Représenté</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (participant.attendance === 'REPRESENTED') {
|
||||
<label>
|
||||
Mandataire
|
||||
<input
|
||||
required
|
||||
[value]="participant.representativeName"
|
||||
(input)="updateParticipant(index, 'representativeName', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Zones et sujets observés</h2>
|
||||
@for (zone of report().zones; track zone.id) {
|
||||
<fieldset>
|
||||
<legend>{{ zone.label || 'Zone' }}</legend>
|
||||
<label>
|
||||
Espace privatif
|
||||
<select
|
||||
required
|
||||
[value]="zone.privateAreaId"
|
||||
(change)="updateZone(zone.id, 'privateAreaId', $any($event.target).value)"
|
||||
>
|
||||
@for (area of privateAreas(); track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Libellé
|
||||
<input
|
||||
required
|
||||
[value]="zone.label"
|
||||
(input)="updateZone(zone.id, 'label', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Équipement
|
||||
<select
|
||||
[value]="zone.equipment"
|
||||
(change)="updateZone(zone.id, 'equipment', $any($event.target).value)"
|
||||
>
|
||||
<option value="NONE">Aucun</option>
|
||||
<option value="CLIMATISATION">Climatisation</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (canRemoveZone(zone.id)) {
|
||||
<button type="button" (click)="removeZone(zone.id)">Supprimer cette zone</button>
|
||||
}
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addZone()">Ajouter une zone</button>
|
||||
@for (observation of report().observations; track observation.subjectId) {
|
||||
<fieldset>
|
||||
<legend>{{ observationLabel(observation.subjectId) }}</legend>
|
||||
<label>
|
||||
État
|
||||
<select
|
||||
[value]="observation.state"
|
||||
(change)="updateObservation(observation.subjectId, 'state', $any($event.target).value)"
|
||||
>
|
||||
@for (state of states; track state) {
|
||||
<option [value]="state">{{ state }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (needsObservation(observation.state)) {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="needsObservation(observation.state)"
|
||||
[value]="observation.observation"
|
||||
(input)="
|
||||
updateObservation(observation.subjectId, 'observation', $any($event.target).value)
|
||||
"
|
||||
></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
}
|
||||
</section>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ConditionState, PropertyConditionReport } from '../../services/condition-report.service';
|
||||
|
||||
const states: readonly ConditionState[] = [
|
||||
'NEUF',
|
||||
'BON_ETAT',
|
||||
'ETAT_USAGE',
|
||||
'DEGRADE',
|
||||
'ABSENT',
|
||||
'NON_APPLICABLE',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-details-section',
|
||||
templateUrl: './condition-report-details-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportDetailsSection {
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly states = states;
|
||||
|
||||
setOccurredOn(occurredOn: string): void {
|
||||
this.update((report) => ({ ...report, occurredOn }));
|
||||
}
|
||||
|
||||
setTimeZone(timeZone: string): void {
|
||||
this.update((report) => ({ ...report, timeZone }));
|
||||
}
|
||||
|
||||
updateParticipant(
|
||||
index: number,
|
||||
field: 'attendance' | 'representativeName',
|
||||
value: string,
|
||||
): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
participants: report.participants.map((participant, item) =>
|
||||
item === index ? { ...participant, [field]: value } : participant,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
addZone(): void {
|
||||
const id = crypto.randomUUID();
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: [
|
||||
...report.zones,
|
||||
{ id, privateAreaId: this.privateAreas()[0]?.id ?? '', label: '', equipment: 'NONE' },
|
||||
],
|
||||
observations: [...report.observations, { subjectId: id, state: 'BON_ETAT', observation: '' }],
|
||||
}));
|
||||
}
|
||||
|
||||
updateZone(id: string, field: 'privateAreaId' | 'label' | 'equipment', value: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: report.zones.map((zone) => (zone.id === id ? { ...zone, [field]: value } : zone)),
|
||||
}));
|
||||
}
|
||||
|
||||
removeZone(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zones: report.zones.filter((zone) => zone.id !== id),
|
||||
observations: report.observations.filter((observation) => observation.subjectId !== id),
|
||||
zonePhotoAbsences: report.zonePhotoAbsences.filter((absence) => absence.zoneId !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
canRemoveZone(id: string): boolean {
|
||||
return (
|
||||
!id.startsWith('inventory:') &&
|
||||
![':floor', ':walls', ':ceiling'].some((suffix) => id.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
updateObservation(id: string, field: 'state' | 'observation', value: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
observations: report.observations.map((observation) =>
|
||||
observation.subjectId === id ? { ...observation, [field]: value } : observation,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
needsObservation(state: ConditionState): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state);
|
||||
}
|
||||
|
||||
privateAreas(): { id: string; label: string }[] {
|
||||
const areas = new Map<string, string>();
|
||||
for (const zone of this.report().zones) {
|
||||
if (!zone.privateAreaId || areas.has(zone.privateAreaId)) continue;
|
||||
const separator = zone.label.indexOf(' — ');
|
||||
areas.set(
|
||||
zone.privateAreaId,
|
||||
separator < 0 ? zone.privateAreaId : zone.label.slice(0, separator),
|
||||
);
|
||||
}
|
||||
return [...areas].map(([id, label]) => ({ id, label }));
|
||||
}
|
||||
|
||||
observationLabel(subjectId: string): string {
|
||||
return this.report().zones.find((zone) => zone.id === subjectId)?.label || 'Zone';
|
||||
}
|
||||
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<form (submit)="$event.preventDefault(); save()">
|
||||
<fieldset [disabled]="report().status !== 'DRAFT' || saving() || finalizing()">
|
||||
<app-condition-report-details-section
|
||||
[report]="report()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
/>
|
||||
<app-condition-report-inventory-section
|
||||
[report]="report()"
|
||||
[type]="type()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
/>
|
||||
<app-condition-report-evidence-section
|
||||
[leaseId]="leaseId()"
|
||||
[type]="type()"
|
||||
[report]="report()"
|
||||
[entryPhotos]="entryPhotos()"
|
||||
[editingDirty]="dirty()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
(errorOccurred)="errorOccurred.emit($event)"
|
||||
(successOccurred)="successOccurred.emit($event)"
|
||||
(reloadRequested)="reloadRequested.emit()"
|
||||
/>
|
||||
</fieldset>
|
||||
@if (report().status === 'DRAFT') {
|
||||
<button type="submit" [disabled]="saving()">
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer le brouillon' }}
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import {
|
||||
ConditionPhoto,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
PropertyConditionReport,
|
||||
SaveConditionReportRequest,
|
||||
} from '../../services/condition-report.service';
|
||||
import { ConditionReportDetailsSection } from './condition-report-details-section';
|
||||
import { ConditionReportEvidenceSection } from './condition-report-evidence-section';
|
||||
import { ConditionReportInventorySection } from './condition-report-inventory-section';
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-editor',
|
||||
imports: [
|
||||
ConditionReportDetailsSection,
|
||||
ConditionReportInventorySection,
|
||||
ConditionReportEvidenceSection,
|
||||
],
|
||||
templateUrl: './condition-report-editor.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportEditor {
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly entryPhotos = input<ConditionPhoto[]>([]);
|
||||
readonly dirty = input(false);
|
||||
readonly saving = input(false);
|
||||
readonly finalizing = input(false);
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly saveRequested = output<SaveConditionReportRequest>();
|
||||
readonly errorOccurred = output<string>();
|
||||
readonly successOccurred = output<string>();
|
||||
readonly reloadRequested = output<void>();
|
||||
|
||||
changeReport(report: PropertyConditionReport): void {
|
||||
if (!this.saving() && !this.finalizing()) this.reportChanged.emit(report);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const report = this.report();
|
||||
const error = this.validationError(report);
|
||||
if (this.saving() || error) {
|
||||
if (error) this.errorOccurred.emit(error);
|
||||
return;
|
||||
}
|
||||
this.saveRequested.emit(this.request(report));
|
||||
}
|
||||
|
||||
private request(report: PropertyConditionReport): SaveConditionReportRequest {
|
||||
return {
|
||||
version: report.version,
|
||||
occurredOn: report.occurredOn,
|
||||
timeZone: report.timeZone,
|
||||
participants: report.participants,
|
||||
forwardingAddresses: report.forwardingAddresses,
|
||||
zones: report.zones,
|
||||
observations: report.observations,
|
||||
meterReadings: report.meterReadings,
|
||||
accessMeans: report.accessMeans,
|
||||
inventoryConditions: report.inventoryConditions,
|
||||
zonePhotoAbsences: report.zonePhotoAbsences.map(({ zoneId, absence }) => ({
|
||||
zoneId,
|
||||
reason: absence.reason,
|
||||
})),
|
||||
entryPhotoDispositions: report.entryPhotoDispositions,
|
||||
};
|
||||
}
|
||||
|
||||
private validationError(report: PropertyConditionReport): string {
|
||||
if (!report.occurredOn || !report.timeZone)
|
||||
return 'Renseignez la date et le fuseau horaire IANA du navigateur.';
|
||||
if (
|
||||
report.participants.some(
|
||||
(participant) =>
|
||||
!participant.partyId.trim() ||
|
||||
!participant.name.trim() ||
|
||||
(participant.attendance === 'REPRESENTED' && !participant.representativeName.trim()),
|
||||
)
|
||||
)
|
||||
return 'Renseignez chaque partie et le mandataire des parties représentées.';
|
||||
if (report.zones.some((zone) => !zone.id.trim() || !zone.label.trim()))
|
||||
return 'Renseignez l’identifiant et le libellé de chaque zone.';
|
||||
if (
|
||||
report.observations.some(
|
||||
(observation) =>
|
||||
!observation.subjectId.trim() ||
|
||||
this.needsObservation(observation.state, observation.observation),
|
||||
)
|
||||
)
|
||||
return 'Chaque état dégradé, absent ou non applicable exige une observation.';
|
||||
if (
|
||||
report.meterReadings.some(
|
||||
(meter) =>
|
||||
!meter.id.trim() ||
|
||||
!meter.unit.trim() ||
|
||||
!Number.isFinite(meter.index) ||
|
||||
meter.index < 0 ||
|
||||
(meter.type === 'OTHER' && !meter.label.trim()),
|
||||
)
|
||||
)
|
||||
return 'Renseignez le type, l’index non négatif, l’unité et le libellé des compteurs personnalisés.';
|
||||
if (
|
||||
report.accessMeans.some(
|
||||
(access) =>
|
||||
!access.id.trim() ||
|
||||
!access.destination.trim() ||
|
||||
!Number.isInteger(access.quantity) ||
|
||||
access.quantity < 0,
|
||||
)
|
||||
)
|
||||
return 'Renseignez les moyens d’accès et leur quantité entière non négative.';
|
||||
if (
|
||||
report.inventoryConditions.some(
|
||||
(condition) =>
|
||||
!condition.inventoryLineId.trim() ||
|
||||
!Number.isInteger(condition.contractualQuantity) ||
|
||||
condition.contractualQuantity < 0 ||
|
||||
condition.splits.reduce((sum, split) => sum + split.quantity, 0) !==
|
||||
condition.contractualQuantity ||
|
||||
condition.splits.some(
|
||||
(split) =>
|
||||
!Number.isInteger(split.quantity) ||
|
||||
split.quantity < 0 ||
|
||||
this.needsObservation(split.state, split.observation),
|
||||
),
|
||||
)
|
||||
)
|
||||
return 'Répartissez exactement chaque quantité d’inventaire par état et observez les états dégradé, absent ou non applicable.';
|
||||
if (
|
||||
report.zones.some(
|
||||
(zone) =>
|
||||
!report.photos.some((photo) => photo.zoneId === zone.id) &&
|
||||
!report.zonePhotoAbsences
|
||||
.find((absence) => absence.zoneId === zone.id)
|
||||
?.absence.reason.trim(),
|
||||
)
|
||||
)
|
||||
return 'Ajoutez une photo ou un motif d’absence pour chaque zone.';
|
||||
if (
|
||||
this.type() === 'EXIT' &&
|
||||
report.participants.some(
|
||||
(participant) =>
|
||||
participant.role === 'TENANT' &&
|
||||
!report.forwardingAddresses
|
||||
.find((address) => address.tenantPartyId === participant.partyId)
|
||||
?.address.trim(),
|
||||
)
|
||||
)
|
||||
return 'Renseignez l’adresse de correspondance de chaque locataire.';
|
||||
if (
|
||||
this.type() === 'EXIT' &&
|
||||
this.entryPhotos().some((photo) => {
|
||||
const disposition = report.entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === photo.reference,
|
||||
);
|
||||
return !disposition?.exitPhotoId && !disposition?.absenceReason?.trim();
|
||||
})
|
||||
)
|
||||
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
|
||||
return '';
|
||||
}
|
||||
|
||||
private needsObservation(state: ConditionState, observation: string): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state) && !observation.trim();
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<section>
|
||||
<h2>Parcours photos guidé</h2>
|
||||
<p>Chaque zone exige une photo ou un motif d’absence.</p>
|
||||
@for (zone of report().zones; track zone.id) {
|
||||
<article>
|
||||
<h3>{{ zone.label }}</h3>
|
||||
<label>
|
||||
Motif d’absence
|
||||
<input
|
||||
[value]="absenceReason(zone.id)"
|
||||
(input)="setZoneAbsence(zone.id, $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Prendre ou choisir une photo
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
|
||||
capture="environment"
|
||||
[disabled]="editingDirty()"
|
||||
(change)="selectPhoto(zone.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (pendingPhotos()[zone.id]; as pending) {
|
||||
<img [src]="pending.previewUrl" alt="Aperçu de la photo de {{ zone.label }}" />
|
||||
<p>{{ pending.file.name }}</p>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id || editingDirty()"
|
||||
(click)="uploadPhoto(zone.id)"
|
||||
>
|
||||
{{ busyPhotoZone() === zone.id ? 'Téléversement…' : 'Utiliser cette photo' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id"
|
||||
(click)="retakePhoto(zone.id)"
|
||||
>
|
||||
Reprendre le cliché
|
||||
</button>
|
||||
}
|
||||
@for (photo of report().photos; track photo.reference) {
|
||||
@if (photo.zoneId === zone.id) {
|
||||
<img
|
||||
[src]="photoUrl(type(), photo.reference)"
|
||||
alt="Photo enregistrée de {{ zone.label }}"
|
||||
/>
|
||||
<p>
|
||||
{{ photo.reference }}
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="busyPhotoZone() === zone.id || editingDirty()"
|
||||
(click)="removePhoto(photo)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (type() === 'EXIT') {
|
||||
<section>
|
||||
<h2>Photos d’entrée à comparer</h2>
|
||||
@for (entryPhoto of entryPhotos(); track entryPhoto.reference) {
|
||||
<article>
|
||||
<img [src]="photoUrl('ENTRY', entryPhoto.reference)" alt="Photo d’entrée à comparer" />
|
||||
<p>{{ entryPhoto.reference }}</p>
|
||||
<label>
|
||||
Photo de sortie correspondante
|
||||
<select
|
||||
[value]="disposition(entryPhoto.reference).exitPhotoId ?? ''"
|
||||
(change)="
|
||||
setEntryPhotoDisposition(
|
||||
entryPhoto.reference,
|
||||
'exitPhotoId',
|
||||
$any($event.target).value
|
||||
)
|
||||
"
|
||||
>
|
||||
<option value="">Aucune</option>
|
||||
@for (exitPhoto of report().photos; track exitPhoto.reference) {
|
||||
<option [value]="exitPhoto.reference">{{ exitPhoto.reference }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Motif d’absence
|
||||
<input
|
||||
[value]="disposition(entryPhoto.reference).absenceReason ?? ''"
|
||||
(input)="
|
||||
setEntryPhotoDisposition(
|
||||
entryPhoto.reference,
|
||||
'absenceReason',
|
||||
$any($event.target).value
|
||||
)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { Component, OnDestroy, inject, input, output, signal } from '@angular/core';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
ConditionPhoto,
|
||||
ConditionReportService,
|
||||
ConditionReportType,
|
||||
EntryPhotoDisposition,
|
||||
PropertyConditionReport,
|
||||
} from '../../services/condition-report.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
|
||||
interface PendingPhoto {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-evidence-section',
|
||||
templateUrl: './condition-report-evidence-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportEvidenceSection implements OnDestroy {
|
||||
private readonly reports = inject(ConditionReportService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly entryPhotos = input<ConditionPhoto[]>([]);
|
||||
readonly editingDirty = input(false);
|
||||
readonly busyPhotoZone = signal('');
|
||||
readonly pendingPhotos = signal<Record<string, PendingPhoto>>({});
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly errorOccurred = output<string>();
|
||||
readonly successOccurred = output<string>();
|
||||
readonly reloadRequested = output<void>();
|
||||
|
||||
ngOnDestroy(): void {
|
||||
Object.values(this.pendingPhotos()).forEach(({ previewUrl }) =>
|
||||
URL.revokeObjectURL(previewUrl),
|
||||
);
|
||||
}
|
||||
|
||||
setZoneAbsence(zoneId: string, reason: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
zonePhotoAbsences: [
|
||||
...report.zonePhotoAbsences.filter((absence) => absence.zoneId !== zoneId),
|
||||
...(reason.trim() ? [{ zoneId, absence: { reason, author: '', recordedAt: '' } }] : []),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
setEntryPhotoDisposition(
|
||||
entryPhotoId: string,
|
||||
field: keyof EntryPhotoDisposition,
|
||||
value: string,
|
||||
): void {
|
||||
this.update((report) => {
|
||||
const current = report.entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === entryPhotoId,
|
||||
) ?? { entryPhotoId };
|
||||
const next = { ...current, [field]: value || undefined };
|
||||
return {
|
||||
...report,
|
||||
entryPhotoDispositions: [
|
||||
...report.entryPhotoDispositions.filter((item) => item.entryPhotoId !== entryPhotoId),
|
||||
next,
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
selectPhoto(zoneId: string, event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (!file) return;
|
||||
const previous = this.pendingPhotos()[zoneId];
|
||||
if (previous) URL.revokeObjectURL(previous.previewUrl);
|
||||
this.pendingPhotos.update((photos) => ({
|
||||
...photos,
|
||||
[zoneId]: { file, previewUrl: URL.createObjectURL(file) },
|
||||
}));
|
||||
}
|
||||
|
||||
retakePhoto(zoneId: string): void {
|
||||
const photo = this.pendingPhotos()[zoneId];
|
||||
if (!photo) return;
|
||||
URL.revokeObjectURL(photo.previewUrl);
|
||||
this.pendingPhotos.update((photos) => {
|
||||
const remaining = { ...photos };
|
||||
delete remaining[zoneId];
|
||||
return remaining;
|
||||
});
|
||||
}
|
||||
|
||||
uploadPhoto(zoneId: string): void {
|
||||
const pending = this.pendingPhotos()[zoneId];
|
||||
if (!pending || this.busyPhotoZone() || this.editingDirty()) return;
|
||||
this.busyPhotoZone.set(zoneId);
|
||||
this.reports
|
||||
.uploadPhoto(this.leaseId(), this.type(), pending.file, zoneId)
|
||||
.pipe(rxFinalize(() => this.busyPhotoZone.set('')))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.retakePhoto(zoneId);
|
||||
this.successOccurred.emit('Photo ajoutée.');
|
||||
this.reloadRequested.emit();
|
||||
},
|
||||
error: (error) =>
|
||||
this.errorOccurred.emit(
|
||||
httpErrorMessage(error, 'La photo a été refusée. Reprenez le cliché puis réessayez.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
removePhoto(photo: ConditionPhoto): void {
|
||||
if (this.busyPhotoZone() || this.editingDirty()) return;
|
||||
this.busyPhotoZone.set(photo.zoneId);
|
||||
this.reports
|
||||
.deletePhoto(this.leaseId(), this.type(), photo.reference, this.report().version)
|
||||
.pipe(rxFinalize(() => this.busyPhotoZone.set('')))
|
||||
.subscribe({
|
||||
next: () => this.reloadRequested.emit(),
|
||||
error: (error) =>
|
||||
this.errorOccurred.emit(httpErrorMessage(error, 'Impossible de supprimer cette photo.')),
|
||||
});
|
||||
}
|
||||
|
||||
absenceReason(zoneId: string): string {
|
||||
return (
|
||||
this.report().zonePhotoAbsences.find((absence) => absence.zoneId === zoneId)?.absence
|
||||
.reason ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
disposition(entryPhotoId: string): EntryPhotoDisposition {
|
||||
return (
|
||||
this.report().entryPhotoDispositions.find((item) => item.entryPhotoId === entryPhotoId) ?? {
|
||||
entryPhotoId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
photoUrl(type: ConditionReportType, reference: string): string {
|
||||
return this.reports.photoUrl(this.leaseId(), type, reference);
|
||||
}
|
||||
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
<section>
|
||||
<h2>Compteurs</h2>
|
||||
@for (meter of report().meterReadings; track meter.id) {
|
||||
<fieldset>
|
||||
<legend>{{ meter.label || meter.type }}</legend>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
[value]="meter.type"
|
||||
(change)="updateMeter(meter.id, 'type', $any($event.target).value)"
|
||||
>
|
||||
<option value="ELECTRICITE">Électricité</option>
|
||||
<option value="GAZ">Gaz</option>
|
||||
<option value="EAU_FROIDE">Eau froide</option>
|
||||
<option value="EAU_CHAUDE">Eau chaude</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Libellé / numéro
|
||||
<input
|
||||
[required]="meter.type === 'OTHER'"
|
||||
[value]="meter.label"
|
||||
(input)="updateMeter(meter.id, 'label', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Unité
|
||||
<input
|
||||
required
|
||||
[value]="meter.unit"
|
||||
(input)="updateMeter(meter.id, 'unit', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Index
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
[value]="meter.index"
|
||||
(input)="updateMeter(meter.id, 'index', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Référence photo
|
||||
<input
|
||||
[value]="meter.photoId"
|
||||
(input)="updateMeter(meter.id, 'photoId', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
<textarea
|
||||
[value]="meter.observation"
|
||||
(input)="updateMeter(meter.id, 'observation', $any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeMeter(meter.id)">Supprimer ce compteur</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addMeter()">Ajouter un compteur</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Moyens d’accès</h2>
|
||||
@for (access of report().accessMeans; track access.id) {
|
||||
<fieldset>
|
||||
<legend>{{ access.destination || 'Accès' }}</legend>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
[value]="access.type"
|
||||
(change)="updateAccessMean(access.id, 'type', $any($event.target).value)"
|
||||
>
|
||||
<option value="CLE">Clé</option>
|
||||
<option value="BADGE">Badge</option>
|
||||
<option value="TELECOMMANDE">Télécommande</option>
|
||||
<option value="OTHER">Autre</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Destination
|
||||
<input
|
||||
required
|
||||
[value]="access.destination"
|
||||
(input)="updateAccessMean(access.id, 'destination', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="access.quantity"
|
||||
(input)="updateAccessMean(access.id, 'quantity', $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
<textarea
|
||||
[value]="access.observation"
|
||||
(input)="updateAccessMean(access.id, 'observation', $any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeAccessMean(access.id)">
|
||||
Supprimer ce moyen d’accès
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addAccessMean()">Ajouter un moyen d’accès</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Inventaire constaté</h2>
|
||||
@for (
|
||||
condition of report().inventoryConditions;
|
||||
track condition.inventoryLineId;
|
||||
let conditionIndex = $index
|
||||
) {
|
||||
<fieldset>
|
||||
<legend>{{ condition.designation }}</legend>
|
||||
<p>Espace : {{ condition.privateAreaLabel }}</p>
|
||||
<p>Quantité contractuelle : {{ condition.contractualQuantity }}</p>
|
||||
@for (split of condition.splits; track $index; let splitIndex = $index) {
|
||||
<fieldset>
|
||||
<legend>Répartition</legend>
|
||||
<label>
|
||||
État
|
||||
<select
|
||||
[value]="split.state"
|
||||
(change)="
|
||||
updateInventorySplit(conditionIndex, splitIndex, 'state', $any($event.target).value)
|
||||
"
|
||||
>
|
||||
@for (state of states; track state) {
|
||||
<option [value]="state">{{ state }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="split.quantity"
|
||||
(input)="
|
||||
updateInventorySplit(
|
||||
conditionIndex,
|
||||
splitIndex,
|
||||
'quantity',
|
||||
$any($event.target).value
|
||||
)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Observation
|
||||
@if (needsObservation(split.state)) {
|
||||
<span>(obligatoire)</span>
|
||||
}
|
||||
<textarea
|
||||
[required]="needsObservation(split.state)"
|
||||
[value]="split.observation"
|
||||
(input)="
|
||||
updateInventorySplit(
|
||||
conditionIndex,
|
||||
splitIndex,
|
||||
'observation',
|
||||
$any($event.target).value
|
||||
)
|
||||
"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeInventorySplit(conditionIndex, splitIndex)">
|
||||
Supprimer cette répartition
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addInventorySplit(conditionIndex)">
|
||||
Ajouter une répartition
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (type() === 'EXIT') {
|
||||
<section>
|
||||
<h2>Adresses de correspondance</h2>
|
||||
@for (participant of report().participants; track participant.partyId) {
|
||||
@if (participant.role === 'TENANT') {
|
||||
<label>
|
||||
{{ participant.name || participant.partyId }}
|
||||
<textarea
|
||||
required
|
||||
[value]="forwardingAddress(participant.partyId)"
|
||||
(input)="updateForwardingAddress(participant.partyId, $any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import {
|
||||
AccessMeans,
|
||||
ConditionReportType,
|
||||
ConditionState,
|
||||
InventoryCondition,
|
||||
InventoryQuantitySplit,
|
||||
MeterReading,
|
||||
PropertyConditionReport,
|
||||
} from '../../services/condition-report.service';
|
||||
|
||||
const states: readonly ConditionState[] = [
|
||||
'NEUF',
|
||||
'BON_ETAT',
|
||||
'ETAT_USAGE',
|
||||
'DEGRADE',
|
||||
'ABSENT',
|
||||
'NON_APPLICABLE',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-inventory-section',
|
||||
templateUrl: './condition-report-inventory-section.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportInventorySection {
|
||||
readonly report = input.required<PropertyConditionReport>();
|
||||
readonly type = input.required<ConditionReportType>();
|
||||
readonly reportChanged = output<PropertyConditionReport>();
|
||||
readonly states = states;
|
||||
|
||||
addMeter(): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: [
|
||||
...report.meterReadings,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'ELECTRICITE',
|
||||
index: 0,
|
||||
unit: 'kWh',
|
||||
label: '',
|
||||
photoId: '',
|
||||
observation: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
updateMeter(id: string, field: keyof MeterReading, value: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: report.meterReadings.map((meter) =>
|
||||
meter.id === id ? { ...meter, [field]: field === 'index' ? Number(value) : value } : meter,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
removeMeter(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
meterReadings: report.meterReadings.filter((meter) => meter.id !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
addAccessMean(): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: [
|
||||
...report.accessMeans,
|
||||
{ id: crypto.randomUUID(), type: 'CLE', destination: '', quantity: 1, observation: '' },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
updateAccessMean(id: string, field: keyof AccessMeans, value: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: report.accessMeans.map((access) =>
|
||||
access.id === id
|
||||
? { ...access, [field]: field === 'quantity' ? Number(value) : value }
|
||||
: access,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
removeAccessMean(id: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
accessMeans: report.accessMeans.filter((access) => access.id !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
addInventorySplit(index: number): void {
|
||||
this.updateInventoryCondition(index, (condition) => ({
|
||||
...condition,
|
||||
splits: [
|
||||
...condition.splits,
|
||||
{
|
||||
inventoryLineId: condition.inventoryLineId,
|
||||
state: 'BON_ETAT',
|
||||
quantity: 0,
|
||||
observation: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
updateInventorySplit(
|
||||
conditionIndex: number,
|
||||
splitIndex: number,
|
||||
field: keyof InventoryQuantitySplit,
|
||||
value: string,
|
||||
): void {
|
||||
this.updateInventoryCondition(conditionIndex, (condition) => ({
|
||||
...condition,
|
||||
splits: condition.splits.map((split, item) =>
|
||||
item === splitIndex
|
||||
? {
|
||||
...split,
|
||||
[field]: field === 'quantity' ? Number(value) : value,
|
||||
...(field === 'inventoryLineId'
|
||||
? {}
|
||||
: { inventoryLineId: condition.inventoryLineId }),
|
||||
}
|
||||
: split,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
removeInventorySplit(conditionIndex: number, splitIndex: number): void {
|
||||
this.updateInventoryCondition(conditionIndex, (condition) => ({
|
||||
...condition,
|
||||
splits: condition.splits.filter((_, item) => item !== splitIndex),
|
||||
}));
|
||||
}
|
||||
|
||||
updateForwardingAddress(tenantPartyId: string, address: string): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
forwardingAddresses: [
|
||||
...report.forwardingAddresses.filter(
|
||||
(candidate) => candidate.tenantPartyId !== tenantPartyId,
|
||||
),
|
||||
{ tenantPartyId, address },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
forwardingAddress(tenantPartyId: string): string {
|
||||
return (
|
||||
this.report().forwardingAddresses.find((address) => address.tenantPartyId === tenantPartyId)
|
||||
?.address ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
needsObservation(state: ConditionState): boolean {
|
||||
return ['DEGRADE', 'ABSENT', 'NON_APPLICABLE'].includes(state);
|
||||
}
|
||||
|
||||
private update(change: (report: PropertyConditionReport) => PropertyConditionReport): void {
|
||||
this.reportChanged.emit(change(this.report()));
|
||||
}
|
||||
|
||||
private updateInventoryCondition(
|
||||
index: number,
|
||||
change: (condition: InventoryCondition) => InventoryCondition,
|
||||
): void {
|
||||
this.update((report) => ({
|
||||
...report,
|
||||
inventoryConditions: report.inventoryConditions.map((condition, item) =>
|
||||
item === index ? change(condition) : condition,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
article {
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button {
|
||||
margin: 6px;
|
||||
}
|
||||
img {
|
||||
max-width: min(100%, 480px);
|
||||
height: auto;
|
||||
}
|
||||
.photo-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<section class="form-page" [attr.aria-busy]="loading() || saving() || finalizing()">
|
||||
<a routerLink="/leases">← Retour aux baux</a>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">État des lieux</p>
|
||||
<h1>{{ type === 'ENTRY' ? 'Entrée' : 'Sortie' }}</h1>
|
||||
<p>Constat contradictoire, photos de preuve et remise aux parties.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<p role="status">Chargement de l’état des lieux…</p>
|
||||
} @else if (error() && !report()) {
|
||||
<p role="alert" class="operation-error">{{ error() }}</p>
|
||||
<button type="button" (click)="load()">Réessayer</button>
|
||||
} @else if (report(); as value) {
|
||||
@if (error()) {
|
||||
<p role="alert" class="operation-error">{{ error() }}</p>
|
||||
}
|
||||
@if (success()) {
|
||||
<p role="status">{{ success() }}</p>
|
||||
}
|
||||
@if (value.leaseRevisionNumber !== null) {
|
||||
<p>
|
||||
Révision de bail source {{ value.leaseRevisionNumber }}
|
||||
@if (value.leaseRevisionFingerprint) {
|
||||
· empreinte {{ value.leaseRevisionFingerprint }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-condition-report-editor
|
||||
[leaseId]="leaseId"
|
||||
[type]="type"
|
||||
[report]="value"
|
||||
[entryPhotos]="entryPhotos()"
|
||||
[dirty]="dirty()"
|
||||
[saving]="saving()"
|
||||
[finalizing]="finalizing()"
|
||||
(reportChanged)="changeReport($event)"
|
||||
(saveRequested)="save($event)"
|
||||
(errorOccurred)="error.set($event)"
|
||||
(successOccurred)="success.set($event)"
|
||||
(reloadRequested)="load()"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2>Révision et finalisation</h2>
|
||||
@if (value.status === 'DRAFT') {
|
||||
<button type="button" [disabled]="revisionPending() || dirty()" (click)="createRevision()">
|
||||
{{ revisionPending() ? 'Génération…' : 'Générer et télécharger la révision PDF' }}
|
||||
</button>
|
||||
<label>
|
||||
Scan signé PDF
|
||||
<input type="file" accept="application/pdf,.pdf" (change)="selectSignedScan($event)" />
|
||||
</label>
|
||||
@if (signedScan(); as scan) {
|
||||
<p>{{ scan.name }} · attestation de correspondance incluse à la finalisation.</p>
|
||||
}
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="finalizationAttested()"
|
||||
[disabled]="finalizing()"
|
||||
(change)="finalizationAttested.set($any($event.target).checked)"
|
||||
/>
|
||||
J’atteste que le scan signé correspond à la dernière révision générée.
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allPartiesSigned()"
|
||||
[disabled]="finalizing()"
|
||||
(change)="allPartiesSigned.set($any($event.target).checked)"
|
||||
/>
|
||||
J’atteste que chaque partie présente ou son mandataire identifié a signé le document.
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
finalizing() ||
|
||||
!signedScan() ||
|
||||
!finalizationAttested() ||
|
||||
!allPartiesSigned() ||
|
||||
dirty()
|
||||
"
|
||||
(click)="finalize()"
|
||||
>
|
||||
{{ finalizing() ? 'Finalisation…' : 'Finaliser l’état des lieux' }}
|
||||
</button>
|
||||
} @else if (value.status === 'FINALIZED') {
|
||||
<p>État des lieux finalisé.</p>
|
||||
<button type="button" (click)="downloadBundle()">Télécharger le dossier ZIP</button>
|
||||
<h3>Attestations de remise</h3>
|
||||
@for (participant of value.participants; track participant.partyId) {
|
||||
@if (hasAttested(participant.partyId)) {
|
||||
<p>{{ participant.name }} : remise attestée.</p>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!!busyAttestationPartyId()"
|
||||
(click)="attestDelivery(participant.partyId)"
|
||||
>
|
||||
{{
|
||||
busyAttestationPartyId() === participant.partyId
|
||||
? 'Attestation…'
|
||||
: 'Attester la remise à ' + participant.name
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (type === 'ENTRY') {
|
||||
<label>
|
||||
Date de demande du complément
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
[value]="supplementRequestedOn()"
|
||||
(input)="supplementRequestedOn.set($any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ajouts acceptés
|
||||
<textarea
|
||||
required
|
||||
[value]="supplementAcceptedAdditions()"
|
||||
(input)="supplementAcceptedAdditions.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Début de la période de chauffe (facultatif)
|
||||
<input
|
||||
type="date"
|
||||
[value]="heatingPeriodStartedOn()"
|
||||
(input)="heatingPeriodStartedOn.set($any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Motif du dépassement de délai
|
||||
<textarea
|
||||
[value]="supplementReason()"
|
||||
(input)="supplementReason.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Photos du complément (facultatives)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
|
||||
multiple
|
||||
[disabled]="supplementPending()"
|
||||
(change)="selectSupplementPhotos($event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Complément signé PDF (obligatoire)
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
[disabled]="supplementPending()"
|
||||
(change)="selectSupplementSignedPdf($event)"
|
||||
/>
|
||||
</label>
|
||||
@if (supplementSignedPdf(); as pdf) {
|
||||
<p>{{ pdf.name }} · {{ supplementPhotos().length }} photo(s).</p>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="supplementPending() || !supplementSignedPdf()"
|
||||
(click)="appendSupplement()"
|
||||
>
|
||||
{{ supplementPending() ? 'Ajout…' : 'Ajouter le complément signé' }}
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<p role="status">Ce brouillon est obsolète car la révision du bail a changé.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (type === 'EXIT') {
|
||||
<section>
|
||||
<h2>Comparaison structurée</h2>
|
||||
<p>Les constats sont alignés sans responsabilité ni montant.</p>
|
||||
<button type="button" [disabled]="comparisonPending()" (click)="loadComparison()">
|
||||
{{ comparisonPending() ? 'Comparaison…' : 'Comparer entrée et sortie' }}
|
||||
</button>
|
||||
@for (section of comparisonSections(); track section.title) {
|
||||
<h3>{{ section.title }}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Référence</th>
|
||||
<th>Entrée</th>
|
||||
<th>Sortie</th>
|
||||
<th>Changement</th>
|
||||
<th>Motif d’absence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of section.lines; track line.reference) {
|
||||
<tr>
|
||||
<th>{{ line.reference }}</th>
|
||||
<td>{{ line.entry | json }}</td>
|
||||
<td>{{ line.exit | json }}</td>
|
||||
<td>{{ line.changed ? 'Oui' : 'Non' }}</td>
|
||||
<td>{{ line.absence | json }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (comparison(); as compared) {
|
||||
<h3>Photos</h3>
|
||||
@for (line of compared.photos; track line.reference) {
|
||||
<article class="photo-comparison">
|
||||
<div>
|
||||
<h4>Entrée</h4>
|
||||
@if (line.entry) {
|
||||
<img [src]="photoUrl('ENTRY', line.entry.reference)" alt="Photo d’entrée" />
|
||||
} @else {
|
||||
<p>Aucune photo d’entrée.</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h4>Sortie</h4>
|
||||
@if (line.exit) {
|
||||
<img [src]="photoUrl('EXIT', line.exit.reference)" alt="Photo de sortie" />
|
||||
} @else {
|
||||
<p>Aucune photo de sortie.</p>
|
||||
}
|
||||
</div>
|
||||
@if (line.absence) {
|
||||
<p>Motif d’absence : {{ line.absence.reason }}</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,154 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
ConditionReportComparison,
|
||||
ConditionReportService,
|
||||
PropertyConditionReport,
|
||||
} from '../../services/condition-report.service';
|
||||
import { FileDownloadService } from '../../services/file-download.service';
|
||||
import { ConditionReportEditor } from './condition-report-editor';
|
||||
import { ConditionReportPage } from './condition-report-page';
|
||||
|
||||
const report: PropertyConditionReport = {
|
||||
id: 'report-1',
|
||||
type: 'ENTRY',
|
||||
status: 'DRAFT',
|
||||
occurredOn: '2026-07-22',
|
||||
timeZone: 'Europe/Paris',
|
||||
participants: [],
|
||||
forwardingAddresses: [],
|
||||
zones: [{ id: 'kitchen', privateAreaId: '', label: 'Cuisine', equipment: 'NONE' }],
|
||||
observations: [],
|
||||
meterReadings: [],
|
||||
accessMeans: [],
|
||||
inventoryConditions: [],
|
||||
photos: [],
|
||||
zonePhotoAbsences: [],
|
||||
entryPhotoDispositions: [],
|
||||
supplements: [],
|
||||
entrySnapshot: null,
|
||||
revisions: [],
|
||||
currentRevision: null,
|
||||
finalization: null,
|
||||
deliveryAttestations: [],
|
||||
leaseRevisionNumber: 3,
|
||||
leaseRevisionFingerprint: 'lease-revision-fingerprint',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('ConditionReportEditor', () => {
|
||||
it('uses a rear-camera input and explains an invalid degraded observation before saving', () => {
|
||||
const service = {
|
||||
getReport: vi.fn().mockReturnValue(of(report)),
|
||||
saveReport: vi.fn(),
|
||||
uploadPhoto: vi.fn(),
|
||||
deletePhoto: vi.fn(),
|
||||
createRevision: vi.fn(),
|
||||
downloadRevision: vi.fn(),
|
||||
finalize: vi.fn(),
|
||||
downloadBundle: vi.fn(),
|
||||
getComparison: vi.fn(),
|
||||
appendSupplement: vi.fn(),
|
||||
attestDelivery: vi.fn(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ConditionReportService, useValue: service }],
|
||||
});
|
||||
const fixture = TestBed.createComponent(ConditionReportEditor);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('type', 'ENTRY');
|
||||
fixture.componentRef.setInput('report', report);
|
||||
fixture.componentRef.setInput('entryPhotos', []);
|
||||
fixture.detectChanges();
|
||||
const editor = fixture.componentInstance;
|
||||
let error = '';
|
||||
editor.errorOccurred.subscribe((message) => (error = message));
|
||||
fixture.componentRef.setInput('report', {
|
||||
...report,
|
||||
observations: [{ subjectId: 'kitchen', state: 'DEGRADE', observation: '' }],
|
||||
});
|
||||
editor.save();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('input[type=file][capture]').getAttribute('capture'),
|
||||
).toBe('environment');
|
||||
expect(error).toContain('observation');
|
||||
expect(service.saveReport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConditionReportPage', () => {
|
||||
it('refreshes the revision version, requires attestation, and renders every comparison section', () => {
|
||||
const refreshed = { ...report, version: 2 };
|
||||
const comparison: ConditionReportComparison = {
|
||||
observations: [],
|
||||
zones: [],
|
||||
inventory: [],
|
||||
meters: [],
|
||||
accessMeans: [],
|
||||
supplements: [],
|
||||
photos: [{ reference: 'photo-1', entry: null, exit: null, changed: true }],
|
||||
};
|
||||
const service = {
|
||||
getReport: vi.fn().mockReturnValueOnce(of(report)).mockReturnValue(of(refreshed)),
|
||||
createRevision: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
of({ number: 1, sha256: 'sha', sourceFingerprint: 'source', obsolete: false }),
|
||||
),
|
||||
downloadRevision: vi.fn().mockReturnValue(of(new Blob(['pdf']))),
|
||||
finalize: vi.fn().mockReturnValue(of({ ...refreshed, status: 'FINALIZED' as const })),
|
||||
getComparison: vi.fn().mockReturnValue(of(comparison)),
|
||||
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
|
||||
};
|
||||
const downloads = { download: vi.fn() };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { paramMap: convertToParamMap({ leaseId: 'lease-1', type: 'ENTRY' }) },
|
||||
},
|
||||
},
|
||||
{ provide: ConditionReportService, useValue: service },
|
||||
{ provide: FileDownloadService, useValue: downloads },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(ConditionReportPage);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.componentInstance;
|
||||
|
||||
page.dirty.set(true);
|
||||
page.createRevision();
|
||||
expect(service.createRevision).not.toHaveBeenCalled();
|
||||
page.dirty.set(false);
|
||||
page.createRevision();
|
||||
expect(service.getReport).toHaveBeenCalledTimes(2);
|
||||
expect(page.report()?.version).toBe(2);
|
||||
page.signedScan.set(new File(['pdf'], 'signed.pdf', { type: 'application/pdf' }));
|
||||
page.finalize();
|
||||
expect(service.finalize).not.toHaveBeenCalled();
|
||||
page.finalizationAttested.set(true);
|
||||
page.finalize();
|
||||
expect(service.finalize).not.toHaveBeenCalled();
|
||||
page.allPartiesSigned.set(true);
|
||||
page.finalize();
|
||||
expect(service.finalize).toHaveBeenCalledWith('lease-1', 'ENTRY', {
|
||||
signedReport: expect.any(File),
|
||||
version: 2,
|
||||
allPartiesSigned: true,
|
||||
});
|
||||
page.loadComparison();
|
||||
expect(page.comparisonSections().map((section) => section.title)).toEqual([
|
||||
'Observations',
|
||||
'Zones',
|
||||
'Inventaire',
|
||||
'Compteurs',
|
||||
'Moyens d’accès',
|
||||
'Compléments acceptés',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { JsonPipe } from '@angular/common';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
ConditionPhoto,
|
||||
ConditionReportComparison,
|
||||
ConditionReportService,
|
||||
ConditionReportType,
|
||||
PropertyConditionReport,
|
||||
SaveConditionReportRequest,
|
||||
} from '../../services/condition-report.service';
|
||||
import { FileDownloadService } from '../../services/file-download.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { ConditionReportEditor } from './condition-report-editor';
|
||||
|
||||
@Component({
|
||||
selector: 'app-condition-report-page',
|
||||
imports: [ConditionReportEditor, JsonPipe, RouterLink],
|
||||
templateUrl: './condition-report-page.html',
|
||||
styleUrl: './condition-report-page.css',
|
||||
})
|
||||
export class ConditionReportPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly reports = inject(ConditionReportService);
|
||||
private readonly downloads = inject(FileDownloadService);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly type = (this.route.snapshot.paramMap.get('type') === 'EXIT'
|
||||
? 'EXIT'
|
||||
: 'ENTRY') as ConditionReportType;
|
||||
readonly report = signal<PropertyConditionReport | null>(null);
|
||||
readonly dirty = signal(false);
|
||||
readonly entryPhotos = signal<ConditionPhoto[]>([]);
|
||||
readonly comparison = signal<ConditionReportComparison | null>(null);
|
||||
readonly comparisonSections = computed(() => {
|
||||
const comparison = this.comparison();
|
||||
if (!comparison) return [];
|
||||
return [
|
||||
{ title: 'Observations', lines: comparison.observations },
|
||||
{ title: 'Zones', lines: comparison.zones },
|
||||
{ title: 'Inventaire', lines: comparison.inventory },
|
||||
{ title: 'Compteurs', lines: comparison.meters },
|
||||
{ title: 'Moyens d’accès', lines: comparison.accessMeans },
|
||||
{ title: 'Compléments acceptés', lines: comparison.supplements },
|
||||
];
|
||||
});
|
||||
readonly loading = signal(true);
|
||||
readonly saving = signal(false);
|
||||
readonly revisionPending = signal(false);
|
||||
readonly finalizing = signal(false);
|
||||
readonly finalizationAttested = signal(false);
|
||||
readonly allPartiesSigned = signal(false);
|
||||
readonly supplementPending = signal(false);
|
||||
readonly comparisonPending = signal(false);
|
||||
readonly busyAttestationPartyId = signal('');
|
||||
readonly error = signal('');
|
||||
readonly success = signal('');
|
||||
readonly signedScan = signal<File | null>(null);
|
||||
readonly supplementRequestedOn = signal('');
|
||||
readonly supplementAcceptedAdditions = signal('');
|
||||
readonly heatingPeriodStartedOn = signal('');
|
||||
readonly supplementReason = signal('');
|
||||
readonly supplementSignedPdf = signal<File | null>(null);
|
||||
readonly supplementPhotos = signal<File[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.reports
|
||||
.getReport(this.leaseId, this.type)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (report) => {
|
||||
this.report.set(report);
|
||||
this.dirty.set(false);
|
||||
if (this.type === 'EXIT') this.entryPhotos.set(report.entrySnapshot?.photos ?? []);
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de charger cet état des lieux.')),
|
||||
});
|
||||
}
|
||||
|
||||
save(request: SaveConditionReportRequest): void {
|
||||
if (this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.reports
|
||||
.saveReport(this.leaseId, this.type, request)
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.report.set(saved);
|
||||
this.dirty.set(false);
|
||||
this.success.set('Brouillon enregistré.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’enregistrer le brouillon.')),
|
||||
});
|
||||
}
|
||||
|
||||
createRevision(): void {
|
||||
const report = this.report();
|
||||
if (!report || this.revisionPending()) return;
|
||||
if (this.dirty()) {
|
||||
this.error.set('Enregistrez le brouillon avant de générer sa révision.');
|
||||
return;
|
||||
}
|
||||
this.revisionPending.set(true);
|
||||
this.reports
|
||||
.createRevision(this.leaseId, this.type, report.version)
|
||||
.pipe(rxFinalize(() => this.revisionPending.set(false)))
|
||||
.subscribe({
|
||||
next: (revision) => {
|
||||
this.downloads.download(
|
||||
`condition-report-revision-${this.leaseId}-${this.type}`,
|
||||
this.reports.downloadRevision(this.leaseId, this.type, revision.number),
|
||||
`etat-des-lieux-${this.type.toLowerCase()}-revision-${revision.number}.pdf`,
|
||||
'Impossible de télécharger la révision PDF.',
|
||||
);
|
||||
this.success.set(`Révision ${revision.number} générée.`);
|
||||
this.load();
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de générer la révision PDF.')),
|
||||
});
|
||||
}
|
||||
|
||||
selectSignedScan(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (!file || (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
this.signedScan.set(null);
|
||||
this.error.set('Sélectionnez le scan signé au format PDF.');
|
||||
return;
|
||||
}
|
||||
this.signedScan.set(file);
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
const report = this.report();
|
||||
const scan = this.signedScan();
|
||||
if (
|
||||
!report ||
|
||||
!scan ||
|
||||
!this.finalizationAttested() ||
|
||||
!this.allPartiesSigned() ||
|
||||
this.finalizing()
|
||||
) {
|
||||
if (!scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
|
||||
this.error.set('Téléversez le scan PDF et confirmez les deux attestations.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.dirty()) {
|
||||
this.error.set('Enregistrez le brouillon avant sa finalisation.');
|
||||
return;
|
||||
}
|
||||
this.finalizing.set(true);
|
||||
this.reports
|
||||
.finalize(this.leaseId, this.type, {
|
||||
signedReport: scan,
|
||||
version: report.version,
|
||||
allPartiesSigned: true,
|
||||
})
|
||||
.pipe(rxFinalize(() => this.finalizing.set(false)))
|
||||
.subscribe({
|
||||
next: (finalized) => {
|
||||
this.report.set(finalized);
|
||||
this.success.set('État des lieux finalisé.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de finaliser cet état des lieux.')),
|
||||
});
|
||||
}
|
||||
|
||||
downloadBundle(): void {
|
||||
this.downloads.download(
|
||||
`condition-report-bundle-${this.leaseId}-${this.type}`,
|
||||
this.reports.downloadBundle(this.leaseId, this.type),
|
||||
`etat-des-lieux-${this.type.toLowerCase()}.zip`,
|
||||
'Impossible de télécharger le dossier ZIP.',
|
||||
);
|
||||
}
|
||||
|
||||
loadComparison(): void {
|
||||
if (this.comparisonPending()) return;
|
||||
this.comparisonPending.set(true);
|
||||
this.reports
|
||||
.getComparison(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.comparisonPending.set(false)))
|
||||
.subscribe({
|
||||
next: (comparison) => this.comparison.set(comparison),
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de charger la comparaison.')),
|
||||
});
|
||||
}
|
||||
|
||||
attestDelivery(partyId: string): void {
|
||||
const report = this.report();
|
||||
if (!report || this.busyAttestationPartyId()) return;
|
||||
this.busyAttestationPartyId.set(partyId);
|
||||
this.reports
|
||||
.attestDelivery(this.leaseId, this.type, report.version, partyId)
|
||||
.pipe(rxFinalize(() => this.busyAttestationPartyId.set('')))
|
||||
.subscribe({
|
||||
next: (saved) => this.report.set(saved),
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’attester la remise.')),
|
||||
});
|
||||
}
|
||||
|
||||
selectSupplementSignedPdf(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.item(0) ?? null;
|
||||
if (input) input.value = '';
|
||||
if (!file || (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
this.supplementSignedPdf.set(null);
|
||||
this.error.set('Sélectionnez le complément signé au format PDF.');
|
||||
return;
|
||||
}
|
||||
this.supplementSignedPdf.set(file);
|
||||
}
|
||||
|
||||
selectSupplementPhotos(event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
this.supplementPhotos.set(input?.files ? Array.from(input.files) : []);
|
||||
if (input) input.value = '';
|
||||
}
|
||||
|
||||
appendSupplement(): void {
|
||||
const signedPdf = this.supplementSignedPdf();
|
||||
const requestedOn = this.supplementRequestedOn().trim();
|
||||
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
|
||||
if (!signedPdf || !requestedOn || !acceptedAdditions || this.supplementPending()) {
|
||||
if (!signedPdf || !requestedOn || !acceptedAdditions) {
|
||||
this.error.set('Renseignez la date, les ajouts acceptés et le PDF signé du complément.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.supplementPending.set(true);
|
||||
this.reports
|
||||
.appendSupplement(this.leaseId, {
|
||||
signedPdf,
|
||||
photos: this.supplementPhotos(),
|
||||
requestedOn,
|
||||
acceptedAdditions,
|
||||
heatingPeriodStartedOn: this.heatingPeriodStartedOn().trim() || undefined,
|
||||
reason: this.supplementReason().trim() || undefined,
|
||||
})
|
||||
.pipe(rxFinalize(() => this.supplementPending.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.report.set(saved);
|
||||
this.supplementSignedPdf.set(null);
|
||||
this.supplementPhotos.set([]);
|
||||
this.success.set('Complément ajouté sans modifier l’état des lieux finalisé.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’ajouter ce complément.')),
|
||||
});
|
||||
}
|
||||
|
||||
hasAttested(partyId: string): boolean {
|
||||
return this.report()?.deliveryAttestations.some((item) => item.partyId === partyId) ?? false;
|
||||
}
|
||||
|
||||
changeReport(report: PropertyConditionReport): void {
|
||||
this.report.set(report);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
|
||||
photoUrl(type: ConditionReportType, reference: string): string {
|
||||
return this.reports.photoUrl(this.leaseId, type, reference);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user