feat(#21): show current rent schedule
This commit is contained in:
1 parent
9cfb55d79c
commit
990792c8c2
5 files changed
+178
-4
No files matched your search
@@ -3,6 +3,10 @@
|
|||||||
gap: var(--space-4, 1rem);
|
gap: var(--space-4, 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-actions {
|
||||||
|
margin-bottom: var(--space-4, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
.rent-payment {
|
.rent-payment {
|
||||||
padding: var(--space-5, 1.25rem);
|
padding: var(--space-5, 1.25rem);
|
||||||
border: 1px solid var(--border, #dce5df);
|
border: 1px solid var(--border, #dce5df);
|
||||||
|
|||||||
@@ -41,8 +41,19 @@
|
|||||||
<p>Les échéances sont créées automatiquement pour les baux actifs.</p>
|
<p>Les échéances sont créées automatiquement pour les baux actifs.</p>
|
||||||
</article>
|
</article>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="rent-payment-grid">
|
<div class="schedule-actions">
|
||||||
@for (payment of workspace.rentPayments(); track payment.id) {
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
aria-controls="rent-payment-schedule"
|
||||||
|
[attr.aria-pressed]="showFullSchedule()"
|
||||||
|
(click)="toggleSchedule()"
|
||||||
|
>
|
||||||
|
{{ showFullSchedule() ? 'Afficher l’échéancier courant' : 'Afficher toutes les échéances' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="rent-payment-schedule" class="rent-payment-grid">
|
||||||
|
@for (payment of displayedPayments(); track payment.id) {
|
||||||
<article class="rent-payment" [attr.aria-busy]="workspace.rentPaymentSaving(payment.id)">
|
<article class="rent-payment" [attr.aria-busy]="workspace.rentPaymentSaving(payment.id)">
|
||||||
<div class="payment-topline">
|
<div class="payment-topline">
|
||||||
<span class="payment-status" [class]="payment.status.toLowerCase()">
|
<span class="payment-status" [class]="payment.status.toLowerCase()">
|
||||||
|
|||||||
@@ -51,8 +51,126 @@ describe('RentPaymentsPage', () => {
|
|||||||
|
|
||||||
expect(workspace.loadRentPayments).toHaveBeenCalled();
|
expect(workspace.loadRentPayments).toHaveBeenCalled();
|
||||||
expect(fixture.nativeElement.textContent).toContain('T2');
|
expect(fixture.nativeElement.textContent).toContain('T2');
|
||||||
fixture.nativeElement.querySelector('button').click();
|
fixture.nativeElement.querySelector('.card-actions button').click();
|
||||||
expect(dialog.open).toHaveBeenCalled();
|
expect(dialog.open).toHaveBeenCalled();
|
||||||
expect(workspace.markRentPaymentPaid).toHaveBeenCalledWith('payment-1', 800, 50);
|
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<typeof createWorkspace>) {
|
||||||
|
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<typeof payment>[],
|
||||||
|
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<typeof createFixture>): string[] {
|
||||||
|
return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) => element.textContent.trim());
|
||||||
|
}
|
||||||
@@ -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 { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { RentPayment, RentPaymentStatus } from '../../services/rent-payment.service';
|
import { RentPayment, RentPaymentStatus } from '../../services/rent-payment.service';
|
||||||
|
import { currentRentSchedule, sortRentPayments } from '../../services/rent-payment-schedule';
|
||||||
import { WorkspaceService } from '../../services/workspace.service';
|
import { WorkspaceService } from '../../services/workspace.service';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { RentPaymentValidationDialog } from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
|
import { RentPaymentValidationDialog } from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
|
||||||
@@ -17,6 +18,12 @@ import { DatePipe } from '@angular/common';
|
|||||||
export class RentPaymentsPage {
|
export class RentPaymentsPage {
|
||||||
readonly workspace = inject(WorkspaceService);
|
readonly workspace = inject(WorkspaceService);
|
||||||
private readonly dialog = inject(MatDialog);
|
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(
|
readonly contentReady = computed(
|
||||||
() =>
|
() =>
|
||||||
this.workspace.rentPaymentLoadState().loaded &&
|
this.workspace.rentPaymentLoadState().loaded &&
|
||||||
@@ -61,6 +68,10 @@ export class RentPaymentsPage {
|
|||||||
this.workspace.loadApartments();
|
this.workspace.loadApartments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSchedule(): void {
|
||||||
|
this.showFullSchedule.update((showFullSchedule) => !showFullSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
markPaid(payment: RentPayment): void {
|
markPaid(payment: RentPayment): void {
|
||||||
this.dialog
|
this.dialog
|
||||||
.open(RentPaymentValidationDialog, {
|
.open(RentPaymentValidationDialog, {
|
||||||
@@ -74,4 +85,5 @@ export class RentPaymentsPage {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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<string, RentPayment[]>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user