feat: enhance lease and rent payment management

- Added methods in LeaseRepository to find leases by apartment ID and exclude a specific lease ID.
- Introduced a method in RentPaymentRepository to delete all payments by lease ID.
- Updated LeaseService to handle overlapping leases and generate rent payments upon lease creation.
- Implemented rent payment deletion in LeaseService when a lease is deleted.
- Enhanced RentPaymentService to generate rent payments with pro-rata calculations for partial months.
- Modified RentPaymentController to accept rent and charges when marking payments as paid.
- Created RentPaymentPaidRequest DTO for validating rent payment requests.
- Added a RentPaymentValidationDialog for confirming payment amounts before processing.
- Updated frontend components to support new rent payment features, including validation and date filtering for leases.
- Adjusted tests to cover new functionalities and ensure proper integration of rent payment management.
This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 479012d661
commit 9cfb55d79c
25 files changed
+389 -175

No files matched your search

@@ -1,100 +1,101 @@
<section class="page-section">
<header class="page-heading">
<div>
<p class="eyebrow">Location</p>
<h1>Mes loyers</h1>
<p>Suivez les échéances et téléchargez les quittances après règlement.</p>
</div>
</header>
<header class="page-heading">
<div>
<p class="eyebrow">Location</p>
<h1>Mes loyers</h1>
<p>Suivez les échéances et téléchargez les quittances après règlement.</p>
</div>
</header>
<div class="content-region" [attr.aria-busy]="contentLoading()">
@if (!contentReady() && loadError()) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ loadError() }}</p>
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
</article>
} @else if (!contentReady()) {
<div class="rent-payment-grid" aria-label="Chargement des loyers">
@for (placeholder of [1, 2, 3]; track placeholder) {
<article class="skeleton-card" aria-hidden="true">
<span class="skeleton-line skeleton-line-short"></span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-medium"></span>
</article>
}
</div>
} @else {
@if (contentLoading()) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
}
@if (loadError()) {
<div class="refresh-error" role="alert">
<p>{{ loadError() }}</p>
<button mat-button type="button" (click)="load()">Réessayer</button>
</div>
}
@if (workspace.rentPayments().length === 0) {
<article class="empty-state">
<p class="eyebrow">Échéancier</p>
<h2>Aucune échéance de loyer</h2>
<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) {
<article class="rent-payment" [attr.aria-busy]="workspace.rentPaymentSaving(payment.id)">
<div class="payment-topline">
<span class="payment-status" [class]="payment.status.toLowerCase()">
{{ statusLabel(payment.status) }}
</span>
<strong>{{ payment.total.toLocaleString('fr-FR') }} €</strong>
</div>
<h2>{{ apartmentName(payment) }}</h2>
<p class="payment-month">{{ monthLabel(payment.month) }}</p>
<dl>
<div><dt>Loyer</dt><dd>{{ payment.rent.toLocaleString('fr-FR') }} €</dd></div>
<div><dt>Charges</dt><dd>{{ payment.charges.toLocaleString('fr-FR') }} €</dd></div>
<div><dt>Échéance</dt><dd>{{ payment.dueDate }}</dd></div>
</dl>
@if (workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id)) {
<p class="card-error" role="alert">
{{ workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id) }}
</p>
}
<div class="card-actions" aria-live="polite">
@if (payment.status === 'PAID') {
<button
mat-button
type="button"
[disabled]="workspace.rentReceiptDownloading(payment.id)"
(click)="workspace.downloadRentReceipt(payment)"
>
@if (workspace.rentReceiptDownloading(payment.id)) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
} @else {
Télécharger la quittance
}
</button>
} @else {
<button
mat-flat-button
type="button"
[disabled]="workspace.rentPaymentSaving(payment.id)"
(click)="workspace.markRentPaymentPaid(payment.id)"
>
@if (workspace.rentPaymentSaving(payment.id)) {
<mat-spinner diameter="16" aria-hidden="true" /> Enregistrement…
} @else {
Marquer comme payé
}
</button>
}
</div>
</article>
}
</div>
}
}
</div>
</section>
<div class="content-region" [attr.aria-busy]="contentLoading()">
@if (!contentReady() && loadError()) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ loadError() }}</p>
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
</article>
} @else if (!contentReady()) {
<div class="rent-payment-grid" aria-label="Chargement des loyers">
@for (placeholder of [1, 2, 3]; track placeholder) {
<article class="skeleton-card" aria-hidden="true">
<span class="skeleton-line skeleton-line-short"></span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-medium"></span>
</article>
}
</div>
} @else {
@if (contentLoading()) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
}
@if (loadError()) {
<div class="refresh-error" role="alert">
<p>{{ loadError() }}</p>
<button mat-button type="button" (click)="load()">Réessayer</button>
</div>
}
@if (workspace.rentPayments().length === 0) {
<article class="empty-state">
<p class="eyebrow">Échéancier</p>
<h2>Aucune échéance de loyer</h2>
<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) {
<article class="rent-payment" [attr.aria-busy]="workspace.rentPaymentSaving(payment.id)">
<div class="payment-topline">
<span class="payment-status" [class]="payment.status.toLowerCase()">
{{ statusLabel(payment.status) }}
</span>
<strong>{{ payment.total.toLocaleString('fr-FR') }} €</strong>
</div>
<h2>{{ apartmentName(payment) }}</h2>
<p class="payment-month">{{ monthLabel(payment.month) }}</p>
<dl>
<div>
<dt>Loyer</dt>
<dd>{{ payment.rent.toLocaleString('fr-FR') }} €</dd>
</div>
<div>
<dt>Charges</dt>
<dd>{{ payment.charges.toLocaleString('fr-FR') }} €</dd>
</div>
<div>
<dt>Échéance</dt>
<dd>{{ payment.dueDate | date:'dd MMMM yyyy' }}</dd>
</div>
</dl>
@if (workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id)) {
<p class="card-error" role="alert">
{{ workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id) }}
</p>
}
<div class="card-actions" aria-live="polite">
@if (payment.status === 'PAID') {
<button mat-button type="button" [disabled]="workspace.rentReceiptDownloading(payment.id)"
(click)="workspace.downloadRentReceipt(payment)">
@if (workspace.rentReceiptDownloading(payment.id)) {
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
} @else {
Télécharger la quittance
}
</button>
} @else {
<button mat-flat-button type="button" [disabled]="workspace.rentPaymentSaving(payment.id)"
(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>
@@ -1,5 +1,7 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { WorkspaceService } from '../../services/workspace.service';
import { RentPaymentsPage } from './rent-payments-page';
@@ -34,8 +36,14 @@ describe('RentPaymentsPage', () => {
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 }],
providers: [
{ provide: WorkspaceService, useValue: workspace },
{ provide: MatDialog, useValue: dialog },
],
});
const fixture = TestBed.createComponent(RentPaymentsPage);
@@ -44,6 +52,7 @@ describe('RentPaymentsPage', () => {
expect(workspace.loadRentPayments).toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('T2');
fixture.nativeElement.querySelector('button').click();
expect(workspace.markRentPaymentPaid).toHaveBeenCalledWith('payment-1');
expect(dialog.open).toHaveBeenCalled();
expect(workspace.markRentPaymentPaid).toHaveBeenCalledWith('payment-1', 800, 50);
});
});
@@ -4,15 +4,19 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { RentPayment, RentPaymentStatus } from '../../services/rent-payment.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],
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule, DatePipe],
templateUrl: './rent-payments-page.html',
styleUrl: './rent-payments-page.css',
})
export class RentPaymentsPage {
readonly workspace = inject(WorkspaceService);
private readonly dialog = inject(MatDialog);
readonly contentReady = computed(
() =>
this.workspace.rentPaymentLoadState().loaded &&
@@ -56,4 +60,18 @@ export class RentPaymentsPage {
this.workspace.loadLeases();
this.workspace.loadApartments();
}
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);
}
});
}
}