fix(rental): complete condition report invariants (#7)
This commit is contained in:
1 parent
62d0f171f4
commit
5d2d85aa8c
23 files changed
+652
-85
No files matched your search
@@ -150,16 +150,31 @@ export class ConditionReportEditor {
|
||||
)
|
||||
)
|
||||
return 'Renseignez l’adresse de correspondance de chaque locataire.';
|
||||
if (
|
||||
this.type() === 'EXIT' &&
|
||||
this.entryPhotos().some((photo) => {
|
||||
if (this.type() === 'EXIT') {
|
||||
const usedExitPhotoIds = new Set<string>();
|
||||
for (const entryPhoto of this.entryPhotos()) {
|
||||
const disposition = report.entryPhotoDispositions.find(
|
||||
(item) => item.entryPhotoId === photo.reference,
|
||||
(item) => item.entryPhotoId === entryPhoto.reference,
|
||||
);
|
||||
return !disposition?.exitPhotoId && !disposition?.absenceReason?.trim();
|
||||
})
|
||||
)
|
||||
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
|
||||
if (!disposition?.exitPhotoId) {
|
||||
if (!disposition?.absenceReason?.trim()) {
|
||||
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const exitPhoto = report.photos.find(
|
||||
(photo) => photo.reference === disposition.exitPhotoId,
|
||||
);
|
||||
if (
|
||||
!exitPhoto ||
|
||||
exitPhoto.zoneId !== entryPhoto.zoneId ||
|
||||
usedExitPhotoIds.has(exitPhoto.reference)
|
||||
) {
|
||||
return 'Chaque photo d’entrée doit être liée à une photo de sortie unique de la même zone.';
|
||||
}
|
||||
usedExitPhotoIds.add(exitPhoto.reference);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@
|
||||
(change)="setEntryPhotoDisposition(entryPhoto.reference, 'exitPhotoId', $event)"
|
||||
>
|
||||
<option value="">Aucune</option>
|
||||
@for (exitPhoto of report().photos; track exitPhoto.reference) {
|
||||
@for (exitPhoto of exitPhotosFor(entryPhoto); track exitPhoto.reference) {
|
||||
<option [value]="exitPhoto.reference">{{ exitPhoto.reference }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
+17
-1
@@ -105,8 +105,8 @@ export class ConditionReportEvidenceSection implements OnDestroy {
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.retakePhoto(zoneId);
|
||||
this.successOccurred.emit('Photo ajoutée.');
|
||||
this.reloadRequested.emit();
|
||||
this.successOccurred.emit('Photo ajoutée.');
|
||||
},
|
||||
error: (error) =>
|
||||
this.errorOccurred.emit(
|
||||
@@ -143,6 +143,22 @@ export class ConditionReportEvidenceSection implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
exitPhotosFor(entryPhoto: ConditionPhoto): ConditionPhoto[] {
|
||||
const report = this.report();
|
||||
const current = this.disposition(entryPhoto.reference).exitPhotoId;
|
||||
const used = new Set(
|
||||
report.entryPhotoDispositions
|
||||
.filter((item) => item.entryPhotoId !== entryPhoto.reference)
|
||||
.map((item) => item.exitPhotoId)
|
||||
.filter((reference): reference is string => Boolean(reference)),
|
||||
);
|
||||
return report.photos.filter(
|
||||
(photo) =>
|
||||
photo.zoneId === entryPhoto.zoneId &&
|
||||
(photo.reference === current || !used.has(photo.reference)),
|
||||
);
|
||||
}
|
||||
|
||||
photoUrl(type: ConditionReportType, reference: string): string {
|
||||
return this.reports.photoUrl(this.leaseId(), type, reference);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,19 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
@if (loading() && !report()) {
|
||||
<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 (loading()) {
|
||||
<p role="status">Mise à jour de l’état des lieux…</p>
|
||||
}
|
||||
@if (error()) {
|
||||
<p role="alert" class="operation-error">{{ error() }}</p>
|
||||
}
|
||||
@if (success()) {
|
||||
@if (success() && !error()) {
|
||||
<p role="status">{{ success() }}</p>
|
||||
}
|
||||
@if (value.leaseRevisionNumber !== null) {
|
||||
@@ -47,9 +50,22 @@
|
||||
<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
|
||||
type="button"
|
||||
[disabled]="revisionPending() || revisionDownloadPending() || dirty()"
|
||||
(click)="createRevision()"
|
||||
>
|
||||
{{
|
||||
revisionPending()
|
||||
? 'Génération…'
|
||||
: revisionDownloadPending()
|
||||
? 'Téléchargement…'
|
||||
: 'Générer et télécharger la révision PDF'
|
||||
}}
|
||||
</button>
|
||||
@if (revisionDownloadError()) {
|
||||
<p role="alert" class="operation-error">{{ revisionDownloadError() }}</p>
|
||||
}
|
||||
<label>
|
||||
Scan signé PDF
|
||||
<input type="file" accept="application/pdf,.pdf" (change)="selectSignedScan($event)" />
|
||||
@@ -90,7 +106,12 @@
|
||||
</button>
|
||||
} @else if (value.status === 'FINALIZED') {
|
||||
<p>État des lieux finalisé.</p>
|
||||
<button type="button" (click)="downloadBundle()">Télécharger le dossier ZIP</button>
|
||||
<button type="button" [disabled]="bundleDownloadPending()" (click)="downloadBundle()">
|
||||
{{ bundleDownloadPending() ? 'Téléchargement…' : 'Télécharger le dossier ZIP' }}
|
||||
</button>
|
||||
@if (bundleDownloadError()) {
|
||||
<p role="alert" class="operation-error">{{ bundleDownloadError() }}</p>
|
||||
}
|
||||
<h3>Attestations de remise</h3>
|
||||
@for (participant of value.participants; track participant.partyId) {
|
||||
@if (hasAttested(participant.partyId)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
ConditionPhoto,
|
||||
@@ -332,6 +332,20 @@ describe('ConditionReportEditor', () => {
|
||||
});
|
||||
editor.save();
|
||||
expect(errors.at(-1)).toContain('photo d’entrée');
|
||||
fixture.componentRef.setInput('entryPhotos', [
|
||||
...valid.photos,
|
||||
{ ...valid.photos[0]!, reference: 'photo-2' },
|
||||
]);
|
||||
fixture.componentRef.setInput('report', {
|
||||
...valid,
|
||||
forwardingAddresses: [{ tenantPartyId: 'tenant-1', address: '12 rue A' }],
|
||||
entryPhotoDispositions: [
|
||||
{ entryPhotoId: 'photo-1', exitPhotoId: 'photo-1' },
|
||||
{ entryPhotoId: 'photo-2', exitPhotoId: 'photo-1' },
|
||||
],
|
||||
});
|
||||
editor.save();
|
||||
expect(errors.at(-1)).toContain('unique');
|
||||
fixture.componentRef.setInput('type', 'ENTRY');
|
||||
fixture.componentRef.setInput('entryPhotos', []);
|
||||
fixture.componentRef.setInput('report', valid);
|
||||
@@ -530,6 +544,24 @@ describe('Condition-report sections', () => {
|
||||
entryPhotoId: 'entry-photo',
|
||||
exitPhotoId: 'exit-photo',
|
||||
});
|
||||
fixture.componentRef.setInput('report', {
|
||||
...report,
|
||||
photos: [
|
||||
{ ...supplementPhoto, reference: 'exit-photo', zoneId: 'kitchen' },
|
||||
{ ...supplementPhoto, reference: 'used-photo', zoneId: 'kitchen' },
|
||||
],
|
||||
entryPhotoDispositions: [
|
||||
{ entryPhotoId: 'entry-photo', exitPhotoId: 'exit-photo' },
|
||||
{ entryPhotoId: 'other-entry-photo', exitPhotoId: 'used-photo' },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
section.exitPhotosFor({
|
||||
...supplementPhoto,
|
||||
reference: 'entry-photo',
|
||||
zoneId: 'kitchen',
|
||||
}),
|
||||
).toEqual([expect.objectContaining({ reference: 'exit-photo' })]);
|
||||
expect(service.uploadPhoto).toHaveBeenCalledWith('lease-1', 'EXIT', file, 'kitchen');
|
||||
expect(service.deletePhoto).toHaveBeenCalledWith('lease-1', 'EXIT', 'photo-1', 1);
|
||||
expect(section.photoUrl('ENTRY', 'entry-photo')).toBe('/photo.webp');
|
||||
@@ -543,6 +575,38 @@ describe('Condition-report sections', () => {
|
||||
});
|
||||
|
||||
describe('ConditionReportPage', () => {
|
||||
it('preserves the established report while it refreshes', () => {
|
||||
const refresh = new Subject<PropertyConditionReport>();
|
||||
const service = {
|
||||
getReport: vi.fn().mockReturnValueOnce(of(report)).mockReturnValue(refresh),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { paramMap: convertToParamMap({ leaseId: 'lease-1', type: 'ENTRY' }) },
|
||||
},
|
||||
},
|
||||
{ provide: ConditionReportService, useValue: service },
|
||||
{
|
||||
provide: FileDownloadService,
|
||||
useValue: { download: vi.fn(), isPending: vi.fn(() => false), error: vi.fn(() => '') },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(ConditionReportPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.load();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Révision de bail source 3');
|
||||
expect(fixture.nativeElement.textContent).toContain('Mise à jour de l’état des lieux');
|
||||
refresh.next({ ...report, version: 2 });
|
||||
refresh.complete();
|
||||
});
|
||||
|
||||
it('refreshes the revision version, requires attestation, and renders every comparison section', () => {
|
||||
const refreshed = { ...report, version: 2 };
|
||||
const comparison: ConditionReportComparison = {
|
||||
@@ -642,7 +706,11 @@ describe('ConditionReportPage', () => {
|
||||
getComparison: vi.fn().mockReturnValue(of(comparison)),
|
||||
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
|
||||
};
|
||||
const downloads = { download: vi.fn() };
|
||||
const downloads = {
|
||||
download: vi.fn(),
|
||||
isPending: vi.fn(() => false),
|
||||
error: vi.fn(() => ''),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
@@ -730,7 +798,11 @@ describe('ConditionReportPage', () => {
|
||||
appendSupplement: vi.fn().mockReturnValue(of(exitReport)),
|
||||
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
|
||||
};
|
||||
const downloads = { download: vi.fn() };
|
||||
const downloads = {
|
||||
download: vi.fn(),
|
||||
isPending: vi.fn((key: string) => key.includes('bundle')),
|
||||
error: vi.fn((key: string) => (key.includes('bundle') ? 'Téléchargement impossible.' : '')),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
@@ -765,6 +837,9 @@ describe('ConditionReportPage', () => {
|
||||
page.dirty.set(false);
|
||||
page.finalize();
|
||||
page.downloadBundle();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléchargement…');
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléchargement impossible.');
|
||||
page.loadComparison();
|
||||
page.attestDelivery('tenant-2');
|
||||
page.changeReport({ ...exitReport, occurredOn: '2026-07-23' });
|
||||
@@ -838,7 +913,10 @@ describe('ConditionReportPage', () => {
|
||||
},
|
||||
},
|
||||
{ provide: ConditionReportService, useValue: service },
|
||||
{ provide: FileDownloadService, useValue: { download: vi.fn() } },
|
||||
{
|
||||
provide: FileDownloadService,
|
||||
useValue: { download: vi.fn(), isPending: vi.fn(() => false), error: vi.fn(() => '') },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(ConditionReportPage);
|
||||
@@ -850,5 +928,9 @@ describe('ConditionReportPage', () => {
|
||||
expect(page.loading()).toBe(false);
|
||||
expect(page.comparisonPending()).toBe(false);
|
||||
expect(page.error()).toContain('Impossible de charger la comparaison');
|
||||
page.report.set(report);
|
||||
page.success.set('Ancienne réussite');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Ancienne réussite');
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,7 @@ export class ConditionReportPage {
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.clearNotices();
|
||||
this.reports
|
||||
.getReport(this.leaseId, this.type)
|
||||
.pipe(rxFinalize(() => this.loading.set(false)))
|
||||
@@ -117,7 +117,7 @@ export class ConditionReportPage {
|
||||
save(request: SaveConditionReportRequest): void {
|
||||
if (this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.clearNotices();
|
||||
this.reports
|
||||
.saveReport(this.leaseId, this.type, request)
|
||||
.pipe(rxFinalize(() => this.saving.set(false)))
|
||||
@@ -135,6 +135,7 @@ export class ConditionReportPage {
|
||||
createRevision(): void {
|
||||
const report = this.report();
|
||||
if (!report || this.revisionPending()) return;
|
||||
this.clearNotices();
|
||||
if (this.dirty()) {
|
||||
this.error.set('Enregistrez le brouillon avant de générer sa révision.');
|
||||
return;
|
||||
@@ -151,8 +152,8 @@ export class ConditionReportPage {
|
||||
`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();
|
||||
this.success.set(`Révision ${revision.number} générée.`);
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de générer la révision PDF.')),
|
||||
@@ -210,13 +211,9 @@ export class ConditionReportPage {
|
||||
finalize(): void {
|
||||
const report = this.report();
|
||||
const scan = this.signedScan();
|
||||
if (
|
||||
!report ||
|
||||
!scan ||
|
||||
!this.finalizationAttested() ||
|
||||
!this.allPartiesSigned() ||
|
||||
this.finalizing()
|
||||
) {
|
||||
if (this.finalizing()) return;
|
||||
this.clearNotices();
|
||||
if (!report || !scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
|
||||
if (!scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
|
||||
this.error.set('Téléversez le scan PDF et confirmez les deux attestations.');
|
||||
}
|
||||
@@ -255,6 +252,7 @@ export class ConditionReportPage {
|
||||
|
||||
loadComparison(): void {
|
||||
if (this.comparisonPending()) return;
|
||||
this.clearNotices();
|
||||
this.comparisonPending.set(true);
|
||||
this.reports
|
||||
.getComparison(this.leaseId)
|
||||
@@ -269,6 +267,7 @@ export class ConditionReportPage {
|
||||
attestDelivery(partyId: string): void {
|
||||
const report = this.report();
|
||||
if (!report || this.busyAttestationPartyId()) return;
|
||||
this.clearNotices();
|
||||
this.busyAttestationPartyId.set(partyId);
|
||||
this.reports
|
||||
.attestDelivery(this.leaseId, this.type, report.version, partyId)
|
||||
@@ -312,6 +311,8 @@ export class ConditionReportPage {
|
||||
}
|
||||
|
||||
appendSupplement(): void {
|
||||
if (this.supplementPending()) return;
|
||||
this.clearNotices();
|
||||
const signedPdf = this.supplementSignedPdf();
|
||||
const requestedOn = this.supplementRequestedOn().trim();
|
||||
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
|
||||
@@ -321,9 +322,7 @@ export class ConditionReportPage {
|
||||
!requestedOn ||
|
||||
!acceptedAdditions ||
|
||||
(this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) ||
|
||||
(this.supplementIsLate() &&
|
||||
(!this.lateWarningConfirmed() || !this.supplementReason().trim())) ||
|
||||
this.supplementPending()
|
||||
(this.supplementIsLate() && (!this.lateWarningConfirmed() || !this.supplementReason().trim()))
|
||||
) {
|
||||
if (!signedPdf || !requestedOn || !acceptedAdditions) {
|
||||
this.error.set('Renseignez la date, les ajouts acceptés et le PDF signé du complément.');
|
||||
@@ -374,6 +373,27 @@ export class ConditionReportPage {
|
||||
return this.reports.photoUrl(this.leaseId, type, reference);
|
||||
}
|
||||
|
||||
revisionDownloadPending(): boolean {
|
||||
return this.downloads.isPending(`condition-report-revision-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
revisionDownloadError(): string {
|
||||
return this.downloads.error(`condition-report-revision-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
bundleDownloadPending(): boolean {
|
||||
return this.downloads.isPending(`condition-report-bundle-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
bundleDownloadError(): string {
|
||||
return this.downloads.error(`condition-report-bundle-${this.leaseId}-${this.type}`);
|
||||
}
|
||||
|
||||
private clearNotices(): void {
|
||||
this.error.set('');
|
||||
this.success.set('');
|
||||
}
|
||||
|
||||
private value(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement ||
|
||||
|
||||
Reference in new issue
Block a user