feat: add lease end page and resolution forms with necessary services and components

- Implemented LeaseEndPage component for managing lease end processes.
- Created LeaseEndResolutionForms component for handling agreement and closure submissions.
- Added civil date formatting utilities for French date representation.
- Developed styles for lease end resolution forms and rent due correction components.
- Integrated HTTP service for lease end operations including notice recording, withdrawal acceptance, and closure.
- Added unit tests for new components and services to ensure functionality.
This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 3d42c9a256
commit c1e6b905d5
114 files changed
+6582 -345

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ describe('routes', () => {
'apartments',
'tenants',
'leases/:leaseId/finalize',
'leases/:leaseId/end',
'leases',
'rent-payments',
'settings',
+2
View File
@@ -3,6 +3,7 @@ import { ApartmentsPage } from './components/apartments-page/apartments-page';
import { DashboardPage } from './components/dashboard-page/dashboard-page';
import { LeasesPage } from './components/leases-page/leases-page';
import { LeaseFinalizationPage } from './components/lease-finalization-page/lease-finalization-page';
import { LeaseEndPage } from './components/lease-end-page/lease-end-page';
import { RentPaymentsPage } from './components/rent-payments-page/rent-payments-page';
import { SettingsPage } from './components/settings-page/settings-page';
import { TenantsPage } from './components/tenants-page/tenants-page';
@@ -12,6 +13,7 @@ export const routes: Routes = [
{ path: 'apartments', component: ApartmentsPage },
{ path: 'tenants', component: TenantsPage },
{ path: 'leases/:leaseId/finalize', component: LeaseFinalizationPage },
{ path: 'leases/:leaseId/end', component: LeaseEndPage },
{ path: 'leases', component: LeasesPage },
{ path: 'rent-payments', component: RentPaymentsPage },
{ path: 'settings', component: SettingsPage },
@@ -161,3 +161,11 @@
.card-error {
margin: 12px 0 0;
}
.lease-end-unsupported {
margin: 0.875rem 0 0;
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--surface-soft, #f6f7f8);
color: var(--text-muted, #5f6368);
font-size: 0.8rem;
}
@@ -50,10 +50,13 @@
}
<dl class="terms">
<div>
<dt>Période</dt>
<dt>{{ lease().leaseType === 'STANDARD' ? 'Période initiale' : 'Période' }}</dt>
<dd>
{{ civilDate(lease().startDate) }} →
{{ civilDate(lease().endDate) }}
@if (lease().leaseType === 'STANDARD') {
· bail reconductible
}
</dd>
</div>
<div>
@@ -150,7 +153,26 @@
Supprimer
}
</button>
} @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') {
<button
mat-flat-button
type="button"
class="lease-end-action"
(click)="leaseEndRequested.emit(leaseId)"
>
{{
lease().status === 'ACTIVE' && lease().tenantIds.length === 1
? 'Gérer la fin du bail'
: 'Voir la fin du bail'
}}
</button>
}
}
</div>
@if (lease().status === 'ACTIVE' && lease().tenantIds.length > 1) {
<p class="lease-end-unsupported" role="note">
La fin de bail en colocation n’est pas encore prise en charge. Aucun congé ni aucune clôture
ne peut être enregistré ici.
</p>
}
</article>
@@ -194,6 +194,7 @@ describe('LeaseCard', () => {
fixture.componentInstance.finalizationRequested.subscribe(requested);
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('Période initiale 1 janvier 2026 → 1 janvier 2027 · bail reconductible');
expect(text).toContain('Générer / télécharger le bail');
expect(text).toContain('Modifier');
expect(text).toContain('Supprimer');
@@ -215,6 +216,61 @@ describe('LeaseCard', () => {
expect(fixture.nativeElement.textContent).not.toContain('Finaliser le bail');
});
it('manages an active single lease and keeps read access for colocation and archives', () => {
const fixture = TestBed.createComponent(LeaseCard);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-01-01',
endDate: '2028-12-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
});
const requested = vi.fn();
fixture.componentInstance.leaseEndRequested.subscribe(requested);
fixture.detectChanges();
const action: HTMLButtonElement = fixture.nativeElement.querySelector('.lease-end-action');
expect(action.textContent).toContain('Gérer la fin du bail');
action.click();
expect(requested).toHaveBeenCalledWith('lease-1');
expect(fixture.nativeElement.textContent).not.toContain('Archiver');
fixture.componentRef.setInput('lease', {
...fixture.componentInstance.lease(),
tenantIds: ['tenant-1', 'tenant-2'],
});
fixture.detectChanges();
const colocationAction: HTMLButtonElement =
fixture.nativeElement.querySelector('.lease-end-action');
expect(colocationAction.textContent).toContain('Voir la fin du bail');
expect(fixture.nativeElement.textContent).toContain(
'La fin de bail en colocation n’est pas encore prise en charge',
);
fixture.componentRef.setInput('lease', {
...fixture.componentInstance.lease(),
status: 'ARCHIVED',
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.lease-end-action').textContent).toContain(
'Voir la fin du bail',
);
});
it('offers an IRL preview only for an active lease with the revision clause enabled', () => {
TestBed.configureTestingModule({
providers: [
@@ -5,16 +5,10 @@ import { Apartment } from '../../services/apartment.service';
import { Lease, LeaseRevisionMetadata, RentDue } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
import { apartmentDocumentTypeLabel } from '../../services/apartment-document-label';
import { formatFrenchCivilDate } from '../../services/civil-date';
import { leaseWarningDescription } from '../../services/lease-warning-label';
import { RentRevisionPreviewComponent } from '../rent-revision-preview/rent-revision-preview';
const frenchCivilDate = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
@Component({
selector: 'app-lease-card',
imports: [MatButtonModule, MatProgressSpinnerModule, RentRevisionPreviewComponent],
@@ -38,7 +32,9 @@ export class LeaseCard {
readonly dossierRequested = output<Lease>();
readonly editRequested = output<Lease>();
readonly finalizationRequested = output<string>();
readonly leaseEndRequested = output<string>();
readonly rentRevisionOpen = signal(false);
readonly civilDate = formatFrenchCivilDate;
toggleRentRevision(): void {
this.rentRevisionOpen.update((open) => !open);
@@ -76,9 +72,4 @@ export class LeaseCard {
warningLabel(code: string): string {
return leaseWarningDescription(code);
}
civilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return frenchCivilDate.format(new Date(Date.UTC(year, month - 1, day)));
}
}
@@ -0,0 +1,90 @@
:host,
.notice-card,
header > div {
display: block;
min-width: 0;
}
.notice-card {
padding: 1.25rem;
border: 1px solid var(--border, #dfe3e6);
border-radius: 1rem;
background: var(--surface, #fff);
}
header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
h3,
h4 {
margin-block-start: 0;
}
.notice-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: 1rem;
margin-block: 1.25rem;
}
dt {
color: var(--text-muted, #5f6368);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
dd {
margin: 0.25rem 0 0;
}
.evidence-list ul {
display: grid;
gap: 0.5rem;
padding-inline-start: 1.25rem;
}
.evidence-attestation {
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--surface-soft, #f6f7f8);
color: var(--text-muted, #5f6368);
}
.evidence-list li {
min-width: 0;
overflow-wrap: anywhere;
}
.notice-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
gap: 1rem;
margin-block-start: 1.25rem;
padding-block-start: 1.25rem;
border-block-start: 1px solid var(--border, #dfe3e6);
}
fieldset,
label {
display: grid;
gap: 0.5rem;
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
legend {
margin-block-end: 0.75rem;
font-weight: 700;
}
input,
textarea {
min-width: 0;
min-height: 2.75rem;
box-sizing: border-box;
}
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent, #236b5d);
outline-offset: 0.15rem;
}
.operation-error {
color: var(--danger, #b42318);
}
@@ -0,0 +1,192 @@
<article class="notice-card">
<header>
<div>
<p class="eyebrow">{{ statusLabel() }}</p>
<h3>Congé reçu le {{ civilDate(notice().receivedOn) }}</h3>
</div>
<span>Départ demandé le {{ civilDate(notice().requestedDepartureOn) }}</span>
</header>
<dl class="notice-facts">
<div>
<dt>Remise</dt>
<dd>
{{ deliveryModeLabel(notice().deliveryMode) }}
@if (notice().otherDeliveryMode) {
— {{ notice().otherDeliveryMode }}
}
</dd>
</div>
<div>
<dt>Règle figée</dt>
<dd>
Préavis retenu : {{ notice().noticeMonths }} mois · fin minimale le
{{ civilDate(notice().minimumEndOn) }} · Version de règle : {{ notice().ruleVersion }}
</dd>
</div>
<div>
<dt>Audit</dt>
<dd>{{ notice().authorId }} · {{ notice().recordedAt }}</dd>
</div>
@if (notice().withdrawal; as withdrawal) {
<div>
<dt>Retrait accepté</dt>
<dd>
{{ civilDate(withdrawal.acceptedOn) }} · {{ withdrawal.reason }} ·
{{ withdrawal.authorId }} · {{ withdrawal.recordedAt }}
</dd>
</div>
}
@if (notice().invalidation; as invalidation) {
<div>
<dt>Invalidation administrative</dt>
<dd>
{{ civilDate(invalidation.invalidatedOn) }} · {{ invalidation.reason }} ·
{{ invalidation.authorId }} · {{ invalidation.recordedAt }}
</dd>
</div>
}
</dl>
@if (notice().recordedWithoutEvidenceConfirmed) {
<p class="evidence-attestation">
Le bailleur a confirmé que ce congé a été enregistré sans justificatif initial.
</p>
}
<section class="evidence-list" aria-label="Versions des preuves">
<h4>Preuves conservées</h4>
@if (notice().evidenceVersions.length) {
<ul>
@for (evidence of notice().evidenceVersions; track evidence.version) {
<li>
<span>
Version {{ evidence.version }} · {{ evidence.fileName }}
@if (evidence.reason) {
— {{ evidence.reason }}
}
</span>
<span>Empreinte SHA-256 : {{ evidence.sha256 }}</span>
<button
mat-button
type="button"
class="evidence-download"
[disabled]="endService.evidenceDownloading(leaseId(), notice().id, evidence.version)"
(click)="
endService.downloadEvidence(
leaseId(),
notice().id,
evidence.version,
evidence.fileName
)
"
>
{{
endService.evidenceDownloading(leaseId(), notice().id, evidence.version)
? 'Téléchargement…'
: 'Télécharger'
}}
</button>
@if (
endService.evidenceDownloadError(leaseId(), notice().id, evidence.version);
as downloadError
) {
<span class="operation-error" role="alert">{{ downloadError }}</span>
}
</li>
}
</ul>
} @else {
<p>Aucune preuve téléversée.</p>
}
</section>
@if (lifecycleMutationsEnabled() || evidenceUploadEnabled()) {
<div class="notice-actions">
@if (evidenceUploadEnabled()) {
<form
class="evidence-form"
[attr.aria-busy]="evidencePending()"
(submit)="submitEvidence(); $event.preventDefault()"
>
<fieldset [disabled]="evidencePending()">
<legend>Ajouter une version de preuve</legend>
<label>
<span>Fichier (tout format)</span>
<input type="file" (change)="selectEvidence($event)" />
</label>
@if (notice().evidenceVersions.length) {
<label>
<span>Raison de la nouvelle version</span>
<input
type="text"
maxlength="500"
[value]="evidenceReason()"
(input)="updateEvidenceReason($event)"
/>
</label>
}
@if (evidenceError() || evidenceOperationError()) {
<p class="operation-error" role="alert">
{{ evidenceError() || evidenceOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="evidencePending()">
{{ evidencePending() ? 'Ajout…' : 'Ajouter la preuve' }}
</button>
</fieldset>
</form>
}
@if (lifecycleMutationsEnabled() && notice().status === 'ACTIVE') {
<form
[formRoot]="withdrawalForm"
[attr.aria-busy]="withdrawalPending()"
(submit)="submitWithdrawal(); $event.preventDefault()"
>
<fieldset [disabled]="withdrawalPending()">
<legend>Accepter un retrait</legend>
<label
><span>Date d’acceptation</span
><input type="date" [formField]="withdrawalForm.acceptedOn"
/></label>
<label
><span>Raison</span><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
</label>
@if (withdrawalError() || withdrawalOperationError()) {
<p class="operation-error" role="alert">
{{ withdrawalError() || withdrawalOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
{{ withdrawalPending() ? 'Acceptation…' : 'Accepter le retrait' }}
</button>
</fieldset>
</form>
<form
[formRoot]="invalidationForm"
[attr.aria-busy]="invalidationPending()"
(submit)="submitInvalidation(); $event.preventDefault()"
>
<fieldset [disabled]="invalidationPending()">
<legend>Invalider administrativement</legend>
<label
><span>Date d’invalidation</span
><input type="date" [formField]="invalidationForm.invalidatedOn"
/></label>
<label
><span>Raison</span
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
</label>
@if (invalidationError() || invalidationOperationError()) {
<p class="operation-error" role="alert">
{{ invalidationError() || invalidationOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
{{ invalidationPending() ? 'Invalidation…' : 'Invalider ce congé' }}
</button>
</fieldset>
</form>
}
</div>
}
</article>
@@ -0,0 +1,111 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseEndNotice } from '../../services/lease-end.service';
import { LeaseEndService } from '../../services/lease-end.service';
import { LeaseEndNoticeCard } from './lease-end-notice-card';
describe('LeaseEndNoticeCard', () => {
const downloadEvidence = vi.fn();
const evidenceDownloading = vi.fn().mockReturnValue(false);
const evidenceDownloadError = vi.fn().mockReturnValue('');
const notice: LeaseEndNotice = {
id: 'notice-1',
status: 'ACTIVE',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'REGISTERED_LETTER',
otherDeliveryMode: null,
noticeMonths: 1,
minimumEndOn: '2026-08-19',
ruleVersion: '2026-07',
recordedWithoutEvidenceConfirmed: true,
authorId: 'owner-1',
recordedAt: '2026-07-19T10:00:00Z',
evidenceVersions: [
{
version: 1,
fileName: 'preuve.jpg',
contentType: 'image/jpeg',
size: 1200,
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
reason: null,
authorId: 'owner-1',
recordedAt: '2026-07-19T10:00:00Z',
},
],
withdrawal: null,
invalidation: null,
};
function createFixture() {
TestBed.configureTestingModule({
providers: [
{
provide: LeaseEndService,
useValue: { downloadEvidence, evidenceDownloading, evidenceDownloadError },
},
],
});
const fixture = TestBed.createComponent(LeaseEndNoticeCard);
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('notice', notice);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
fixture.componentRef.setInput('evidenceUploadEnabled', true);
fixture.componentRef.setInput('withdrawalPending', false);
fixture.componentRef.setInput('invalidationPending', false);
fixture.componentRef.setInput('evidencePending', false);
fixture.detectChanges();
return fixture;
}
it('shows the rule snapshot and asks a reason from evidence version two', () => {
const fixture = createFixture();
const requested = vi.fn();
fixture.componentInstance.evidenceRequested.subscribe(requested);
const evidence = new File(['new'], 'preuve-2.txt', { type: 'text/plain' });
fixture.componentInstance.evidence.set(evidence);
fixture.componentInstance.submitEvidence();
expect(fixture.componentInstance.evidenceError()).toContain('raison');
fixture.componentInstance.evidenceReason.set('Version plus lisible');
fixture.componentInstance.submitEvidence();
expect(requested).toHaveBeenCalledWith({
noticeId: 'notice-1',
request: { timeZone: 'Europe/Paris', evidence, reason: 'Version plus lisible' },
});
expect(fixture.nativeElement.textContent).toContain('Préavis retenu : 1 mois');
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07');
expect(fixture.nativeElement.textContent).toContain('enregistré sans justificatif');
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(500);
expect(
[...fixture.nativeElement.querySelectorAll('textarea')].map(
(textarea: HTMLTextAreaElement) => textarea.maxLength,
),
).toEqual([500, 500]);
});
it('emits dated withdrawal and evidence download intents', () => {
const fixture = createFixture();
const withdrawal = vi.fn();
fixture.componentInstance.withdrawalRequested.subscribe(withdrawal);
fixture.componentInstance.withdrawalModel.set({
acceptedOn: '2026-07-20',
reason: 'Retrait écrit accepté',
});
fixture.componentInstance.submitWithdrawal();
fixture.nativeElement.querySelector('.evidence-download').click();
expect(withdrawal).toHaveBeenCalledWith({
noticeId: 'notice-1',
request: {
timeZone: 'Europe/Paris',
acceptedOn: '2026-07-20',
reason: 'Retrait écrit accepté',
},
});
expect(downloadEvidence).toHaveBeenCalledWith('lease-1', 'notice-1', 1, 'preuve.jpg');
});
});
@@ -0,0 +1,140 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { formatFrenchCivilDate } from '../../services/civil-date';
import {
AcceptWithdrawalRequest,
AppendEvidenceRequest,
InvalidateNoticeRequest,
LeaseEndNotice,
LeaseEndService,
NoticeDeliveryMode,
} from '../../services/lease-end.service';
export interface NoticeAction<T> {
noticeId: string;
request: T;
}
@Component({
selector: 'app-lease-end-notice-card',
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
templateUrl: './lease-end-notice-card.html',
styleUrl: './lease-end-notice-card.css',
})
export class LeaseEndNoticeCard {
readonly endService = inject(LeaseEndService);
readonly leaseId = input.required<string>();
readonly notice = input.required<LeaseEndNotice>();
readonly timeZone = input.required<string>();
readonly lifecycleMutationsEnabled = input.required<boolean>();
readonly evidenceUploadEnabled = input.required<boolean>();
readonly withdrawalPending = input(false);
readonly invalidationPending = input(false);
readonly evidencePending = input(false);
readonly withdrawalOperationError = input('');
readonly invalidationOperationError = input('');
readonly evidenceOperationError = input('');
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
maxLength(fields.reason, 500);
});
readonly invalidationForm = form(this.invalidationModel, (fields) => {
maxLength(fields.reason, 500);
});
readonly evidence = signal<File | null>(null);
readonly evidenceReason = signal('');
readonly withdrawalError = signal('');
readonly invalidationError = signal('');
readonly evidenceError = signal('');
readonly civilDate = formatFrenchCivilDate;
statusLabel(): string {
return {
ACTIVE: 'Congé en cours',
WITHDRAWN: 'Retrait accepté',
INVALIDATED: 'Congé invalidé',
}[this.notice().status];
}
deliveryModeLabel(mode: NoticeDeliveryMode): string {
return {
REGISTERED_LETTER: 'Lettre recommandée avec avis de réception',
BAILIFF: 'Acte d’un commissaire de justice',
HAND_DELIVERY: 'Remise en main propre contre récépissé',
OTHER: 'Autre mode',
}[mode];
}
selectEvidence(event: Event): void {
const input = event.target;
this.evidence.set(input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null);
if (input instanceof HTMLInputElement) input.value = '';
}
updateEvidenceReason(event: Event): void {
const input = event.target;
this.evidenceReason.set(input instanceof HTMLInputElement ? input.value : '');
}
submitWithdrawal(): void {
if (this.withdrawalPending()) return;
const model = this.withdrawalModel();
if (!model.acceptedOn || !model.reason.trim()) {
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
return;
}
this.withdrawalError.set('');
this.withdrawalRequested.emit({
noticeId: this.notice().id,
request: {
timeZone: this.timeZone(),
acceptedOn: model.acceptedOn,
reason: model.reason.trim(),
},
});
}
submitInvalidation(): void {
if (this.invalidationPending()) return;
const model = this.invalidationModel();
if (!model.invalidatedOn || !model.reason.trim()) {
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
return;
}
this.invalidationError.set('');
this.invalidationRequested.emit({
noticeId: this.notice().id,
request: {
timeZone: this.timeZone(),
invalidatedOn: model.invalidatedOn,
reason: model.reason.trim(),
},
});
}
submitEvidence(): void {
if (this.evidencePending()) return;
const evidence = this.evidence();
const reason = this.evidenceReason().trim();
if (!evidence) {
this.evidenceError.set('Sélectionnez une preuve à conserver.');
return;
}
if (this.notice().evidenceVersions.length > 0 && !reason) {
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
return;
}
this.evidenceError.set('');
this.evidenceRequested.emit({
noticeId: this.notice().id,
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
});
}
}
@@ -0,0 +1,67 @@
:host {
display: block;
min-width: 0;
}
.notice-form {
padding: 1.25rem;
border: 1px solid var(--border, #dfe3e6);
border-radius: 1rem;
background: var(--surface, #fff);
}
h2 {
margin-block-start: 0;
}
.notice-form > p,
small {
color: var(--text-muted, #5f6368);
}
fieldset {
display: grid;
gap: 1rem;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: 1rem;
}
label:not(.confirmation-field) {
display: grid;
gap: 0.4rem;
min-width: 0;
}
input,
select {
min-width: 0;
min-height: 2.75rem;
box-sizing: border-box;
}
.confirmation-field {
display: flex;
align-items: flex-start;
gap: 0.65rem;
min-width: 0;
}
.confirmation-field input {
flex: 0 0 auto;
}
.missing-evidence-warning {
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--surface-soft, #f6f7f8);
}
.missing-evidence-warning p {
margin-block-start: 0;
color: var(--text-muted, #5f6368);
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent, #236b5d);
outline-offset: 0.15rem;
}
.operation-error {
color: var(--danger, #b42318);
}
@@ -0,0 +1,67 @@
<section class="notice-form" aria-labelledby="notice-form-title">
<h2 id="notice-form-title">Enregistrer un congé</h2>
<p>Les dates sont enregistrées comme des dates civiles dans le fuseau {{ timeZone() }}.</p>
<form
[formRoot]="noticeForm"
[attr.aria-busy]="submitting()"
(submit)="submit(); $event.preventDefault()"
>
<fieldset [disabled]="submitting()">
<div class="field-grid">
<label>
<span>Date de réception</span>
<input type="date" [formField]="noticeForm.receivedOn" />
</label>
<label>
<span>Départ demandé</span>
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
</label>
</div>
<label>
<span>Mode de remise</span>
<select [formField]="noticeForm.deliveryMode">
<option value="">Choisissez un mode</option>
@for (mode of deliveryModes; track mode.value) {
<option [value]="mode.value">{{ mode.label }}</option>
}
</select>
</label>
@if (model().deliveryMode === 'OTHER') {
<label>
<span>Mode de remise utilisé</span>
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
</label>
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
</label>
}
@if (lateRegistration()) {
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
<span>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span>
</label>
}
<label>
<span>Preuve (facultative, tout format)</span>
<input class="notice-evidence" type="file" (change)="selectEvidence($event)" />
<small>{{ evidence()?.name ?? 'Aucun fichier sélectionné' }}</small>
</label>
@if (!evidence()) {
<div class="missing-evidence-warning">
<p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p>
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.missingEvidenceConfirmed" />
<span>Je confirme enregistrer ce congé sans justificatif.</span>
</label>
</div>
}
@if (validationError() || operationError()) {
<p class="operation-error" role="alert">{{ validationError() || operationError() }}</p>
}
<button mat-flat-button type="submit" [disabled]="submitting()">
{{ submitting() ? 'Enregistrement…' : 'Enregistrer le congé' }}
</button>
</fieldset>
</form>
</section>
@@ -0,0 +1,119 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseEndNoticeForm } from './lease-end-notice-form';
describe('LeaseEndNoticeForm', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('requires explicit confirmations for a late registration and OTHER delivery', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('submitting', false);
fixture.detectChanges();
fixture.componentInstance.model.set({
receivedOn: '2026-07-18',
requestedDepartureOn: '2026-08-18',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: false,
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
fixture.componentInstance.model.update((model) => ({
...model,
lateRegistrationConfirmed: true,
}));
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('mode de remise non standard');
});
it('emits civil dates, timezone and arbitrary evidence without converting the day', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
fixture.detectChanges();
fixture.componentInstance.model.update((model) => ({
...model,
missingEvidenceConfirmed: true,
}));
const evidence = new File(['photo'], 'preuve.jpg', { type: 'image/jpeg' });
const input = fixture.nativeElement.querySelector('.notice-evidence');
Object.defineProperty(input, 'files', { value: { item: () => evidence } });
input.dispatchEvent(new Event('change'));
fixture.detectChanges();
expect(fixture.componentInstance.model().missingEvidenceConfirmed).toBe(false);
expect(fixture.nativeElement.querySelector('.missing-evidence-warning')).toBeNull();
fixture.componentInstance.model.set({
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: true,
missingEvidenceConfirmed: false,
});
fixture.componentInstance.submit();
expect(requested).toHaveBeenCalledWith({
timeZone: 'America/Los_Angeles',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: true,
missingEvidenceConfirmed: false,
evidence,
});
expect(input.getAttribute('accept')).toBeNull();
});
it('requires and sends an explicit attestation when no evidence is selected', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
fixture.detectChanges();
fixture.componentInstance.model.set({
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'HAND_DELIVERY',
otherDeliveryMode: '',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: false,
});
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
fixture.componentInstance.model.update((model) => ({
...model,
missingEvidenceConfirmed: true,
}));
fixture.componentInstance.submit();
expect(requested).toHaveBeenCalledWith({
timeZone: 'Europe/Paris',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'HAND_DELIVERY',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
});
});
});
@@ -0,0 +1,94 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
@Component({
selector: 'app-lease-end-notice-form',
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
templateUrl: './lease-end-notice-form.html',
styleUrl: './lease-end-notice-form.css',
})
export class LeaseEndNoticeForm {
readonly timeZone = input.required<string>();
readonly today = input.required<string>();
readonly submitting = input.required<boolean>();
readonly operationError = input('');
readonly noticeRequested = output<RecordNoticeRequest>();
readonly model = signal({
receivedOn: '',
requestedDepartureOn: '',
deliveryMode: '' as NoticeDeliveryMode | '',
otherDeliveryMode: '',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: false,
});
readonly noticeForm = form(this.model, (fields) => {
maxLength(fields.otherDeliveryMode, 200);
});
readonly evidence = signal<File | null>(null);
readonly validationError = signal('');
readonly lateRegistration = computed(
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
);
readonly deliveryModes = [
{ value: 'REGISTERED_LETTER', label: 'Lettre recommandée avec avis de réception' },
{ value: 'BAILIFF', label: 'Acte d’un commissaire de justice' },
{ value: 'HAND_DELIVERY', label: 'Remise en main propre contre récépissé' },
{ value: 'OTHER', label: 'Autre mode' },
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
selectEvidence(event: Event): void {
const input = event.target;
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
this.evidence.set(evidence);
if (evidence) {
this.model.update((model) => ({ ...model, missingEvidenceConfirmed: false }));
}
if (input instanceof HTMLInputElement) input.value = '';
}
submit(): void {
if (this.submitting()) return;
const model = this.model();
if (!model.receivedOn || !model.requestedDepartureOn || !model.deliveryMode) {
this.validationError.set('Renseignez les deux dates et le mode de remise du congé.');
return;
}
if (this.lateRegistration() && !model.lateRegistrationConfirmed) {
this.validationError.set('Confirmez cet enregistrement tardif avant de continuer.');
return;
}
if (model.deliveryMode === 'OTHER' && !model.otherDeliveryMode.trim()) {
this.validationError.set('Décrivez le mode de remise utilisé.');
return;
}
if (model.deliveryMode === 'OTHER' && !model.nonStandardDeliveryConfirmed) {
this.validationError.set('Confirmez le mode de remise non standard avant de continuer.');
return;
}
if (!this.evidence() && !model.missingEvidenceConfirmed) {
this.validationError.set(
'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.',
);
return;
}
this.validationError.set('');
const evidence = this.evidence();
this.noticeRequested.emit({
timeZone: this.timeZone(),
receivedOn: model.receivedOn,
requestedDepartureOn: model.requestedDepartureOn,
deliveryMode: model.deliveryMode,
...(model.deliveryMode === 'OTHER'
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
: {}),
lateRegistrationConfirmed: this.lateRegistration(),
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
missingEvidenceConfirmed: evidence === null,
...(evidence ? { evidence } : {}),
});
}
}
@@ -0,0 +1,96 @@
:host {
display: block;
min-width: 0;
}
.lease-end-page {
max-width: 62rem;
}
.lease-end-skeleton {
display: grid;
gap: 1rem;
}
.lease-end-skeleton span {
min-height: 5rem;
border-radius: 1rem;
}
.phase-summary,
.unsupported-state,
.timeline,
.correction-warning {
min-width: 0;
padding: 1.25rem;
border: 1px solid var(--border, #dfe3e6);
border-radius: 1rem;
background: var(--surface, #fff);
}
.phase-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.phase-summary h2,
.timeline h2,
.notice-history > h2,
.unsupported-state h2 {
margin-block-start: 0;
}
.student-summary {
border-color: var(--accent, #236b5d);
}
.student-final-month {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background: var(--surface-accent, #eaf5f1);
}
.student-final-month span {
color: var(--text-muted, #5f6368);
font-size: 0.75rem;
}
.student-final-month strong {
font-size: 1.1rem;
}
.unsupported-state,
.correction-warning,
.timeline,
.notice-history,
app-lease-end-notice-form,
app-lease-end-resolution-forms {
margin-block-start: 1rem;
}
.unsupported-state {
background: var(--surface-soft, #f6f7f8);
}
.correction-warning {
color: var(--danger, #b42318);
}
.timeline ol {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.timeline li {
display: grid;
gap: 0.25rem;
padding-inline-start: 0.75rem;
border-inline-start: 0.2rem solid var(--accent, #236b5d);
}
.timeline span {
color: var(--text-muted, #5f6368);
font-size: 0.75rem;
}
.timeline small {
color: var(--text-muted, #5f6368);
overflow-wrap: anywhere;
}
.notice-history {
display: grid;
gap: 1rem;
}
@@ -0,0 +1,150 @@
<section class="form-page lease-end-page">
<header class="page-heading">
<div>
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
<p class="eyebrow">Bail</p>
<h1>Fin du bail</h1>
<p>Suivez les faits datés jusqu’à la restitution effective des clés.</p>
</div>
</header>
@if (loading()) {
<div class="lease-end-skeleton" aria-label="Chargement de la fin du bail">
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
</div>
} @else if (loadError()) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ loadError() }}</p>
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
</article>
} @else if (state(); as endState) {
<article class="phase-summary" [class.student-summary]="endState.leaseType === 'STUDENT'">
<div>
<p class="eyebrow">Situation actuelle</p>
<h2>{{ phaseLabel(endState.phase) }}</h2>
</div>
@if (endState.leaseType === 'STUDENT' && endState.fixedEndOn) {
<p class="student-final-month">
<span>Dernier mois du bail étudiant</span>
<strong>{{ civilMonth(endState.fixedEndOn) }}</strong>
</p>
}
</article>
@if (!endState.supported) {
<article class="unsupported-state" role="status">
<h2>Gestion non prise en charge</h2>
<p>{{ endState.unsupportedReason ?? 'Ce type de bail ne peut pas être modifié ici.' }}</p>
<p>
Aucun nouveau fait de fin, retrait ou clôture n’est proposé. Les justificatifs des congés
historiques peuvent toujours être complétés.
</p>
</article>
}
@if (endState.correctionRequired) {
<article class="correction-warning" role="alert">
<p>
Une correction financière est requise : contrepasser le paiement concerné, puis
enregistrer le bon paiement.
</p>
<a mat-stroked-button routerLink="/rent-payments">Corriger les paiements</a>
</article>
}
<section class="timeline" aria-labelledby="timeline-title">
<h2 id="timeline-title">Chronologie</h2>
<ol>
@if (endState.fixedEndOn) {
<li>
<span>Terme fixe du bail étudiant</span
><strong>{{ civilDate(endState.fixedEndOn) }}</strong>
</li>
}
@for (notice of endState.notices; track notice.id) {
<li>
<span>Congé reçu</span><strong>{{ civilDate(notice.receivedOn) }}</strong>
</li>
<li>
<span>Départ demandé</span><strong>{{ civilDate(notice.requestedDepartureOn) }}</strong>
</li>
}
@for (agreement of endState.agreedEarlyEnds; track agreement.recordedAt) {
<li>
<span>Fin anticipée convenue</span>
<strong>{{ civilDate(agreement.endOn) }}</strong>
<small>
{{ agreement.reason }} · {{ agreement.authorId }} · {{ agreement.recordedAt }}
</small>
</li>
}
@if (endState.effectiveEndOn) {
<li>
<span>Fin effective retenue</span
><strong>{{ civilDate(endState.effectiveEndOn) }}</strong>
</li>
}
@if (endState.closure) {
<li>
<span>Clés restituées</span
><strong>{{ civilDate(endState.closure.keysReturnedOn) }}</strong>
</li>
}
</ol>
@if (endState.closure) {
<p>
Clôture confirmée par {{ endState.closure.authorId }} · {{ endState.closure.recordedAt }}
</p>
}
</section>
@if (endState.notices.length) {
<section class="notice-history" aria-labelledby="notice-history-title">
<h2 id="notice-history-title">Historique des congés</h2>
@for (notice of endState.notices; track notice.id) {
<app-lease-end-notice-card
[leaseId]="leaseId"
[notice]="notice"
[timeZone]="timeZone"
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
[evidenceUploadEnabled]="true"
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
[invalidationPending]="operationPending('invalidation:' + notice.id)"
[evidencePending]="operationPending('evidence:' + notice.id)"
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
[evidenceOperationError]="operationError('evidence:' + notice.id)"
(withdrawalRequested)="requestWithdrawal($event)"
(invalidationRequested)="requestInvalidation($event)"
(evidenceRequested)="appendEvidence($event)"
/>
}
</section>
}
@if (canRecordNotice()) {
<app-lease-end-notice-form
[timeZone]="timeZone"
[today]="today"
[submitting]="operationPending('notice')"
[operationError]="operationError('notice')"
(noticeRequested)="recordNotice($event)"
/>
}
@if (canRecordAgreement() || canClose()) {
<app-lease-end-resolution-forms
[timeZone]="timeZone"
[showAgreement]="canRecordAgreement()"
[showClosure]="canClose()"
[agreementPending]="operationPending('agreement')"
[closurePending]="operationPending('closure')"
[agreementOperationError]="operationError('agreement')"
[closureOperationError]="operationError('closure')"
(agreedEndRequested)="recordAgreement($event)"
(closureRequested)="closeLease($event)"
/>
}
}
</section>
@@ -0,0 +1,258 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
import {
LeaseEndService,
LeaseEndState,
RecordNoticeRequest,
} from '../../services/lease-end.service';
import { LeaseEndPage } from './lease-end-page';
describe('LeaseEndPage', () => {
const state: LeaseEndState = {
leaseId: 'lease-1',
supported: true,
unsupportedReason: null,
phase: 'NOTICE_PERIOD',
leaseType: 'STUDENT',
fixedEndOn: '2026-07-31',
effectiveEndOn: '2026-07-31',
correctionRequired: false,
agreedEarlyEnds: [],
agreedEarlyEnd: null,
closure: null,
notices: [
{
id: 'notice-1',
status: 'ACTIVE',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'REGISTERED_LETTER',
otherDeliveryMode: null,
noticeMonths: 1,
minimumEndOn: '2026-08-19',
ruleVersion: '2026-07',
recordedWithoutEvidenceConfirmed: true,
authorId: 'owner-1',
recordedAt: '2026-07-19T10:00:00Z',
evidenceVersions: [],
withdrawal: null,
invalidation: null,
},
],
};
const getState = vi.fn();
const recordNotice = vi.fn();
const acceptWithdrawal = vi.fn();
const invalidateNotice = vi.fn();
const appendEvidence = vi.fn();
const recordAgreedEarlyEnd = vi.fn();
const close = vi.fn();
const dialog = { open: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
getState.mockReturnValue(of(state));
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
},
{
provide: LeaseEndService,
useValue: {
getState,
recordNotice,
acceptWithdrawal,
invalidateNotice,
appendEvidence,
recordAgreedEarlyEnd,
close,
evidenceDownloading: vi.fn().mockReturnValue(false),
evidenceDownloadError: vi.fn().mockReturnValue(''),
downloadEvidence: vi.fn(),
},
},
{ provide: MatDialog, useValue: dialog },
],
});
});
it('shows a skeleton, then a retryable load error', () => {
const response = new Subject<LeaseEndState>();
getState.mockReturnValueOnce(response);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(getState).toHaveBeenCalledWith('lease-1', fixture.componentInstance.timeZone);
expect(fixture.nativeElement.querySelector('.lease-end-skeleton')).not.toBeNull();
response.error({ error: { message: 'Bail introuvable' } });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
getState.mockReturnValueOnce(of(state));
fixture.nativeElement.querySelector('.load-error button').click();
fixture.detectChanges();
expect(getState).toHaveBeenCalledTimes(2);
});
it('renders the authoritative phase, civil timeline and student final month', () => {
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('Préavis en cours');
expect(text).toContain('19 juillet 2026');
expect(text).toContain('Juillet 2026');
expect(text).toContain('enregistré sans justificatif initial');
expect(text).not.toContain('18 juillet 2026');
});
it('disables unsupported lifecycle mutations but keeps historical evidence append-only', () => {
getState.mockReturnValueOnce(
of({
...state,
supported: false,
unsupportedReason: 'La fin de bail en colocation doit être gérée locataire par locataire.',
}),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('doit être gérée locataire par locataire');
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
expect(fixture.nativeElement.querySelector('app-lease-end-resolution-forms')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
});
it('keeps archived evidence append-only while hiding lifecycle mutations', () => {
getState.mockReturnValueOnce(
of({
...state,
phase: 'ARCHIVED',
closure: {
keysReturnedOn: '2026-07-31',
authorId: 'owner-1',
recordedAt: '2026-07-31T18:00:00Z',
},
}),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
expect(fixture.nativeElement.textContent).not.toContain('Clôturer le bail');
});
it('links a financial correction to the existing payment reversal journey', () => {
getState.mockReturnValueOnce(of({ ...state, correctionRequired: true }));
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const correction: HTMLAnchorElement =
fixture.nativeElement.querySelector('.correction-warning a');
expect(fixture.nativeElement.textContent).toContain('contrepasser le paiement concerné');
expect(correction.getAttribute('href')).toBe('/rent-payments');
});
it('keeps an earlier agreement actionable before the effective end without offering a new notice', () => {
getState.mockReturnValueOnce(
of({
...state,
notices: [],
agreedEarlyEnds: [
{
endOn: '2026-08-10',
reason: 'Premier accord',
authorId: 'owner-1',
recordedAt: '2026-07-10T10:00:00Z',
},
{
endOn: '2026-07-31',
reason: 'Accord avancé',
authorId: 'owner-1',
recordedAt: '2026-07-12T10:00:00Z',
},
],
}),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Premier accord');
expect(fixture.nativeElement.textContent).toContain('Accord avancé');
expect(fixture.nativeElement.textContent).toContain('owner-1');
expect(fixture.nativeElement.textContent).toContain('Enregistrer l’accord');
});
it('keeps notice loading and failures local without publishing false success', () => {
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
const response = new Subject<LeaseEndState>();
recordNotice.mockReturnValue(response);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const request: RecordNoticeRequest = {
timeZone: 'Europe/Paris',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'HAND_DELIVERY',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
};
fixture.componentInstance.recordNotice(request);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form').textContent).toContain(
'Enregistrement…',
);
response.error({ error: { message: 'Congé refusé' } });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Congé refusé');
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
});
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
const request = {
timeZone: 'Europe/Paris',
acceptedOn: '2026-07-20',
reason: 'Retrait écrit accepté',
};
fixture.componentInstance.requestWithdrawal({ noticeId: 'notice-1', request });
expect(dialog.open).toHaveBeenCalledOnce();
expect(acceptWithdrawal).toHaveBeenCalledWith('lease-1', 'notice-1', request);
});
it('keeps a closure failure actionable', () => {
close.mockReturnValue(throwError(() => ({ error: { message: 'Clôture refusée' } })));
getState.mockReturnValueOnce(of({ ...state, phase: 'AWAITING_CLOSURE' }));
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
fixture.componentInstance.closeLease({
timeZone: 'Europe/Paris',
keysReturnedOn: '2026-08-19',
confirmed: true,
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Clôture refusée');
expect(fixture.componentInstance.state()?.phase).toBe('AWAITING_CLOSURE');
});
});
@@ -0,0 +1,239 @@
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { Observable, finalize } from 'rxjs';
import {
browserTimeZone,
formatFrenchCivilDate,
formatFrenchCivilMonth,
todayInTimeZone,
} from '../../services/civil-date';
import { httpErrorMessage } from '../../services/http-error-message';
import { withoutRecordKey } from '../../services/immutable-state';
import {
AgreedEarlyEndRequest,
AppendEvidenceRequest,
CloseLeaseRequest,
InvalidateNoticeRequest,
LeaseEndPhase,
LeaseEndService,
LeaseEndState,
RecordNoticeRequest,
AcceptWithdrawalRequest,
} from '../../services/lease-end.service';
import {
ConfirmationDialog,
ConfirmationDialogData,
} from '../confirmation-dialog/confirmation-dialog';
import { LeaseEndNoticeForm } from '../lease-end-notice-form/lease-end-notice-form';
import { LeaseEndNoticeCard, NoticeAction } from '../lease-end-notice-card/lease-end-notice-card';
import { LeaseEndResolutionForms } from '../lease-end-resolution-forms/lease-end-resolution-forms';
@Component({
selector: 'app-lease-end-page',
imports: [
MatButtonModule,
RouterLink,
LeaseEndNoticeForm,
LeaseEndNoticeCard,
LeaseEndResolutionForms,
],
templateUrl: './lease-end-page.html',
styleUrl: './lease-end-page.css',
})
export class LeaseEndPage {
private readonly route = inject(ActivatedRoute);
private readonly endService = inject(LeaseEndService);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
readonly timeZone = browserTimeZone;
readonly today = todayInTimeZone(this.timeZone);
readonly state = signal<LeaseEndState | null>(null);
readonly loading = signal(true);
readonly loadError = signal('');
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
readonly operationErrors = signal<Record<string, string>>({});
readonly lifecycleMutationsEnabled = computed(() => {
const state = this.state();
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
});
readonly canRecordNotice = computed(
() =>
this.lifecycleMutationsEnabled() &&
this.state()?.phase === 'ACTIVE' &&
!this.state()?.notices.some((notice) => notice.status === 'ACTIVE'),
);
readonly canRecordAgreement = computed(() => {
const state = this.state();
return (
this.lifecycleMutationsEnabled() &&
!!state &&
(state.phase === 'ACTIVE' || state.phase === 'NOTICE_PERIOD')
);
});
readonly canClose = computed(() => {
const state = this.state();
return (
this.lifecycleMutationsEnabled() &&
!!state &&
(state.phase === 'AWAITING_CLOSURE' || state.phase === 'UNAUTHORIZED_OCCUPANCY')
);
});
readonly civilDate = formatFrenchCivilDate;
readonly civilMonth = formatFrenchCivilMonth;
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.loadError.set('');
this.endService
.getState(this.leaseId, this.timeZone)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.loading.set(false)),
)
.subscribe({
next: (state) => this.state.set(state),
error: (error: unknown) =>
this.loadError.set(httpErrorMessage(error, 'Impossible de charger la fin de ce bail.')),
});
}
phaseLabel(phase: LeaseEndPhase): string {
return {
ACTIVE: 'Bail actif',
NOTICE_PERIOD: 'Préavis en cours',
AWAITING_CLOSURE: 'En attente de clôture',
UNAUTHORIZED_OCCUPANCY: 'Occupation sans droit ni titre',
ARCHIVED: 'Bail clôturé et archivé',
}[phase];
}
recordNotice(request: RecordNoticeRequest): void {
this.mutate(
'notice',
this.endService.recordNotice(this.leaseId, request),
'Impossible d’enregistrer ce congé.',
);
}
requestWithdrawal(action: NoticeAction<AcceptWithdrawalRequest>): void {
const key = `withdrawal:${action.noticeId}`;
this.confirm(
key,
{
title: 'Accepter le retrait de ce congé ?',
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
confirmLabel: 'Accepter le retrait',
},
() =>
this.mutate(
key,
this.endService.acceptWithdrawal(this.leaseId, action.noticeId, action.request),
'Impossible d’accepter ce retrait.',
),
);
}
requestInvalidation(action: NoticeAction<InvalidateNoticeRequest>): void {
const key = `invalidation:${action.noticeId}`;
this.confirm(
key,
{
title: 'Invalider administrativement ce congé ?',
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
confirmLabel: 'Invalider le congé',
},
() =>
this.mutate(
key,
this.endService.invalidateNotice(this.leaseId, action.noticeId, action.request),
'Impossible d’invalider ce congé.',
),
);
}
appendEvidence(action: NoticeAction<AppendEvidenceRequest>): void {
this.mutate(
`evidence:${action.noticeId}`,
this.endService.appendEvidence(this.leaseId, action.noticeId, action.request),
'Impossible d’ajouter cette preuve.',
);
}
recordAgreement(request: AgreedEarlyEndRequest): void {
this.mutate(
'agreement',
this.endService.recordAgreedEarlyEnd(this.leaseId, request),
'Impossible d’enregistrer cette fin anticipée.',
);
}
closeLease(request: CloseLeaseRequest): void {
this.mutate(
'closure',
this.endService.close(this.leaseId, request),
'Impossible de clôturer ce bail.',
);
}
operationPending(key: string): boolean {
return this.pendingOperations().has(key) || this.confirmingOperations().has(key);
}
operationError(key: string): string {
return this.operationErrors()[key] ?? '';
}
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
if (this.operationPending(key)) return;
this.confirmingOperations.update((keys) => new Set(keys).add(key));
this.dialog
.open(ConfirmationDialog, {
data,
role: 'alertdialog',
width: 'min(440px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
})
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((confirmed) => {
this.confirmingOperations.update((keys) => withoutKey(keys, key));
if (confirmed) action();
});
}
private mutate(key: string, request: Observable<LeaseEndState>, fallback: string): void {
if (this.pendingOperations().has(key)) return;
this.pendingOperations.update((keys) => new Set(keys).add(key));
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
request
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.pendingOperations.update((keys) => withoutKey(keys, key))),
)
.subscribe({
next: (state) => this.state.set(state),
error: (error: unknown) =>
this.operationErrors.update((errors) => ({
...errors,
[key]: httpErrorMessage(error, fallback),
})),
});
}
}
function withoutKey(keys: ReadonlySet<string>, key: string): ReadonlySet<string> {
const next = new Set(keys);
next.delete(key);
return next;
}
@@ -0,0 +1,56 @@
:host,
.resolution-card {
display: block;
min-width: 0;
}
.resolution-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
}
.resolution-card {
padding: 1.25rem;
border: 1px solid var(--border, #dfe3e6);
border-radius: 1rem;
background: var(--surface, #fff);
}
.closure-card {
border-color: var(--accent, #236b5d);
}
h2 {
margin-block-start: 0;
}
fieldset,
label:not(.confirmation-field) {
display: grid;
gap: 0.75rem;
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
input,
textarea {
min-width: 0;
min-height: 2.75rem;
box-sizing: border-box;
}
.confirmation-field {
display: flex;
align-items: flex-start;
gap: 0.65rem;
min-width: 0;
}
.confirmation-field input {
flex: 0 0 auto;
}
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent, #236b5d);
outline-offset: 0.15rem;
}
.operation-error {
color: var(--danger, #b42318);
}
@@ -0,0 +1,61 @@
<div class="resolution-grid">
@if (showAgreement()) {
<section class="resolution-card" aria-labelledby="agreement-title">
<h2 id="agreement-title">Fin anticipée convenue</h2>
<form
[formRoot]="agreementForm"
[attr.aria-busy]="agreementPending()"
(submit)="submitAgreement(); $event.preventDefault()"
>
<fieldset [disabled]="agreementPending()">
<label>
<span>Date de fin convenue</span>
<input type="date" [formField]="agreementForm.endOn" />
</label>
<label>
<span>Raison de l’accord</span>
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
</label>
@if (agreementError() || agreementOperationError()) {
<p class="operation-error" role="alert">
{{ agreementError() || agreementOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
{{ agreementPending() ? 'Enregistrement…' : 'Enregistrer l’accord' }}
</button>
</fieldset>
</form>
</section>
}
@if (showClosure()) {
<section class="resolution-card closure-card" aria-labelledby="closure-title">
<h2 id="closure-title">Clôturer le bail</h2>
<form
[formRoot]="closureForm"
[attr.aria-busy]="closurePending()"
(submit)="submitClosure(); $event.preventDefault()"
>
<fieldset [disabled]="closurePending()">
<label>
<span>Date réelle de restitution des clés</span>
<input type="date" [formField]="closureForm.keysReturnedOn" />
</label>
<label class="confirmation-field">
<input type="checkbox" [formField]="closureForm.confirmed" />
<span>Je confirme que les clés ont réellement été restituées à cette date.</span>
</label>
@if (closureError() || closureOperationError()) {
<p class="operation-error" role="alert">
{{ closureError() || closureOperationError() }}
</p>
}
<button mat-flat-button type="submit" [disabled]="closurePending()">
{{ closurePending() ? 'Clôture…' : 'Clôturer le bail' }}
</button>
</fieldset>
</form>
</section>
}
</div>
@@ -0,0 +1,53 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseEndResolutionForms } from './lease-end-resolution-forms';
describe('LeaseEndResolutionForms', () => {
function createFixture() {
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
fixture.componentRef.setInput('agreementPending', false);
fixture.componentRef.setInput('closurePending', false);
fixture.detectChanges();
return fixture;
}
it('records a reasoned agreed end, including a retroactive civil date', () => {
const fixture = createFixture();
const requested = vi.fn();
fixture.componentInstance.agreedEndRequested.subscribe(requested);
fixture.componentInstance.agreementModel.set({
endOn: '2026-07-18',
reason: 'Accord écrit des parties',
});
fixture.componentInstance.submitAgreement();
expect(requested).toHaveBeenCalledWith({
timeZone: 'America/Los_Angeles',
endOn: '2026-07-18',
reason: 'Accord écrit des parties',
});
expect(fixture.nativeElement.querySelector('textarea').maxLength).toBe(500);
});
it('only emits closure after explicit confirmation and keeps the civil day', () => {
const fixture = createFixture();
const requested = vi.fn();
fixture.componentInstance.closureRequested.subscribe(requested);
fixture.componentInstance.closureModel.set({
keysReturnedOn: '2026-07-19',
confirmed: false,
});
fixture.componentInstance.submitClosure();
expect(fixture.componentInstance.closureError()).toContain('Confirmez');
fixture.componentInstance.closureModel.update((model) => ({ ...model, confirmed: true }));
fixture.componentInstance.submitClosure();
expect(requested).toHaveBeenCalledWith({
timeZone: 'America/Los_Angeles',
keysReturnedOn: '2026-07-19',
confirmed: true,
});
});
});
@@ -0,0 +1,65 @@
import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service';
@Component({
selector: 'app-lease-end-resolution-forms',
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
templateUrl: './lease-end-resolution-forms.html',
styleUrl: './lease-end-resolution-forms.css',
})
export class LeaseEndResolutionForms {
readonly timeZone = input.required<string>();
readonly agreementPending = input(false);
readonly closurePending = input(false);
readonly agreementOperationError = input('');
readonly closureOperationError = input('');
readonly showAgreement = input(true);
readonly showClosure = input(true);
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
readonly closureRequested = output<CloseLeaseRequest>();
readonly agreementModel = signal({ endOn: '', reason: '' });
readonly closureModel = signal({ keysReturnedOn: '', confirmed: false });
readonly agreementForm = form(this.agreementModel, (fields) => {
maxLength(fields.reason, 500);
});
readonly closureForm = form(this.closureModel);
readonly agreementError = signal('');
readonly closureError = signal('');
submitAgreement(): void {
if (this.agreementPending()) return;
const model = this.agreementModel();
if (!model.endOn || !model.reason.trim()) {
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
return;
}
this.agreementError.set('');
this.agreedEndRequested.emit({
timeZone: this.timeZone(),
endOn: model.endOn,
reason: model.reason.trim(),
});
}
submitClosure(): void {
if (this.closurePending()) return;
const model = this.closureModel();
if (!model.keysReturnedOn) {
this.closureError.set('Renseignez la date réelle de restitution des clés.');
return;
}
if (!model.confirmed) {
this.closureError.set('Confirmez la restitution réelle des clés avant de clôturer.');
return;
}
this.closureError.set('');
this.closureRequested.emit({
timeZone: this.timeZone(),
keysReturnedOn: model.keysReturnedOn,
confirmed: true,
});
}
}
@@ -265,7 +265,8 @@ describe('LeaseForm', () => {
...lease.legalTerms,
rentRevision: {
enabled: true,
annualRevisionDate: '2026-09-01',
annualRevisionDay: 1,
annualRevisionMonth: 9,
referenceQuarter: 2,
referenceYear: 2026,
seriesId: '001515333',
@@ -284,6 +285,94 @@ describe('LeaseForm', () => {
expect(fixture.nativeElement.textContent).not.toContain('à confirmer lors de la finalisation');
});
it('submits a recurring IRL revision day and month without a year', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentInstance.leaseModel.set({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
leaseType: 'STANDARD',
startDate: '2026-09-01',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
legalTerms: {
...emptyLeaseLegalTerms(),
rentRevision: {
enabled: true,
annualRevisionDay: 1,
annualRevisionMonth: 1,
referenceQuarter: 1,
referenceYear: 2026,
seriesId: '001515333',
},
},
});
fixture.detectChanges();
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.componentInstance.save();
expect(fixture.nativeElement.querySelector('#annualRevisionDay')).not.toBeNull();
expect(fixture.nativeElement.querySelector('#annualRevisionMonth')).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Année de révision');
expect(submitted).toHaveBeenCalledWith(
expect.objectContaining({
legalTerms: expect.objectContaining({
rentRevision: expect.objectContaining({
annualRevisionDay: 1,
annualRevisionMonth: 1,
}),
}),
}),
);
});
it('rejects an impossible recurring IRL calendar date', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentInstance.leaseModel.set({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
leaseType: 'STANDARD',
startDate: '2026-09-01',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
legalTerms: {
...emptyLeaseLegalTerms(),
rentRevision: {
enabled: true,
annualRevisionDay: 31,
annualRevisionMonth: 2,
referenceQuarter: 1,
referenceYear: 2026,
seriesId: '001515333',
},
},
});
fixture.detectChanges();
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.componentInstance.save();
fixture.detectChanges();
expect(submitted).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('ne forment pas une date valide');
});
it('clears an unfinished rent supplement when the reference-rent scheme is disabled', async () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
@@ -102,13 +102,36 @@
}
<div class="form-grid three-columns">
<mat-form-field appearance="outline">
<mat-label>Date annuelle de révision</mat-label>
<mat-label>Jour de révision</mat-label>
<input
matInput
type="date"
[formField]="legalTermsForm().rentRevision.annualRevisionDate"
id="annualRevisionDay"
type="number"
step="1"
[formField]="legalTermsForm().rentRevision.annualRevisionDay"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Mois de révision</mat-label>
<mat-select
id="annualRevisionMonth"
[formField]="legalTermsForm().rentRevision.annualRevisionMonth"
>
<mat-option [value]="null">Non renseigné</mat-option>
<mat-option [value]="1">Janvier</mat-option>
<mat-option [value]="2">Février</mat-option>
<mat-option [value]="3">Mars</mat-option>
<mat-option [value]="4">Avril</mat-option>
<mat-option [value]="5">Mai</mat-option>
<mat-option [value]="6">Juin</mat-option>
<mat-option [value]="7">Juillet</mat-option>
<mat-option [value]="8">Août</mat-option>
<mat-option [value]="9">Septembre</mat-option>
<mat-option [value]="10">Octobre</mat-option>
<mat-option [value]="11">Novembre</mat-option>
<mat-option [value]="12">Décembre</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Trimestre IRL de référence</mat-label>
<mat-select [formField]="legalTermsForm().rentRevision.referenceQuarter">
@@ -85,8 +85,21 @@ export class LeaseLegalTermsEditor {
}
}
if (terms.rentRevision.enabled) {
if (!terms.rentRevision.annualRevisionDate) {
return 'Renseignez la date annuelle de révision du loyer.';
const day = terms.rentRevision.annualRevisionDay;
const month = terms.rentRevision.annualRevisionMonth;
if (day === null || month === null) {
return 'Renseignez le jour et le mois de révision annuelle du loyer.';
}
const lastDayOfMonth = new Date(Date.UTC(2000, month, 0)).getUTCDate();
if (
!Number.isInteger(day) ||
!Number.isInteger(month) ||
month < 1 ||
month > 12 ||
day < 1 ||
day > lastDayOfMonth
) {
return 'Le jour et le mois de révision annuelle ne forment pas une date valide.';
}
if (
terms.rentRevision.referenceQuarter === null ||
@@ -72,6 +72,7 @@
(dossierRequested)="workspace.downloadLeaseDossier($event)"
(editRequested)="openForm($event)"
(finalizationRequested)="openFinalization($event)"
(leaseEndRequested)="openLeaseEnd($event)"
(deleteRequested)="workspace.deleteLease($event)"
/>
}
@@ -81,6 +81,14 @@ describe('LeasesPage', () => {
expect(router.navigate).toHaveBeenCalledWith(['/leases', 'lease-1', 'finalize']);
});
it('navigates an active lease to its dedicated ending page', () => {
const fixture = TestBed.createComponent(LeasesPage);
fixture.componentInstance.openLeaseEnd('lease-1');
expect(router.navigate).toHaveBeenCalledWith(['/leases', 'lease-1', 'end']);
});
it('opens an existing draft for editing', () => {
const fixture = TestBed.createComponent(LeasesPage);
const draft = {
@@ -77,6 +77,10 @@ export class LeasesPage {
void this.router.navigate(['/leases', leaseId, 'finalize']);
}
openLeaseEnd(leaseId: string): void {
void this.router.navigate(['/leases', leaseId, 'end']);
}
retryLoad(): void {
if (!this.workspace.apartmentLoadState().loaded || this.workspace.apartmentLoadState().error)
this.workspace.loadApartments();
@@ -0,0 +1,32 @@
:host {
display: block;
min-width: 0;
}
.rent-due-correction {
min-width: 0;
margin-block-start: var(--space-4, 1rem);
padding: var(--space-4, 1rem);
border: 1px solid var(--warning, #9a6700);
border-radius: var(--radius-md, 0.8125rem);
background: var(--surface-soft, #f5f1e8);
}
.rent-due-correction > strong {
color: var(--warning, #9a6700);
}
.rent-due-correction p {
overflow-wrap: break-word;
}
.correction-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2, 0.5rem);
min-width: 0;
}
.correction-actions button {
min-height: 2.75rem;
}
@@ -0,0 +1,25 @@
<section class="rent-due-correction" role="alert">
<strong>Correction de règlement nécessaire</strong>
<p>
La fin du bail a rendu un ou plusieurs règlements suraffectés. Contrepassez chaque règlement
concerné, puis enregistrez le règlement avec le montant correct.
</p>
@if (!loadState().loaded) {
@if (loadState().error) {
<p>{{ loadState().error }}</p>
<button mat-stroked-button type="button" (click)="retryRequested.emit()">Réessayer</button>
} @else {
<p>Chargement du règlement concerné…</p>
}
} @else {
<div class="correction-actions">
@for (payment of matchingPayments(); track payment.id) {
<button mat-stroked-button type="button" (click)="reversalRequested.emit(payment)">
Contrepasser le règlement du {{ civilDate(payment.receivedAt) }}
</button>
} @empty {
<p>Le règlement confirmé concerné est introuvable ou a déjà été contrepassé.</p>
}
</div>
}
</section>
@@ -0,0 +1,33 @@
import { Component, computed, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { formatFrenchCivilDate } from '../../services/civil-date';
import { RentDue } from '../../services/lease.service';
import { RentPayment } from '../../services/rent-payment.service';
interface PaymentLoadState {
loaded: boolean;
loading: boolean;
error: string;
}
@Component({
selector: 'app-rent-due-correction',
imports: [MatButtonModule],
templateUrl: './rent-due-correction.html',
styleUrl: './rent-due-correction.css',
})
export class RentDueCorrection {
readonly rentDue = input.required<RentDue>();
readonly payments = input.required<RentPayment[]>();
readonly loadState = input.required<PaymentLoadState>();
readonly reversalRequested = output<RentPayment>();
readonly retryRequested = output<void>();
readonly civilDate = formatFrenchCivilDate;
readonly matchingPayments = computed(() =>
this.payments().filter(
(payment) =>
payment.status === 'CONFIRMED' &&
payment.allocations.some((allocation) => allocation.rentDueId === this.rentDue().id),
),
);
}
@@ -119,7 +119,12 @@ describe('rent payment dialogs', () => {
component.form.controls.reason.setValue('Erreur de saisie');
component.submit();
expect(payments.reverse).toHaveBeenCalledWith('payment-1', 3, 'Erreur de saisie');
expect(payments.reverse).toHaveBeenCalledWith(
'payment-1',
3,
'Erreur de saisie',
browserTimeZone,
);
expect(dialogRef.close).toHaveBeenCalledWith(result);
});
@@ -149,6 +154,7 @@ function due(overrides: Partial<RentDue> = {}): RentDue {
remainingAmount: 850,
settlement: 'UNPAID',
timing: 'OVERDUE',
correctionRequired: false,
version: 2,
...overrides,
};
@@ -7,7 +7,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { finalize } from 'rxjs';
import { httpErrorMessage } from '../../services/http-error-message';
import { formatEuro } from '../../services/rent-payment-format';
import { browserTimeZone, formatEuro } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
@@ -45,7 +45,12 @@ export class RentPaymentReversalDialog {
this.saving.set(true);
this.error.set('');
this.payments
.reverse(this.payment.id, this.payment.version, this.form.controls.reason.value)
.reverse(
this.payment.id,
this.payment.version,
this.form.controls.reason.value,
browserTimeZone,
)
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: (payment) => this.dialogRef.close(payment),
@@ -100,29 +100,39 @@
</div>
<div>
<dt>Échéance</dt>
<dd>{{ rentDue.dueDate | date: 'dd MMMM yyyy' }}</dd>
<dd>{{ civilDate(rentDue.dueDate) }}</dd>
</div>
</dl>
<div class="card-actions">
@if (rentDue.settlement !== 'PAID') {
<button mat-flat-button type="button" (click)="recordPayment(rentDue)">
Enregistrer un règlement
</button>
} @else {
<button
mat-stroked-button
type="button"
[disabled]="workspace.rentReceiptDownloading(rentDue.id)"
(click)="workspace.downloadRentReceipt(rentDue)"
>
@if (workspace.rentReceiptDownloading(rentDue.id)) {
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
}
Télécharger la quittance
</button>
}
</div>
@if (workspace.rentReceiptDownloadError(rentDue.id)) {
@if (rentDue.correctionRequired) {
<app-rent-due-correction
[rentDue]="rentDue"
[payments]="workspace.rentPayments()"
[loadState]="workspace.rentPaymentLoadState()"
(reversalRequested)="reversePayment($event)"
(retryRequested)="workspace.loadRentPayments()"
/>
} @else {
<div class="card-actions">
@if (rentDue.settlement !== 'PAID') {
<button mat-flat-button type="button" (click)="recordPayment(rentDue)">
Enregistrer un règlement
</button>
} @else {
<button
mat-stroked-button
type="button"
[disabled]="workspace.rentReceiptDownloading(rentDue.id)"
(click)="workspace.downloadRentReceipt(rentDue)"
>
@if (workspace.rentReceiptDownloading(rentDue.id)) {
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
}
Télécharger la quittance
</button>
}
</div>
}
@if (!rentDue.correctionRequired && workspace.rentReceiptDownloadError(rentDue.id)) {
<p class="card-error" role="alert">
{{ workspace.rentReceiptDownloadError(rentDue.id) }}
</p>
@@ -175,7 +185,7 @@
</span>
<strong>{{ formatEuro(payment.amount) }}</strong>
</div>
<p>Reçu le {{ payment.receivedAt | date: 'dd MMMM yyyy' }}</p>
<p>Reçu le {{ civilDate(payment.receivedAt) }}</p>
<dl>
<div>
<dt>Affecté</dt>
@@ -210,7 +220,7 @@
<ul>
@for (refund of payment.refunds; track refund.id) {
<li>
<span>{{ refund.refundedAt | date: 'dd MMMM yyyy' }}</span
<span>{{ civilDate(refund.refundedAt) }}</span
><strong>{{ formatEuro(refund.amount) }}</strong>
</li>
}
@@ -219,7 +229,7 @@
}
@if (payment.reversal; as reversal) {
<div class="reversal-details">
<strong>Contrepassé le {{ reversal.reversedAt | date: 'dd MMMM yyyy' }}</strong>
<strong>Contrepassé le {{ civilDate(reversal.reversedAt) }}</strong>
<span>{{ reversal.reason }}</span>
</div>
}
@@ -44,6 +44,70 @@ describe('RentPaymentsPage', () => {
expect(card.querySelector('.timing-status.overdue')?.textContent).toContain('En retard');
});
it('offers the existing reversal journey when a lease end overallocates a due', () => {
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
correctionRequired: true,
});
const allocatedPayment = payment();
const { fixture, dialog } = createFixture([overallocated], [allocatedPayment]);
dialog.open.mockReturnValueOnce({ afterClosed: () => of(undefined) });
const warning = fixture.nativeElement.querySelector('.rent-due-correction') as HTMLElement;
expect(warning.getAttribute('role')).toBe('alert');
expect(warning.textContent).toContain(
'La fin du bail a rendu un ou plusieurs règlements suraffectés',
);
expect(button(fixture, 'Contrepasser le règlement')).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Télécharger la quittance');
button(fixture, 'Contrepasser le règlement')?.click();
expect(dialog.open).toHaveBeenCalledWith(
RentPaymentReversalDialog,
expect.objectContaining({ data: allocatedPayment, role: 'alertdialog' }),
);
});
it('offers reversal for every confirmed partial payment allocated to the corrected due', () => {
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
allocatedAmount: 900,
remainingAmount: 0,
correctionRequired: true,
});
const first = payment({
id: 'payment-1',
amount: 500,
allocatedAmount: 500,
allocations: [
{ id: 'allocation-1', rentDueId: 'due-1', amount: 500, allocatedAt: '2026-07-06' },
],
});
const second = payment({
id: 'payment-2',
receivedAt: '2026-07-07',
amount: 400,
allocatedAmount: 400,
allocations: [
{ id: 'allocation-2', rentDueId: 'due-1', amount: 400, allocatedAt: '2026-07-07' },
],
});
const { fixture, dialog } = createFixture([overallocated], [first, second]);
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
const actions = [
...fixture.nativeElement.querySelectorAll('.rent-due-correction button'),
] as HTMLButtonElement[];
expect(actions).toHaveLength(2);
actions[1].click();
expect(dialog.open).toHaveBeenCalledWith(
RentPaymentReversalDialog,
expect.objectContaining({ data: second }),
);
});
it('shows overdue dues through the next upcoming due', () => {
const { fixture } = createFixture([
rentDue('overdue-january', '2026-01', 'OVERDUE'),
@@ -74,6 +138,47 @@ describe('RentPaymentsPage', () => {
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026', 'avril 2026', 'mai 2026']);
});
it('never hides an older due that still requires correction', () => {
const { fixture } = createFixture([
rentDue('corrected-january', '2026-01', 'OVERDUE', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
correctionRequired: true,
}),
rentDue('paid-february', '2026-02', 'OVERDUE', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
}),
rentDue('unpaid-march', '2026-03', 'OVERDUE'),
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
]);
expect(paymentMonths(fixture)).toEqual([
'janvier 2026',
'février 2026',
'mars 2026',
'avril 2026',
]);
});
it('extends the current schedule through every prepaid due that requires correction', () => {
const { fixture } = createFixture([
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
}),
rentDue('upcoming-february', '2026-02', 'UPCOMING'),
rentDue('corrected-march', '2026-03', 'UPCOMING', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
correctionRequired: true,
}),
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
]);
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026']);
});
it('reveals and hides the full chronological schedule', () => {
const { fixture } = createFixture([
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
@@ -241,6 +346,7 @@ function rentDue(
remainingAmount: 850,
settlement,
timing,
correctionRequired: false,
version: 0,
...overrides,
};
@@ -1,4 +1,3 @@
import { DatePipe } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -19,15 +18,12 @@ import { formatEuro } from '../../services/rent-payment-format';
import { PaymentAllocation, RentPayment } from '../../services/rent-payment.service';
import { WorkspaceService } from '../../services/workspace.service';
import { RentPaymentDialogResult, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
interface PaymentFeedback {
kind: 'success' | 'error';
message: string;
}
import { RentDueCorrection } from '../rent-due-correction/rent-due-correction';
import { formatFrenchCivilDate } from '../../services/civil-date';
@Component({
selector: 'app-rent-payments-page',
imports: [DatePipe, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule, RentDueCorrection],
templateUrl: './rent-payments-page.html',
styleUrl: './rent-payments-page.css',
})
@@ -35,8 +31,9 @@ export class RentPaymentsPage {
readonly workspace = inject(WorkspaceService);
private readonly dialog = inject(MatDialog);
readonly showFullSchedule = signal(false);
readonly feedback = signal<PaymentFeedback | null>(null);
readonly feedback = signal<{ kind: 'success' | 'error'; message: string } | null>(null);
readonly formatEuro = formatEuro;
readonly civilDate = formatFrenchCivilDate;
readonly displayedRentDues = computed(() =>
this.showFullSchedule()
? sortRentDues(this.workspace.rentDues())
@@ -227,14 +224,21 @@ function currentRentDues(rentDues: readonly RentDue[]): RentDue[] {
function currentSchedule(schedule: RentDue[]): RentDue[] {
const nextDueIndex = schedule.findIndex((rentDue) => rentDue.timing === 'UPCOMING');
const throughNextDue = nextDueIndex === -1 ? schedule : schedule.slice(0, nextDueIndex + 1);
const lastCorrectionIndex = schedule.reduce(
(lastCorrection, rentDue, index) => (rentDue.correctionRequired ? index : lastCorrection),
-1,
);
const visibleEndIndex =
nextDueIndex === -1 ? schedule.length - 1 : Math.max(nextDueIndex, lastCorrectionIndex);
const throughNextDue = schedule.slice(0, visibleEndIndex + 1);
const lastPaidIndex = throughNextDue.reduce(
(lastPaid, rentDue, index) => (rentDue.settlement === 'PAID' ? index : lastPaid),
-1,
);
const firstUnpaidIndex = throughNextDue.findIndex((rentDue) => rentDue.settlement !== 'PAID');
const firstCorrectionIndex = throughNextDue.findIndex((rentDue) => rentDue.correctionRequired);
const firstVisibleIndex = Math.min(
...[lastPaidIndex, firstUnpaidIndex].filter((index) => index !== -1),
...[lastPaidIndex, firstUnpaidIndex, firstCorrectionIndex].filter((index) => index !== -1),
);
return firstVisibleIndex === Infinity ? [] : throughNextDue.slice(firstVisibleIndex);
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { formatFrenchCivilDate, formatFrenchCivilMonth, todayInTimeZone } from './civil-date';
describe('civil dates', () => {
it('formats full French dates without shifting the stored day', () => {
expect(formatFrenchCivilDate('2026-07-19')).toBe('19 juillet 2026');
expect(formatFrenchCivilMonth('2026-07-31')).toBe('Juillet 2026');
});
it('derives today from the user timezone rather than the server day', () => {
const sameInstant = new Date('2026-07-19T00:30:00Z');
expect(todayInTimeZone('America/Los_Angeles', sameInstant)).toBe('2026-07-18');
expect(todayInTimeZone('Europe/Paris', sameInstant)).toBe('2026-07-19');
});
});
+41
View File
@@ -0,0 +1,41 @@
const frenchMonths = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
] as const;
export function formatFrenchCivilDate(value: string): string {
const [year, month, day] = value.split('-');
const monthName = frenchMonths[Number(month) - 1];
return monthName && year && day ? `${Number(day)} ${monthName} ${year}` : value;
}
export function formatFrenchCivilMonth(value: string): string {
const [year, month] = value.split('-');
const monthName = frenchMonths[Number(month) - 1];
if (!monthName || !year) return value;
return `${monthName.charAt(0).toUpperCase()}${monthName.slice(1)} ${year}`;
}
export { browserTimeZone } from './rent-payment-format';
export function todayInTimeZone(timeZone: string, now = new Date()): string {
const parts = new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone,
}).formatToParts(now);
const part = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((candidate) => candidate.type === type)?.value ?? '';
return `${part('year')}-${part('month')}-${part('day')}`;
}
@@ -0,0 +1,190 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { LeaseEndService } from './lease-end.service';
import { FileDownloadService } from './file-download.service';
describe('LeaseEndService', () => {
const http = {
get: vi.fn().mockReturnValue(of({})),
post: vi.fn().mockReturnValue(of({})),
};
const downloads = {
download: vi.fn(),
isPending: vi.fn().mockReturnValue(false),
error: vi.fn().mockReturnValue(''),
};
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [
LeaseEndService,
{ provide: HttpClient, useValue: http },
{ provide: FileDownloadService, useValue: downloads },
],
});
});
it('loads the canonical lease-end state', () => {
TestBed.inject(LeaseEndService).getState('lease-1', 'America/Los_Angeles').subscribe();
const options = http.get.mock.calls[0][1] as { params: HttpParams };
expect(http.get.mock.calls[0][0]).toBe('/api/leases/lease-1/end');
expect(options.params.toString()).toBe('timeZone=America/Los_Angeles');
});
it('records a notice as civil facts and keeps arbitrary evidence', () => {
const evidence = new File(['photo'], 'remise-des-cles.jpg', { type: 'image/jpeg' });
TestBed.inject(LeaseEndService)
.recordNotice('lease-1', {
timeZone: 'America/Los_Angeles',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: true,
nonStandardDeliveryConfirmed: true,
missingEvidenceConfirmed: false,
evidence,
})
.subscribe();
const formData = http.post.mock.calls[0][1] as FormData;
expect(http.post.mock.calls[0][0]).toBe('/api/leases/lease-1/end/notices');
expect(Object.fromEntries(formData.entries())).toEqual({
timeZone: 'America/Los_Angeles',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: 'true',
nonStandardDeliveryConfirmed: 'true',
missingEvidenceConfirmed: 'false',
evidence,
});
});
it('keeps the explicit confirmation when a notice has no evidence', () => {
TestBed.inject(LeaseEndService)
.recordNotice('lease-1', {
timeZone: 'Europe/Paris',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'HAND_DELIVERY',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
})
.subscribe();
const formData = http.post.mock.calls[0][1] as FormData;
expect(formData.get('missingEvidenceConfirmed')).toBe('true');
expect(formData.has('evidence')).toBe(false);
});
it('records withdrawal and administrative invalidation with their civil dates', () => {
const service = TestBed.inject(LeaseEndService);
service
.acceptWithdrawal('lease-1', 'notice-1', {
timeZone: 'Europe/Paris',
acceptedOn: '2026-07-19',
reason: 'Retrait écrit accepté',
})
.subscribe();
service
.invalidateNotice('lease-1', 'notice-1', {
timeZone: 'Europe/Paris',
invalidatedOn: '2026-07-20',
reason: 'Saisie administrative en double',
})
.subscribe();
expect(http.post).toHaveBeenNthCalledWith(
1,
'/api/leases/lease-1/end/notices/notice-1/withdrawals',
{
timeZone: 'Europe/Paris',
acceptedOn: '2026-07-19',
reason: 'Retrait écrit accepté',
},
);
expect(http.post).toHaveBeenNthCalledWith(
2,
'/api/leases/lease-1/end/notices/notice-1/invalidations',
{
timeZone: 'Europe/Paris',
invalidatedOn: '2026-07-20',
reason: 'Saisie administrative en double',
},
);
});
it('appends unrestricted evidence and an optional version reason', () => {
const evidence = new File(['message'], 'message.txt', { type: 'text/plain' });
TestBed.inject(LeaseEndService)
.appendEvidence('lease-1', 'notice-1', {
timeZone: 'Europe/Paris',
evidence,
reason: 'Version plus lisible',
})
.subscribe();
const formData = http.post.mock.calls[0][1] as FormData;
expect(http.post.mock.calls[0][0]).toBe('/api/leases/lease-1/end/notices/notice-1/evidence');
expect(Object.fromEntries(formData.entries())).toEqual({
timeZone: 'Europe/Paris',
evidence,
reason: 'Version plus lisible',
});
});
it('downloads one opaque evidence version without previewing it', () => {
const response = of(new Blob(['proof']));
http.get.mockReturnValueOnce(response);
TestBed.inject(LeaseEndService).downloadEvidence('lease-1', 'notice-1', 2, 'preuve.jpg');
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/end/notices/notice-1/evidence/2', {
responseType: 'blob',
});
expect(downloads.download).toHaveBeenCalledWith(
'lease-end-evidence:lease-1:notice-1:2',
response,
'preuve.jpg',
'Impossible de télécharger cette preuve.',
);
});
it('records an agreed early end and a confirmed closure', () => {
const service = TestBed.inject(LeaseEndService);
service
.recordAgreedEarlyEnd('lease-1', {
timeZone: 'Europe/London',
endOn: '2026-08-01',
reason: 'Accord écrit des parties',
})
.subscribe();
service
.close('lease-1', {
timeZone: 'Europe/London',
keysReturnedOn: '2026-08-01',
confirmed: true,
})
.subscribe();
expect(http.post).toHaveBeenNthCalledWith(1, '/api/leases/lease-1/end/agreed-early-end', {
timeZone: 'Europe/London',
endOn: '2026-08-01',
reason: 'Accord écrit des parties',
});
expect(http.post).toHaveBeenNthCalledWith(2, '/api/leases/lease-1/end/closure', {
timeZone: 'Europe/London',
keysReturnedOn: '2026-08-01',
confirmed: true,
});
});
});
@@ -0,0 +1,212 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { FileDownloadService } from './file-download.service';
export type LeaseEndPhase =
'ACTIVE' | 'NOTICE_PERIOD' | 'AWAITING_CLOSURE' | 'UNAUTHORIZED_OCCUPANCY' | 'ARCHIVED';
export type NoticeDeliveryMode = 'REGISTERED_LETTER' | 'BAILIFF' | 'HAND_DELIVERY' | 'OTHER';
export interface LeaseEndEvidence {
version: number;
fileName: string;
contentType: string;
size: number;
sha256: string;
reason: string | null;
authorId: string;
recordedAt: string;
}
export interface LeaseEndAudit {
authorId: string;
recordedAt: string;
}
export interface LeaseEndNotice {
id: string;
status: 'ACTIVE' | 'WITHDRAWN' | 'INVALIDATED';
receivedOn: string;
requestedDepartureOn: string;
deliveryMode: NoticeDeliveryMode;
otherDeliveryMode: string | null;
noticeMonths: number;
minimumEndOn: string;
ruleVersion: string;
recordedWithoutEvidenceConfirmed: boolean;
authorId: string;
recordedAt: string;
evidenceVersions: LeaseEndEvidence[];
withdrawal: (LeaseEndAudit & { acceptedOn: string; reason: string }) | null;
invalidation: (LeaseEndAudit & { invalidatedOn: string; reason: string }) | null;
}
export interface LeaseEndState {
leaseId: string;
supported: boolean;
unsupportedReason: string | null;
phase: LeaseEndPhase;
leaseType: 'STANDARD' | 'STUDENT';
fixedEndOn: string | null;
effectiveEndOn: string | null;
correctionRequired: boolean;
notices: LeaseEndNotice[];
agreedEarlyEnds: LeaseEndAgreement[];
agreedEarlyEnd: LeaseEndAgreement | null;
closure: {
keysReturnedOn: string;
authorId: string;
recordedAt: string;
} | null;
}
export interface LeaseEndAgreement {
endOn: string;
reason: string;
authorId: string;
recordedAt: string;
}
export interface RecordNoticeRequest {
timeZone: string;
receivedOn: string;
requestedDepartureOn: string;
deliveryMode: NoticeDeliveryMode;
otherDeliveryMode?: string;
lateRegistrationConfirmed: boolean;
nonStandardDeliveryConfirmed: boolean;
missingEvidenceConfirmed: boolean;
evidence?: File;
}
export interface AcceptWithdrawalRequest {
timeZone: string;
acceptedOn: string;
reason: string;
}
export interface InvalidateNoticeRequest {
timeZone: string;
invalidatedOn: string;
reason: string;
}
export interface AppendEvidenceRequest {
timeZone: string;
evidence: File;
reason?: string;
}
export interface AgreedEarlyEndRequest {
timeZone: string;
endOn: string;
reason: string;
}
export interface CloseLeaseRequest {
timeZone: string;
keysReturnedOn: string;
confirmed: true;
}
@Injectable({ providedIn: 'root' })
export class LeaseEndService {
private readonly http = inject(HttpClient);
private readonly downloads = inject(FileDownloadService);
getState(leaseId: string, timeZone: string): Observable<LeaseEndState> {
const params = new HttpParams().set('timeZone', timeZone);
return this.http.get<LeaseEndState>(`/api/leases/${leaseId}/end`, { params });
}
recordNotice(leaseId: string, request: RecordNoticeRequest): Observable<LeaseEndState> {
return this.http.post<LeaseEndState>(
`/api/leases/${leaseId}/end/notices`,
noticeFormData(request),
);
}
acceptWithdrawal(
leaseId: string,
noticeId: string,
request: AcceptWithdrawalRequest,
): Observable<LeaseEndState> {
return this.http.post<LeaseEndState>(
`/api/leases/${leaseId}/end/notices/${noticeId}/withdrawals`,
request,
);
}
invalidateNotice(
leaseId: string,
noticeId: string,
request: InvalidateNoticeRequest,
): Observable<LeaseEndState> {
return this.http.post<LeaseEndState>(
`/api/leases/${leaseId}/end/notices/${noticeId}/invalidations`,
request,
);
}
appendEvidence(
leaseId: string,
noticeId: string,
request: AppendEvidenceRequest,
): Observable<LeaseEndState> {
const formData = new FormData();
formData.append('timeZone', request.timeZone);
formData.append('evidence', request.evidence);
if (request.reason) formData.append('reason', request.reason);
return this.http.post<LeaseEndState>(
`/api/leases/${leaseId}/end/notices/${noticeId}/evidence`,
formData,
);
}
downloadEvidence(leaseId: string, noticeId: string, version: number, fileName: string): void {
const key = this.evidenceDownloadKey(leaseId, noticeId, version);
this.downloads.download(
key,
this.http.get(`/api/leases/${leaseId}/end/notices/${noticeId}/evidence/${version}`, {
responseType: 'blob',
}),
fileName,
'Impossible de télécharger cette preuve.',
);
}
evidenceDownloading(leaseId: string, noticeId: string, version: number): boolean {
return this.downloads.isPending(this.evidenceDownloadKey(leaseId, noticeId, version));
}
evidenceDownloadError(leaseId: string, noticeId: string, version: number): string {
return this.downloads.error(this.evidenceDownloadKey(leaseId, noticeId, version));
}
recordAgreedEarlyEnd(leaseId: string, request: AgreedEarlyEndRequest): Observable<LeaseEndState> {
return this.http.post<LeaseEndState>(`/api/leases/${leaseId}/end/agreed-early-end`, request);
}
close(leaseId: string, request: CloseLeaseRequest): Observable<LeaseEndState> {
return this.http.post<LeaseEndState>(`/api/leases/${leaseId}/end/closure`, request);
}
private evidenceDownloadKey(leaseId: string, noticeId: string, version: number): string {
return `lease-end-evidence:${leaseId}:${noticeId}:${version}`;
}
}
function noticeFormData(request: RecordNoticeRequest): FormData {
const formData = new FormData();
formData.append('timeZone', request.timeZone);
formData.append('receivedOn', request.receivedOn);
formData.append('requestedDepartureOn', request.requestedDepartureOn);
formData.append('deliveryMode', request.deliveryMode);
if (request.otherDeliveryMode) formData.append('otherDeliveryMode', request.otherDeliveryMode);
formData.append('lateRegistrationConfirmed', String(request.lateRegistrationConfirmed));
formData.append('nonStandardDeliveryConfirmed', String(request.nonStandardDeliveryConfirmed));
formData.append('missingEvidenceConfirmed', String(request.missingEvidenceConfirmed));
if (request.evidence) formData.append('evidence', request.evidence);
return formData;
}
@@ -36,12 +36,12 @@ export function normalizeLeaseLegalTerms(
rentRevision: rentRevisionEnabled
? {
...terms.rentRevision,
annualRevisionDate: terms.rentRevision.annualRevisionDate || null,
seriesId: terms.rentRevision.seriesId.trim(),
}
: {
enabled: false,
annualRevisionDate: null,
annualRevisionDay: null,
annualRevisionMonth: null,
referenceQuarter: null,
referenceYear: null,
seriesId: '',
+5 -2
View File
@@ -52,7 +52,8 @@ export interface LeaseLegalTerms {
};
rentRevision: {
enabled: boolean;
annualRevisionDate: string | null;
annualRevisionDay: number | null;
annualRevisionMonth: number | null;
referenceQuarter: number | null;
referenceYear: number | null;
seriesId: string;
@@ -87,7 +88,8 @@ export function emptyLeaseLegalTerms(): LeaseLegalTerms {
},
rentRevision: {
enabled: false,
annualRevisionDate: null,
annualRevisionDay: null,
annualRevisionMonth: null,
referenceQuarter: null,
referenceYear: null,
seriesId: '',
@@ -144,6 +146,7 @@ export interface RentDue {
remainingAmount: number;
settlement: 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
timing: 'UPCOMING' | 'OVERDUE';
correctionRequired: boolean;
version: number;
}
@@ -40,7 +40,7 @@ describe('RentPaymentService', () => {
service.record(record).subscribe();
service.allocate('payment-1', allocation).subscribe();
service.refund('payment-1', refund).subscribe();
service.reverse('payment-1', 3, 'Erreur de saisie').subscribe();
service.reverse('payment-1', 3, 'Erreur de saisie', 'America/Los_Angeles').subscribe();
service.downloadReceipt('payment-1').subscribe();
expect(http.get).toHaveBeenCalledWith('/api/rent-payments');
@@ -50,6 +50,7 @@ describe('RentPaymentService', () => {
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/reversal', {
paymentVersion: 3,
reason: 'Erreur de saisie',
timeZone: 'America/Los_Angeles',
});
expect(http.get).toHaveBeenCalledWith('/api/rent-payments/payment-1/receipt', {
responseType: 'blob',
@@ -86,10 +86,16 @@ export class RentPaymentService {
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/refunds`, request);
}
reverse(paymentId: string, paymentVersion: number, reason: string): Observable<RentPayment> {
reverse(
paymentId: string,
paymentVersion: number,
reason: string,
timeZone: string,
): Observable<RentPayment> {
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/reversal`, {
paymentVersion,
reason,
timeZone,
});
}
@@ -74,6 +74,7 @@ describe('WorkspaceService', () => {
remainingAmount: 850,
settlement: 'UNPAID' as const,
timing: 'UPCOMING' as const,
correctionRequired: false,
version: 0,
};
const leaseRevision = {