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,49 @@
:host {
display: block;
margin-top: 30px;
padding-top: 28px;
border-top: 1px solid var(--border);
}
fieldset {
display: grid;
gap: 14px;
padding: 0;
border: 0;
}
.section-heading p,
.selected-file,
.signature-notice {
color: var(--text-muted);
}
.file-action,
.date-field {
display: grid;
gap: 6px;
font-weight: 700;
}
.date-field input {
max-width: 260px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font: inherit;
}
.selected-file,
.signature-notice,
.operation-error {
margin: 0;
font-size: 0.82rem;
}
.warnings {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 12px;
background: var(--surface-soft);
}
.warnings h3 {
margin: 0;
font-size: 0.9rem;
}
@@ -0,0 +1,79 @@
<section aria-labelledby="signed-lease-heading">
<div class="section-heading">
<h2 id="signed-lease-heading">Bail signé</h2>
<p>
Document attendu : révision {{ revision().number }} · modèle {{ revision().templateVersion }}.
</p>
</div>
<form (submit)="submit(); $event.preventDefault()" [attr.aria-busy]="submitting()">
<fieldset [disabled]="submitting()">
<label class="file-action">
<span>Bail signé au format PDF</span>
<input
type="file"
accept="application/pdf,.pdf"
aria-label="Bail signé au format PDF"
(change)="selectSignedLease($event)"
/>
</label>
@if (signedLease(); as file) {
<p class="selected-file">{{ file.name }}</p>
}
<label class="date-field" for="lastSignatureDate">
<span>Date de la dernière signature</span>
<input
id="lastSignatureDate"
type="date"
required
[value]="lastSignatureDate()"
(input)="setLastSignatureDate($event)"
/>
</label>
<mat-checkbox [checked]="allPartiesSigned()" (change)="allPartiesSigned.set($event.checked)">
J'atteste que toutes les parties ont signé le PDF transmis.
</mat-checkbox>
<mat-checkbox [checked]="revisionMatches()" (change)="revisionMatches.set($event.checked)">
J'ai vérifié que le PDF signé correspond à la révision {{ revision().number }} · modèle
{{ revision().templateVersion }}.
</mat-checkbox>
@if (warnings().length) {
<div class="warnings" aria-labelledby="warnings-heading">
<h3 id="warnings-heading">Avertissements à confirmer</h3>
@for (warning of warnings(); track warning.code + ':' + warning.ruleVersion) {
<mat-checkbox
[checked]="confirmedWarningCodes().has(warning.code)"
(change)="setWarning(warning.code, $event.checked)"
>
{{ warningLabel(warning.code) }}
<small>Version de règle : {{ warning.ruleVersion }}</small>
</mat-checkbox>
}
</div>
}
<p class="signature-notice">
L'application conserve le document et vos attestations ; elle ne vérifie pas les signatures
contenues dans le PDF.
</p>
@if (!guaranteesComplete()) {
<p class="operation-error" role="alert">
Téléversez tous les actes de cautionnement requis avant de finaliser.
</p>
}
@if (validationError() || submitError()) {
<p class="operation-error" role="alert">{{ validationError() || submitError() }}</p>
}
<button mat-flat-button type="submit" [disabled]="submitting() || !guaranteesComplete()">
@if (submitting()) {
<mat-spinner diameter="18" aria-hidden="true" /> Finalisation…
} @else {
Finaliser le bail
}
</button>
</fieldset>
</form>
</section>
@@ -0,0 +1,123 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseRevisionMetadata } from '../../services/lease.service';
import { LeaseFinalizationForm } from './lease-finalization-form';
describe('LeaseFinalizationForm', () => {
const revision: LeaseRevisionMetadata = {
number: 4,
templateVersion: '2026.10',
sourceFingerprint: 'source',
sha256: 'sha256',
generatedAt: '2026-07-19T10:00:00Z',
authorId: 'owner-1',
annexes: [],
warningCodes: [],
warnings: [],
};
function createForm(warningCodes: string[] = []) {
const fixture = TestBed.createComponent(LeaseFinalizationForm);
fixture.componentRef.setInput('revision', revision);
fixture.componentRef.setInput('warningCodes', warningCodes);
fixture.componentRef.setInput(
'warnings',
warningCodes.map((code) => ({ code, ruleVersion: '2026-07-01' })),
);
fixture.detectChanges();
return fixture;
}
it('emits the exact civil date string and confirmations', () => {
const fixture = createForm([
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
'IRL_REVISION_WITH_F_OR_G_DPE',
'FUTURE_WARNING',
]);
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.setWarning('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS', true);
component.setWarning('IRL_REVISION_WITH_F_OR_G_DPE', true);
component.setWarning('FUTURE_WARNING', true);
component.submit();
expect(requested).toHaveBeenCalledWith({
signedLease: file,
revisionNumber: 4,
lastSignatureDate: '2026-07-19',
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: [
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
'IRL_REVISION_WITH_F_OR_G_DPE',
'FUTURE_WARNING',
],
});
});
it('uses actionable French warning labels without exposing raw codes', () => {
const fixture = createForm([
'SECURITY_DEPOSIT_ABOVE_TWO_MONTHS',
'IRL_REVISION_WITH_F_OR_G_DPE',
'FUTURE_WARNING',
]);
const text = fixture.nativeElement.textContent;
expect(text).toContain('dépasse deux mois de loyer hors charges');
expect(text).toContain('confirme forcer ce montant');
expect(text).toContain('classé F ou G');
expect(text).toContain('maintenir la clause de révision IRL');
expect(text).toContain('avertissement légal signalé');
expect(text).not.toContain('SECURITY_DEPOSIT_ABOVE_TWO_MONTHS');
expect(text).not.toContain('FUTURE_WARNING');
expect(text).not.toContain('IRL_REVISION_WITH_F_OR_G_DPE');
expect(text).toContain('Version de règle : 2026-07-01');
});
it('explains every document warning precisely in French', () => {
const warningCodes = [
'DPE_MISSING',
'DPE_EXPIRED',
'DIAGNOSTIC_EXPIRED',
'CO_OWNERSHIP_RULES_EXTRACT_MISSING',
'PRIOR_RENTAL_AUTHORIZATION_MISSING',
];
const fixture = createForm(warningCodes);
const text = fixture.nativeElement.textContent;
expect(text).toContain('DPE est absent du dossier');
expect(text).toContain('DPE présent dans le dossier est expiré');
expect(text).toContain('diagnostic autre que le DPE est expiré');
expect(text).toContain('extrait du règlement de copropriété manque');
expect(text).toContain('autorisation préalable de mise en location manque');
for (const code of warningCodes) expect(text).not.toContain(code);
});
it('blocks an incomplete submission with an actionable error', () => {
const fixture = createForm(['SECURITY_DEPOSIT_ABOVE_TWO_MONTHS']);
const requested = vi.fn();
fixture.componentInstance.finalizationRequested.subscribe(requested);
fixture.componentInstance.submit();
fixture.detectChanges();
expect(requested).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Sélectionnez le bail signé');
});
it('blocks finalization while a required guarantee deed is missing', () => {
const fixture = createForm();
fixture.componentRef.setInput('guaranteesComplete', false);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Téléversez tous les actes');
});
});
@@ -0,0 +1,95 @@
import { Component, 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 { FinalizeLeaseRequest } from '../../services/lease-finalization.service';
import { LeaseRevisionMetadata, LeaseRevisionWarning } from '../../services/lease.service';
import { leaseWarningConfirmation } from '../../services/lease-warning-label';
@Component({
selector: 'app-lease-finalization-form',
imports: [MatButtonModule, MatCheckboxModule, MatProgressSpinnerModule],
templateUrl: './lease-finalization-form.html',
styleUrl: './lease-finalization-form.css',
})
export class LeaseFinalizationForm {
readonly revision = input.required<LeaseRevisionMetadata>();
readonly warningCodes = input<string[]>([]);
readonly warnings = input<LeaseRevisionWarning[]>([]);
readonly guaranteesComplete = input(true);
readonly submitting = input(false);
readonly submitError = input('');
readonly finalizationRequested = output<FinalizeLeaseRequest>();
readonly signedLease = signal<File | null>(null);
readonly lastSignatureDate = signal('');
readonly allPartiesSigned = signal(false);
readonly revisionMatches = signal(false);
readonly confirmedWarningCodes = signal<ReadonlySet<string>>(new Set());
readonly validationError = signal('');
selectSignedLease(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.item(0) ?? null;
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
this.signedLease.set(null);
this.validationError.set('Sélectionnez le bail signé au format PDF.');
} else {
this.signedLease.set(file);
this.validationError.set('');
}
input.value = '';
}
setLastSignatureDate(event: Event): void {
const target = event.target;
this.lastSignatureDate.set(target instanceof HTMLInputElement ? target.value : '');
}
setWarning(code: string, checked: boolean): void {
this.confirmedWarningCodes.update((codes) => {
const next = new Set(codes);
if (checked) next.add(code);
else next.delete(code);
return next;
});
}
warningLabel(code: string): string {
return leaseWarningConfirmation(code);
}
submit(): void {
if (this.submitting()) return;
const error = this.errorMessage();
this.validationError.set(error);
const signedLease = this.signedLease();
if (error || !signedLease) return;
this.finalizationRequested.emit({
signedLease,
revisionNumber: this.revision().number,
lastSignatureDate: this.lastSignatureDate(),
allPartiesSigned: true,
revisionMatches: true,
confirmedWarningCodes: this.warningCodes(),
});
}
private errorMessage(): string {
if (!this.guaranteesComplete()) {
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
}
if (!this.signedLease()) return 'Sélectionnez le bail signé au format PDF.';
if (!this.lastSignatureDate()) return 'Renseignez la date de la dernière signature.';
if (!this.allPartiesSigned()) {
return 'Attestez que toutes les parties ont signé le document transmis.';
}
if (!this.revisionMatches()) {
return `Confirmez que le document signé correspond à la révision ${this.revision().number}.`;
}
const missingWarning = this.warningCodes().find(
(code) => !this.confirmedWarningCodes().has(code),
);
return missingWarning ? 'Confirmez chaque avertissement légal avant de finaliser.' : '';
}
}