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

@@ -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);