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:
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);
|
||||
|
||||
Reference in new issue
Block a user