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.
This commit is contained in:
1 parent
5d2d85aa8c
commit
878da155bd
564 files changed
+61467
-47179
No files matched your search
@@ -1,188 +1,188 @@
|
||||
.rent-payment-grid,
|
||||
.payment-history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
gap: var(--space-4, 1rem);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.schedule-actions,
|
||||
.payment-history {
|
||||
margin-block-start: var(--space-6, 1.5rem);
|
||||
margin-block-start: var(--space-6, 1.5rem);
|
||||
}
|
||||
|
||||
.schedule-actions {
|
||||
margin-block-end: var(--space-4, 1rem);
|
||||
margin-block-end: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.payment-history {
|
||||
padding-block-start: var(--space-8, 2rem);
|
||||
border-block-start: 1px solid var(--border, #dce5df);
|
||||
padding-block-start: var(--space-8, 2rem);
|
||||
border-block-start: 1px solid var(--border, #dce5df);
|
||||
}
|
||||
|
||||
.operation-feedback {
|
||||
padding: var(--space-4, 1rem);
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
padding: var(--space-4, 1rem);
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
border-color: var(--accent, #0f766e);
|
||||
background: var(--surface-accent, #dcefeb);
|
||||
color: var(--accent-strong, #0a5f59);
|
||||
border-color: var(--accent, #0f766e);
|
||||
background: var(--surface-accent, #dcefeb);
|
||||
color: var(--accent-strong, #0a5f59);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
border-color: var(--danger, #b42318);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
border-color: var(--danger, #b42318);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.rent-payment,
|
||||
.payment-history-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: var(--space-5, 1.25rem);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-xl, 1.375rem);
|
||||
background: var(--surface, #ffffff);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: var(--space-5, 1.25rem);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-xl, 1.375rem);
|
||||
background: var(--surface, #ffffff);
|
||||
}
|
||||
|
||||
.payment-history-card.reversed {
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
}
|
||||
|
||||
.payment-topline,
|
||||
.rent-payment dl > div,
|
||||
.payment-history-card dl > div,
|
||||
.movement-list li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
}
|
||||
|
||||
.payment-topline {
|
||||
margin-block-end: var(--space-4, 1rem);
|
||||
margin-block-end: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.rent-payment h2 {
|
||||
margin-block-end: var(--space-1, 0.25rem);
|
||||
margin-block-end: var(--space-1, 0.25rem);
|
||||
}
|
||||
|
||||
.payment-month,
|
||||
.rent-payment dt,
|
||||
.payment-history-card dt,
|
||||
.payment-history-card > p {
|
||||
color: var(--text-muted, #687670);
|
||||
color: var(--text-muted, #687670);
|
||||
}
|
||||
|
||||
.rent-payment dl,
|
||||
.payment-history-card dl {
|
||||
margin: var(--space-5, 1.25rem) 0;
|
||||
margin: var(--space-5, 1.25rem) 0;
|
||||
}
|
||||
|
||||
.rent-payment dl > div,
|
||||
.payment-history-card dl > div {
|
||||
padding-block: var(--space-2, 0.5rem);
|
||||
border-block-end: 1px solid var(--border, #dce5df);
|
||||
padding-block: var(--space-2, 0.5rem);
|
||||
border-block-end: 1px solid var(--border, #dce5df);
|
||||
}
|
||||
|
||||
.rent-payment dd,
|
||||
.payment-history-card dd {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rent-due-labels,
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-block-start: auto;
|
||||
padding-block-start: var(--space-4, 1rem);
|
||||
margin-block-start: auto;
|
||||
padding-block-start: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.card-actions button {
|
||||
min-height: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.settlement-status,
|
||||
.timing-status,
|
||||
.payment-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settlement-status.paid,
|
||||
.payment-status.confirmed {
|
||||
color: var(--accent, #0f766e);
|
||||
color: var(--accent, #0f766e);
|
||||
}
|
||||
|
||||
.settlement-status.unpaid,
|
||||
.settlement-status.partially_paid {
|
||||
color: var(--warning, #9a6700);
|
||||
color: var(--warning, #9a6700);
|
||||
}
|
||||
|
||||
.timing-status.overdue,
|
||||
.payment-status.reversed,
|
||||
.danger-link.mat-mdc-button {
|
||||
color: var(--danger, #b42318);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.timing-status.upcoming {
|
||||
color: var(--text-muted, #687670);
|
||||
color: var(--text-muted, #687670);
|
||||
}
|
||||
|
||||
.movement-list {
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.movement-list h3 {
|
||||
margin-block-end: var(--space-2, 0.5rem);
|
||||
font-size: 1rem;
|
||||
margin-block-end: var(--space-2, 0.5rem);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.movement-list ul {
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.movement-list li {
|
||||
min-width: 0;
|
||||
padding: var(--space-2, 0.5rem);
|
||||
border-radius: var(--radius-sm, 0.75rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
min-width: 0;
|
||||
padding: var(--space-2, 0.5rem);
|
||||
border-radius: var(--radius-sm, 0.75rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
}
|
||||
|
||||
.movement-list li span {
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.reversal-details {
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.card-error {
|
||||
margin: var(--space-3, 0.75rem) 0 0;
|
||||
color: var(--danger, #b42318);
|
||||
margin: var(--space-3, 0.75rem) 0 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
@@ -1,282 +1,335 @@
|
||||
<section class="page-section">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Location</p>
|
||||
<h1>Mes loyers</h1>
|
||||
<p>Suivez les échéances et enregistrez les règlements déjà reçus.</p>
|
||||
</div>
|
||||
</header>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Location</p>
|
||||
<h1>Mes loyers</h1>
|
||||
<p>Suivez les échéances et enregistrez les règlements déjà reçus.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (feedback(); as feedback) {
|
||||
<p
|
||||
class="operation-feedback"
|
||||
[class.success-message]="feedback.kind === 'success'"
|
||||
[class.error-message]="feedback.kind === 'error'"
|
||||
[attr.role]="feedback.kind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
}
|
||||
@if (feedback(); as feedback) {
|
||||
<p
|
||||
class="operation-feedback"
|
||||
[class.success-message]="feedback.kind === 'success'"
|
||||
[class.error-message]="feedback.kind === 'error'"
|
||||
[attr.role]="feedback.kind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="content-region" [attr.aria-busy]="scheduleLoading()">
|
||||
@if (!scheduleReady() && scheduleError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ scheduleError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (!scheduleReady()) {
|
||||
<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 (scheduleLoading()) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
|
||||
}
|
||||
@if (scheduleError()) {
|
||||
<div class="refresh-error" role="alert">
|
||||
<p>{{ scheduleError() }}</p>
|
||||
<button mat-button type="button" (click)="load()">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
@if (workspace.rentDues().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="schedule-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
aria-controls="rent-payment-schedule"
|
||||
[attr.aria-pressed]="showFullSchedule()"
|
||||
(click)="toggleSchedule()"
|
||||
>
|
||||
{{
|
||||
showFullSchedule() ? 'Afficher l’échéancier courant' : 'Afficher toutes les échéances'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div id="rent-payment-schedule" class="rent-payment-grid">
|
||||
@for (rentDue of displayedRentDues(); track rentDue.id) {
|
||||
<article class="rent-payment">
|
||||
<div class="payment-topline">
|
||||
<div class="rent-due-labels">
|
||||
<span class="settlement-status" [class]="rentDue.settlement.toLowerCase()">
|
||||
{{ settlementLabel(rentDue.settlement) }}
|
||||
</span>
|
||||
<span class="timing-status" [class]="rentDue.timing.toLowerCase()">
|
||||
{{ timingLabel(rentDue.timing) }}
|
||||
</span>
|
||||
</div>
|
||||
<strong>{{ formatEuro(rentDue.total) }}</strong>
|
||||
</div>
|
||||
<h2>{{ apartmentName(rentDue) }}</h2>
|
||||
<p class="payment-month">{{ monthLabel(rentDue.month) }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Loyer</dt>
|
||||
<dd>{{ formatEuro(rentDue.rent) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ formatEuro(rentDue.charges) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Déjà réglé</dt>
|
||||
<dd>{{ formatEuro(rentDue.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Restant à régler</dt>
|
||||
<dd>{{ formatEuro(rentDue.remainingAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Échéance</dt>
|
||||
<dd>{{ civilDate(rentDue.dueDate) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (rentDue.correctionRequired) {
|
||||
<app-rent-due-correction
|
||||
[rentDue]="rentDue"
|
||||
[payments]="workspace.rentPayments()"
|
||||
[loadState]="workspace.rentPaymentLoadState()"
|
||||
(reversalRequested)="reversePayment($event)"
|
||||
(retryRequested)="workspace.loadRentPayments()"
|
||||
/>
|
||||
} @else {
|
||||
<div class="card-actions">
|
||||
@if (rentDue.settlement !== 'PAID') {
|
||||
<button mat-flat-button type="button" (click)="recordPayment(rentDue)">
|
||||
Enregistrer un règlement
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="workspace.rentReceiptDownloading(rentDue.id)"
|
||||
(click)="workspace.downloadRentReceipt(rentDue)"
|
||||
>
|
||||
@if (workspace.rentReceiptDownloading(rentDue.id)) {
|
||||
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
|
||||
}
|
||||
Télécharger la quittance
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!rentDue.correctionRequired && workspace.rentReceiptDownloadError(rentDue.id)) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ workspace.rentReceiptDownloadError(rentDue.id) }}
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="content-region" [attr.aria-busy]="scheduleLoading()">
|
||||
@if (!scheduleReady() && scheduleError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ scheduleError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (!scheduleReady()) {
|
||||
<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 (scheduleLoading()) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
|
||||
}
|
||||
@if (scheduleError()) {
|
||||
<div class="refresh-error" role="alert">
|
||||
<p>{{ scheduleError() }}</p>
|
||||
<button mat-button type="button" (click)="load()">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
@if (workspace.rentDues().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="schedule-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
aria-controls="rent-payment-schedule"
|
||||
[attr.aria-pressed]="showFullSchedule()"
|
||||
(click)="toggleSchedule()"
|
||||
>
|
||||
{{
|
||||
showFullSchedule()
|
||||
? 'Afficher l’échéancier courant'
|
||||
: 'Afficher toutes les échéances'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div id="rent-payment-schedule" class="rent-payment-grid">
|
||||
@for (rentDue of displayedRentDues(); track rentDue.id) {
|
||||
<article class="rent-payment">
|
||||
<div class="payment-topline">
|
||||
<div class="rent-due-labels">
|
||||
<span
|
||||
class="settlement-status"
|
||||
[class]="rentDue.settlement.toLowerCase()"
|
||||
>
|
||||
{{ settlementLabel(rentDue.settlement) }}
|
||||
</span>
|
||||
<span
|
||||
class="timing-status"
|
||||
[class]="rentDue.timing.toLowerCase()"
|
||||
>
|
||||
{{ timingLabel(rentDue.timing) }}
|
||||
</span>
|
||||
</div>
|
||||
<strong>{{ formatEuro(rentDue.total) }}</strong>
|
||||
</div>
|
||||
<h2>{{ apartmentName(rentDue) }}</h2>
|
||||
<p class="payment-month">{{ monthLabel(rentDue.month) }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Loyer</dt>
|
||||
<dd>{{ formatEuro(rentDue.rent) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ formatEuro(rentDue.charges) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Déjà réglé</dt>
|
||||
<dd>{{ formatEuro(rentDue.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Restant à régler</dt>
|
||||
<dd>{{ formatEuro(rentDue.remainingAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Échéance</dt>
|
||||
<dd>{{ civilDate(rentDue.dueDate) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (rentDue.correctionRequired) {
|
||||
<app-rent-due-correction
|
||||
[rentDue]="rentDue"
|
||||
[payments]="workspace.rentPayments()"
|
||||
[loadState]="workspace.rentPaymentLoadState()"
|
||||
(reversalRequested)="reversePayment($event)"
|
||||
(retryRequested)="workspace.loadRentPayments()"
|
||||
/>
|
||||
} @else {
|
||||
<div class="card-actions">
|
||||
@if (rentDue.settlement !== 'PAID') {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="recordPayment(rentDue)"
|
||||
>
|
||||
Enregistrer un règlement
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
workspace.rentReceiptDownloading(rentDue.id)
|
||||
"
|
||||
(click)="workspace.downloadRentReceipt(rentDue)"
|
||||
>
|
||||
@if (workspace.rentReceiptDownloading(rentDue.id)) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-label="Téléchargement en cours"
|
||||
/>
|
||||
}
|
||||
Télécharger la quittance
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
!rentDue.correctionRequired &&
|
||||
workspace.rentReceiptDownloadError(rentDue.id)
|
||||
) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ workspace.rentReceiptDownloadError(rentDue.id) }}
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<section class="payment-history" aria-labelledby="payment-history-title">
|
||||
<header>
|
||||
<p class="eyebrow">Encaissements</p>
|
||||
<h2 id="payment-history-title">Règlements enregistrés</h2>
|
||||
</header>
|
||||
<div [attr.aria-busy]="workspace.rentPaymentLoadState().loading">
|
||||
@if (!workspace.rentPaymentLoadState().loaded && workspace.rentPaymentLoadState().error) {
|
||||
<article class="load-error" role="alert">
|
||||
<p>{{ workspace.rentPaymentLoadState().error }}</p>
|
||||
<button mat-stroked-button type="button" (click)="workspace.loadRentPayments()">
|
||||
Réessayer
|
||||
</button>
|
||||
</article>
|
||||
} @else if (!workspace.rentPaymentLoadState().loaded) {
|
||||
<div class="payment-history-grid" aria-label="Chargement des règlements">
|
||||
@for (placeholder of [1, 2]; track placeholder) {
|
||||
<article class="skeleton-card" aria-hidden="true"></article>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (workspace.rentPaymentLoadState().loading) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des règlements" />
|
||||
}
|
||||
@if (workspace.rentPaymentLoadState().error) {
|
||||
<p class="refresh-error" role="alert">{{ workspace.rentPaymentLoadState().error }}</p>
|
||||
}
|
||||
@if (displayedPayments().length === 0) {
|
||||
<article class="empty-state"><p>Aucun règlement enregistré.</p></article>
|
||||
} @else {
|
||||
<div class="payment-history-grid">
|
||||
@for (payment of displayedPayments(); track payment.id) {
|
||||
<article
|
||||
class="payment-history-card"
|
||||
[class.reversed]="payment.status === 'REVERSED'"
|
||||
>
|
||||
<div class="payment-topline">
|
||||
<span class="payment-status" [class]="payment.status.toLowerCase()">
|
||||
{{ paymentStatusLabel(payment.status) }}
|
||||
</span>
|
||||
<strong>{{ formatEuro(payment.amount) }}</strong>
|
||||
</div>
|
||||
<p>Reçu le {{ civilDate(payment.receivedAt) }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Affecté</dt>
|
||||
<dd>{{ formatEuro(payment.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Remboursé</dt>
|
||||
<dd>{{ formatEuro(payment.refundedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Solde non affecté</dt>
|
||||
<dd>{{ formatEuro(payment.unallocatedBalance) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<section class="payment-history" aria-labelledby="payment-history-title">
|
||||
<header>
|
||||
<p class="eyebrow">Encaissements</p>
|
||||
<h2 id="payment-history-title">Règlements enregistrés</h2>
|
||||
</header>
|
||||
<div [attr.aria-busy]="workspace.rentPaymentLoadState().loading">
|
||||
@if (
|
||||
!workspace.rentPaymentLoadState().loaded && workspace.rentPaymentLoadState().error
|
||||
) {
|
||||
<article class="load-error" role="alert">
|
||||
<p>{{ workspace.rentPaymentLoadState().error }}</p>
|
||||
<button mat-stroked-button type="button" (click)="workspace.loadRentPayments()">
|
||||
Réessayer
|
||||
</button>
|
||||
</article>
|
||||
} @else if (!workspace.rentPaymentLoadState().loaded) {
|
||||
<div class="payment-history-grid" aria-label="Chargement des règlements">
|
||||
@for (placeholder of [1, 2]; track placeholder) {
|
||||
<article class="skeleton-card" aria-hidden="true"></article>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (workspace.rentPaymentLoadState().loading) {
|
||||
<mat-progress-bar
|
||||
mode="indeterminate"
|
||||
aria-label="Actualisation des règlements"
|
||||
/>
|
||||
}
|
||||
@if (workspace.rentPaymentLoadState().error) {
|
||||
<p class="refresh-error" role="alert">
|
||||
{{ workspace.rentPaymentLoadState().error }}
|
||||
</p>
|
||||
}
|
||||
@if (displayedPayments().length === 0) {
|
||||
<article class="empty-state"><p>Aucun règlement enregistré.</p></article>
|
||||
} @else {
|
||||
<div class="payment-history-grid">
|
||||
@for (payment of displayedPayments(); track payment.id) {
|
||||
<article
|
||||
class="payment-history-card"
|
||||
[class.reversed]="payment.status === 'REVERSED'"
|
||||
>
|
||||
<div class="payment-topline">
|
||||
<span
|
||||
class="payment-status"
|
||||
[class]="payment.status.toLowerCase()"
|
||||
>
|
||||
{{ paymentStatusLabel(payment.status) }}
|
||||
</span>
|
||||
<strong>{{ formatEuro(payment.amount) }}</strong>
|
||||
</div>
|
||||
<p>Reçu le {{ civilDate(payment.receivedAt) }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Affecté</dt>
|
||||
<dd>{{ formatEuro(payment.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Remboursé</dt>
|
||||
<dd>{{ formatEuro(payment.refundedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Solde non affecté</dt>
|
||||
<dd>{{ formatEuro(payment.unallocatedBalance) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (payment.allocations.length > 0) {
|
||||
<div class="movement-list">
|
||||
<h3>Affectations</h3>
|
||||
<ul>
|
||||
@for (allocation of payment.allocations; track allocation.id) {
|
||||
<li>
|
||||
<span>{{ allocationLabel(allocation) }}</span
|
||||
><strong>{{ formatEuro(allocation.amount) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (payment.refunds.length > 0) {
|
||||
<div class="movement-list">
|
||||
<h3>Remboursements</h3>
|
||||
<ul>
|
||||
@for (refund of payment.refunds; track refund.id) {
|
||||
<li>
|
||||
<span>{{ civilDate(refund.refundedAt) }}</span
|
||||
><strong>{{ formatEuro(refund.amount) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (payment.reversal; as reversal) {
|
||||
<div class="reversal-details">
|
||||
<strong>Contrepassé le {{ civilDate(reversal.reversedAt) }}</strong>
|
||||
<span>{{ reversal.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (payment.allocations.length > 0) {
|
||||
<div class="movement-list">
|
||||
<h3>Affectations</h3>
|
||||
<ul>
|
||||
@for (
|
||||
allocation of payment.allocations;
|
||||
track allocation.id
|
||||
) {
|
||||
<li>
|
||||
<span>{{ allocationLabel(allocation) }}</span
|
||||
><strong>{{
|
||||
formatEuro(allocation.amount)
|
||||
}}</strong>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (payment.refunds.length > 0) {
|
||||
<div class="movement-list">
|
||||
<h3>Remboursements</h3>
|
||||
<ul>
|
||||
@for (refund of payment.refunds; track refund.id) {
|
||||
<li>
|
||||
<span>{{ civilDate(refund.refundedAt) }}</span
|
||||
><strong>{{
|
||||
formatEuro(refund.amount)
|
||||
}}</strong>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (payment.reversal; as reversal) {
|
||||
<div class="reversal-details">
|
||||
<strong
|
||||
>Contrepassé le
|
||||
{{ civilDate(reversal.reversedAt) }}</strong
|
||||
>
|
||||
<span>{{ reversal.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (payment.status === 'CONFIRMED') {
|
||||
<div class="card-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="workspace.paymentReceiptDownloading(payment.id)"
|
||||
(click)="workspace.downloadPaymentReceipt(payment)"
|
||||
>
|
||||
@if (workspace.paymentReceiptDownloading(payment.id)) {
|
||||
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
|
||||
}
|
||||
Télécharger le reçu
|
||||
</button>
|
||||
@if (canAllocate(payment)) {
|
||||
<button mat-stroked-button type="button" (click)="allocateSurplus(payment)">
|
||||
Affecter
|
||||
</button>
|
||||
}
|
||||
@if (payment.unallocatedBalance > 0) {
|
||||
<button mat-stroked-button type="button" (click)="refundSurplus(payment)">
|
||||
Rembourser
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-link"
|
||||
(click)="reversePayment(payment)"
|
||||
>
|
||||
Contrepasser
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (workspace.paymentReceiptDownloadError(payment.id)) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ workspace.paymentReceiptDownloadError(payment.id) }}
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@if (payment.status === 'CONFIRMED') {
|
||||
<div class="card-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
workspace.paymentReceiptDownloading(payment.id)
|
||||
"
|
||||
(click)="workspace.downloadPaymentReceipt(payment)"
|
||||
>
|
||||
@if (workspace.paymentReceiptDownloading(payment.id)) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-label="Téléchargement en cours"
|
||||
/>
|
||||
}
|
||||
Télécharger le reçu
|
||||
</button>
|
||||
@if (canAllocate(payment)) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="allocateSurplus(payment)"
|
||||
>
|
||||
Affecter
|
||||
</button>
|
||||
}
|
||||
@if (payment.unallocatedBalance > 0) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="refundSurplus(payment)"
|
||||
>
|
||||
Rembourser
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-link"
|
||||
(click)="reversePayment(payment)"
|
||||
>
|
||||
Contrepasser
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (workspace.paymentReceiptDownloadError(payment.id)) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ workspace.paymentReceiptDownloadError(payment.id) }}
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -13,377 +13,393 @@ import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { RentPaymentsPage } from './rent-payments-page';
|
||||
|
||||
describe('RentPaymentsPage', () => {
|
||||
it('loads dues and payment history and offers recording on every non-paid due', () => {
|
||||
const { fixture, workspace } = createFixture([
|
||||
rentDue('due-1', '2027-08', 'UPCOMING'),
|
||||
rentDue('due-2', '2027-09', 'UPCOMING', 'PAID', { allocatedAmount: 850, remainingAmount: 0 }),
|
||||
]);
|
||||
it('loads dues and payment history and offers recording on every non-paid due', () => {
|
||||
const { fixture, workspace } = createFixture([
|
||||
rentDue('due-1', '2027-08', 'UPCOMING'),
|
||||
rentDue('due-2', '2027-09', 'UPCOMING', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(workspace.loadRentDues).toHaveBeenCalledOnce();
|
||||
expect(workspace.loadRentPayments).toHaveBeenCalledOnce();
|
||||
expect(button(fixture, 'Enregistrer un règlement')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Aucun règlement enregistré');
|
||||
});
|
||||
expect(workspace.loadRentDues).toHaveBeenCalledOnce();
|
||||
expect(workspace.loadRentPayments).toHaveBeenCalledOnce();
|
||||
expect(button(fixture, 'Enregistrer un règlement')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Aucun règlement enregistré');
|
||||
});
|
||||
|
||||
it('renders amounts, settlement, and timing as independent RentDue facts', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('due-1', '2026-07', 'OVERDUE', 'PARTIALLY_PAID', {
|
||||
allocatedAmount: 500,
|
||||
remainingAmount: 350,
|
||||
}),
|
||||
]);
|
||||
const card = fixture.nativeElement.querySelector('.rent-payment') as HTMLElement;
|
||||
it('renders amounts, settlement, and timing as independent RentDue facts', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('due-1', '2026-07', 'OVERDUE', 'PARTIALLY_PAID', {
|
||||
allocatedAmount: 500,
|
||||
remainingAmount: 350,
|
||||
}),
|
||||
]);
|
||||
const card = fixture.nativeElement.querySelector('.rent-payment') as HTMLElement;
|
||||
|
||||
expect(card.textContent).toContain('T2');
|
||||
expect(card.textContent).toContain('800,00 €');
|
||||
expect(card.textContent).toContain('500,00 €');
|
||||
expect(card.textContent).toContain('350,00 €');
|
||||
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.textContent).toContain('T2');
|
||||
expect(card.textContent).toContain('800,00 €');
|
||||
expect(card.textContent).toContain('500,00 €');
|
||||
expect(card.textContent).toContain('350,00 €');
|
||||
expect(card.querySelector('.settlement-status.partially_paid')?.textContent).toContain(
|
||||
'Partiellement réglée',
|
||||
);
|
||||
expect(card.querySelector('.timing-status.overdue')?.textContent).toContain('En retard');
|
||||
});
|
||||
|
||||
it('offers the existing reversal journey when a lease end overallocates a due', () => {
|
||||
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
});
|
||||
const allocatedPayment = payment();
|
||||
const { fixture, dialog } = createFixture([overallocated], [allocatedPayment]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(undefined) });
|
||||
it('offers the existing reversal journey when a lease end overallocates a due', () => {
|
||||
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
});
|
||||
const allocatedPayment = payment();
|
||||
const { fixture, dialog } = createFixture([overallocated], [allocatedPayment]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(undefined) });
|
||||
|
||||
const warning = fixture.nativeElement.querySelector('.rent-due-correction') as HTMLElement;
|
||||
expect(warning.getAttribute('role')).toBe('alert');
|
||||
expect(warning.textContent).toContain(
|
||||
'La fin du bail a rendu un ou plusieurs règlements suraffectés',
|
||||
);
|
||||
expect(button(fixture, 'Contrepasser le règlement')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Télécharger la quittance');
|
||||
const warning = fixture.nativeElement.querySelector('.rent-due-correction') as HTMLElement;
|
||||
expect(warning.getAttribute('role')).toBe('alert');
|
||||
expect(warning.textContent).toContain(
|
||||
'La fin du bail a rendu un ou plusieurs règlements suraffectés',
|
||||
);
|
||||
expect(button(fixture, 'Contrepasser le règlement')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Télécharger la quittance');
|
||||
|
||||
button(fixture, 'Contrepasser le règlement')?.click();
|
||||
button(fixture, 'Contrepasser le règlement')?.click();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentReversalDialog,
|
||||
expect.objectContaining({ data: allocatedPayment, role: 'alertdialog' }),
|
||||
);
|
||||
});
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentReversalDialog,
|
||||
expect.objectContaining({ data: allocatedPayment, role: 'alertdialog' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers reversal for every confirmed partial payment allocated to the corrected due', () => {
|
||||
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 900,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
});
|
||||
const first = payment({
|
||||
id: 'payment-1',
|
||||
amount: 500,
|
||||
allocatedAmount: 500,
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 500, allocatedAt: '2026-07-06' },
|
||||
],
|
||||
});
|
||||
const second = payment({
|
||||
id: 'payment-2',
|
||||
receivedAt: '2026-07-07',
|
||||
amount: 400,
|
||||
allocatedAmount: 400,
|
||||
allocations: [
|
||||
{ id: 'allocation-2', rentDueId: 'due-1', amount: 400, allocatedAt: '2026-07-07' },
|
||||
],
|
||||
});
|
||||
const { fixture, dialog } = createFixture([overallocated], [first, second]);
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
it('offers reversal for every confirmed partial payment allocated to the corrected due', () => {
|
||||
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 900,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
});
|
||||
const first = payment({
|
||||
id: 'payment-1',
|
||||
amount: 500,
|
||||
allocatedAmount: 500,
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 500, allocatedAt: '2026-07-06' },
|
||||
],
|
||||
});
|
||||
const second = payment({
|
||||
id: 'payment-2',
|
||||
receivedAt: '2026-07-07',
|
||||
amount: 400,
|
||||
allocatedAmount: 400,
|
||||
allocations: [
|
||||
{ id: 'allocation-2', rentDueId: 'due-1', amount: 400, allocatedAt: '2026-07-07' },
|
||||
],
|
||||
});
|
||||
const { fixture, dialog } = createFixture([overallocated], [first, second]);
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
|
||||
const actions = [
|
||||
...fixture.nativeElement.querySelectorAll('.rent-due-correction button'),
|
||||
] as HTMLButtonElement[];
|
||||
expect(actions).toHaveLength(2);
|
||||
const actions = [
|
||||
...fixture.nativeElement.querySelectorAll('.rent-due-correction button'),
|
||||
] as HTMLButtonElement[];
|
||||
expect(actions).toHaveLength(2);
|
||||
|
||||
actions[1].click();
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentReversalDialog,
|
||||
expect.objectContaining({ data: second }),
|
||||
);
|
||||
});
|
||||
actions[1].click();
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentReversalDialog,
|
||||
expect.objectContaining({ data: second }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows overdue dues through the next upcoming due', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('overdue-january', '2026-01', 'OVERDUE'),
|
||||
rentDue('overdue-february', '2026-02', 'OVERDUE'),
|
||||
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
it('shows overdue dues through the next upcoming due', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('overdue-january', '2026-01', 'OVERDUE'),
|
||||
rentDue('overdue-february', '2026-02', 'OVERDUE'),
|
||||
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
|
||||
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 { fixture } = createFixture([
|
||||
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-february', '2026-02', 'OVERDUE'),
|
||||
rentDue('paid-march', '2026-03', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-april', '2026-04', 'OVERDUE'),
|
||||
rentDue('upcoming-may', '2026-05', 'UPCOMING'),
|
||||
rentDue('upcoming-june', '2026-06', 'UPCOMING'),
|
||||
]);
|
||||
it('starts the current schedule at the older of the last paid and first unpaid due', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-february', '2026-02', 'OVERDUE'),
|
||||
rentDue('paid-march', '2026-03', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-april', '2026-04', 'OVERDUE'),
|
||||
rentDue('upcoming-may', '2026-05', 'UPCOMING'),
|
||||
rentDue('upcoming-june', '2026-06', 'UPCOMING'),
|
||||
]);
|
||||
|
||||
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026', 'avril 2026', 'mai 2026']);
|
||||
});
|
||||
expect(paymentMonths(fixture)).toEqual([
|
||||
'février 2026',
|
||||
'mars 2026',
|
||||
'avril 2026',
|
||||
'mai 2026',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never hides an older due that still requires correction', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('corrected-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
}),
|
||||
rentDue('paid-february', '2026-02', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-march', '2026-03', 'OVERDUE'),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
it('never hides an older due that still requires correction', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('corrected-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
}),
|
||||
rentDue('paid-february', '2026-02', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-march', '2026-03', 'OVERDUE'),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extends the current schedule through every prepaid due that requires correction', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('upcoming-february', '2026-02', 'UPCOMING'),
|
||||
rentDue('corrected-march', '2026-03', 'UPCOMING', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
}),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
it('extends the current schedule through every prepaid due that requires correction', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('upcoming-february', '2026-02', 'UPCOMING'),
|
||||
rentDue('corrected-march', '2026-03', 'UPCOMING', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
}),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
|
||||
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026']);
|
||||
});
|
||||
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026']);
|
||||
});
|
||||
|
||||
it('reveals and hides the full chronological schedule', () => {
|
||||
const { fixture } = createFixture([
|
||||
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 toggle = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
||||
it('reveals and hides the full chronological schedule', () => {
|
||||
const { fixture } = createFixture([
|
||||
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 toggle = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(paymentMonths(fixture)).toEqual([
|
||||
'janvier 2026',
|
||||
'février 2026',
|
||||
'mars 2026',
|
||||
'avril 2026',
|
||||
]);
|
||||
});
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(paymentMonths(fixture)).toEqual([
|
||||
'janvier 2026',
|
||||
'février 2026',
|
||||
'mars 2026',
|
||||
'avril 2026',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the payment modal result and refreshes the ledger after success', () => {
|
||||
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
|
||||
const { fixture, workspace, dialog } = createFixture([rentDue('due-1', '2026-07', 'UPCOMING')]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(recorded) });
|
||||
it('keeps the payment modal result and refreshes the ledger after success', () => {
|
||||
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
|
||||
const { fixture, workspace, dialog } = createFixture([
|
||||
rentDue('due-1', '2026-07', 'UPCOMING'),
|
||||
]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(recorded) });
|
||||
|
||||
button(fixture, 'Enregistrer un règlement')?.click();
|
||||
fixture.detectChanges();
|
||||
button(fixture, 'Enregistrer un règlement')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentValidationDialog,
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ apartmentName: 'T2' }),
|
||||
}),
|
||||
);
|
||||
expect(workspace.acceptRentPayment).toHaveBeenCalledWith(recorded);
|
||||
expect(fixture.nativeElement.textContent).toContain('Le règlement a été enregistré.');
|
||||
});
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentValidationDialog,
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ apartmentName: 'T2' }),
|
||||
}),
|
||||
);
|
||||
expect(workspace.acceptRentPayment).toHaveBeenCalledWith(recorded);
|
||||
expect(fixture.nativeElement.textContent).toContain('Le règlement a été enregistré.');
|
||||
});
|
||||
|
||||
it('refreshes stale payment data and announces the rejected operation', () => {
|
||||
const { fixture, workspace, dialog } = createFixture([rentDue('due-1', '2026-07', 'UPCOMING')]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(RENT_PAYMENT_DATA_STALE) });
|
||||
it('refreshes stale payment data and announces the rejected operation', () => {
|
||||
const { fixture, workspace, dialog } = createFixture([
|
||||
rentDue('due-1', '2026-07', 'UPCOMING'),
|
||||
]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(RENT_PAYMENT_DATA_STALE) });
|
||||
|
||||
button(fixture, 'Enregistrer un règlement')?.click();
|
||||
fixture.detectChanges();
|
||||
button(fixture, 'Enregistrer un règlement')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(workspace.loadRentDues).toHaveBeenCalledTimes(2);
|
||||
expect(workspace.loadRentPayments).toHaveBeenCalledTimes(2);
|
||||
expect(workspace.acceptRentPayment).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Les données ont changé',
|
||||
);
|
||||
});
|
||||
expect(workspace.loadRentDues).toHaveBeenCalledTimes(2);
|
||||
expect(workspace.loadRentPayments).toHaveBeenCalledTimes(2);
|
||||
expect(workspace.acceptRentPayment).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Les données ont changé',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows confirmed payment history and opens each lifecycle action', () => {
|
||||
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
|
||||
const { fixture, dialog } = createFixture(
|
||||
[
|
||||
rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('due-2', '2026-08', 'UPCOMING'),
|
||||
],
|
||||
[recorded],
|
||||
);
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
it('shows confirmed payment history and opens each lifecycle action', () => {
|
||||
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
|
||||
const { fixture, dialog } = createFixture(
|
||||
[
|
||||
rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('due-2', '2026-08', 'UPCOMING'),
|
||||
],
|
||||
[recorded],
|
||||
);
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
|
||||
button(fixture, 'Affecter')?.click();
|
||||
button(fixture, 'Rembourser')?.click();
|
||||
button(fixture, 'Contrepasser')?.click();
|
||||
button(fixture, 'Affecter')?.click();
|
||||
button(fixture, 'Rembourser')?.click();
|
||||
button(fixture, 'Contrepasser')?.click();
|
||||
|
||||
expect(dialog.open.mock.calls.map((call) => call[0])).toEqual([
|
||||
RentPaymentAllocationDialog,
|
||||
RentPaymentRefundDialog,
|
||||
RentPaymentReversalDialog,
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('100,00 €');
|
||||
expect(fixture.nativeElement.textContent).toContain('Télécharger le reçu');
|
||||
});
|
||||
expect(dialog.open.mock.calls.map((call) => call[0])).toEqual([
|
||||
RentPaymentAllocationDialog,
|
||||
RentPaymentRefundDialog,
|
||||
RentPaymentReversalDialog,
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('100,00 €');
|
||||
expect(fixture.nativeElement.textContent).toContain('Télécharger le reçu');
|
||||
});
|
||||
|
||||
it('keeps reversal metadata visible without mutation actions', () => {
|
||||
const reversed = payment({
|
||||
status: 'REVERSED',
|
||||
reversal: {
|
||||
id: 'reversal-1',
|
||||
reversedAt: '2026-07-08',
|
||||
reason: 'Erreur de saisie',
|
||||
reversedAllocations: [],
|
||||
},
|
||||
});
|
||||
const { fixture } = createFixture([], [reversed]);
|
||||
const history = fixture.nativeElement.querySelector('.payment-history-card') as HTMLElement;
|
||||
it('keeps reversal metadata visible without mutation actions', () => {
|
||||
const reversed = payment({
|
||||
status: 'REVERSED',
|
||||
reversal: {
|
||||
id: 'reversal-1',
|
||||
reversedAt: '2026-07-08',
|
||||
reason: 'Erreur de saisie',
|
||||
reversedAllocations: [],
|
||||
},
|
||||
});
|
||||
const { fixture } = createFixture([], [reversed]);
|
||||
const history = fixture.nativeElement.querySelector('.payment-history-card') as HTMLElement;
|
||||
|
||||
expect(history.textContent).toContain('Contrepassé');
|
||||
expect(history.textContent).toContain('Erreur de saisie');
|
||||
expect(history.textContent).not.toContain('Télécharger le reçu');
|
||||
});
|
||||
expect(history.textContent).toContain('Contrepassé');
|
||||
expect(history.textContent).toContain('Erreur de saisie');
|
||||
expect(history.textContent).not.toContain('Télécharger le reçu');
|
||||
});
|
||||
|
||||
it('shows independent loading and error states', () => {
|
||||
const { fixture } = createFixture([], [], { loaded: false, loading: false, error: 'Erreur.' });
|
||||
expect(fixture.nativeElement.textContent).toContain('Erreur.');
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
|
||||
});
|
||||
it('shows independent loading and error states', () => {
|
||||
const { fixture } = createFixture([], [], {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: 'Erreur.',
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Erreur.');
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function createFixture(
|
||||
rentDues: RentDue[],
|
||||
rentPayments: RentPayment[] = [],
|
||||
loadState = { loaded: true, loading: false, error: '' },
|
||||
rentDues: RentDue[],
|
||||
rentPayments: RentPayment[] = [],
|
||||
loadState = { loaded: true, loading: false, error: '' },
|
||||
) {
|
||||
TestBed.resetTestingModule();
|
||||
const workspace = {
|
||||
apartments: signal([{ id: 'apartment-1', name: 'T2' }]),
|
||||
leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]),
|
||||
rentDues: signal(rentDues),
|
||||
rentPayments: signal(rentPayments),
|
||||
apartmentLoadState: signal(loadState),
|
||||
leaseLoadState: signal(loadState),
|
||||
rentDueLoadState: signal(loadState),
|
||||
rentPaymentLoadState: signal(loadState),
|
||||
loadApartments: vi.fn(),
|
||||
loadLeases: vi.fn(),
|
||||
loadRentDues: vi.fn(),
|
||||
loadRentPayments: vi.fn(),
|
||||
acceptRentPayment: vi.fn(),
|
||||
rentReceiptDownloading: vi.fn(() => false),
|
||||
rentReceiptDownloadError: vi.fn(() => ''),
|
||||
downloadRentReceipt: vi.fn(),
|
||||
paymentReceiptDownloading: vi.fn(() => false),
|
||||
paymentReceiptDownloadError: vi.fn(() => ''),
|
||||
downloadPaymentReceipt: vi.fn(),
|
||||
};
|
||||
const dialog = {
|
||||
open: vi
|
||||
.fn()
|
||||
.mockReturnValue({ afterClosed: () => of<RentPaymentDialogResult | undefined>(undefined) }),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(RentPaymentsPage);
|
||||
fixture.detectChanges();
|
||||
return { fixture, workspace, dialog };
|
||||
TestBed.resetTestingModule();
|
||||
const workspace = {
|
||||
apartments: signal([{ id: 'apartment-1', name: 'T2' }]),
|
||||
leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]),
|
||||
rentDues: signal(rentDues),
|
||||
rentPayments: signal(rentPayments),
|
||||
apartmentLoadState: signal(loadState),
|
||||
leaseLoadState: signal(loadState),
|
||||
rentDueLoadState: signal(loadState),
|
||||
rentPaymentLoadState: signal(loadState),
|
||||
loadApartments: vi.fn(),
|
||||
loadLeases: vi.fn(),
|
||||
loadRentDues: vi.fn(),
|
||||
loadRentPayments: vi.fn(),
|
||||
acceptRentPayment: vi.fn(),
|
||||
rentReceiptDownloading: vi.fn(() => false),
|
||||
rentReceiptDownloadError: vi.fn(() => ''),
|
||||
downloadRentReceipt: vi.fn(),
|
||||
paymentReceiptDownloading: vi.fn(() => false),
|
||||
paymentReceiptDownloadError: vi.fn(() => ''),
|
||||
downloadPaymentReceipt: vi.fn(),
|
||||
};
|
||||
const dialog = {
|
||||
open: vi.fn().mockReturnValue({
|
||||
afterClosed: () => of<RentPaymentDialogResult | undefined>(undefined),
|
||||
}),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(RentPaymentsPage);
|
||||
fixture.detectChanges();
|
||||
return { fixture, workspace, dialog };
|
||||
}
|
||||
|
||||
function rentDue(
|
||||
id: string,
|
||||
month: string,
|
||||
timing: RentDue['timing'],
|
||||
settlement: RentDue['settlement'] = 'UNPAID',
|
||||
overrides: Partial<RentDue> = {},
|
||||
id: string,
|
||||
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,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 850,
|
||||
settlement,
|
||||
timing,
|
||||
correctionRequired: false,
|
||||
version: 0,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
id,
|
||||
leaseId: 'lease-1',
|
||||
month,
|
||||
dueDate: `${month}-05`,
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 850,
|
||||
settlement,
|
||||
timing,
|
||||
correctionRequired: false,
|
||||
version: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function payment(overrides: Partial<RentPayment> = {}): RentPayment {
|
||||
return {
|
||||
id: 'payment-1',
|
||||
leaseId: 'lease-1',
|
||||
receivedAt: '2026-07-06',
|
||||
amount: 850,
|
||||
status: 'CONFIRMED',
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 850, allocatedAt: '2026-07-06' },
|
||||
],
|
||||
allocatedAmount: 850,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 0,
|
||||
confirmedAt: '2026-07-06',
|
||||
version: 0,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
id: 'payment-1',
|
||||
leaseId: 'lease-1',
|
||||
receivedAt: '2026-07-06',
|
||||
amount: 850,
|
||||
status: 'CONFIRMED',
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 850, allocatedAt: '2026-07-06' },
|
||||
],
|
||||
allocatedAmount: 850,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 0,
|
||||
confirmedAt: '2026-07-06',
|
||||
version: 0,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function paymentMonths(fixture: ReturnType<typeof TestBed.createComponent>): string[] {
|
||||
return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) =>
|
||||
element.textContent.trim(),
|
||||
);
|
||||
return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) =>
|
||||
element.textContent.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function button(
|
||||
fixture: ReturnType<typeof TestBed.createComponent>,
|
||||
text: string,
|
||||
fixture: ReturnType<typeof TestBed.createComponent>,
|
||||
text: string,
|
||||
): HTMLButtonElement | undefined {
|
||||
return [...fixture.nativeElement.querySelectorAll('button')].find((element) =>
|
||||
element.textContent.includes(text),
|
||||
);
|
||||
return [...fixture.nativeElement.querySelectorAll('button')].find((element) =>
|
||||
element.textContent.includes(text),
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import {
|
||||
EligibleRentDue,
|
||||
RentPaymentAllocationDialog,
|
||||
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,
|
||||
RentPaymentValidationDialog,
|
||||
RentPaymentValidationDialogData,
|
||||
} from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
|
||||
import { RentDue } from '../../services/lease.service';
|
||||
import { formatEuro } from '../../services/rent-payment-format';
|
||||
@@ -22,224 +22,230 @@ 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',
|
||||
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,
|
||||
);
|
||||
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();
|
||||
}
|
||||
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'
|
||||
);
|
||||
}
|
||||
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`),
|
||||
);
|
||||
}
|
||||
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
|
||||
];
|
||||
}
|
||||
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];
|
||||
}
|
||||
timingLabel(timing: RentDue['timing']): string {
|
||||
return { UPCOMING: 'À venir', OVERDUE: 'En retard' }[timing];
|
||||
}
|
||||
|
||||
paymentStatusLabel(status: RentPayment['status']): string {
|
||||
return { CONFIRMED: 'Confirmé', REVERSED: 'Contrepassé' }[status];
|
||||
}
|
||||
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';
|
||||
}
|
||||
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();
|
||||
}
|
||||
load(): void {
|
||||
this.workspace.loadRentDues();
|
||||
this.workspace.loadLeases();
|
||||
this.workspace.loadApartments();
|
||||
this.workspace.loadRentPayments();
|
||||
}
|
||||
|
||||
toggleSchedule(): void {
|
||||
this.showFullSchedule.update((showFullSchedule) => !showFullSchedule);
|
||||
}
|
||||
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é.'));
|
||||
}
|
||||
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é.'));
|
||||
}
|
||||
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é.'));
|
||||
}
|
||||
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é.'));
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 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 });
|
||||
}
|
||||
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));
|
||||
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));
|
||||
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),
|
||||
);
|
||||
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);
|
||||
return firstVisibleIndex === Infinity ? [] : throughNextDue.slice(firstVisibleIndex);
|
||||
}
|
||||
Reference in new issue
Block a user