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
@@ -78,6 +78,10 @@
|
||||
</section>
|
||||
|
||||
<app-apartment-legal-profile-form [legalProfileForm]="apartmentForm.legalProfile" />
|
||||
<app-private-area-editor
|
||||
[areas]="apartmentModel().privateAreas"
|
||||
(areasChanged)="setPrivateAreas($event)"
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="closed.emit()">Annuler</button
|
||||
|
||||
@@ -10,8 +10,6 @@ describe('ApartmentForm', () => {
|
||||
ownershipRegime: 'Copropriété',
|
||||
constructionPeriod: '1990_2005' as const,
|
||||
habitableSurface: 42,
|
||||
roomCount: 2,
|
||||
otherPrivateAreas: 'Balcon',
|
||||
equipment: 'Cuisine équipée',
|
||||
heating: 'Individuel électrique',
|
||||
hotWater: 'Ballon électrique',
|
||||
@@ -33,6 +31,7 @@ describe('ApartmentForm', () => {
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: 'Cinquième étage',
|
||||
privateAreas: [],
|
||||
legalProfile: completeLegalProfile,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -65,6 +64,7 @@ describe('ApartmentForm', () => {
|
||||
address: ' ',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ describe('ApartmentForm', () => {
|
||||
address: ' 45 rue des Entrepreneurs ',
|
||||
purchasePrice: 250000,
|
||||
description: ' Cinquième étage ',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...completeLegalProfile,
|
||||
taxIdentifier: ' 751150001234 ',
|
||||
@@ -95,10 +96,12 @@ describe('ApartmentForm', () => {
|
||||
form.save();
|
||||
|
||||
expect(saved).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'T2 Paris 15',
|
||||
address: '45 rue des Entrepreneurs',
|
||||
purchasePrice: 250000,
|
||||
description: 'Cinquième étage',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...completeLegalProfile,
|
||||
taxIdentifier: '751150001234',
|
||||
@@ -132,6 +135,7 @@ describe('ApartmentForm', () => {
|
||||
address: '45 rue des Entrepreneurs',
|
||||
purchasePrice: 265000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
});
|
||||
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
|
||||
@@ -157,6 +161,7 @@ describe('ApartmentForm', () => {
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: { ...emptyApartmentLegalProfile(), habitableSurface: -1 },
|
||||
});
|
||||
|
||||
@@ -190,6 +195,7 @@ describe('ApartmentForm', () => {
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...emptyApartmentLegalProfile(),
|
||||
estimatedAnnualEnergyCost: 850,
|
||||
@@ -217,6 +223,7 @@ describe('ApartmentForm', () => {
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: {
|
||||
...emptyApartmentLegalProfile(),
|
||||
estimatedAnnualEnergyCostReferenceYear: 2021,
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
Apartment,
|
||||
ApartmentLegalProfile,
|
||||
emptyApartmentLegalProfile,
|
||||
PrivateArea,
|
||||
} from '../../services/apartment.service';
|
||||
import { ApartmentLegalProfileForm } from '../apartment-legal-profile-form/apartment-legal-profile-form';
|
||||
import { PrivateAreaEditor } from '../private-area-editor/private-area-editor';
|
||||
|
||||
const requiredFieldsError =
|
||||
'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).';
|
||||
@@ -26,6 +28,7 @@ const requiredFieldsError =
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ApartmentLegalProfileForm,
|
||||
PrivateAreaEditor,
|
||||
],
|
||||
templateUrl: './apartment-form.html',
|
||||
styleUrl: './apartment-form.css',
|
||||
@@ -43,6 +46,7 @@ export class ApartmentForm {
|
||||
purchasePrice: 0,
|
||||
description: '',
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
privateAreas: [],
|
||||
});
|
||||
|
||||
readonly apartmentForm = form(this.apartmentModel, (f) => {
|
||||
@@ -53,9 +57,6 @@ export class ApartmentForm {
|
||||
min(f.legalProfile.habitableSurface, 0, {
|
||||
message: 'La surface habitable doit être positive ou nulle.',
|
||||
});
|
||||
min(f.legalProfile.roomCount, 0, {
|
||||
message: 'Le nombre de pièces doit être positif ou nul.',
|
||||
});
|
||||
min(f.legalProfile.estimatedAnnualEnergyCost, 0, {
|
||||
message: 'Le coût annuel énergétique doit être positif ou nul.',
|
||||
});
|
||||
@@ -97,9 +98,14 @@ export class ApartmentForm {
|
||||
purchasePrice: this.apartmentModel().purchasePrice,
|
||||
description: this.apartmentModel().description.trim(),
|
||||
legalProfile: this.normalizedLegalProfile(),
|
||||
privateAreas: this.apartmentModel().privateAreas,
|
||||
});
|
||||
}
|
||||
|
||||
setPrivateAreas(privateAreas: PrivateArea[]): void {
|
||||
this.apartmentModel.update((apartment) => ({ ...apartment, privateAreas }));
|
||||
}
|
||||
|
||||
private resetForm(apartment: Apartment | null): void {
|
||||
this.apartmentModel.set({
|
||||
id: apartment?.id,
|
||||
@@ -108,6 +114,7 @@ export class ApartmentForm {
|
||||
purchasePrice: apartment?.purchasePrice ?? 0,
|
||||
description: apartment?.description ?? '',
|
||||
legalProfile: apartment?.legalProfile ?? emptyApartmentLegalProfile(),
|
||||
privateAreas: apartment?.privateAreas ?? [],
|
||||
});
|
||||
this.error.set('');
|
||||
}
|
||||
@@ -122,7 +129,6 @@ export class ApartmentForm {
|
||||
: profile.estimatedAnnualEnergyCostReferenceYear,
|
||||
taxIdentifier: profile.taxIdentifier.trim(),
|
||||
ownershipRegime: profile.ownershipRegime.trim(),
|
||||
otherPrivateAreas: profile.otherPrivateAreas.trim(),
|
||||
equipment: profile.equipment.trim(),
|
||||
heating: profile.heating.trim(),
|
||||
hotWater: profile.hotWater.trim(),
|
||||
|
||||
-19
@@ -68,15 +68,6 @@
|
||||
[formField]="legalProfileForm().habitableSurface"
|
||||
/><span matTextSuffix>m²</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nombre de pièces principales</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
step="1"
|
||||
id="roomCount"
|
||||
[formField]="legalProfileForm().roomCount"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Classe énergétique</mat-label
|
||||
><mat-select id="energyRating" [formField]="legalProfileForm().energyRating">
|
||||
@@ -113,16 +104,6 @@
|
||||
[formField]="legalProfileForm().destination"
|
||||
placeholder="Usage d'habitation"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Autres parties privatives</mat-label
|
||||
><textarea
|
||||
matInput
|
||||
id="otherPrivateAreas"
|
||||
[formField]="legalProfileForm().otherPrivateAreas"
|
||||
rows="2"
|
||||
placeholder="Balcon, terrasse, jardin…"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('ApartmentsPage', () => {
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
@@ -103,6 +104,7 @@ describe('ApartmentsPage', () => {
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
},
|
||||
]);
|
||||
@@ -124,6 +126,7 @@ describe('ApartmentsPage', () => {
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
@@ -143,6 +146,7 @@ describe('ApartmentsPage', () => {
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
@@ -165,6 +169,7 @@ describe('ApartmentsPage', () => {
|
||||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||||
purchasePrice: 250000,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
};
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.inventory-line {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.inventory-line label {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.inventory-line span {
|
||||
display: block;
|
||||
}
|
||||
.inventory-line input,
|
||||
.inventory-line select,
|
||||
.inventory-line textarea {
|
||||
max-width: 32rem;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<section class="form-page" [attr.aria-busy]="loading() || saving()">
|
||||
<a routerLink="/leases">← Retour aux baux</a>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Inventaire du mobilier</h1>
|
||||
<p>
|
||||
Déclarez les éléments réglementaires et les autres meubles mis à disposition du locataire.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<p role="status">Chargement de l’inventaire…</p>
|
||||
} @else {
|
||||
@if (error()) {
|
||||
<p role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (success()) {
|
||||
<p role="status">{{ success() }}</p>
|
||||
}
|
||||
@if (inventory().frozen) {
|
||||
<p role="status">Cet inventaire est figé.</p>
|
||||
}
|
||||
|
||||
<p>
|
||||
@if (inventory().readyForLeaseDocument) {
|
||||
L’inventaire est prêt pour la génération du bail.
|
||||
} @else {
|
||||
Des éléments réglementaires doivent encore être déclarés.
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (inventory().legalWarnings.length) {
|
||||
<section aria-labelledby="inventory-warnings-heading">
|
||||
<h2 id="inventory-warnings-heading">Avertissements juridiques</h2>
|
||||
<ul>
|
||||
@for (warning of inventory().legalWarnings; track warning.code) {
|
||||
<li>
|
||||
{{ warningLabel(warning.category) }} est déclaré absent (règle
|
||||
{{ warning.ruleVersion }}).
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<form (submit)="$event.preventDefault(); save()">
|
||||
<fieldset [disabled]="inventory().frozen || saving()">
|
||||
<section aria-labelledby="mandatory-furniture-heading">
|
||||
<h2 id="mandatory-furniture-heading">Catalogue réglementaire</h2>
|
||||
@for (line of inventory().lines; track line.id) {
|
||||
@if (line.category) {
|
||||
<fieldset class="inventory-line">
|
||||
<legend>{{ line.designation || line.category }}</legend>
|
||||
<p>{{ line.category }}</p>
|
||||
<label>
|
||||
<span>Présence</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.presence ?? ''"
|
||||
(change)="updatePresence(line.id, $event)"
|
||||
>
|
||||
<option value="">À déclarer</option>
|
||||
<option value="PRESENT">Présent</option>
|
||||
<option value="ABSENT">Absent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zone</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.privateAreaId"
|
||||
(change)="updateText(line.id, 'privateAreaId', $event)"
|
||||
>
|
||||
<option value="">Choisir un espace</option>
|
||||
@for (area of inventory().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Désignation</span>
|
||||
<input
|
||||
required
|
||||
[value]="line.designation"
|
||||
(input)="updateText(line.id, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Quantité</span>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
[min]="line.presence === 'PRESENT' ? 1 : 0"
|
||||
step="1"
|
||||
[value]="line.quantity"
|
||||
(input)="updateQuantity(line.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
[value]="line.description"
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="free-furniture-heading">
|
||||
<h2 id="free-furniture-heading">Éléments complémentaires</h2>
|
||||
@for (line of inventory().lines; track line.id) {
|
||||
@if (line.category === null) {
|
||||
<fieldset class="inventory-line">
|
||||
<legend>{{ line.designation || 'Ligne libre' }}</legend>
|
||||
<label>
|
||||
<span>Zone</span>
|
||||
<select
|
||||
required
|
||||
[value]="line.privateAreaId"
|
||||
(change)="updateText(line.id, 'privateAreaId', $event)"
|
||||
>
|
||||
<option value="">Choisir un espace</option>
|
||||
@for (area of inventory().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Désignation</span>
|
||||
<input
|
||||
required
|
||||
[value]="line.designation"
|
||||
(input)="updateText(line.id, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Quantité</span>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
[value]="line.quantity"
|
||||
(input)="updateQuantity(line.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
[value]="line.description"
|
||||
(input)="updateText(line.id, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeLine(line.id)">Supprimer cette ligne</button>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
<button type="button" (click)="addLine()">Ajouter une ligne libre</button>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
@if (!inventory().frozen) {
|
||||
<button type="submit" [disabled]="saving()">
|
||||
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
ConditionReportService,
|
||||
FurnitureInventory,
|
||||
} from '../../services/condition-report.service';
|
||||
import { InventoryPage } from './inventory-page';
|
||||
|
||||
const inventory: FurnitureInventory = {
|
||||
id: 'inventory-1',
|
||||
templateVersion: '2025-01',
|
||||
lines: [
|
||||
{
|
||||
id: 'bed-1',
|
||||
category: 'BEDDING',
|
||||
presence: 'PRESENT',
|
||||
privateAreaId: 'bedroom-1',
|
||||
designation: 'Lit',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
missingCategories: [],
|
||||
absentCategories: [],
|
||||
legalWarnings: [],
|
||||
readyForLeaseDocument: true,
|
||||
frozen: false,
|
||||
privateAreas: [{ id: 'bedroom-1', label: 'Chambre' }],
|
||||
version: 3,
|
||||
};
|
||||
|
||||
describe('InventoryPage', () => {
|
||||
function createPage(value: FurnitureInventory = inventory) {
|
||||
const service = {
|
||||
getInventory: vi.fn().mockReturnValue(of(value)),
|
||||
saveInventory: vi.fn().mockReturnValue(of({ ...value, version: 4 })),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{ provide: ConditionReportService, useValue: service },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(InventoryPage);
|
||||
fixture.detectChanges();
|
||||
return { fixture, service };
|
||||
}
|
||||
|
||||
it('saves the editable lines with the aggregate version', () => {
|
||||
const { fixture, service } = createPage();
|
||||
|
||||
fixture.componentInstance.save();
|
||||
|
||||
expect(service.saveInventory).toHaveBeenCalledWith('lease-1', {
|
||||
version: 3,
|
||||
lines: inventory.lines,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks an undeclared legal category with an actionable error', () => {
|
||||
const { fixture, service } = createPage({
|
||||
...inventory,
|
||||
lines: [{ ...inventory.lines[0], presence: null, quantity: 0 }],
|
||||
missingCategories: ['BEDDING'],
|
||||
readyForLeaseDocument: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.save();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(service.saveInventory).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Déclarez la présence ou l’absence');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { finalize as rxFinalize } from 'rxjs';
|
||||
import {
|
||||
ConditionReportService,
|
||||
FurnitureInventory,
|
||||
FurnitureInventoryLine,
|
||||
FurniturePresence,
|
||||
} from '../../services/condition-report.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
|
||||
const emptyInventory: FurnitureInventory = {
|
||||
id: '',
|
||||
templateVersion: '',
|
||||
lines: [],
|
||||
missingCategories: [],
|
||||
absentCategories: [],
|
||||
legalWarnings: [],
|
||||
readyForLeaseDocument: false,
|
||||
frozen: false,
|
||||
privateAreas: [],
|
||||
version: 0,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-inventory-page',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './inventory-page.html',
|
||||
styleUrl: './inventory-page.css',
|
||||
})
|
||||
export class InventoryPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly inventories = inject(ConditionReportService);
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly inventory = signal<FurnitureInventory>(emptyInventory);
|
||||
readonly loading = signal(true);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly success = signal('');
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
this.inventories
|
||||
.getInventory(this.leaseId)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (inventory) => this.inventory.set(inventory),
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible de charger l’inventaire du mobilier.'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
updateText(
|
||||
id: string,
|
||||
field: 'privateAreaId' | 'designation' | 'description',
|
||||
event: Event,
|
||||
): void {
|
||||
const value =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
? event.target.value
|
||||
: '';
|
||||
this.updateLine(id, { [field]: value });
|
||||
}
|
||||
|
||||
updateQuantity(id: string, event: Event): void {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const quantity =
|
||||
!input || input.value === '' || !Number.isFinite(input.valueAsNumber)
|
||||
? -1
|
||||
: input.valueAsNumber;
|
||||
this.updateLine(id, { quantity });
|
||||
}
|
||||
|
||||
updatePresence(id: string, event: Event): void {
|
||||
const line = this.inventory().lines.find((candidate) => candidate.id === id);
|
||||
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
|
||||
const presence: FurniturePresence | null =
|
||||
value === 'PRESENT' || value === 'ABSENT' ? value : null;
|
||||
if (!line?.category) return;
|
||||
this.updateLine(id, {
|
||||
presence,
|
||||
quantity:
|
||||
presence === 'ABSENT'
|
||||
? 0
|
||||
: presence === 'PRESENT' && line.quantity === 0
|
||||
? 1
|
||||
: line.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
addLine(): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: [
|
||||
...inventory.lines,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
category: null,
|
||||
presence: null,
|
||||
privateAreaId: '',
|
||||
designation: '',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
removeLine(id: string): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: inventory.lines.filter((line) => line.id !== id || line.category !== null),
|
||||
}));
|
||||
}
|
||||
|
||||
warningLabel(category: string): string {
|
||||
return (
|
||||
this.inventory().lines.find((line) => line.category === category)?.designation ?? category
|
||||
);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const inventory = this.inventory();
|
||||
if (inventory.frozen) {
|
||||
this.error.set('Cet inventaire est figé et ne peut plus être modifié.');
|
||||
return;
|
||||
}
|
||||
const validationError = this.validationError(inventory.lines);
|
||||
if (validationError) {
|
||||
this.error.set(validationError);
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
this.inventories
|
||||
.saveInventory(this.leaseId, { version: inventory.version, lines: inventory.lines })
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (saved) => {
|
||||
this.inventory.set(saved);
|
||||
this.success.set('Inventaire enregistré.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’enregistrer l’inventaire.')),
|
||||
});
|
||||
}
|
||||
|
||||
private updateLine(id: string, changes: Partial<FurnitureInventoryLine>): void {
|
||||
if (this.inventory().frozen || this.saving()) return;
|
||||
this.inventory.update((inventory) => ({
|
||||
...inventory,
|
||||
lines: inventory.lines.map((line) => (line.id === id ? { ...line, ...changes } : line)),
|
||||
}));
|
||||
}
|
||||
|
||||
private validationError(lines: FurnitureInventoryLine[]): string | null {
|
||||
for (const line of lines) {
|
||||
if (!line.privateAreaId.trim()) return 'Renseignez la zone de chaque ligne.';
|
||||
if (!line.designation.trim()) return 'Renseignez la désignation de chaque ligne.';
|
||||
if (!Number.isInteger(line.quantity) || line.quantity < 0) {
|
||||
return 'Saisissez une quantité entière positive ou nulle.';
|
||||
}
|
||||
if (line.category && line.presence === null) {
|
||||
return 'Déclarez la présence ou l’absence de chaque élément réglementaire.';
|
||||
}
|
||||
if (line.category && line.presence === 'PRESENT' && line.quantity === 0) {
|
||||
return 'Un élément réglementaire présent doit avoir une quantité positive.';
|
||||
}
|
||||
if (line.category && line.presence === 'ABSENT' && line.quantity !== 0) {
|
||||
return 'Un élément réglementaire absent doit avoir une quantité nulle.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,18 @@
|
||||
}
|
||||
</button>
|
||||
@if (lease().status === 'DRAFT') {
|
||||
<button mat-button type="button" (click)="inventoryRequested.emit(leaseId)">
|
||||
Inventaire du mobilier
|
||||
</button>
|
||||
@if (revision()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'ENTRY' })"
|
||||
>
|
||||
État des lieux d’entrée
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
@@ -154,6 +166,20 @@
|
||||
}
|
||||
</button>
|
||||
} @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'ENTRY' })"
|
||||
>
|
||||
État des lieux d’entrée
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="conditionReportRequested.emit({ leaseId, type: 'EXIT' })"
|
||||
>
|
||||
État des lieux de sortie
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
|
||||
@@ -199,6 +199,7 @@ describe('LeaseCard', () => {
|
||||
expect(text).toContain('Modifier');
|
||||
expect(text).toContain('Supprimer');
|
||||
expect(text).toContain('Finaliser le bail');
|
||||
expect(text).not.toContain('État des lieux d’entrée');
|
||||
expect(text).not.toContain('Activer');
|
||||
const finalization = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
|
||||
button.textContent.includes('Finaliser le bail'),
|
||||
@@ -206,6 +207,26 @@ describe('LeaseCard', () => {
|
||||
finalization.click();
|
||||
expect(requested).toHaveBeenCalledWith('lease-1');
|
||||
|
||||
fixture.componentRef.setInput('revision', {
|
||||
number: 1,
|
||||
templateVersion: 'FR-MEUBLE-2025-01',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-01-01T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const conditionReportRequested = vi.fn();
|
||||
fixture.componentInstance.conditionReportRequested.subscribe(conditionReportRequested);
|
||||
const entryReport = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
|
||||
button.textContent.includes('État des lieux d’entrée'),
|
||||
);
|
||||
entryReport.click();
|
||||
expect(conditionReportRequested).toHaveBeenCalledWith({ leaseId: 'lease-1', type: 'ENTRY' });
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ACTIVE',
|
||||
|
||||
@@ -33,6 +33,8 @@ export class LeaseCard {
|
||||
readonly editRequested = output<Lease>();
|
||||
readonly finalizationRequested = output<string>();
|
||||
readonly leaseEndRequested = output<string>();
|
||||
readonly inventoryRequested = output<string>();
|
||||
readonly conditionReportRequested = output<{ leaseId: string; type: 'ENTRY' | 'EXIT' }>();
|
||||
readonly rentRevisionOpen = signal(false);
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@
|
||||
[timeZone]="timeZone"
|
||||
[showAgreement]="canRecordAgreement()"
|
||||
[showClosure]="canClose()"
|
||||
[keysReturnedOn]="exitReportDate()"
|
||||
[closureEnabled]="closureReady()"
|
||||
[agreementPending]="operationPending('agreement')"
|
||||
[closurePending]="operationPending('closure')"
|
||||
[agreementOperationError]="operationError('agreement')"
|
||||
|
||||
@@ -23,6 +23,10 @@ describe('LeaseEndPage', () => {
|
||||
agreedEarlyEnds: [],
|
||||
agreedEarlyEnd: null,
|
||||
closure: null,
|
||||
exitConditionReport: {
|
||||
occurredOn: '2026-07-31',
|
||||
finalized: true,
|
||||
},
|
||||
notices: [
|
||||
{
|
||||
id: 'notice-1',
|
||||
@@ -247,7 +251,6 @@ describe('LeaseEndPage', () => {
|
||||
|
||||
fixture.componentInstance.closeLease({
|
||||
timeZone: 'Europe/Paris',
|
||||
keysReturnedOn: '2026-08-19',
|
||||
confirmed: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -84,6 +84,11 @@ export class LeaseEndPage {
|
||||
(state.phase === 'AWAITING_CLOSURE' || state.phase === 'UNAUTHORIZED_OCCUPANCY')
|
||||
);
|
||||
});
|
||||
readonly exitReportDate = computed(() => {
|
||||
const report = this.state()?.exitConditionReport;
|
||||
return report?.finalized ? report.occurredOn : null;
|
||||
});
|
||||
readonly closureReady = computed(() => this.canClose() && !!this.exitReportDate());
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly civilMonth = formatFrenchCivilMonth;
|
||||
|
||||
@@ -46,6 +46,14 @@ textarea {
|
||||
.confirmation-field input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.report-date {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
.report-date span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
|
||||
+5
-5
@@ -38,10 +38,10 @@
|
||||
(submit)="submitClosure(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="closurePending()">
|
||||
<label>
|
||||
<span>Date réelle de restitution des clés</span>
|
||||
<input type="date" [formField]="closureForm.keysReturnedOn" />
|
||||
</label>
|
||||
<p class="report-date">
|
||||
<span>Date de restitution des clés, d’après l’état des lieux de sortie</span>
|
||||
<strong>{{ keysReturnedOn() ?? 'État des lieux de sortie finalisé requis' }}</strong>
|
||||
</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="closureForm.confirmed" />
|
||||
<span>Je confirme que les clés ont réellement été restituées à cette date.</span>
|
||||
@@ -51,7 +51,7 @@
|
||||
{{ closureError() || closureOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="closurePending()">
|
||||
<button mat-flat-button type="submit" [disabled]="closurePending() || !closureEnabled()">
|
||||
{{ closurePending() ? 'Clôture…' : 'Clôturer le bail' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
+6
-6
@@ -30,23 +30,23 @@ describe('LeaseEndResolutionForms', () => {
|
||||
expect(fixture.nativeElement.querySelector('textarea').maxLength).toBe(500);
|
||||
});
|
||||
|
||||
it('only emits closure after explicit confirmation and keeps the civil day', () => {
|
||||
it('displays the exit report date and only emits closure after explicit confirmation', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.componentRef.setInput('keysReturnedOn', '2026-07-19');
|
||||
fixture.componentRef.setInput('closureEnabled', true);
|
||||
fixture.detectChanges();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.closureRequested.subscribe(requested);
|
||||
fixture.componentInstance.closureModel.set({
|
||||
keysReturnedOn: '2026-07-19',
|
||||
confirmed: false,
|
||||
});
|
||||
fixture.componentInstance.closureModel.set({ confirmed: false });
|
||||
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(fixture.componentInstance.closureError()).toContain('Confirmez');
|
||||
expect(fixture.nativeElement.textContent).toContain('2026-07-19');
|
||||
|
||||
fixture.componentInstance.closureModel.update((model) => ({ ...model, confirmed: true }));
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
keysReturnedOn: '2026-07-19',
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
|
||||
+5
-4
@@ -18,10 +18,12 @@ export class LeaseEndResolutionForms {
|
||||
readonly closureOperationError = input('');
|
||||
readonly showAgreement = input(true);
|
||||
readonly showClosure = input(true);
|
||||
readonly keysReturnedOn = input<string | null>(null);
|
||||
readonly closureEnabled = input(false);
|
||||
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
|
||||
readonly closureRequested = output<CloseLeaseRequest>();
|
||||
readonly agreementModel = signal({ endOn: '', reason: '' });
|
||||
readonly closureModel = signal({ keysReturnedOn: '', confirmed: false });
|
||||
readonly closureModel = signal({ confirmed: false });
|
||||
readonly agreementForm = form(this.agreementModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
@@ -47,8 +49,8 @@ export class LeaseEndResolutionForms {
|
||||
submitClosure(): void {
|
||||
if (this.closurePending()) return;
|
||||
const model = this.closureModel();
|
||||
if (!model.keysReturnedOn) {
|
||||
this.closureError.set('Renseignez la date réelle de restitution des clés.');
|
||||
if (!this.keysReturnedOn() || !this.closureEnabled()) {
|
||||
this.closureError.set('Finalisez l’état des lieux de sortie avant de clôturer le bail.');
|
||||
return;
|
||||
}
|
||||
if (!model.confirmed) {
|
||||
@@ -58,7 +60,6 @@ export class LeaseEndResolutionForms {
|
||||
this.closureError.set('');
|
||||
this.closureRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
keysReturnedOn: model.keysReturnedOn,
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,4 +76,97 @@
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<dialog
|
||||
#discrepancyDialog
|
||||
aria-modal="true"
|
||||
aria-labelledby="inventory-discrepancy-title"
|
||||
aria-describedby="inventory-discrepancy-description"
|
||||
>
|
||||
<h3 id="inventory-discrepancy-title">Incohérences d’inventaire à l’entrée</h3>
|
||||
<p id="inventory-discrepancy-description">
|
||||
L’état des lieux d’entrée a-t-il révélé des différences avec l’inventaire imprimé ?
|
||||
</p>
|
||||
@if (discrepancyAnswer() !== true) {
|
||||
<div class="dialog-actions">
|
||||
<button type="button" (click)="answerNo()">Non, finaliser le bail</button>
|
||||
<button type="button" (click)="answerYes()">Oui, déclarer les corrections</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p>Décrivez uniquement les corrections d’inventaire manuscrites et paraphées.</p>
|
||||
@for (correction of inventoryCorrections(); track correction.id; let index = $index) {
|
||||
<fieldset>
|
||||
<legend>Correction {{ index + 1 }}</legend>
|
||||
<label>
|
||||
Élément de l’inventaire
|
||||
<select
|
||||
[value]="correction.inventoryLineId ?? ''"
|
||||
(change)="selectInventoryLine(index, $event)"
|
||||
>
|
||||
<option value="">Ajout ou correction exceptionnelle</option>
|
||||
@for (line of revision().furnitureInventory.lines; track line.id) {
|
||||
<option [value]="line.id">{{ line.designation }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Espace privatif
|
||||
<select
|
||||
required
|
||||
[value]="correction.privateAreaId"
|
||||
(change)="setCorrection(index, 'privateAreaId', $event)"
|
||||
>
|
||||
@for (area of revision().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Désignation
|
||||
<input
|
||||
#firstCorrectionInput
|
||||
required
|
||||
[value]="correction.designation"
|
||||
(input)="setCorrection(index, 'designation', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
[value]="correction.quantity"
|
||||
(input)="setCorrection(index, 'quantity', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description de la correction
|
||||
<textarea
|
||||
required
|
||||
[value]="correction.description"
|
||||
(input)="setCorrection(index, 'description', $event)"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="button" (click)="removeCorrection(index)">
|
||||
Supprimer cette correction
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
<button type="button" (click)="addCorrection()">Ajouter une correction</button>
|
||||
<mat-checkbox
|
||||
[checked]="allInventoryCorrectionsInitialed()"
|
||||
(change)="allInventoryCorrectionsInitialed.set($event.checked)"
|
||||
>
|
||||
Toutes les parties ont paraphé chaque correction manuscrite déclarée.
|
||||
</mat-checkbox>
|
||||
@if (validationError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() }}</p>
|
||||
}
|
||||
<button type="button" (click)="continueWithCorrections()">
|
||||
Finaliser avec ces corrections
|
||||
</button>
|
||||
}
|
||||
<p>Le numéro de révision imprimé ne se corrige jamais à la main.</p>
|
||||
</dialog>
|
||||
</section>
|
||||
+81
-2
@@ -1,16 +1,33 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseRevisionMetadata } from '../../services/lease.service';
|
||||
import { FinalizationRevision } from '../../services/lease-finalization.service';
|
||||
import { LeaseFinalizationForm } from './lease-finalization-form';
|
||||
|
||||
describe('LeaseFinalizationForm', () => {
|
||||
const revision: LeaseRevisionMetadata = {
|
||||
const revision: FinalizationRevision = {
|
||||
number: 4,
|
||||
templateVersion: '2026.10',
|
||||
sourceFingerprint: 'source',
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
furnitureInventory: {
|
||||
templateVersion: 'FR-MEUBLE-2025-01',
|
||||
lines: [
|
||||
{
|
||||
id: 'line-1',
|
||||
category: null,
|
||||
presence: null,
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
privateAreas: [
|
||||
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'LIVING_ROOM', label: 'Séjour' },
|
||||
],
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
@@ -42,6 +59,7 @@ describe('LeaseFinalizationForm', () => {
|
||||
component.lastSignatureDate.set('2026-07-19');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
component.discrepancyAnswer.set(false);
|
||||
component.setWarning('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', true);
|
||||
component.setWarning('IRL_REVISION_WITH_F_OR_G_DPE', true);
|
||||
component.setWarning('FUTURE_WARNING', true);
|
||||
@@ -59,6 +77,9 @@ describe('LeaseFinalizationForm', () => {
|
||||
'IRL_REVISION_WITH_F_OR_G_DPE',
|
||||
'FUTURE_WARNING',
|
||||
],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,4 +141,62 @@ describe('LeaseFinalizationForm', () => {
|
||||
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléversez tous les actes');
|
||||
});
|
||||
|
||||
it('sends an initialed structured inventory correction after a yes answer', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['lease'], 'bail-signe.pdf', { type: 'application/pdf' });
|
||||
const requested = vi.fn();
|
||||
component.finalizationRequested.subscribe(requested);
|
||||
component.signedLease.set(file);
|
||||
component.lastSignatureDate.set('2026-07-19');
|
||||
component.allPartiesSigned.set(true);
|
||||
component.revisionMatches.set(true);
|
||||
component.discrepancyAnswer.set(true);
|
||||
component.inventoryCorrections.set([
|
||||
{
|
||||
id: 'correction-1',
|
||||
inventoryLineId: null,
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 0,
|
||||
description: 'Absente',
|
||||
},
|
||||
]);
|
||||
component.allInventoryCorrectionsInitialed.set(true);
|
||||
|
||||
component.submit();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entryInventoryDiscrepancies: true,
|
||||
inventoryCorrections: [expect.objectContaining({ id: 'correction-1', quantity: 0 })],
|
||||
allInventoryCorrectionsInitialed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('prefills known entry discrepancies with readable inventory choices', () => {
|
||||
const fixture = createForm();
|
||||
fixture.componentRef.setInput('suggestedInventoryCorrections', [
|
||||
{
|
||||
id: 'entry-line-1',
|
||||
inventoryLineId: 'line-1',
|
||||
privateAreaId: 'area-1',
|
||||
designation: 'Chaise',
|
||||
quantity: 0,
|
||||
description: 'Chaise manquante',
|
||||
},
|
||||
]);
|
||||
|
||||
fixture.componentInstance.answerYes();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.inventoryCorrections()).toEqual([
|
||||
expect.objectContaining({ inventoryLineId: 'line-1', quantity: 0 }),
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('Chaise');
|
||||
expect(fixture.nativeElement.textContent).toContain('Séjour');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('ID d’espace privatif');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, ElementRef, input, output, signal, viewChild } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { FinalizeLeaseRequest } from '../../services/lease-finalization.service';
|
||||
import { LeaseRevisionMetadata, LeaseRevisionWarning } from '../../services/lease.service';
|
||||
import {
|
||||
FinalizationRevision,
|
||||
FinalizeLeaseRequest,
|
||||
InventoryCorrection,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { LeaseRevisionWarning } from '../../services/lease.service';
|
||||
import { leaseWarningConfirmation } from '../../services/lease-warning-label';
|
||||
|
||||
@Component({
|
||||
@@ -13,7 +17,8 @@ import { leaseWarningConfirmation } from '../../services/lease-warning-label';
|
||||
styleUrl: './lease-finalization-form.css',
|
||||
})
|
||||
export class LeaseFinalizationForm {
|
||||
readonly revision = input.required<LeaseRevisionMetadata>();
|
||||
readonly revision = input.required<FinalizationRevision>();
|
||||
readonly suggestedInventoryCorrections = input<InventoryCorrection[]>([]);
|
||||
readonly warningCodes = input<string[]>([]);
|
||||
readonly warnings = input<LeaseRevisionWarning[]>([]);
|
||||
readonly guaranteesComplete = input(true);
|
||||
@@ -27,6 +32,11 @@ export class LeaseFinalizationForm {
|
||||
readonly revisionMatches = signal(false);
|
||||
readonly confirmedWarningCodes = signal<ReadonlySet<string>>(new Set());
|
||||
readonly validationError = signal('');
|
||||
readonly discrepancyAnswer = signal<boolean | null>(null);
|
||||
readonly inventoryCorrections = signal<InventoryCorrection[]>([]);
|
||||
readonly allInventoryCorrectionsInitialed = signal(false);
|
||||
readonly discrepancyDialog = viewChild<ElementRef<HTMLDialogElement>>('discrepancyDialog');
|
||||
readonly firstCorrectionInput = viewChild<ElementRef<HTMLInputElement>>('firstCorrectionInput');
|
||||
|
||||
selectSignedLease(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -61,6 +71,15 @@ export class LeaseFinalizationForm {
|
||||
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
if (this.discrepancyAnswer() === null) {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.showModal();
|
||||
return;
|
||||
}
|
||||
const error = this.errorMessage();
|
||||
this.validationError.set(error);
|
||||
const signedLease = this.signedLease();
|
||||
@@ -72,9 +91,108 @@ export class LeaseFinalizationForm {
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: this.warningCodes(),
|
||||
entryInventoryDiscrepancies: this.discrepancyAnswer() === true,
|
||||
inventoryCorrections: this.inventoryCorrections(),
|
||||
allInventoryCorrectionsInitialed: this.allInventoryCorrectionsInitialed(),
|
||||
});
|
||||
}
|
||||
|
||||
answerNo(): void {
|
||||
this.discrepancyAnswer.set(false);
|
||||
this.inventoryCorrections.set([]);
|
||||
this.allInventoryCorrectionsInitialed.set(false);
|
||||
this.discrepancyDialog()?.nativeElement.close();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
answerYes(): void {
|
||||
this.discrepancyAnswer.set(true);
|
||||
if (!this.inventoryCorrections().length) {
|
||||
const suggestions = this.suggestedInventoryCorrections();
|
||||
if (suggestions.length) {
|
||||
this.inventoryCorrections.set(suggestions.map((correction) => ({ ...correction })));
|
||||
} else {
|
||||
this.addCorrection();
|
||||
}
|
||||
}
|
||||
queueMicrotask(() => this.firstCorrectionInput()?.nativeElement.focus());
|
||||
}
|
||||
|
||||
addCorrection(): void {
|
||||
this.inventoryCorrections.update((corrections) => [
|
||||
...corrections,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
inventoryLineId: null,
|
||||
privateAreaId: this.revision().privateAreas[0]?.id ?? '',
|
||||
designation: '',
|
||||
quantity: 0,
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
removeCorrection(index: number): void {
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.filter((_, item) => item !== index),
|
||||
);
|
||||
}
|
||||
|
||||
setCorrection(index: number, field: keyof InventoryCorrection, event: Event): void {
|
||||
const value =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
? event.target.value
|
||||
: '';
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.map((correction, item) =>
|
||||
item === index
|
||||
? {
|
||||
...correction,
|
||||
[field]:
|
||||
field === 'quantity'
|
||||
? Number(value)
|
||||
: field === 'inventoryLineId'
|
||||
? value || null
|
||||
: value,
|
||||
}
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
selectInventoryLine(index: number, event: Event): void {
|
||||
const target = event.target;
|
||||
const lineId = target instanceof HTMLSelectElement ? target.value : '';
|
||||
const line = this.revision().furnitureInventory.lines.find(
|
||||
(candidate) => candidate.id === lineId,
|
||||
);
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.map((correction, item) =>
|
||||
item === index
|
||||
? {
|
||||
...correction,
|
||||
inventoryLineId: line?.id ?? null,
|
||||
privateAreaId: line?.privateAreaId ?? correction.privateAreaId,
|
||||
designation: line?.designation ?? correction.designation,
|
||||
quantity: line?.quantity ?? correction.quantity,
|
||||
}
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
continueWithCorrections(): void {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.close();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
private errorMessage(): string {
|
||||
if (!this.guaranteesComplete()) {
|
||||
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
|
||||
@@ -87,6 +205,26 @@ export class LeaseFinalizationForm {
|
||||
if (!this.revisionMatches()) {
|
||||
return `Confirmez que le document signé correspond à la révision ${this.revision().number}.`;
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.inventoryCorrections().length) {
|
||||
return 'Déclarez au moins une correction d’inventaire.';
|
||||
}
|
||||
if (
|
||||
this.discrepancyAnswer() &&
|
||||
this.inventoryCorrections().some(
|
||||
(correction) =>
|
||||
!correction.id ||
|
||||
!correction.privateAreaId.trim() ||
|
||||
!correction.designation.trim() ||
|
||||
!correction.description.trim() ||
|
||||
!Number.isInteger(correction.quantity) ||
|
||||
correction.quantity < 0,
|
||||
)
|
||||
) {
|
||||
return 'Complétez chaque correction d’inventaire avec son espace, sa désignation, sa quantité et sa description.';
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.allInventoryCorrectionsInitialed()) {
|
||||
return 'Attestez que chaque correction manuscrite d’inventaire a été paraphée.';
|
||||
}
|
||||
const missingWarning = this.warningCodes().find(
|
||||
(code) => !this.confirmedWarningCodes().has(code),
|
||||
);
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
|
||||
<app-lease-finalization-form
|
||||
[revision]="finalizationState.revision"
|
||||
[suggestedInventoryCorrections]="finalizationState.suggestedInventoryCorrections"
|
||||
[warningCodes]="finalizationState.warningCodes"
|
||||
[warnings]="finalizationState.warnings"
|
||||
[guaranteesComplete]="guaranteesComplete()"
|
||||
|
||||
@@ -20,6 +20,8 @@ describe('LeaseFinalizationPage', () => {
|
||||
sha256: 'sha256',
|
||||
generatedAt: '2026-07-19T10:00:00Z',
|
||||
authorId: 'owner-1',
|
||||
furnitureInventory: { templateVersion: 'FR-MEUBLE-2025-01', lines: [] },
|
||||
privateAreas: [],
|
||||
annexes: [],
|
||||
warningCodes: [],
|
||||
warnings: [],
|
||||
@@ -30,6 +32,7 @@ describe('LeaseFinalizationPage', () => {
|
||||
warningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
warnings: [{ code: 'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', ruleVersion: '2026-07-01' }],
|
||||
guaranteeRequirements: [],
|
||||
suggestedInventoryCorrections: [],
|
||||
finalization: null,
|
||||
};
|
||||
const finalization: FinalizationMetadata = {
|
||||
@@ -41,6 +44,9 @@ describe('LeaseFinalizationPage', () => {
|
||||
authorId: 'owner-1',
|
||||
finalizedAt: '2026-07-19T10:30:00Z',
|
||||
overrides: [],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
const lease = {
|
||||
id: 'lease-1',
|
||||
@@ -129,6 +135,9 @@ describe('LeaseFinalizationPage', () => {
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
@@ -168,6 +177,9 @@ describe('LeaseFinalizationPage', () => {
|
||||
allPartiesSigned: true,
|
||||
revisionMatches: true,
|
||||
confirmedWarningCodes: ['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS'],
|
||||
entryInventoryDiscrepancies: false,
|
||||
inventoryCorrections: [],
|
||||
allInventoryCorrectionsInitialed: false,
|
||||
};
|
||||
|
||||
fixture.componentInstance.requestFinalization(request);
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
(editRequested)="openForm($event)"
|
||||
(finalizationRequested)="openFinalization($event)"
|
||||
(leaseEndRequested)="openLeaseEnd($event)"
|
||||
(inventoryRequested)="openInventory($event)"
|
||||
(conditionReportRequested)="openConditionReport($event)"
|
||||
(deleteRequested)="workspace.deleteLease($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ export class LeasesPage {
|
||||
void this.router.navigate(['/leases', leaseId, 'end']);
|
||||
}
|
||||
|
||||
openInventory(leaseId: string): void {
|
||||
void this.router.navigate(['/leases', leaseId, 'inventory']);
|
||||
}
|
||||
|
||||
openConditionReport(event: { leaseId: string; type: 'ENTRY' | 'EXIT' }): void {
|
||||
void this.router.navigate(['/leases', event.leaseId, 'condition-reports', event.type]);
|
||||
}
|
||||
|
||||
retryLoad(): void {
|
||||
if (!this.workspace.apartmentLoadState().loaded || this.workspace.apartmentLoadState().error)
|
||||
this.workspace.loadApartments();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.private-areas {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.private-areas fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.private-areas label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.private-areas input,
|
||||
.private-areas select {
|
||||
min-height: 40px;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<section class="form-section" aria-labelledby="private-areas-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="private-areas-heading">Espaces privatifs</h2>
|
||||
<p>
|
||||
Le nombre réglementaire de pièces principales est calculé depuis ces espaces :
|
||||
{{ mainRoomCount() }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="private-areas">
|
||||
@for (area of areas(); track area.id) {
|
||||
<fieldset>
|
||||
<legend>{{ area.label || 'Espace privatif' }}</legend>
|
||||
<label>
|
||||
<span>Famille</span>
|
||||
<select [value]="area.family" (change)="updateArea(area.id, 'family', $event)">
|
||||
@for (family of families; track family.value) {
|
||||
<option [value]="family.value">{{ family.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Sous-type</span>
|
||||
<select [value]="area.subtype" (change)="updateArea(area.id, 'subtype', $event)">
|
||||
@for (subtype of subtypesFor(area.family); track subtype.value) {
|
||||
<option [value]="subtype.value">{{ subtype.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Libellé</span>
|
||||
<input [value]="area.label" (input)="updateArea(area.id, 'label', $event)" />
|
||||
</label>
|
||||
<button type="button" (click)="removeArea(area.id)">Supprimer</button>
|
||||
</fieldset>
|
||||
}
|
||||
</div>
|
||||
<button type="button" (click)="addArea()">Ajouter un espace privatif</button>
|
||||
</section>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { PrivateAreaEditor } from './private-area-editor';
|
||||
|
||||
describe('PrivateAreaEditor', () => {
|
||||
it('adds a stable private area and derives the main-room count from its family', () => {
|
||||
const fixture = TestBed.createComponent(PrivateAreaEditor);
|
||||
fixture.componentRef.setInput('areas', []);
|
||||
const changed = vi.fn();
|
||||
fixture.componentInstance.areasChanged.subscribe(changed);
|
||||
|
||||
fixture.componentInstance.addArea();
|
||||
|
||||
expect(changed).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ family: 'PIECE_PRINCIPALE', subtype: 'OTHER' }),
|
||||
]);
|
||||
fixture.componentRef.setInput('areas', changed.mock.calls[0][0]);
|
||||
expect(fixture.componentInstance.mainRoomCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import {
|
||||
PrivateArea,
|
||||
PrivateAreaFamily,
|
||||
PrivateAreaSubtype,
|
||||
} from '../../services/apartment.service';
|
||||
|
||||
const families: readonly { value: PrivateAreaFamily; label: string }[] = [
|
||||
{ value: 'PIECE_PRINCIPALE', label: 'Pièce principale' },
|
||||
{ value: 'PIECE_DE_SERVICE', label: 'Pièce de service' },
|
||||
{ value: 'ANNEXE', label: 'Annexe' },
|
||||
];
|
||||
|
||||
const subtypes: readonly { value: PrivateAreaSubtype; label: string }[] = [
|
||||
{ value: 'LIVING_ROOM', label: 'Séjour' },
|
||||
{ value: 'DINING_ROOM', label: 'Salle à manger' },
|
||||
{ value: 'BEDROOM', label: 'Chambre' },
|
||||
{ value: 'KITCHEN', label: 'Cuisine' },
|
||||
{ value: 'BATHROOM', label: 'Salle de bains' },
|
||||
{ value: 'TOILET', label: 'Toilettes' },
|
||||
{ value: 'HALLWAY', label: 'Couloir' },
|
||||
{ value: 'LAUNDRY_ROOM', label: 'Buanderie' },
|
||||
{ value: 'BALCONY', label: 'Balcon' },
|
||||
{ value: 'TERRACE', label: 'Terrasse' },
|
||||
{ value: 'CELLAR', label: 'Cave' },
|
||||
{ value: 'PARKING', label: 'Stationnement' },
|
||||
{ value: 'OTHER', label: 'Autre' },
|
||||
];
|
||||
|
||||
const subtypeFamilies: Record<PrivateAreaFamily, readonly PrivateAreaSubtype[]> = {
|
||||
PIECE_PRINCIPALE: ['LIVING_ROOM', 'DINING_ROOM', 'BEDROOM', 'OTHER'],
|
||||
PIECE_DE_SERVICE: ['KITCHEN', 'BATHROOM', 'TOILET', 'HALLWAY', 'LAUNDRY_ROOM', 'OTHER'],
|
||||
ANNEXE: ['BALCONY', 'TERRACE', 'CELLAR', 'PARKING', 'OTHER'],
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-private-area-editor',
|
||||
templateUrl: './private-area-editor.html',
|
||||
styleUrl: './private-area-editor.css',
|
||||
})
|
||||
export class PrivateAreaEditor {
|
||||
readonly areas = input.required<PrivateArea[]>();
|
||||
readonly areasChanged = output<PrivateArea[]>();
|
||||
readonly families = families;
|
||||
readonly mainRoomCount = computed(
|
||||
() => this.areas().filter(({ family }) => family === 'PIECE_PRINCIPALE').length,
|
||||
);
|
||||
|
||||
addArea(): void {
|
||||
this.areasChanged.emit([
|
||||
...this.areas(),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
family: 'PIECE_PRINCIPALE',
|
||||
subtype: 'OTHER',
|
||||
label: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
updateArea(id: string, field: keyof Omit<PrivateArea, 'id'>, event: Event): void {
|
||||
const value =
|
||||
event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement
|
||||
? event.target.value
|
||||
: '';
|
||||
this.areasChanged.emit(
|
||||
this.areas().map((area) =>
|
||||
area.id === id
|
||||
? {
|
||||
...area,
|
||||
[field]: value,
|
||||
...(field === 'family' ? { subtype: 'OTHER' as const } : {}),
|
||||
}
|
||||
: area,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
subtypesFor(family: PrivateAreaFamily): typeof subtypes {
|
||||
return subtypes.filter(({ value }) => subtypeFamilies[family].includes(value));
|
||||
}
|
||||
|
||||
removeArea(id: string): void {
|
||||
this.areasChanged.emit(this.areas().filter((area) => area.id !== id));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user