Files
lmnp-organizer/frontend/src/app/components/rent-payments-page/rent-payments-page.ts
T
nessar 878da155bd chore: add husky pre-commit hook and lint-staged configuration
- Created a pre-commit hook in .husky to run lint-staged.
- Added .lintstagedrc.mjs to format frontend files with Prettier and apply Spotless to backend Java files.
- Introduced .prettierignore to exclude specific directories from formatting.
2026-08-17 14:18:16 +02:00

252 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
EligibleRentDue,
RentPaymentAllocationDialog,
} from '../rent-payment-allocation-dialog/rent-payment-allocation-dialog';
import { RentPaymentRefundDialog } from '../rent-payment-refund-dialog/rent-payment-refund-dialog';
import { RentPaymentReversalDialog } from '../rent-payment-reversal-dialog/rent-payment-reversal-dialog';
import {
RentPaymentValidationDialog,
RentPaymentValidationDialogData,
} from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
import { RentDue } from '../../services/lease.service';
import { formatEuro } from '../../services/rent-payment-format';
import { PaymentAllocation, RentPayment } from '../../services/rent-payment.service';
import { WorkspaceService } from '../../services/workspace.service';
import { RentPaymentDialogResult, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
import { RentDueCorrection } from '../rent-due-correction/rent-due-correction';
import { formatFrenchCivilDate } from '../../services/civil-date';
@Component({
selector: 'app-rent-payments-page',
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule, RentDueCorrection],
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 feedback = signal<{ kind: 'success' | 'error'; message: string } | null>(null);
readonly formatEuro = formatEuro;
readonly civilDate = formatFrenchCivilDate;
readonly displayedRentDues = computed(() =>
this.showFullSchedule()
? sortRentDues(this.workspace.rentDues())
: currentRentDues(this.workspace.rentDues()),
);
readonly displayedPayments = computed(() =>
[...this.workspace.rentPayments()].sort(
(left, right) =>
right.receivedAt.localeCompare(left.receivedAt) ||
right.confirmedAt.localeCompare(left.confirmedAt),
),
);
readonly scheduleReady = computed(
() =>
this.workspace.rentDueLoadState().loaded &&
this.workspace.leaseLoadState().loaded &&
this.workspace.apartmentLoadState().loaded,
);
readonly scheduleLoading = computed(
() =>
this.workspace.rentDueLoadState().loading ||
this.workspace.leaseLoadState().loading ||
this.workspace.apartmentLoadState().loading,
);
readonly scheduleError = computed(
() =>
this.workspace.rentDueLoadState().error ||
this.workspace.leaseLoadState().error ||
this.workspace.apartmentLoadState().error,
);
constructor() {
this.load();
}
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 {
return new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
new Date(`${month}-01T12:00:00`),
);
}
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];
}
paymentStatusLabel(status: RentPayment['status']): string {
return { CONFIRMED: 'Confirmé', REVERSED: 'Contrepassé' }[status];
}
allocationLabel(allocation: PaymentAllocation): string {
const due = this.workspace.rentDues().find((item) => item.id === allocation.rentDueId);
return due
? `${this.apartmentName(due)} — ${this.monthLabel(due.month)}`
: 'Échéance inconnue';
}
load(): void {
this.workspace.loadRentDues();
this.workspace.loadLeases();
this.workspace.loadApartments();
this.workspace.loadRentPayments();
}
toggleSchedule(): void {
this.showFullSchedule.update((showFullSchedule) => !showFullSchedule);
}
recordPayment(rentDue: RentDue): void {
this.dialog
.open<
RentPaymentValidationDialog,
RentPaymentValidationDialogData,
RentPaymentDialogResult
>(RentPaymentValidationDialog, {
data: { rentDue, apartmentName: this.apartmentName(rentDue) },
width: 'min(36rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
})
.afterClosed()
.subscribe((payment) => this.accept(payment, 'Le règlement a été enregistré.'));
}
allocateSurplus(payment: RentPayment): void {
const eligibleRentDues = this.eligibleRentDues(payment);
if (eligibleRentDues.length === 0) return;
this.dialog
.open<
RentPaymentAllocationDialog,
{ payment: RentPayment; eligibleRentDues: EligibleRentDue[] },
RentPaymentDialogResult
>(RentPaymentAllocationDialog, {
data: { payment, eligibleRentDues },
width: 'min(36rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
})
.afterClosed()
.subscribe((updated) => this.accept(updated, 'Le surplus a été affecté.'));
}
refundSurplus(payment: RentPayment): void {
this.dialog
.open<RentPaymentRefundDialog, RentPayment, RentPaymentDialogResult>(
RentPaymentRefundDialog,
{
data: payment,
width: 'min(34rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
},
)
.afterClosed()
.subscribe((updated) => this.accept(updated, 'Le remboursement a été enregistré.'));
}
reversePayment(payment: RentPayment): void {
this.dialog
.open<RentPaymentReversalDialog, RentPayment, RentPaymentDialogResult>(
RentPaymentReversalDialog,
{
data: payment,
role: 'alertdialog',
width: 'min(34rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
},
)
.afterClosed()
.subscribe((updated) => this.accept(updated, 'Le règlement a été contrepassé.'));
}
canAllocate(payment: RentPayment): boolean {
return payment.unallocatedBalance > 0 && this.eligibleRentDues(payment).length > 0;
}
private eligibleRentDues(payment: RentPayment): EligibleRentDue[] {
const allocatedDueIds = new Set(
payment.allocations.map((allocation) => allocation.rentDueId),
);
return sortRentDues(this.workspace.rentDues())
.filter(
(due) =>
due.leaseId === payment.leaseId &&
due.settlement !== 'PAID' &&
!allocatedDueIds.has(due.id),
)
.map((rentDue) => ({
rentDue,
label: `${this.apartmentName(rentDue)} — ${this.monthLabel(rentDue.month)}`,
}));
}
private accept(result: RentPaymentDialogResult | undefined, message: string): void {
if (!result) return;
if (result === RENT_PAYMENT_DATA_STALE) {
this.workspace.loadRentDues();
this.workspace.loadRentPayments();
this.feedback.set({
kind: 'error',
message:
'Les données ont changé. Elles ont été actualisées ; recommencez l’opération.',
});
return;
}
this.workspace.acceptRentPayment(result);
this.feedback.set({ kind: 'success', message });
}
}
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<string, RentDue[]>();
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 lastCorrectionIndex = schedule.reduce(
(lastCorrection, rentDue, index) => (rentDue.correctionRequired ? index : lastCorrection),
-1,
);
const visibleEndIndex =
nextDueIndex === -1 ? schedule.length - 1 : Math.max(nextDueIndex, lastCorrectionIndex);
const throughNextDue = schedule.slice(0, visibleEndIndex + 1);
const lastPaidIndex = throughNextDue.reduce(
(lastPaid, rentDue, index) => (rentDue.settlement === 'PAID' ? index : lastPaid),
-1,
);
const firstUnpaidIndex = throughNextDue.findIndex((rentDue) => rentDue.settlement !== 'PAID');
const firstCorrectionIndex = throughNextDue.findIndex((rentDue) => rentDue.correctionRequired);
const firstVisibleIndex = Math.min(
...[lastPaidIndex, firstUnpaidIndex, firstCorrectionIndex].filter((index) => index !== -1),
);
return firstVisibleIndex === Infinity ? [] : throughNextDue.slice(firstVisibleIndex);
}