From 990792c8c2d9ef858314395311c3fc30eee7c425 Mon Sep 17 00:00:00 2001 From: Nessar Date: Fri, 17 Jul 2026 13:21:34 +0200 Subject: [PATCH] feat(#21): show current rent schedule --- .../rent-payments-page/rent-payments-page.css | 4 + .../rent-payments-page.html | 17 ++- .../rent-payments-page.spec.ts | 120 +++++++++++++++++- .../rent-payments-page/rent-payments-page.ts | 14 +- .../src/app/services/rent-payment-schedule.ts | 29 +++++ 5 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/services/rent-payment-schedule.ts diff --git a/frontend/src/app/components/rent-payments-page/rent-payments-page.css b/frontend/src/app/components/rent-payments-page/rent-payments-page.css index e6c5cce..3dad636 100644 --- a/frontend/src/app/components/rent-payments-page/rent-payments-page.css +++ b/frontend/src/app/components/rent-payments-page/rent-payments-page.css @@ -3,6 +3,10 @@ gap: var(--space-4, 1rem); } +.schedule-actions { + margin-bottom: var(--space-4, 1rem); +} + .rent-payment { padding: var(--space-5, 1.25rem); border: 1px solid var(--border, #dce5df); diff --git a/frontend/src/app/components/rent-payments-page/rent-payments-page.html b/frontend/src/app/components/rent-payments-page/rent-payments-page.html index f8ff2ad..45d4bce 100644 --- a/frontend/src/app/components/rent-payments-page/rent-payments-page.html +++ b/frontend/src/app/components/rent-payments-page/rent-payments-page.html @@ -41,8 +41,19 @@

Les échéances sont créées automatiquement pour les baux actifs.

} @else { -
- @for (payment of workspace.rentPayments(); track payment.id) { +
+ +
+
+ @for (payment of displayedPayments(); track payment.id) {
@@ -98,4 +109,4 @@ } }
- \ No newline at end of file + diff --git a/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts b/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts index 1d5d862..f176171 100644 --- a/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts +++ b/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts @@ -51,8 +51,126 @@ describe('RentPaymentsPage', () => { expect(workspace.loadRentPayments).toHaveBeenCalled(); expect(fixture.nativeElement.textContent).toContain('T2'); - fixture.nativeElement.querySelector('button').click(); + fixture.nativeElement.querySelector('.card-actions button').click(); expect(dialog.open).toHaveBeenCalled(); expect(workspace.markRentPaymentPaid).toHaveBeenCalledWith('payment-1', 800, 50); }); + + it('shows the current schedule from an older unpaid rent through the next rent due', () => { + const workspace = createWorkspace([ + payment('paid-january', '2026-01', 'PAID'), + payment('late-february', '2026-02', 'LATE'), + payment('paid-march', '2026-03', 'PAID'), + payment('late-april', '2026-04', 'LATE'), + payment('upcoming-may', '2026-05', 'UPCOMING'), + payment('upcoming-june', '2026-06', 'UPCOMING'), + ]); + const fixture = createFixture(workspace); + + expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026', 'avril 2026', 'mai 2026']); + }); + + it('starts the current schedule at the first unpaid rent when none has been paid', () => { + const workspace = createWorkspace([ + payment('late-january', '2026-01', 'LATE'), + payment('late-february', '2026-02', 'LATE'), + payment('upcoming-march', '2026-03', 'UPCOMING'), + payment('upcoming-april', '2026-04', 'UPCOMING'), + ]); + const fixture = createFixture(workspace); + + expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']); + }); + + it('reveals and hides the full chronological schedule', () => { + const workspace = createWorkspace([ + payment('upcoming-april', '2026-04', 'UPCOMING'), + payment('paid-january', '2026-01', 'PAID'), + payment('upcoming-march', '2026-03', 'UPCOMING'), + payment('late-february', '2026-02', 'LATE'), + ]); + const fixture = createFixture(workspace); + + const toggle = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement; + expect(toggle.textContent).toContain('Afficher toutes les échéances'); + expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']); + + toggle.click(); + fixture.detectChanges(); + + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026', 'avril 2026']); + + toggle.click(); + fixture.detectChanges(); + + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']); + }); + + it('shows loading and error states before the schedule is ready', () => { + const loadingFixture = createFixture( + createWorkspace([], { loaded: false, loading: true, error: '' }), + ); + expect(loadingFixture.nativeElement.querySelector('.skeleton-card')).not.toBeNull(); + + const errorFixture = createFixture( + createWorkspace([], { loaded: false, loading: false, error: 'Erreur de chargement.' }), + ); + expect(errorFixture.nativeElement.textContent).toContain('Erreur de chargement.'); + }); }); + +function createFixture(workspace: ReturnType) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: WorkspaceService, useValue: workspace }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + ], + }); + const fixture = TestBed.createComponent(RentPaymentsPage); + fixture.detectChanges(); + return fixture; +} + +function createWorkspace( + rentPayments: ReturnType[], + loadState = { loaded: true, loading: false, error: '' }, +) { + return { + apartments: signal([{ id: 'apartment-1', name: 'T2' }]), + leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]), + rentPayments: signal(rentPayments), + apartmentLoadState: signal(loadState), + leaseLoadState: signal(loadState), + rentPaymentLoadState: signal(loadState), + loadApartments: vi.fn(), + loadLeases: vi.fn(), + loadRentPayments: vi.fn(), + markRentPaymentPaid: vi.fn(), + rentPaymentSaving: vi.fn(() => false), + rentPaymentSaveError: vi.fn(() => ''), + downloadRentReceipt: vi.fn(), + rentReceiptDownloading: vi.fn(() => false), + rentReceiptDownloadError: vi.fn(() => ''), + }; +} + +function payment(id: string, month: string, status: 'PAID' | 'LATE' | 'UPCOMING') { + return { + id, + leaseId: 'lease-1', + month, + dueDate: `${month}-05`, + rent: 800, + charges: 50, + total: 850, + status, + paidAt: status === 'PAID' ? `${month}-05` : null, + }; +} + +function paymentMonths(fixture: ReturnType): string[] { + return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) => element.textContent.trim()); +} diff --git a/frontend/src/app/components/rent-payments-page/rent-payments-page.ts b/frontend/src/app/components/rent-payments-page/rent-payments-page.ts index 8310216..cce9968 100644 --- a/frontend/src/app/components/rent-payments-page/rent-payments-page.ts +++ b/frontend/src/app/components/rent-payments-page/rent-payments-page.ts @@ -1,8 +1,9 @@ -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { RentPayment, RentPaymentStatus } from '../../services/rent-payment.service'; +import { currentRentSchedule, sortRentPayments } from '../../services/rent-payment-schedule'; import { WorkspaceService } from '../../services/workspace.service'; import { MatDialog } from '@angular/material/dialog'; import { RentPaymentValidationDialog } from '../rent-payment-validation-dialog/rent-payment-validation-dialog'; @@ -17,6 +18,12 @@ import { DatePipe } from '@angular/common'; export class RentPaymentsPage { readonly workspace = inject(WorkspaceService); private readonly dialog = inject(MatDialog); + readonly showFullSchedule = signal(false); + readonly displayedPayments = computed(() => + this.showFullSchedule() + ? sortRentPayments(this.workspace.rentPayments()) + : currentRentSchedule(this.workspace.rentPayments()), + ); readonly contentReady = computed( () => this.workspace.rentPaymentLoadState().loaded && @@ -61,6 +68,10 @@ export class RentPaymentsPage { this.workspace.loadApartments(); } + toggleSchedule(): void { + this.showFullSchedule.update((showFullSchedule) => !showFullSchedule); + } + markPaid(payment: RentPayment): void { this.dialog .open(RentPaymentValidationDialog, { @@ -74,4 +85,5 @@ export class RentPaymentsPage { } }); } + } diff --git a/frontend/src/app/services/rent-payment-schedule.ts b/frontend/src/app/services/rent-payment-schedule.ts new file mode 100644 index 0000000..52a5e3f --- /dev/null +++ b/frontend/src/app/services/rent-payment-schedule.ts @@ -0,0 +1,29 @@ +import { RentPayment } from './rent-payment.service'; + +export function sortRentPayments(payments: readonly RentPayment[]): RentPayment[] { + return [...payments].sort((left, right) => left.dueDate.localeCompare(right.dueDate)); +} + +export function currentRentSchedule(payments: readonly RentPayment[]): RentPayment[] { + const paymentsByLease = new Map(); + for (const payment of sortRentPayments(payments)) { + const schedule = paymentsByLease.get(payment.leaseId); + if (schedule) schedule.push(payment); + else paymentsByLease.set(payment.leaseId, [payment]); + } + + return sortRentPayments([...paymentsByLease.values()].flatMap(currentSchedule)); +} + +function currentSchedule(schedule: RentPayment[]): RentPayment[] { + const nextDueIndex = schedule.findIndex((payment) => payment.status === 'UPCOMING'); + const paymentsThroughNextDue = nextDueIndex === -1 ? schedule : schedule.slice(0, nextDueIndex + 1); + const lastPaidIndex = paymentsThroughNextDue.reduce( + (lastPaid, payment, index) => (payment.status === 'PAID' ? index : lastPaid), + -1, + ); + const firstUnpaidIndex = paymentsThroughNextDue.findIndex((payment) => payment.status !== 'PAID'); + const firstVisibleIndex = Math.min(...[lastPaidIndex, firstUnpaidIndex].filter((index) => index !== -1)); + + return firstVisibleIndex === Infinity ? [] : paymentsThroughNextDue.slice(firstVisibleIndex); +}