feat: finalize lease documents and IRL revisions

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent f127c69a28
commit 1bf6a3449c
196 files changed
+20564 -666

No files matched your search

@@ -0,0 +1,47 @@
:host {
display: block;
}
.section-heading p,
details p,
.uploaded-deed span,
.selected-file {
color: var(--text-muted);
}
.guarantee-card {
margin-top: 14px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 14px;
}
.guarantee-card h3 {
margin: 0 0 10px;
font-size: 0.95rem;
}
details {
margin-bottom: 14px;
font-size: 0.82rem;
}
summary {
cursor: pointer;
font-weight: 700;
}
.file-action,
.uploaded-deed {
display: grid;
gap: 6px;
}
.file-action span {
font-weight: 700;
}
.selected-file,
.operation-error {
margin: 8px 0;
font-size: 0.8rem;
}
mat-checkbox {
display: block;
margin: 12px 0;
}
.uploaded-deed {
margin: 0;
}
@@ -0,0 +1,75 @@
<section aria-labelledby="guarantees-heading">
<div class="section-heading">
<h2 id="guarantees-heading">Actes de cautionnement</h2>
<p>Un acte signé est demandé pour chaque paire garant–locataire.</p>
</div>
@for (requirement of requirements(); track key(requirement)) {
<article class="guarantee-card" [attr.aria-busy]="uploadingKeys().has(key(requirement))">
<h3>{{ requirement.guarantorName }} garantit {{ requirement.tenantName }}</h3>
<details>
<summary>ⓘ Aide pour préparer l'acte</summary>
<p>
Utilisez si besoin le
<a [href]="simulatorUrl()" target="_blank" rel="noopener noreferrer"
>simulateur officiel Service-Public.fr</a
>. L'application conserve le PDF transmis sans vérifier les signatures.
</p>
</details>
@if (requirement.deed; as deed) {
<p class="uploaded-deed">
<strong>{{ deed.fileName }}</strong>
<span>Acte téléversé le {{ deed.uploadedAt }}</span>
</p>
} @else {
<label class="file-action">
<span>Acte signé au format PDF</span>
<input
type="file"
accept="application/pdf,.pdf"
[disabled]="uploadingKeys().has(key(requirement))"
[attr.aria-label]="
'Acte de cautionnement de ' +
requirement.guarantorName +
' pour ' +
requirement.tenantName
"
(change)="selectFile($event, requirement)"
/>
</label>
@if (selectedFiles()[key(requirement)]; as file) {
<p class="selected-file">{{ file.name }}</p>
}
<mat-checkbox
[checked]="attestedKeys().has(key(requirement))"
[disabled]="uploadingKeys().has(key(requirement))"
(change)="setAttestation(requirement, $event.checked)"
>
J'atteste transmettre l'acte signé correspondant à ce garant et ce locataire.
</mat-checkbox>
<button
mat-stroked-button
type="button"
[disabled]="
!selectedFiles()[key(requirement)] ||
!attestedKeys().has(key(requirement)) ||
uploadingKeys().has(key(requirement))
"
(click)="upload(requirement)"
>
@if (uploadingKeys().has(key(requirement))) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléversement…
} @else {
Téléverser l'acte
}
</button>
@if (errors()[key(requirement)]) {
<p class="operation-error" role="alert">{{ errors()[key(requirement)] }}</p>
}
}
</article>
} @empty {
<p>Aucun acte de cautionnement n'est requis pour ce bail.</p>
}
</section>
@@ -0,0 +1,109 @@
import { TestBed } from '@angular/core/testing';
import { Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
import {
GuaranteeDeed,
GuaranteeRequirement,
LeaseFinalizationService,
} from '../../services/lease-finalization.service';
import { GuaranteeDeedList } from './guarantee-deed-list';
describe('GuaranteeDeedList', () => {
const requirement: GuaranteeRequirement = {
guarantorId: 'guarantor-1',
guarantorName: 'Alex Durand',
tenantId: 'tenant-1',
tenantName: 'Camille Martin',
deed: null,
};
const deed: GuaranteeDeed = {
guarantorId: 'guarantor-1',
tenantId: 'tenant-1',
fileName: 'cautionnement.pdf',
sha256: 'sha256',
authorId: 'owner-1',
uploadedAt: '2026-07-19T10:00:00Z',
};
const uploadGuaranteeDeed = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [{ provide: LeaseFinalizationService, useValue: { uploadGuaranteeDeed } }],
});
});
function createList(requirements = [requirement]) {
const fixture = TestBed.createComponent(GuaranteeDeedList);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('requirements', requirements);
fixture.componentRef.setInput('simulatorUrl', 'https://www.service-public.fr/simulateur');
fixture.detectChanges();
return fixture;
}
it('shows each pair and an accessible official simulator link', () => {
const fixture = createList();
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('details a');
expect(fixture.nativeElement.textContent).toContain('Alex Durand garantit Camille Martin');
expect(link.href).toBe('https://www.service-public.fr/simulateur');
expect(link.target).toBe('_blank');
expect(link.rel).toContain('noopener');
expect(fixture.nativeElement.textContent).toContain('sans vérifier les signatures');
});
it('requires both a PDF and the attestation, with local pending feedback', () => {
const response = new Subject<GuaranteeDeed>();
uploadGuaranteeDeed.mockReturnValue(response);
const fixture = createList();
const component = fixture.componentInstance;
const file = new File(['deed'], 'cautionnement.pdf', { type: 'application/pdf' });
const uploaded = vi.fn();
component.deedUploaded.subscribe(uploaded);
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
component.selectedFiles.set({ 'guarantor-1:tenant-1': file });
component.setAttestation(requirement, true);
fixture.detectChanges();
button.click();
fixture.detectChanges();
expect(uploadGuaranteeDeed).toHaveBeenCalledWith('lease-1', file, 'guarantor-1', 'tenant-1');
expect(fixture.nativeElement.textContent).toContain('Téléversement…');
response.next(deed);
response.complete();
expect(uploaded).toHaveBeenCalledWith({
guarantorId: 'guarantor-1',
tenantId: 'tenant-1',
deed,
});
});
it('keeps an upload failure local to its pair', () => {
uploadGuaranteeDeed.mockReturnValue(throwError(() => ({ error: { message: 'Acte refusé' } })));
const fixture = createList();
const component = fixture.componentInstance;
component.selectedFiles.set({
'guarantor-1:tenant-1': new File(['deed'], 'cautionnement.pdf', {
type: 'application/pdf',
}),
});
component.setAttestation(requirement, true);
component.upload(requirement);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Acte refusé');
expect(fixture.nativeElement.querySelector('[role=alert]')).not.toBeNull();
});
it('renders an already uploaded deed without another upload form', () => {
const fixture = createList([{ ...requirement, deed }]);
expect(fixture.nativeElement.textContent).toContain('cautionnement.pdf');
expect(fixture.nativeElement.querySelector('input[type=file]')).toBeNull();
});
});
@@ -0,0 +1,89 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { finalize } from 'rxjs';
import {
GuaranteeDeed,
GuaranteeRequirement,
LeaseFinalizationService,
} from '../../services/lease-finalization.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { withoutRecordKey, withoutSetValue } from '../../services/immutable-state';
export interface GuaranteeDeedUploaded {
guarantorId: string;
tenantId: string;
deed: GuaranteeDeed;
}
@Component({
selector: 'app-guarantee-deed-list',
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
templateUrl: './guarantee-deed-list.html',
styleUrl: './guarantee-deed-list.css',
})
export class GuaranteeDeedList {
private readonly finalizationService = inject(LeaseFinalizationService);
readonly leaseId = input.required<string>();
readonly requirements = input.required<GuaranteeRequirement[]>();
readonly simulatorUrl = input.required<string>();
readonly deedUploaded = output<GuaranteeDeedUploaded>();
readonly selectedFiles = signal<Record<string, File | undefined>>({});
readonly attestedKeys = signal<ReadonlySet<string>>(new Set());
readonly uploadingKeys = signal<ReadonlySet<string>>(new Set());
readonly errors = signal<Record<string, string>>({});
key(requirement: GuaranteeRequirement): string {
return `${requirement.guarantorId}:${requirement.tenantId}`;
}
selectFile(event: Event, requirement: GuaranteeRequirement): void {
const input = event.target as HTMLInputElement;
const file = input.files?.item(0);
const key = this.key(requirement);
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
this.errors.update((errors) => ({ ...errors, [key]: 'Sélectionnez un fichier PDF.' }));
} else {
this.errors.update((errors) => withoutRecordKey(errors, key));
this.selectedFiles.update((files) => ({ ...files, [key]: file ?? undefined }));
}
input.value = '';
}
setAttestation(requirement: GuaranteeRequirement, checked: boolean): void {
const key = this.key(requirement);
this.attestedKeys.update((keys) => {
const next = new Set(keys);
if (checked) next.add(key);
else next.delete(key);
return next;
});
}
upload(requirement: GuaranteeRequirement): void {
const key = this.key(requirement);
const file = this.selectedFiles()[key];
if (!file || !this.attestedKeys().has(key) || this.uploadingKeys().has(key)) return;
this.uploadingKeys.update((keys) => new Set(keys).add(key));
this.errors.update((errors) => withoutRecordKey(errors, key));
this.finalizationService
.uploadGuaranteeDeed(this.leaseId(), file, requirement.guarantorId, requirement.tenantId)
.pipe(finalize(() => this.uploadingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({
next: (deed) =>
this.deedUploaded.emit({
guarantorId: requirement.guarantorId,
tenantId: requirement.tenantId,
deed,
}),
error: (error) =>
this.errors.update((errors) => ({
...errors,
[key]: httpErrorMessage(error, 'Impossible de téléverser cet acte de cautionnement.'),
})),
});
}
}