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 3dad636..851e433 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 @@ -49,19 +49,40 @@ font-weight: 700; } -.payment-status { +.rent-due-labels { + display: flex; + flex-wrap: wrap; + gap: var(--space-2, 0.5rem); +} + +.settlement-status, +.timing-status { font-size: 0.8rem; font-weight: 700; } -.payment-status.paid { color: var(--accent, #0f766e); } -.payment-status.late { color: var(--danger, #b42318); } -.payment-status.upcoming { color: var(--text-muted, #687670); } +.settlement-status.paid { + color: var(--accent, #0f766e); +} +.settlement-status.unpaid, +.settlement-status.partially_paid { + color: var(--warning, #9a6700); +} +.timing-status.overdue { + color: var(--danger, #b42318); +} +.timing-status.upcoming { + color: var(--text-muted, #687670); +} @media (min-width: 700px) { - .rent-payment-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .rent-payment-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (min-width: 1100px) { - .rent-payment-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .rent-payment-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } 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 45d4bce..9d5a2b9 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 @@ -1,112 +1,97 @@
-
-
-

Location

-

Mes loyers

-

Suivez les échéances et téléchargez les quittances après règlement.

-
-
+
+
+

Location

+

Mes loyers

+

Suivez les échéances de vos baux actifs.

+
+
-
- @if (!contentReady() && loadError()) { - - } @else if (!contentReady()) { -
- @for (placeholder of [1, 2, 3]; track placeholder) { - - } -
- } @else { - @if (contentLoading()) { - - } - @if (loadError()) { - - } - @if (workspace.rentPayments().length === 0) { -
-

Échéancier

-

Aucune échéance de loyer

-

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

-
- } @else { -
- -
-
- @for (payment of displayedPayments(); track payment.id) { -
-
- - {{ statusLabel(payment.status) }} - - {{ payment.total.toLocaleString('fr-FR') }} € -
-

{{ apartmentName(payment) }}

-

{{ monthLabel(payment.month) }}

-
-
-
Loyer
-
{{ payment.rent.toLocaleString('fr-FR') }} €
-
-
-
Charges
-
{{ payment.charges.toLocaleString('fr-FR') }} €
-
-
-
Échéance
-
{{ payment.dueDate | date:'dd MMMM yyyy' }}
-
-
- @if (workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id)) { - - } -
- @if (payment.status === 'PAID') { - - } @else { - - } -
-
- } -
- } - } -
+
+ @if (!contentReady() && loadError()) { + + } @else if (!contentReady()) { +
+ @for (placeholder of [1, 2, 3]; track placeholder) { + + } +
+ } @else { + @if (contentLoading()) { + + } + @if (loadError()) { + + } + @if (workspace.rentDues().length === 0) { +
+

Échéancier

+

Aucune échéance de loyer

+

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

+
+ } @else { +
+ +
+
+ @for (rentDue of displayedRentDues(); track rentDue.id) { +
+
+
+ + {{ settlementLabel(rentDue.settlement) }} + + + {{ timingLabel(rentDue.timing) }} + +
+ {{ rentDue.total.toLocaleString('fr-FR') }} € +
+

{{ apartmentName(rentDue) }}

+

{{ monthLabel(rentDue.month) }}

+
+
+
Loyer
+
{{ rentDue.rent.toLocaleString('fr-FR') }} €
+
+
+
Charges
+
{{ rentDue.charges.toLocaleString('fr-FR') }} €
+
+
+
Restant à régler
+
{{ rentDue.remainingAmount.toLocaleString('fr-FR') }} €
+
+
+
Échéance
+
{{ rentDue.dueDate | date: 'dd MMMM yyyy' }}
+
+
+
+ } +
+ } + } +
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 f176171..7e4c78c 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 @@ -1,93 +1,93 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { of } from 'rxjs'; +import { RentDue } from '../../services/lease.service'; import { WorkspaceService } from '../../services/workspace.service'; import { RentPaymentsPage } from './rent-payments-page'; describe('RentPaymentsPage', () => { - it('loads the schedule and exposes payment actions after its dependencies are ready', () => { + it('loads and renders RentDue records instead of the legacy combined model', () => { const workspace = { - apartments: signal([{ id: 'apartment-1', name: 'T2' }]), - leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]), - rentPayments: signal([ - { - id: 'payment-1', - leaseId: 'lease-1', - month: '2026-07', - dueDate: '2026-07-01', - rent: 800, - charges: 50, - total: 850, - status: 'LATE', - paidAt: null, - }, - ]), - apartmentLoadState: signal({ loaded: true, loading: false, error: '' }), - leaseLoadState: signal({ loaded: true, loading: false, error: '' }), - rentPaymentLoadState: signal({ loaded: true, loading: false, error: '' }), - loadApartments: vi.fn(), - loadLeases: vi.fn(), + ...createWorkspace([rentDue('due-1', '2027-08', 'UPCOMING')]), + rentPayments: signal([payment('legacy-payment', '2026-07', 'LATE')]), loadRentPayments: vi.fn(), - markRentPaymentPaid: vi.fn(), - rentPaymentSaving: vi.fn(() => false), - rentPaymentSaveError: vi.fn(() => ''), - downloadRentReceipt: vi.fn(), - rentReceiptDownloading: vi.fn(() => false), - rentReceiptDownloadError: vi.fn(() => ''), }; - const dialog = { - open: vi.fn(() => ({ afterClosed: () => of({ rent: 800, charges: 50 }) })), - }; - TestBed.configureTestingModule({ - providers: [ - { provide: WorkspaceService, useValue: workspace }, - { provide: MatDialog, useValue: dialog }, - ], - }); - - const fixture = TestBed.createComponent(RentPaymentsPage); - fixture.detectChanges(); - - expect(workspace.loadRentPayments).toHaveBeenCalled(); - expect(fixture.nativeElement.textContent).toContain('T2'); - 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']); + expect(workspace.loadRentDues).toHaveBeenCalledOnce(); + expect(workspace.loadRentPayments).not.toHaveBeenCalled(); + expect(paymentMonths(fixture)).toEqual(['août 2027']); + expect(fixture.nativeElement.textContent).not.toContain('Marquer comme payé'); }); - it('starts the current schedule at the first unpaid rent when none has been paid', () => { + it('renders amounts, settlement and timing as independent RentDue facts', () => { + const fixture = createFixture( + createWorkspace([ + rentDue('due-1', '2026-07', 'OVERDUE', 'PARTIALLY_PAID', { + remainingAmount: 350, + }), + ]), + ); + const card = fixture.nativeElement.querySelector('.rent-payment') as HTMLElement; + + expect(card.textContent).toContain('T2'); + expect(card.textContent).toContain('800 €'); + expect(card.textContent).toContain('50 €'); + expect(card.textContent).toContain('850 €'); + expect(card.textContent).toContain('350 €'); + expect(card.querySelector('.settlement-status.partially_paid')?.textContent).toContain( + 'Partiellement réglée', + ); + expect(card.querySelector('.timing-status.overdue')?.textContent).toContain('En retard'); + expect(card.querySelector('.card-actions')).toBeNull(); + }); + + it('shows overdue dues through the next upcoming due', () => { 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'), + rentDue('overdue-january', '2026-01', 'OVERDUE'), + rentDue('overdue-february', '2026-02', 'OVERDUE'), + rentDue('upcoming-march', '2026-03', 'UPCOMING'), + rentDue('upcoming-april', '2026-04', 'UPCOMING'), ]); const fixture = createFixture(workspace); expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']); }); + it('starts the current schedule at the older of the last paid and first unpaid due', () => { + const workspace = createWorkspace([ + rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID'), + rentDue('unpaid-february', '2026-02', 'OVERDUE'), + rentDue('paid-march', '2026-03', 'OVERDUE', 'PAID'), + rentDue('unpaid-april', '2026-04', 'OVERDUE'), + rentDue('upcoming-may', '2026-05', 'UPCOMING'), + rentDue('upcoming-june', '2026-06', 'UPCOMING'), + ]); + const fixture = createFixture(workspace); + + expect(paymentMonths(fixture)).toEqual([ + 'février 2026', + 'mars 2026', + 'avril 2026', + 'mai 2026', + ]); + }); + + it('shows only the next due when none are overdue', () => { + const workspace = createWorkspace([ + rentDue('upcoming-march', '2026-03', 'UPCOMING'), + rentDue('upcoming-april', '2026-04', 'UPCOMING'), + ]); + const fixture = createFixture(workspace); + + expect(paymentMonths(fixture)).toEqual(['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'), + rentDue('upcoming-april', '2026-04', 'UPCOMING'), + rentDue('overdue-january', '2026-01', 'OVERDUE'), + rentDue('upcoming-march', '2026-03', 'UPCOMING'), + rentDue('overdue-february', '2026-02', 'OVERDUE'), ]); const fixture = createFixture(workspace); @@ -99,7 +99,12 @@ describe('RentPaymentsPage', () => { fixture.detectChanges(); expect(toggle.getAttribute('aria-pressed')).toBe('true'); - expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026', 'avril 2026']); + expect(paymentMonths(fixture)).toEqual([ + 'janvier 2026', + 'février 2026', + 'mars 2026', + 'avril 2026', + ]); toggle.click(); fixture.detectChanges(); @@ -124,10 +129,7 @@ describe('RentPaymentsPage', () => { function createFixture(workspace: ReturnType) { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - providers: [ - { provide: WorkspaceService, useValue: workspace }, - { provide: MatDialog, useValue: { open: vi.fn() } }, - ], + providers: [{ provide: WorkspaceService, useValue: workspace }], }); const fixture = TestBed.createComponent(RentPaymentsPage); fixture.detectChanges(); @@ -135,25 +137,41 @@ function createFixture(workspace: ReturnType) { } function createWorkspace( - rentPayments: ReturnType[], + rentDues: RentDue[], 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), + rentDues: signal(rentDues), apartmentLoadState: signal(loadState), leaseLoadState: signal(loadState), - rentPaymentLoadState: signal(loadState), + rentDueLoadState: 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(() => ''), + loadRentDues: vi.fn(), + }; +} + +function rentDue( + id: string, + month: string, + timing: RentDue['timing'], + settlement: RentDue['settlement'] = 'UNPAID', + overrides: Partial = {}, +): RentDue { + return { + id, + leaseId: 'lease-1', + month, + dueDate: `${month}-05`, + rent: 800, + charges: 50, + total: 850, + remainingAmount: 850, + settlement, + timing, + ...overrides, }; } @@ -172,5 +190,7 @@ function payment(id: string, month: string, status: 'PAID' | 'LATE' | 'UPCOMING' } function paymentMonths(fixture: ReturnType): string[] { - return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) => element.textContent.trim()); + 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 cce9968..20a120f 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,44 +1,39 @@ 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 { RentDue } from '../../services/lease.service'; import { WorkspaceService } from '../../services/workspace.service'; -import { MatDialog } from '@angular/material/dialog'; -import { RentPaymentValidationDialog } from '../rent-payment-validation-dialog/rent-payment-validation-dialog'; import { DatePipe } from '@angular/common'; @Component({ selector: 'app-rent-payments-page', - imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule, DatePipe], + imports: [MatButtonModule, MatProgressBarModule, DatePipe], templateUrl: './rent-payments-page.html', styleUrl: './rent-payments-page.css', }) export class RentPaymentsPage { readonly workspace = inject(WorkspaceService); - private readonly dialog = inject(MatDialog); readonly showFullSchedule = signal(false); - readonly displayedPayments = computed(() => + readonly displayedRentDues = computed(() => this.showFullSchedule() - ? sortRentPayments(this.workspace.rentPayments()) - : currentRentSchedule(this.workspace.rentPayments()), + ? sortRentDues(this.workspace.rentDues()) + : currentRentDues(this.workspace.rentDues()), ); readonly contentReady = computed( () => - this.workspace.rentPaymentLoadState().loaded && + this.workspace.rentDueLoadState().loaded && this.workspace.leaseLoadState().loaded && this.workspace.apartmentLoadState().loaded, ); readonly contentLoading = computed( () => - this.workspace.rentPaymentLoadState().loading || + this.workspace.rentDueLoadState().loading || this.workspace.leaseLoadState().loading || this.workspace.apartmentLoadState().loading, ); readonly loadError = computed( () => - this.workspace.rentPaymentLoadState().error || + this.workspace.rentDueLoadState().error || this.workspace.leaseLoadState().error || this.workspace.apartmentLoadState().error, ); @@ -47,9 +42,12 @@ export class RentPaymentsPage { this.load(); } - apartmentName(payment: RentPayment): string { - const lease = this.workspace.leases().find((item) => item.id === payment.leaseId); - return this.workspace.apartments().find((item) => item.id === lease?.apartmentId)?.name ?? 'Bien inconnu'; + apartmentName(rentDue: RentDue): string { + const lease = this.workspace.leases().find((item) => item.id === rentDue.leaseId); + return ( + this.workspace.apartments().find((item) => item.id === lease?.apartmentId)?.name ?? + 'Bien inconnu' + ); } monthLabel(month: string): string { @@ -58,12 +56,18 @@ export class RentPaymentsPage { ); } - statusLabel(status: RentPaymentStatus): string { - return { PAID: 'Payé', LATE: 'En retard', UPCOMING: 'À venir' }[status]; + settlementLabel(settlement: RentDue['settlement']): string { + return { UNPAID: 'Non réglée', PARTIALLY_PAID: 'Partiellement réglée', PAID: 'Réglée' }[ + settlement + ]; + } + + timingLabel(timing: RentDue['timing']): string { + return { UPCOMING: 'À venir', OVERDUE: 'En retard' }[timing]; } load(): void { - this.workspace.loadRentPayments(); + this.workspace.loadRentDues(); this.workspace.loadLeases(); this.workspace.loadApartments(); } @@ -71,19 +75,33 @@ export class RentPaymentsPage { toggleSchedule(): void { this.showFullSchedule.update((showFullSchedule) => !showFullSchedule); } +} - markPaid(payment: RentPayment): void { - this.dialog - .open(RentPaymentValidationDialog, { - data: { rent: payment.rent, charges: payment.charges }, - width: '400px', - }) - .afterClosed() - .subscribe((result) => { - if (result) { - this.workspace.markRentPaymentPaid(payment.id, result.rent, result.charges); - } - }); +function sortRentDues(rentDues: readonly RentDue[]): RentDue[] { + return [...rentDues].sort((left, right) => left.dueDate.localeCompare(right.dueDate)); +} + +function currentRentDues(rentDues: readonly RentDue[]): RentDue[] { + const rentDuesByLease = new Map(); + for (const rentDue of sortRentDues(rentDues)) { + const schedule = rentDuesByLease.get(rentDue.leaseId); + if (schedule) schedule.push(rentDue); + else rentDuesByLease.set(rentDue.leaseId, [rentDue]); } + return sortRentDues([...rentDuesByLease.values()].flatMap(currentSchedule)); +} + +function currentSchedule(schedule: RentDue[]): RentDue[] { + const nextDueIndex = schedule.findIndex((rentDue) => rentDue.timing === 'UPCOMING'); + const throughNextDue = nextDueIndex === -1 ? schedule : schedule.slice(0, nextDueIndex + 1); + const lastPaidIndex = throughNextDue.reduce( + (lastPaid, rentDue, index) => (rentDue.settlement === 'PAID' ? index : lastPaid), + -1, + ); + const firstUnpaidIndex = throughNextDue.findIndex((rentDue) => rentDue.settlement !== 'PAID'); + const firstVisibleIndex = Math.min( + ...[lastPaidIndex, firstUnpaidIndex].filter((index) => index !== -1), + ); + return firstVisibleIndex === Infinity ? [] : throughNextDue.slice(firstVisibleIndex); }