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, { 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, { 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(); 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); }