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,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.' : '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user