feat(#21): show current rent schedule

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 9cfb55d79c
commit 990792c8c2
5 files changed
+179 -5

No files matched your search

@@ -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);
@@ -41,8 +41,19 @@
<p>Les échéances sont créées automatiquement pour les baux actifs.</p>
</article>
} @else {
<div class="rent-payment-grid">
@for (payment of workspace.rentPayments(); track payment.id) {
<div class="schedule-actions">
<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)">
<div class="payment-topline">
<span class="payment-status" [class]="payment.status.toLowerCase()">
@@ -98,4 +109,4 @@
}
}
</div>
</section>
</section>
@@ -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<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 { 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 {
}
});
}
}