fix: use rent dues in rent schedule (#28)
This commit is contained in:
1 parent
c2afe515c0
commit
a039243d03
4 files changed
+274
-230
No files matched your search
@@ -49,19 +49,40 @@
|
|||||||
font-weight: 700;
|
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-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payment-status.paid { color: var(--accent, #0f766e); }
|
.settlement-status.paid {
|
||||||
.payment-status.late { color: var(--danger, #b42318); }
|
color: var(--accent, #0f766e);
|
||||||
.payment-status.upcoming { color: var(--text-muted, #687670); }
|
}
|
||||||
|
.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) {
|
@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) {
|
@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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,112 +1,97 @@
|
|||||||
<section class="page-section">
|
<section class="page-section">
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Location</p>
|
<p class="eyebrow">Location</p>
|
||||||
<h1>Mes loyers</h1>
|
<h1>Mes loyers</h1>
|
||||||
<p>Suivez les échéances et téléchargez les quittances après règlement.</p>
|
<p>Suivez les échéances de vos baux actifs.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-region" [attr.aria-busy]="contentLoading()">
|
<div class="content-region" [attr.aria-busy]="contentLoading()">
|
||||||
@if (!contentReady() && loadError()) {
|
@if (!contentReady() && loadError()) {
|
||||||
<article class="load-error" role="alert">
|
<article class="load-error" role="alert">
|
||||||
<h2>Chargement impossible</h2>
|
<h2>Chargement impossible</h2>
|
||||||
<p>{{ loadError() }}</p>
|
<p>{{ loadError() }}</p>
|
||||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||||
</article>
|
</article>
|
||||||
} @else if (!contentReady()) {
|
} @else if (!contentReady()) {
|
||||||
<div class="rent-payment-grid" aria-label="Chargement des loyers">
|
<div class="rent-payment-grid" aria-label="Chargement des loyers">
|
||||||
@for (placeholder of [1, 2, 3]; track placeholder) {
|
@for (placeholder of [1, 2, 3]; track placeholder) {
|
||||||
<article class="skeleton-card" aria-hidden="true">
|
<article class="skeleton-card" aria-hidden="true">
|
||||||
<span class="skeleton-line skeleton-line-short"></span>
|
<span class="skeleton-line skeleton-line-short"></span>
|
||||||
<span class="skeleton-line skeleton-line-title"></span>
|
<span class="skeleton-line skeleton-line-title"></span>
|
||||||
<span class="skeleton-line skeleton-line-medium"></span>
|
<span class="skeleton-line skeleton-line-medium"></span>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@if (contentLoading()) {
|
@if (contentLoading()) {
|
||||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
|
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
|
||||||
}
|
}
|
||||||
@if (loadError()) {
|
@if (loadError()) {
|
||||||
<div class="refresh-error" role="alert">
|
<div class="refresh-error" role="alert">
|
||||||
<p>{{ loadError() }}</p>
|
<p>{{ loadError() }}</p>
|
||||||
<button mat-button type="button" (click)="load()">Réessayer</button>
|
<button mat-button type="button" (click)="load()">Réessayer</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (workspace.rentPayments().length === 0) {
|
@if (workspace.rentDues().length === 0) {
|
||||||
<article class="empty-state">
|
<article class="empty-state">
|
||||||
<p class="eyebrow">Échéancier</p>
|
<p class="eyebrow">Échéancier</p>
|
||||||
<h2>Aucune échéance de loyer</h2>
|
<h2>Aucune échéance de loyer</h2>
|
||||||
<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="schedule-actions">
|
<div class="schedule-actions">
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
aria-controls="rent-payment-schedule"
|
aria-controls="rent-payment-schedule"
|
||||||
[attr.aria-pressed]="showFullSchedule()"
|
[attr.aria-pressed]="showFullSchedule()"
|
||||||
(click)="toggleSchedule()"
|
(click)="toggleSchedule()"
|
||||||
>
|
>
|
||||||
{{ showFullSchedule() ? 'Afficher l’échéancier courant' : 'Afficher toutes les échéances' }}
|
{{
|
||||||
</button>
|
showFullSchedule() ? 'Afficher l’échéancier courant' : 'Afficher toutes les échéances'
|
||||||
</div>
|
}}
|
||||||
<div id="rent-payment-schedule" class="rent-payment-grid">
|
</button>
|
||||||
@for (payment of displayedPayments(); track payment.id) {
|
</div>
|
||||||
<article class="rent-payment" [attr.aria-busy]="workspace.rentPaymentSaving(payment.id)">
|
<div id="rent-payment-schedule" class="rent-payment-grid">
|
||||||
<div class="payment-topline">
|
@for (rentDue of displayedRentDues(); track rentDue.id) {
|
||||||
<span class="payment-status" [class]="payment.status.toLowerCase()">
|
<article class="rent-payment">
|
||||||
{{ statusLabel(payment.status) }}
|
<div class="payment-topline">
|
||||||
</span>
|
<div class="rent-due-labels">
|
||||||
<strong>{{ payment.total.toLocaleString('fr-FR') }} €</strong>
|
<span class="settlement-status" [class]="rentDue.settlement.toLowerCase()">
|
||||||
</div>
|
{{ settlementLabel(rentDue.settlement) }}
|
||||||
<h2>{{ apartmentName(payment) }}</h2>
|
</span>
|
||||||
<p class="payment-month">{{ monthLabel(payment.month) }}</p>
|
<span class="timing-status" [class]="rentDue.timing.toLowerCase()">
|
||||||
<dl>
|
{{ timingLabel(rentDue.timing) }}
|
||||||
<div>
|
</span>
|
||||||
<dt>Loyer</dt>
|
</div>
|
||||||
<dd>{{ payment.rent.toLocaleString('fr-FR') }} €</dd>
|
<strong>{{ rentDue.total.toLocaleString('fr-FR') }} €</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h2>{{ apartmentName(rentDue) }}</h2>
|
||||||
<dt>Charges</dt>
|
<p class="payment-month">{{ monthLabel(rentDue.month) }}</p>
|
||||||
<dd>{{ payment.charges.toLocaleString('fr-FR') }} €</dd>
|
<dl>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<dt>Loyer</dt>
|
||||||
<dt>Échéance</dt>
|
<dd>{{ rentDue.rent.toLocaleString('fr-FR') }} €</dd>
|
||||||
<dd>{{ payment.dueDate | date:'dd MMMM yyyy' }}</dd>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</dl>
|
<dt>Charges</dt>
|
||||||
@if (workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id)) {
|
<dd>{{ rentDue.charges.toLocaleString('fr-FR') }} €</dd>
|
||||||
<p class="card-error" role="alert">
|
</div>
|
||||||
{{ workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id) }}
|
<div>
|
||||||
</p>
|
<dt>Restant à régler</dt>
|
||||||
}
|
<dd>{{ rentDue.remainingAmount.toLocaleString('fr-FR') }} €</dd>
|
||||||
<div class="card-actions" aria-live="polite">
|
</div>
|
||||||
@if (payment.status === 'PAID') {
|
<div>
|
||||||
<button mat-button type="button" [disabled]="workspace.rentReceiptDownloading(payment.id)"
|
<dt>Échéance</dt>
|
||||||
(click)="workspace.downloadRentReceipt(payment)">
|
<dd>{{ rentDue.dueDate | date: 'dd MMMM yyyy' }}</dd>
|
||||||
@if (workspace.rentReceiptDownloading(payment.id)) {
|
</div>
|
||||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
|
</dl>
|
||||||
} @else {
|
</article>
|
||||||
Télécharger la quittance
|
}
|
||||||
}
|
</div>
|
||||||
</button>
|
}
|
||||||
} @else {
|
}
|
||||||
<button mat-flat-button type="button" [disabled]="workspace.rentPaymentSaving(payment.id)"
|
</div>
|
||||||
(click)="markPaid(payment)">
|
|
||||||
@if (workspace.rentPaymentSaving(payment.id)) {
|
|
||||||
<mat-spinner diameter="16" aria-hidden="true" /> Enregistrement…
|
|
||||||
} @else {
|
|
||||||
Marquer comme payé
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
@@ -1,93 +1,93 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { RentDue } from '../../services/lease.service';
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { WorkspaceService } from '../../services/workspace.service';
|
import { WorkspaceService } from '../../services/workspace.service';
|
||||||
import { RentPaymentsPage } from './rent-payments-page';
|
import { RentPaymentsPage } from './rent-payments-page';
|
||||||
|
|
||||||
describe('RentPaymentsPage', () => {
|
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 = {
|
const workspace = {
|
||||||
apartments: signal([{ id: 'apartment-1', name: 'T2' }]),
|
...createWorkspace([rentDue('due-1', '2027-08', 'UPCOMING')]),
|
||||||
leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]),
|
rentPayments: signal([payment('legacy-payment', '2026-07', 'LATE')]),
|
||||||
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(),
|
|
||||||
loadRentPayments: 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(() => ''),
|
|
||||||
};
|
};
|
||||||
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);
|
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([
|
const workspace = createWorkspace([
|
||||||
payment('late-january', '2026-01', 'LATE'),
|
rentDue('overdue-january', '2026-01', 'OVERDUE'),
|
||||||
payment('late-february', '2026-02', 'LATE'),
|
rentDue('overdue-february', '2026-02', 'OVERDUE'),
|
||||||
payment('upcoming-march', '2026-03', 'UPCOMING'),
|
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
|
||||||
payment('upcoming-april', '2026-04', 'UPCOMING'),
|
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||||
]);
|
]);
|
||||||
const fixture = createFixture(workspace);
|
const fixture = createFixture(workspace);
|
||||||
|
|
||||||
expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']);
|
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', () => {
|
it('reveals and hides the full chronological schedule', () => {
|
||||||
const workspace = createWorkspace([
|
const workspace = createWorkspace([
|
||||||
payment('upcoming-april', '2026-04', 'UPCOMING'),
|
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||||
payment('paid-january', '2026-01', 'PAID'),
|
rentDue('overdue-january', '2026-01', 'OVERDUE'),
|
||||||
payment('upcoming-march', '2026-03', 'UPCOMING'),
|
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
|
||||||
payment('late-february', '2026-02', 'LATE'),
|
rentDue('overdue-february', '2026-02', 'OVERDUE'),
|
||||||
]);
|
]);
|
||||||
const fixture = createFixture(workspace);
|
const fixture = createFixture(workspace);
|
||||||
|
|
||||||
@@ -99,7 +99,12 @@ describe('RentPaymentsPage', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
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();
|
toggle.click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -124,10 +129,7 @@ describe('RentPaymentsPage', () => {
|
|||||||
function createFixture(workspace: ReturnType<typeof createWorkspace>) {
|
function createFixture(workspace: ReturnType<typeof createWorkspace>) {
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [{ provide: WorkspaceService, useValue: workspace }],
|
||||||
{ provide: WorkspaceService, useValue: workspace },
|
|
||||||
{ provide: MatDialog, useValue: { open: vi.fn() } },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const fixture = TestBed.createComponent(RentPaymentsPage);
|
const fixture = TestBed.createComponent(RentPaymentsPage);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -135,25 +137,41 @@ function createFixture(workspace: ReturnType<typeof createWorkspace>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWorkspace(
|
function createWorkspace(
|
||||||
rentPayments: ReturnType<typeof payment>[],
|
rentDues: RentDue[],
|
||||||
loadState = { loaded: true, loading: false, error: '' },
|
loadState = { loaded: true, loading: false, error: '' },
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
apartments: signal([{ id: 'apartment-1', name: 'T2' }]),
|
apartments: signal([{ id: 'apartment-1', name: 'T2' }]),
|
||||||
leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]),
|
leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]),
|
||||||
rentPayments: signal(rentPayments),
|
rentDues: signal(rentDues),
|
||||||
apartmentLoadState: signal(loadState),
|
apartmentLoadState: signal(loadState),
|
||||||
leaseLoadState: signal(loadState),
|
leaseLoadState: signal(loadState),
|
||||||
rentPaymentLoadState: signal(loadState),
|
rentDueLoadState: signal(loadState),
|
||||||
loadApartments: vi.fn(),
|
loadApartments: vi.fn(),
|
||||||
loadLeases: vi.fn(),
|
loadLeases: vi.fn(),
|
||||||
loadRentPayments: vi.fn(),
|
loadRentDues: vi.fn(),
|
||||||
markRentPaymentPaid: vi.fn(),
|
};
|
||||||
rentPaymentSaving: vi.fn(() => false),
|
}
|
||||||
rentPaymentSaveError: vi.fn(() => ''),
|
|
||||||
downloadRentReceipt: vi.fn(),
|
function rentDue(
|
||||||
rentReceiptDownloading: vi.fn(() => false),
|
id: string,
|
||||||
rentReceiptDownloadError: vi.fn(() => ''),
|
month: string,
|
||||||
|
timing: RentDue['timing'],
|
||||||
|
settlement: RentDue['settlement'] = 'UNPAID',
|
||||||
|
overrides: Partial<RentDue> = {},
|
||||||
|
): 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<typeof createFixture>): string[] {
|
function paymentMonths(fixture: ReturnType<typeof createFixture>): string[] {
|
||||||
return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) => element.textContent.trim());
|
return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) =>
|
||||||
|
element.textContent.trim(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,39 @@
|
|||||||
import { Component, computed, inject, signal } 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 { RentDue } from '../../services/lease.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 { RentPaymentValidationDialog } from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
|
|
||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-rent-payments-page',
|
selector: 'app-rent-payments-page',
|
||||||
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule, DatePipe],
|
imports: [MatButtonModule, MatProgressBarModule, DatePipe],
|
||||||
templateUrl: './rent-payments-page.html',
|
templateUrl: './rent-payments-page.html',
|
||||||
styleUrl: './rent-payments-page.css',
|
styleUrl: './rent-payments-page.css',
|
||||||
})
|
})
|
||||||
export class RentPaymentsPage {
|
export class RentPaymentsPage {
|
||||||
readonly workspace = inject(WorkspaceService);
|
readonly workspace = inject(WorkspaceService);
|
||||||
private readonly dialog = inject(MatDialog);
|
|
||||||
readonly showFullSchedule = signal(false);
|
readonly showFullSchedule = signal(false);
|
||||||
readonly displayedPayments = computed(() =>
|
readonly displayedRentDues = computed(() =>
|
||||||
this.showFullSchedule()
|
this.showFullSchedule()
|
||||||
? sortRentPayments(this.workspace.rentPayments())
|
? sortRentDues(this.workspace.rentDues())
|
||||||
: currentRentSchedule(this.workspace.rentPayments()),
|
: currentRentDues(this.workspace.rentDues()),
|
||||||
);
|
);
|
||||||
readonly contentReady = computed(
|
readonly contentReady = computed(
|
||||||
() =>
|
() =>
|
||||||
this.workspace.rentPaymentLoadState().loaded &&
|
this.workspace.rentDueLoadState().loaded &&
|
||||||
this.workspace.leaseLoadState().loaded &&
|
this.workspace.leaseLoadState().loaded &&
|
||||||
this.workspace.apartmentLoadState().loaded,
|
this.workspace.apartmentLoadState().loaded,
|
||||||
);
|
);
|
||||||
readonly contentLoading = computed(
|
readonly contentLoading = computed(
|
||||||
() =>
|
() =>
|
||||||
this.workspace.rentPaymentLoadState().loading ||
|
this.workspace.rentDueLoadState().loading ||
|
||||||
this.workspace.leaseLoadState().loading ||
|
this.workspace.leaseLoadState().loading ||
|
||||||
this.workspace.apartmentLoadState().loading,
|
this.workspace.apartmentLoadState().loading,
|
||||||
);
|
);
|
||||||
readonly loadError = computed(
|
readonly loadError = computed(
|
||||||
() =>
|
() =>
|
||||||
this.workspace.rentPaymentLoadState().error ||
|
this.workspace.rentDueLoadState().error ||
|
||||||
this.workspace.leaseLoadState().error ||
|
this.workspace.leaseLoadState().error ||
|
||||||
this.workspace.apartmentLoadState().error,
|
this.workspace.apartmentLoadState().error,
|
||||||
);
|
);
|
||||||
@@ -47,9 +42,12 @@ export class RentPaymentsPage {
|
|||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
apartmentName(payment: RentPayment): string {
|
apartmentName(rentDue: RentDue): string {
|
||||||
const lease = this.workspace.leases().find((item) => item.id === payment.leaseId);
|
const lease = this.workspace.leases().find((item) => item.id === rentDue.leaseId);
|
||||||
return this.workspace.apartments().find((item) => item.id === lease?.apartmentId)?.name ?? 'Bien inconnu';
|
return (
|
||||||
|
this.workspace.apartments().find((item) => item.id === lease?.apartmentId)?.name ??
|
||||||
|
'Bien inconnu'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
monthLabel(month: string): string {
|
monthLabel(month: string): string {
|
||||||
@@ -58,12 +56,18 @@ export class RentPaymentsPage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
statusLabel(status: RentPaymentStatus): string {
|
settlementLabel(settlement: RentDue['settlement']): string {
|
||||||
return { PAID: 'Payé', LATE: 'En retard', UPCOMING: 'À venir' }[status];
|
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 {
|
load(): void {
|
||||||
this.workspace.loadRentPayments();
|
this.workspace.loadRentDues();
|
||||||
this.workspace.loadLeases();
|
this.workspace.loadLeases();
|
||||||
this.workspace.loadApartments();
|
this.workspace.loadApartments();
|
||||||
}
|
}
|
||||||
@@ -71,19 +75,33 @@ export class RentPaymentsPage {
|
|||||||
toggleSchedule(): void {
|
toggleSchedule(): void {
|
||||||
this.showFullSchedule.update((showFullSchedule) => !showFullSchedule);
|
this.showFullSchedule.update((showFullSchedule) => !showFullSchedule);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
markPaid(payment: RentPayment): void {
|
function sortRentDues(rentDues: readonly RentDue[]): RentDue[] {
|
||||||
this.dialog
|
return [...rentDues].sort((left, right) => left.dueDate.localeCompare(right.dueDate));
|
||||||
.open(RentPaymentValidationDialog, {
|
}
|
||||||
data: { rent: payment.rent, charges: payment.charges },
|
|
||||||
width: '400px',
|
function currentRentDues(rentDues: readonly RentDue[]): RentDue[] {
|
||||||
})
|
const rentDuesByLease = new Map<string, RentDue[]>();
|
||||||
.afterClosed()
|
for (const rentDue of sortRentDues(rentDues)) {
|
||||||
.subscribe((result) => {
|
const schedule = rentDuesByLease.get(rentDue.leaseId);
|
||||||
if (result) {
|
if (schedule) schedule.push(rentDue);
|
||||||
this.workspace.markRentPaymentPaid(payment.id, result.rent, result.charges);
|
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);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user