feat: activate draft leases into rent dues (#28)
This commit is contained in:
1 parent
971ffd6feb
commit
c2afe515c0
42 files changed
+1735
-113
No files matched your search
@@ -63,6 +63,45 @@
|
||||
width: 100%;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.rent-schedule {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rent-schedule h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.rent-due {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
.rent-due + .rent-due {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rent-due dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.rent-due dt {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rent-due dd {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.rent-due-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.card-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<article class="lease-card" [attr.aria-busy]="deleting() || downloading()">
|
||||
<article class="lease-card" [attr.aria-busy]="deleting() || downloading() || activating()">
|
||||
<div class="lease-topline">
|
||||
<span class="status-dot"></span><span>{{ statusLabel() }}</span
|
||||
><strong>{{ lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
|
||||
@@ -8,7 +8,10 @@
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>Période</dt>
|
||||
<dd>{{ lease().startDate | date:'dd MMMM yyyy' }} → {{ lease().endDate | date:'dd MMMM yyyy' }}</dd>
|
||||
<dd>
|
||||
{{ lease().startDate | date: 'dd MMMM yyyy' }} →
|
||||
{{ lease().endDate | date: 'dd MMMM yyyy' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
@@ -27,14 +30,48 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (deleteError() || downloadError()) {
|
||||
<p class="card-error" role="alert">{{ deleteError() || downloadError() }}</p>
|
||||
@if (rentDues().length) {
|
||||
<section class="rent-schedule" aria-label="Échéancier de loyer">
|
||||
<h3>Échéancier</h3>
|
||||
@for (rentDue of rentDues(); track rentDue.id) {
|
||||
<article class="rent-due">
|
||||
<strong>{{ rentDue.month }}</strong>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Loyer</dt>
|
||||
<dd>{{ rentDue.rent.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ rentDue.charges.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Total</dt>
|
||||
<dd>{{ rentDue.total.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Restant</dt>
|
||||
<dd>{{ rentDue.remainingAmount.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="rent-due-labels">
|
||||
<span>Règlement : {{ settlementLabel(rentDue.settlement) }}</span>
|
||||
<span>Temporalité : {{ timingLabel(rentDue.timing) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (deleteError() || downloadError() || activationError()) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ deleteError() || downloadError() || activationError() }}
|
||||
</p>
|
||||
}
|
||||
<div class="card-actions" aria-live="polite">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="deleting() || downloading()"
|
||||
[disabled]="deleting() || downloading() || activating()"
|
||||
(click)="pdfRequested.emit(lease())"
|
||||
>
|
||||
@if (downloading()) {
|
||||
@@ -43,12 +80,32 @@
|
||||
Télécharger le bail
|
||||
}
|
||||
</button>
|
||||
@if (lease().id) {
|
||||
@if (lease().id && lease().status === 'DRAFT') {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="deleting() || downloading() || activating()"
|
||||
(click)="editRequested.emit(lease())"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="deleting() || downloading() || activating()"
|
||||
(click)="activationRequested.emit(lease().id!)"
|
||||
>
|
||||
@if (activating()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Activation…
|
||||
} @else {
|
||||
Activer
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
[disabled]="deleting()"
|
||||
[disabled]="deleting() || downloading() || activating()"
|
||||
(click)="deleteRequested.emit(lease().id!)"
|
||||
>
|
||||
@if (deleting()) {
|
||||
|
||||
@@ -73,4 +73,87 @@ describe('LeaseCard', () => {
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de télécharger ce bail.');
|
||||
});
|
||||
|
||||
it('offers draft lifecycle actions with activation progress and an actionable failure', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2027-01-01',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
fixture.componentRef.setInput('activating', true);
|
||||
fixture.componentRef.setInput('activationError', 'Un bail actif chevauche cette période.');
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Modifier');
|
||||
expect(text).toContain('Supprimer');
|
||||
expect(text).toContain('Activation…');
|
||||
expect(text).toContain('Un bail actif chevauche cette période.');
|
||||
const activation = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
|
||||
button.textContent.includes('Activation…'),
|
||||
);
|
||||
expect(activation.disabled).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.componentRef.setInput('activating', false);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Modifier');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Supprimer');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Activer');
|
||||
});
|
||||
|
||||
it('shows the resulting rent schedule with separate settlement and timing labels', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
fixture.componentRef.setInput('rentDues', [
|
||||
{
|
||||
id: 'due-1',
|
||||
leaseId: 'lease-1',
|
||||
month: '2026-01',
|
||||
dueDate: '2026-01-01',
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'OVERDUE',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
const amounts = [...fixture.nativeElement.querySelectorAll('.rent-due dl div')].map((term) =>
|
||||
[...term.children].map((item) => item.textContent.trim()).join(' '),
|
||||
);
|
||||
expect(text).toContain('2026-01');
|
||||
expect(amounts).toEqual(['Loyer 800 €', 'Charges 50 €', 'Total 850 €', 'Restant 850 €']);
|
||||
expect(text).toContain('Règlement : Non réglée');
|
||||
expect(text).toContain('Temporalité : En retard');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { DatePipe } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Apartment } from '../../services/apartment.service';
|
||||
import { Lease } from '../../services/lease.service';
|
||||
import { Lease, RentDue } from '../../services/lease.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
|
||||
@Component({
|
||||
@@ -20,8 +20,13 @@ export class LeaseCard {
|
||||
readonly deleteError = input('');
|
||||
readonly downloading = input(false);
|
||||
readonly downloadError = input('');
|
||||
readonly rentDues = input<RentDue[]>([]);
|
||||
readonly activating = input(false);
|
||||
readonly activationError = input('');
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly pdfRequested = output<Lease>();
|
||||
readonly editRequested = output<Lease>();
|
||||
readonly activationRequested = output<string>();
|
||||
|
||||
apartmentName(): string {
|
||||
return (
|
||||
@@ -43,4 +48,14 @@ export class LeaseCard {
|
||||
this.lease().status
|
||||
];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<p class="eyebrow">Nouveau bail</p>
|
||||
<h1>Créer un bail</h1>
|
||||
<p class="eyebrow">{{ lease() ? 'Brouillon' : 'Nouveau bail' }}</p>
|
||||
<h1>{{ lease() ? 'Modifier le brouillon' : 'Créer un brouillon de bail' }}</h1>
|
||||
<p>Associez le bien, les locataires et les éventuels garants.</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -43,9 +43,7 @@
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Appartement</mat-label
|
||||
><mat-select
|
||||
id="apartmentId"
|
||||
[formField]="leaseForm.apartmentId"
|
||||
><mat-select id="apartmentId" [formField]="leaseForm.apartmentId"
|
||||
><mat-option value="">Sélectionnez un appartement</mat-option>
|
||||
@for (apartment of apartments(); track apartment.id) {
|
||||
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
|
||||
@@ -54,11 +52,7 @@
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Locataires</mat-label
|
||||
><mat-select
|
||||
id="tenantIds"
|
||||
[formField]="leaseForm.tenantIds"
|
||||
multiple
|
||||
>
|
||||
><mat-select id="tenantIds" [formField]="leaseForm.tenantIds" multiple>
|
||||
@for (tenant of tenants(); track tenant.id) {
|
||||
<mat-option [value]="tenant.id"
|
||||
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
|
||||
@@ -163,7 +157,7 @@
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else {
|
||||
Enregistrer le bail
|
||||
{{ lease() ? 'Enregistrer les modifications' : 'Enregistrer le brouillon' }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -18,12 +18,84 @@ describe('LeaseForm', () => {
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
});
|
||||
let startDate = '';
|
||||
component.submitted.subscribe((lease) => (startDate = lease.startDate));
|
||||
let submitted: unknown;
|
||||
component.submitted.subscribe((lease) => (submitted = lease));
|
||||
|
||||
component.save();
|
||||
|
||||
expect(startDate).toBe('2026-09-01');
|
||||
expect(submitted).toEqual({
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2027-08-31',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows overlapping draft dates but leaves active dates unavailable', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('leases', [
|
||||
{
|
||||
id: 'draft-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
{
|
||||
id: 'active-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
startDate: '2026-02-01',
|
||||
endDate: '2026-02-28',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
]);
|
||||
fixture.componentInstance.leaseModel.update((lease) => ({
|
||||
...lease,
|
||||
apartmentId: 'apartment-1',
|
||||
}));
|
||||
|
||||
expect(fixture.componentInstance.dateFilter(new Date(2026, 0, 15))).toBe(true);
|
||||
expect(fixture.componentInstance.dateFilter(new Date(2026, 1, 15))).toBe(false);
|
||||
});
|
||||
|
||||
it('loads a draft for editing', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'draft-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2027-08-31',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.leaseModel().apartmentId).toBe('apartment-1');
|
||||
expect(fixture.nativeElement.textContent).toContain('Modifier le brouillon');
|
||||
});
|
||||
|
||||
it('keeps guarantors in the lease form module', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, effect, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormRoot, FormField, form, required, min } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -8,7 +8,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Apartment } from '../../services/apartment.service';
|
||||
import { Guarantor, Lease } from '../../services/lease.service';
|
||||
import { Guarantor, Lease, LeaseInput } from '../../services/lease.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
import { GuarantorForm } from '../guarantor-form/guarantor-form';
|
||||
|
||||
@@ -33,10 +33,11 @@ export class LeaseForm {
|
||||
readonly apartments = input.required<Apartment[]>();
|
||||
readonly tenants = input.required<Tenant[]>();
|
||||
readonly leases = input<Lease[]>([]);
|
||||
readonly lease = input<Lease>();
|
||||
readonly saving = input(false);
|
||||
readonly saveError = input('');
|
||||
readonly cancelled = output<void>();
|
||||
readonly submitted = output<Lease>();
|
||||
readonly submitted = output<LeaseInput>();
|
||||
|
||||
readonly leaseModel = signal({
|
||||
apartmentId: '',
|
||||
@@ -62,6 +63,23 @@ export class LeaseForm {
|
||||
readonly guarantors = signal<Guarantor[]>([]);
|
||||
readonly error = signal('');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const lease = this.lease();
|
||||
if (!lease) return;
|
||||
this.leaseModel.set({
|
||||
apartmentId: lease.apartmentId,
|
||||
tenantIds: lease.tenantIds,
|
||||
startDate: this.fromIsoDate(lease.startDate),
|
||||
endDate: this.fromIsoDate(lease.endDate),
|
||||
monthlyRent: lease.monthlyRent,
|
||||
monthlyCharges: lease.monthlyCharges,
|
||||
securityDeposit: lease.securityDeposit,
|
||||
});
|
||||
this.guarantors.set(lease.guarantors);
|
||||
});
|
||||
}
|
||||
|
||||
readonly dateFilter = (d: Date | null): boolean => {
|
||||
if (!d) return false;
|
||||
const aptId = this.leaseModel().apartmentId;
|
||||
@@ -70,7 +88,7 @@ export class LeaseForm {
|
||||
const time = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
|
||||
const overlapping = this.leases().some((lease) => {
|
||||
if (lease.apartmentId !== aptId) return false;
|
||||
if (lease.status === 'DRAFT' || lease.apartmentId !== aptId) return false;
|
||||
const [startYear, startMonth, startDay] = lease.startDate.split('-').map(Number);
|
||||
const [endYear, endMonth, endDay] = lease.endDate.split('-').map(Number);
|
||||
const start = new Date(startYear, startMonth - 1, startDay).getTime();
|
||||
@@ -110,11 +128,15 @@ export class LeaseForm {
|
||||
monthlyRent: modelVal.monthlyRent,
|
||||
monthlyCharges: modelVal.monthlyCharges,
|
||||
securityDeposit: modelVal.securityDeposit,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
}
|
||||
|
||||
private toIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private fromIsoDate(date: string): Date {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Location</p>
|
||||
<h1>Mes baux</h1>
|
||||
<p>Les baux actifs, leurs parties et leurs conditions financières.</p>
|
||||
<p>Préparez vos baux, activez-les et consultez leurs échéanciers.</p>
|
||||
</div>
|
||||
<button
|
||||
mat-flat-button
|
||||
@@ -62,7 +62,12 @@
|
||||
[deleteError]="lease.id ? (workspace.leaseDeleteErrors()[lease.id] ?? '') : ''"
|
||||
[downloading]="!!lease.id && workspace.leaseDownloading(lease.id)"
|
||||
[downloadError]="lease.id ? workspace.leaseDownloadError(lease.id) : ''"
|
||||
[rentDues]="rentDuesFor(lease)"
|
||||
[activating]="!!lease.id && workspace.leaseActivating(lease.id)"
|
||||
[activationError]="lease.id ? workspace.leaseActivationError(lease.id) : ''"
|
||||
(pdfRequested)="workspace.downloadLeasePdf($event)"
|
||||
(editRequested)="openForm($event)"
|
||||
(activationRequested)="workspace.activateLease($event)"
|
||||
(deleteRequested)="workspace.deleteLease($event)"
|
||||
/>
|
||||
}
|
||||
@@ -76,8 +81,9 @@
|
||||
[apartments]="workspace.apartments()"
|
||||
[tenants]="workspace.tenants()"
|
||||
[leases]="workspace.leases()"
|
||||
[saving]="workspace.leaseSaving()"
|
||||
[saveError]="workspace.leaseSaveError()"
|
||||
[lease]="editingLease() ?? undefined"
|
||||
[saving]="workspace.leaseSaving(editingLease()?.id)"
|
||||
[saveError]="workspace.leaseSaveError(editingLease()?.id)"
|
||||
(cancelled)="closeForm()"
|
||||
(submitted)="saveLease($event)"
|
||||
/>
|
||||
|
||||
@@ -2,26 +2,35 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LeasesPage } from './leases-page';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { Lease, RentDue } from '../../services/lease.service';
|
||||
|
||||
describe('LeasesPage', () => {
|
||||
const workspace = {
|
||||
apartments: signal([]),
|
||||
tenants: signal([]),
|
||||
leases: signal([]),
|
||||
leases: signal<Lease[]>([]),
|
||||
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
tenantLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
leaseLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
rentDues: signal<RentDue[]>([]),
|
||||
rentDueLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
deletingLeaseIds: signal<ReadonlySet<string>>(new Set()),
|
||||
leaseDeleteErrors: signal<Record<string, string>>({}),
|
||||
loadApartments: vi.fn(),
|
||||
loadTenants: vi.fn(),
|
||||
loadLeases: vi.fn(),
|
||||
loadRentDues: vi.fn(),
|
||||
saveLease: vi.fn(),
|
||||
deleteLease: vi.fn(),
|
||||
activateLease: vi.fn(),
|
||||
downloadLeasePdf: vi.fn(),
|
||||
leaseSaving: vi.fn(() => false),
|
||||
leaseSaveError: vi.fn(() => ''),
|
||||
clearLeaseSaveError: vi.fn(),
|
||||
leaseDownloading: vi.fn(() => false),
|
||||
leaseDownloadError: vi.fn(() => ''),
|
||||
leaseActivating: vi.fn(() => false),
|
||||
leaseActivationError: vi.fn(() => ''),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -29,12 +38,15 @@ describe('LeasesPage', () => {
|
||||
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
workspace.tenantLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
workspace.leaseLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
workspace.rentDueLoadState.set({ loaded: false, loading: true, error: '' });
|
||||
workspace.leases.set([]);
|
||||
workspace.rentDues.set([]);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: WorkspaceService, useValue: workspace }],
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for baux, appartements, and locataires before revealing content', () => {
|
||||
it('waits for baux, appartements, locataires, and échéances before revealing content', () => {
|
||||
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
fixture.detectChanges();
|
||||
@@ -53,4 +65,113 @@ describe('LeasesPage', () => {
|
||||
component.closeForm();
|
||||
expect(component.showForm()).toBe(false);
|
||||
});
|
||||
|
||||
it('opens an existing draft for editing', () => {
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
const draft = {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
status: 'DRAFT' as const,
|
||||
};
|
||||
|
||||
fixture.componentInstance.openForm(draft);
|
||||
|
||||
expect(fixture.componentInstance.editingLease()).toEqual(draft);
|
||||
expect(fixture.componentInstance.showForm()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows and retries an échéance load failure', () => {
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.tenantLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.rentDueLoadState.set({
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: 'Impossible de charger les échéances de loyer.',
|
||||
});
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de charger les échéances de loyer.',
|
||||
);
|
||||
workspace.loadRentDues.mockClear();
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
expect(workspace.loadRentDues).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders each échéance only under its matching bail', () => {
|
||||
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.tenantLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.rentDueLoadState.set({ loaded: true, loading: false, error: '' });
|
||||
workspace.leases.set([
|
||||
{
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: 'lease-2',
|
||||
apartmentId: 'apartment-2',
|
||||
tenantIds: [],
|
||||
guarantors: [],
|
||||
startDate: '2027-01-01',
|
||||
endDate: '2027-12-31',
|
||||
monthlyRent: 900,
|
||||
monthlyCharges: 60,
|
||||
securityDeposit: 900,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
]);
|
||||
workspace.rentDues.set([
|
||||
{
|
||||
id: 'due-1',
|
||||
leaseId: 'lease-1',
|
||||
month: '2026-01',
|
||||
dueDate: '2026-01-01',
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'OVERDUE',
|
||||
},
|
||||
{
|
||||
id: 'due-2',
|
||||
leaseId: 'lease-2',
|
||||
month: '2027-01',
|
||||
dueDate: '2027-01-01',
|
||||
rent: 900,
|
||||
charges: 60,
|
||||
total: 960,
|
||||
remainingAmount: 960,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'UPCOMING',
|
||||
},
|
||||
]);
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const cards = fixture.nativeElement.querySelectorAll('.lease-card');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0].textContent).toContain('2026-01');
|
||||
expect(cards[0].textContent).not.toContain('2027-01');
|
||||
expect(cards[1].textContent).toContain('2027-01');
|
||||
expect(cards[1].textContent).not.toContain('2026-01');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { Lease } from '../../services/lease.service';
|
||||
import { Lease, LeaseInput, RentDue } from '../../services/lease.service';
|
||||
import { LeaseCard } from '../lease-card/lease-card';
|
||||
import { LeaseForm } from '../lease-form/lease-form';
|
||||
|
||||
@@ -15,21 +15,34 @@ import { LeaseForm } from '../lease-form/lease-form';
|
||||
export class LeasesPage {
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
readonly showForm = signal(false);
|
||||
readonly editingLease = signal<Lease | null>(null);
|
||||
readonly rentDuesByLease = computed(() => {
|
||||
const rentDuesByLease = new Map<string, RentDue[]>();
|
||||
for (const rentDue of this.workspace.rentDues()) {
|
||||
const leaseRentDues = rentDuesByLease.get(rentDue.leaseId);
|
||||
if (leaseRentDues) leaseRentDues.push(rentDue);
|
||||
else rentDuesByLease.set(rentDue.leaseId, [rentDue]);
|
||||
}
|
||||
return rentDuesByLease;
|
||||
});
|
||||
readonly contentReady = computed(
|
||||
() =>
|
||||
this.workspace.apartmentLoadState().loaded &&
|
||||
this.workspace.tenantLoadState().loaded &&
|
||||
this.workspace.leaseLoadState().loaded,
|
||||
this.workspace.leaseLoadState().loaded &&
|
||||
this.workspace.rentDueLoadState().loaded,
|
||||
);
|
||||
readonly contentLoading = computed(
|
||||
() =>
|
||||
this.workspace.apartmentLoadState().loading ||
|
||||
this.workspace.tenantLoadState().loading ||
|
||||
this.workspace.leaseLoadState().loading,
|
||||
this.workspace.leaseLoadState().loading ||
|
||||
this.workspace.rentDueLoadState().loading,
|
||||
);
|
||||
readonly loadError = computed(
|
||||
() =>
|
||||
this.workspace.leaseLoadState().error ||
|
||||
this.workspace.rentDueLoadState().error ||
|
||||
this.workspace.apartmentLoadState().error ||
|
||||
this.workspace.tenantLoadState().error,
|
||||
);
|
||||
@@ -38,17 +51,24 @@ export class LeasesPage {
|
||||
this.workspace.loadApartments();
|
||||
this.workspace.loadTenants();
|
||||
this.workspace.loadLeases();
|
||||
this.workspace.loadRentDues();
|
||||
}
|
||||
|
||||
openForm(): void {
|
||||
this.workspace.clearLeaseSaveError();
|
||||
openForm(lease: Lease | null = null): void {
|
||||
this.editingLease.set(lease);
|
||||
this.workspace.clearLeaseSaveError(lease?.id);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
closeForm(): void {
|
||||
this.showForm.set(false);
|
||||
this.editingLease.set(null);
|
||||
}
|
||||
saveLease(lease: Lease): void {
|
||||
this.workspace.saveLease(lease, () => this.closeForm());
|
||||
saveLease(lease: LeaseInput): void {
|
||||
this.workspace.saveLease(lease, this.editingLease()?.id, () => this.closeForm());
|
||||
}
|
||||
|
||||
rentDuesFor(lease: Lease): RentDue[] {
|
||||
return lease.id ? (this.rentDuesByLease().get(lease.id) ?? []) : [];
|
||||
}
|
||||
|
||||
retryLoad(): void {
|
||||
@@ -58,5 +78,7 @@ export class LeasesPage {
|
||||
this.workspace.loadTenants();
|
||||
if (!this.workspace.leaseLoadState().loaded || this.workspace.leaseLoadState().error)
|
||||
this.workspace.loadLeases();
|
||||
if (!this.workspace.rentDueLoadState().loaded || this.workspace.rentDueLoadState().error)
|
||||
this.workspace.loadRentDues();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { Lease, LeaseService } from './lease.service';
|
||||
import { LeaseInput, LeaseService } from './lease.service';
|
||||
|
||||
describe('LeaseService', () => {
|
||||
const http = {
|
||||
@@ -19,9 +19,9 @@ describe('LeaseService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses lease CRUD and PDF endpoints', () => {
|
||||
it('uses lease lifecycle, schedule, and PDF endpoints without sending status', () => {
|
||||
const service = TestBed.inject(LeaseService);
|
||||
const lease: Lease = {
|
||||
const lease: LeaseInput = {
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
@@ -30,18 +30,21 @@ describe('LeaseService', () => {
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
|
||||
service.getLeases().subscribe();
|
||||
service.getRentDues().subscribe();
|
||||
service.createLease(lease).subscribe();
|
||||
service.updateLease('lease-1', lease).subscribe();
|
||||
service.activateLease('lease-1').subscribe();
|
||||
service.deleteLease('lease-1').subscribe();
|
||||
service.downloadPdf('lease-1').subscribe();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/leases');
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-dues');
|
||||
expect(http.post).toHaveBeenCalledWith('/api/leases', lease);
|
||||
expect(http.put).toHaveBeenCalledWith('/api/leases/lease-1', lease);
|
||||
expect(http.post).toHaveBeenCalledWith('/api/leases/lease-1/activate', {});
|
||||
expect(http.delete).toHaveBeenCalledWith('/api/leases/lease-1');
|
||||
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/pdf', {
|
||||
responseType: 'blob',
|
||||
|
||||
@@ -11,8 +11,7 @@ export interface Guarantor {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface Lease {
|
||||
id?: string;
|
||||
export interface LeaseInput {
|
||||
apartmentId: string;
|
||||
tenantIds: string[];
|
||||
guarantors: Guarantor[];
|
||||
@@ -21,9 +20,31 @@ export interface Lease {
|
||||
monthlyRent: number;
|
||||
monthlyCharges: number;
|
||||
securityDeposit: number;
|
||||
}
|
||||
|
||||
export interface Lease extends LeaseInput {
|
||||
id?: string;
|
||||
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
||||
}
|
||||
|
||||
export interface RentDue {
|
||||
id: string;
|
||||
leaseId: string;
|
||||
month: string;
|
||||
dueDate: string;
|
||||
rent: number;
|
||||
charges: number;
|
||||
total: number;
|
||||
remainingAmount: number;
|
||||
settlement: 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
|
||||
timing: 'UPCOMING' | 'OVERDUE';
|
||||
}
|
||||
|
||||
export interface LeaseActivation {
|
||||
lease: Lease;
|
||||
rentDues: RentDue[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LeaseService {
|
||||
private readonly http = inject(HttpClient);
|
||||
@@ -32,14 +53,22 @@ export class LeaseService {
|
||||
return this.http.get<Lease[]>('/api/leases');
|
||||
}
|
||||
|
||||
createLease(lease: Lease): Observable<Lease> {
|
||||
getRentDues(): Observable<RentDue[]> {
|
||||
return this.http.get<RentDue[]>('/api/rent-dues');
|
||||
}
|
||||
|
||||
createLease(lease: LeaseInput): Observable<Lease> {
|
||||
return this.http.post<Lease>('/api/leases', lease);
|
||||
}
|
||||
|
||||
updateLease(id: string, lease: Lease): Observable<Lease> {
|
||||
updateLease(id: string, lease: LeaseInput): Observable<Lease> {
|
||||
return this.http.put<Lease>(`/api/leases/${id}`, lease);
|
||||
}
|
||||
|
||||
activateLease(id: string): Observable<LeaseActivation> {
|
||||
return this.http.post<LeaseActivation>(`/api/leases/${id}/activate`, {});
|
||||
}
|
||||
|
||||
deleteLease(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/leases/${id}`);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@ describe('WorkspaceService', () => {
|
||||
status: 'LATE' as const,
|
||||
paidAt: null,
|
||||
};
|
||||
const rentDue = {
|
||||
id: 'due-1',
|
||||
leaseId: 'lease-1',
|
||||
month: '2026-01',
|
||||
dueDate: '2026-01-01',
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID' as const,
|
||||
timing: 'UPCOMING' as const,
|
||||
};
|
||||
const apartments = {
|
||||
getApartments: vi.fn(),
|
||||
createApartment: vi.fn(),
|
||||
@@ -66,7 +78,10 @@ describe('WorkspaceService', () => {
|
||||
};
|
||||
const leases = {
|
||||
getLeases: vi.fn(),
|
||||
getRentDues: vi.fn(),
|
||||
createLease: vi.fn(),
|
||||
updateLease: vi.fn(),
|
||||
activateLease: vi.fn(),
|
||||
deleteLease: vi.fn(),
|
||||
downloadPdf: vi.fn(),
|
||||
};
|
||||
@@ -93,7 +108,10 @@ describe('WorkspaceService', () => {
|
||||
tenants.downloadAttachment.mockReturnValue(of(new Blob(['identity'])));
|
||||
tenants.deleteAttachment.mockReturnValue(of(undefined));
|
||||
leases.getLeases.mockReturnValue(of([lease]));
|
||||
leases.getRentDues.mockReturnValue(of([rentDue]));
|
||||
leases.createLease.mockReturnValue(of(lease));
|
||||
leases.updateLease.mockReturnValue(of(lease));
|
||||
leases.activateLease.mockReturnValue(of({ lease, rentDues: [rentDue] }));
|
||||
leases.deleteLease.mockReturnValue(of(undefined));
|
||||
leases.downloadPdf.mockReturnValue(of(new Blob(['pdf'])));
|
||||
rentPayments.getRentPayments.mockReturnValue(of([rentPayment]));
|
||||
@@ -349,7 +367,8 @@ describe('WorkspaceService', () => {
|
||||
service.saveTenant(tenant, tenant.id);
|
||||
service.deleteTenant(tenant.id);
|
||||
service.loadLeases();
|
||||
service.saveLease(lease);
|
||||
service.loadRentDues();
|
||||
service.saveLease(lease, lease.id);
|
||||
service.deleteLease(lease.id);
|
||||
service.loadRentPayments();
|
||||
service.markRentPaymentPaid(rentPayment.id, rentPayment.rent, rentPayment.charges);
|
||||
@@ -357,9 +376,20 @@ describe('WorkspaceService', () => {
|
||||
expect(service.apartments()).toEqual([apartment]);
|
||||
expect(service.tenants()).toEqual([tenant]);
|
||||
expect(service.leases()).toEqual([lease]);
|
||||
expect(service.rentDues()).toEqual([rentDue]);
|
||||
expect(service.rentPayments()).toEqual([rentPayment]);
|
||||
expect(apartments.updateApartment).toHaveBeenCalledWith('apartment-1', apartment);
|
||||
expect(tenants.updateTenant).toHaveBeenCalledWith('tenant-1', tenant);
|
||||
expect(leases.updateLease).toHaveBeenCalledWith('lease-1', {
|
||||
apartmentId: lease.apartmentId,
|
||||
tenantIds: lease.tenantIds,
|
||||
guarantors: lease.guarantors,
|
||||
startDate: lease.startDate,
|
||||
endDate: lease.endDate,
|
||||
monthlyRent: lease.monthlyRent,
|
||||
monthlyCharges: lease.monthlyCharges,
|
||||
securityDeposit: lease.securityDeposit,
|
||||
});
|
||||
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
|
||||
expect(rentPayments.markPaid).toHaveBeenCalledWith(
|
||||
'payment-1',
|
||||
@@ -442,4 +472,46 @@ describe('WorkspaceService', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes activation results immediately, prevents duplicates, then reconciles', () => {
|
||||
const activation = new Subject<{ lease: typeof lease; rentDues: (typeof rentDue)[] }>();
|
||||
const leaseRefresh = new Subject<(typeof lease)[]>();
|
||||
const dueRefresh = new Subject<(typeof rentDue)[]>();
|
||||
leases.activateLease.mockReturnValueOnce(activation);
|
||||
leases.getLeases.mockReturnValueOnce(leaseRefresh);
|
||||
leases.getRentDues.mockReturnValueOnce(dueRefresh);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
const draft = { ...lease, status: 'DRAFT' as const };
|
||||
service.leases.set([draft]);
|
||||
|
||||
service.activateLease(lease.id);
|
||||
service.activateLease(lease.id);
|
||||
expect(service.leaseActivating(lease.id)).toBe(true);
|
||||
expect(leases.activateLease).toHaveBeenCalledTimes(1);
|
||||
expect(service.leases()).toEqual([draft]);
|
||||
|
||||
activation.next({ lease, rentDues: [rentDue] });
|
||||
activation.complete();
|
||||
|
||||
expect(service.leases()).toEqual([lease]);
|
||||
expect(service.rentDues()).toEqual([rentDue]);
|
||||
expect(service.leaseActivating(lease.id)).toBe(false);
|
||||
expect(service.leaseLoadState().loading).toBe(true);
|
||||
expect(service.rentDueLoadState().loading).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the draft and exposes the backend activation failure', () => {
|
||||
leases.activateLease.mockReturnValueOnce(
|
||||
throwError(() => ({ error: { message: 'Un bail actif chevauche cette période.' } })),
|
||||
);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
const draft = { ...lease, status: 'DRAFT' as const };
|
||||
service.leases.set([draft]);
|
||||
|
||||
service.activateLease(lease.id);
|
||||
|
||||
expect(service.leases()).toEqual([draft]);
|
||||
expect(service.leaseActivating(lease.id)).toBe(false);
|
||||
expect(service.leaseActivationError(lease.id)).toBe('Un bail actif chevauche cette période.');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
} from '../components/confirmation-dialog/confirmation-dialog';
|
||||
import { Apartment, ApartmentService } from './apartment.service';
|
||||
import { FileDownloadService } from './file-download.service';
|
||||
import { Lease, LeaseService } from './lease.service';
|
||||
import { finalize } from 'rxjs';
|
||||
import { Lease, LeaseInput, LeaseService } from './lease.service';
|
||||
import { RentPayment, RentPaymentService } from './rent-payment.service';
|
||||
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
|
||||
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
|
||||
import { WorkspaceResource } from './workspace-resource';
|
||||
import { httpErrorMessage } from './http-error-message';
|
||||
import { withoutRecordKey, withoutSetValue } from './immutable-state';
|
||||
|
||||
const newResourceKey = 'new';
|
||||
|
||||
@@ -42,6 +45,11 @@ export class WorkspaceService {
|
||||
save: 'Impossible d’enregistrer ce bail.',
|
||||
delete: 'Impossible de supprimer ce bail.',
|
||||
});
|
||||
private readonly rentDueResource = new WorkspaceResource(() => this.leaseService.getRentDues(), {
|
||||
load: 'Impossible de charger les échéances de loyer.',
|
||||
save: '',
|
||||
delete: '',
|
||||
});
|
||||
private readonly rentPaymentResource = new WorkspaceResource(
|
||||
() => this.rentPaymentService.getRentPayments(),
|
||||
{
|
||||
@@ -63,6 +71,10 @@ export class WorkspaceService {
|
||||
readonly leaseLoadState = this.leaseResource.loadState;
|
||||
readonly deletingLeaseIds = this.leaseResource.deletingIds;
|
||||
readonly leaseDeleteErrors = this.leaseResource.deleteErrors;
|
||||
readonly rentDues = this.rentDueResource.items;
|
||||
readonly rentDueLoadState = this.rentDueResource.loadState;
|
||||
private readonly activatingLeaseIds = signal<ReadonlySet<string>>(new Set());
|
||||
private readonly leaseActivationErrors = signal<Record<string, string>>({});
|
||||
readonly rentPayments = this.rentPaymentResource.items;
|
||||
readonly rentPaymentLoadState = this.rentPaymentResource.loadState;
|
||||
|
||||
@@ -193,20 +205,68 @@ export class WorkspaceService {
|
||||
this.leaseResource.load();
|
||||
}
|
||||
|
||||
saveLease(lease: Lease, onSuccess?: () => void): void {
|
||||
this.leaseResource.save(newResourceKey, this.leaseService.createLease(lease), onSuccess);
|
||||
loadRentDues(): void {
|
||||
this.rentDueResource.load();
|
||||
}
|
||||
|
||||
leaseSaving(): boolean {
|
||||
return this.leaseResource.savingKeys().has(newResourceKey);
|
||||
saveLease(lease: LeaseInput, id?: string, onSuccess?: () => void): void {
|
||||
const input: LeaseInput = {
|
||||
apartmentId: lease.apartmentId,
|
||||
tenantIds: lease.tenantIds,
|
||||
guarantors: lease.guarantors,
|
||||
startDate: lease.startDate,
|
||||
endDate: lease.endDate,
|
||||
monthlyRent: lease.monthlyRent,
|
||||
monthlyCharges: lease.monthlyCharges,
|
||||
securityDeposit: lease.securityDeposit,
|
||||
};
|
||||
this.leaseResource.save(
|
||||
this.resourceKey(id),
|
||||
id ? this.leaseService.updateLease(id, input) : this.leaseService.createLease(input),
|
||||
onSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
leaseSaveError(): string {
|
||||
return this.leaseResource.saveErrors()[newResourceKey] ?? '';
|
||||
leaseSaving(id?: string): boolean {
|
||||
return this.leaseResource.savingKeys().has(this.resourceKey(id));
|
||||
}
|
||||
|
||||
clearLeaseSaveError(): void {
|
||||
this.leaseResource.clearSaveError(newResourceKey);
|
||||
leaseSaveError(id?: string): string {
|
||||
return this.leaseResource.saveErrors()[this.resourceKey(id)] ?? '';
|
||||
}
|
||||
|
||||
clearLeaseSaveError(id?: string): void {
|
||||
this.leaseResource.clearSaveError(this.resourceKey(id));
|
||||
}
|
||||
|
||||
activateLease(id: string): void {
|
||||
if (this.activatingLeaseIds().has(id)) return;
|
||||
this.activatingLeaseIds.update((ids) => new Set(ids).add(id));
|
||||
this.leaseActivationErrors.update((errors) => withoutRecordKey(errors, id));
|
||||
this.leaseService
|
||||
.activateLease(id)
|
||||
.pipe(finalize(() => this.activatingLeaseIds.update((ids) => withoutSetValue(ids, id))))
|
||||
.subscribe({
|
||||
next: ({ lease, rentDues }) => {
|
||||
this.leaseResource.upsert(lease);
|
||||
rentDues.forEach((rentDue) => this.rentDueResource.upsert(rentDue));
|
||||
this.leaseResource.load();
|
||||
this.rentDueResource.load();
|
||||
},
|
||||
error: (error) =>
|
||||
this.leaseActivationErrors.update((errors) => ({
|
||||
...errors,
|
||||
[id]: httpErrorMessage(error, 'Impossible d’activer ce bail.'),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
leaseActivating(id: string): boolean {
|
||||
return this.activatingLeaseIds().has(id);
|
||||
}
|
||||
|
||||
leaseActivationError(id: string): string {
|
||||
return this.leaseActivationErrors()[id] ?? '';
|
||||
}
|
||||
|
||||
deleteLease(id: string): void {
|
||||
|
||||
Reference in new issue
Block a user