feat: finalize lease documents and IRL revisions
This commit is contained in:
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.'),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user