Align core lease and amendment validation (#49)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 96bd02e3a6
commit ca3e28d3a2
20 files changed
+1361 -56

No files matched your search

@@ -0,0 +1,165 @@
<section class="form-page">
<header class="page-heading">
<div>
<button
mat-button
type="button"
class="back-action"
[disabled]="saving()"
(click)="cancelled.emit()"
>
← Retour
</button>
<p class="eyebrow">Bail actif</p>
<h1>Préparer un avenant</h1>
<p>
Seuls les locataires, garants, terme et conditions de paiement peuvent être
modifiés.
</p>
</div>
</header>
<form
class="entity-form"
[formRoot]="amendmentForm"
(ngSubmit)="save()"
[attr.aria-busy]="saving()"
>
@if (showSaveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Prise d’effet</mat-label>
<input
matInput
id="effectiveDate"
type="date"
[formField]="amendmentForm.effectiveDate"
(change)="fieldWasChanged('effectiveDate')"
/>
@if (fieldError('effectiveDate')) {
<mat-error>{{ fieldError('effectiveDate') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Locataires</mat-label>
<mat-select
id="tenantIds"
[formField]="amendmentForm.tenantIds"
multiple
(selectionChange)="fieldWasChanged('tenantIds')"
>
@for (tenant of tenants(); track tenant.id) {
<mat-option [value]="tenant.id"
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
>
}
</mat-select>
@if (fieldError('tenantIds')) {
<mat-error>{{ fieldError('tenantIds') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Fin de bail</mat-label>
<input
matInput
id="endDate"
type="date"
[formField]="amendmentForm.endDate"
(change)="fieldWasChanged('endDate')"
/>
@if (fieldError('endDate')) {
<mat-error>{{ fieldError('endDate') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Loyer mensuel</mat-label>
<input
matInput
id="monthlyRent"
type="number"
step="0.01"
[formField]="amendmentForm.monthlyRent"
(change)="fieldWasChanged('monthlyRent')"
/>
<span matTextSuffix>€</span>
@if (fieldError('monthlyRent')) {
<mat-error>{{ fieldError('monthlyRent') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Charges mensuelles</mat-label>
<input
matInput
id="monthlyCharges"
type="number"
step="0.01"
[formField]="amendmentForm.monthlyCharges"
(change)="fieldWasChanged('monthlyCharges')"
/>
<span matTextSuffix>€</span>
@if (fieldError('monthlyCharges')) {
<mat-error>{{ fieldError('monthlyCharges') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Mode de récupération des charges</mat-label>
<mat-select
id="chargeMethod"
[formField]="amendmentForm.chargeMethod"
(selectionChange)="fieldWasChanged('chargeMethod')"
>
<mat-option value="FLAT_RATE">Forfait de charges</mat-option>
<mat-option value="PROVISION"
>Provision avec régularisation annuelle</mat-option
>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Jour d’échéance</mat-label>
<input
matInput
id="dueDay"
type="number"
step="1"
[formField]="amendmentForm.dueDay"
(change)="fieldWasChanged('dueDay')"
/>
@if (fieldError('dueDay')) {
<mat-error>{{ fieldError('dueDay') }}</mat-error>
}
</mat-form-field>
@if (amendmentModel().chargeMethod === 'FLAT_RATE') {
<mat-checkbox id="indexFlatRate" [formField]="amendmentForm.indexFlatRate"
>Indexer le forfait de charges avec le loyer</mat-checkbox
>
}
</div>
<section class="guarantor-section">
<h2>Garants</h2>
@for (guarantor of guarantors(); track $index) {
<div class="guarantor-chip">
<span>{{ guarantor.firstName }} {{ guarantor.lastName }}</span>
<button mat-button type="button" (click)="removeGuarantor($index)">
Retirer
</button>
</div>
}
<app-guarantor-form (submitted)="addGuarantor($event)" />
</section>
<div class="form-actions">
<button mat-button type="button" [disabled]="saving()" (click)="cancelled.emit()">
Annuler
</button>
<button mat-flat-button type="submit" class="primary-action" [disabled]="saving()">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer l’avenant
}
</button>
</div>
</fieldset>
</form>
</section>
@@ -0,0 +1,182 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { emptyLeaseLegalTerms } from '../../services/lease.service';
import { LeaseAmendmentForm } from './lease-amendment-form';
describe('LeaseAmendmentForm', () => {
afterEach(() => vi.useRealTimers());
it('submits only mutable contract facts', () => {
const fixture = TestBed.createComponent(LeaseAmendmentForm);
fixture.componentRef.setInput('tenants', [
{
id: 'tenant-1',
firstName: 'Camille',
lastName: 'Martin',
email: '',
phone: '',
address: '',
},
]);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
});
fixture.componentInstance.amendmentModel.update((model) => ({
...model,
effectiveDate: '2026-10-01',
monthlyRent: 0.01,
monthlyCharges: 0,
dueDay: 28,
}));
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
fixture.componentInstance.save();
expect(submitted).toHaveBeenCalledWith(
expect.objectContaining({
effectiveDate: '2026-10-01',
tenantIds: ['tenant-1'],
endDate: '2027-08-31',
monthlyRent: 0.01,
monthlyCharges: 0,
dueDay: 28,
}),
);
expect(submitted.mock.calls[0][0]).not.toHaveProperty('securityDeposit');
expect(submitted.mock.calls[0][0]).not.toHaveProperty('apartmentId');
});
it('blocks a date before the amendment takes effect', () => {
const fixture = TestBed.createComponent(LeaseAmendmentForm);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
});
fixture.componentInstance.amendmentModel.update((model) => ({
...model,
effectiveDate: '2026-10-01',
endDate: '2026-09-30',
}));
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
fixture.componentInstance.save();
expect(submitted).not.toHaveBeenCalled();
expect(fixture.componentInstance.fieldError('endDate')).toContain('ne peut pas précéder');
});
it('blocks a past or out-of-period effective date and focuses it', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-24T12:00:00'));
const fixture = TestBed.createComponent(LeaseAmendmentForm);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-07-01',
endDate: '2027-06-30',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
});
fixture.componentInstance.amendmentModel.update((model) => ({
...model,
effectiveDate: '2026-07-23',
endDate: '2027-06-30',
}));
fixture.detectChanges();
fixture.componentInstance.save();
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(fixture.componentInstance.fieldError('effectiveDate')).toContain(
'entre aujourd’hui',
);
expect(document.activeElement?.id).toBe('effectiveDate');
fixture.componentInstance.amendmentModel.update((model) => ({
...model,
effectiveDate: '2027-07-01',
}));
expect(fixture.componentInstance.fieldError('effectiveDate')).toContain('fin du bail');
});
it('clears virtual tenant and end-date error paths with their visible controls', () => {
const fixture = TestBed.createComponent(LeaseAmendmentForm);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
specialClauses: [],
legalTerms: emptyLeaseLegalTerms(),
leaseType: 'STANDARD',
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
status: 'ACTIVE',
});
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'tenantIdsDistinct', code: 'assert_true' },
{ field: 'endDateOnOrAfterEffectiveDate', code: 'assert_true' },
]);
const changed = vi.fn();
fixture.componentInstance.fieldChanged.subscribe(changed);
fixture.detectChanges();
expect(fixture.componentInstance.fieldError('tenantIds')).toContain('sans doublon');
expect(fixture.componentInstance.fieldError('endDate')).toContain('ne peut pas précéder');
fixture.componentInstance.fieldWasChanged('tenantIds');
fixture.componentInstance.fieldWasChanged('endDate');
expect(changed).toHaveBeenCalledWith('tenantIdsDistinct');
expect(changed).toHaveBeenCalledWith('endDateOnOrAfterEffectiveDate');
});
});
@@ -0,0 +1,265 @@
import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, max, min, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { ChargeMethod, Guarantor, Lease, LeaseAmendmentInput } from '../../services/lease.service';
import { FieldValidationError } from '../../services/http-error-message';
import { Tenant } from '../../services/tenant.service';
import { GuarantorForm } from '../guarantor-form/guarantor-form';
type AmendmentField =
'effectiveDate' | 'tenantIds' | 'endDate' | 'monthlyRent' | 'monthlyCharges' | 'dueDay';
const serverErrorCodes: Record<AmendmentField, ReadonlySet<string>> = {
effectiveDate: new Set(['not_null', 'not_past']),
tenantIds: new Set(['not_empty', 'assert_true']),
endDate: new Set(['not_null', 'assert_true']),
monthlyRent: new Set(['positive']),
monthlyCharges: new Set(['positive_or_zero']),
dueDay: new Set(['min', 'max']),
};
const amendmentFields: AmendmentField[] = [
'effectiveDate',
'tenantIds',
'endDate',
'monthlyRent',
'monthlyCharges',
'dueDay',
];
@Component({
selector: 'app-lease-amendment-form',
imports: [
FormsModule,
FormRoot,
FormField,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
GuarantorForm,
],
templateUrl: './lease-amendment-form.html',
})
export class LeaseAmendmentForm {
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
readonly lease = input.required<Lease>();
readonly tenants = input.required<Tenant[]>();
readonly saving = input(false);
readonly saveError = input('');
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly cancelled = output<void>();
readonly submitted = output<LeaseAmendmentInput>();
readonly fieldChanged = output<string>();
private previousServerErrorSignature = '';
readonly amendmentModel = signal({
effectiveDate: '',
tenantIds: [] as string[],
endDate: '',
monthlyRent: 0,
monthlyCharges: 0,
dueDay: 5,
chargeMethod: 'FLAT_RATE' as ChargeMethod,
indexFlatRate: false,
});
readonly guarantors = signal<Guarantor[]>([]);
readonly amendmentForm = form(this.amendmentModel, (f) => {
required(f.effectiveDate);
validate(f.effectiveDate, ({ value, valueOf }) => {
const effectiveDate = value();
const endDate = valueOf(f.endDate);
return !effectiveDate ||
(effectiveDate >= browserToday() &&
effectiveDate >= this.lease().startDate &&
effectiveDate <= this.lease().endDate &&
(!endDate || effectiveDate <= endDate))
? null
: {
kind: 'amendment-effective-date',
message:
'La prise d’effet doit être comprise entre aujourd’hui et la fin du bail, sans dépasser sa nouvelle fin.',
};
});
required(f.tenantIds);
required(f.endDate);
validate(f.endDate, ({ value, valueOf }) => {
const endDate = value();
const effectiveDate = valueOf(f.effectiveDate);
return !endDate ||
(endDate >= this.lease().startDate && (!effectiveDate || endDate >= effectiveDate))
? null
: {
kind: 'amendment-end-date',
message:
'La fin du bail ne peut pas précéder sa prise d’effet ni son début.',
};
});
required(f.monthlyRent);
min(f.monthlyRent, Number.MIN_VALUE);
required(f.monthlyCharges);
min(f.monthlyCharges, 0);
required(f.dueDay);
min(f.dueDay, 1);
max(f.dueDay, 28);
validate(f.dueDay, ({ value }) =>
Number.isInteger(value())
? null
: { kind: 'whole-day', message: 'Le jour d’échéance doit être un nombre entier.' },
);
});
constructor() {
effect(() => this.reset(this.lease()));
effect(() => {
const errors = this.saveFieldErrors();
const signature = errors
.map((error) => `${error.field}:${error.code}`)
.sort()
.join('|');
if (signature && signature !== this.previousServerErrorSignature) {
queueMicrotask(() => this.focusFirstInvalid());
}
this.previousServerErrorSignature = signature;
});
}
save(): void {
if (this.saving()) return;
this.amendmentForm().markAsTouched();
if (this.amendmentForm().invalid()) {
queueMicrotask(() => this.focusFirstInvalid());
return;
}
const model = this.amendmentModel();
this.submitted.emit({
...model,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Paris',
guarantors: this.guarantors(),
indexFlatRate: model.chargeMethod === 'FLAT_RATE' && model.indexFlatRate,
});
}
addGuarantor(guarantor: Guarantor): void {
this.guarantors.update((guarantors) => [...guarantors, guarantor]);
}
removeGuarantor(index: number): void {
this.guarantors.update((guarantors) => guarantors.filter((_, item) => item !== index));
}
fieldWasChanged(field: string): void {
this.fieldChanged.emit(field);
if (field === 'tenantIds') this.fieldChanged.emit('tenantIdsDistinct');
if (field === 'endDate') this.fieldChanged.emit('endDateOnOrAfterEffectiveDate');
}
showSaveError(): boolean {
return (
Boolean(this.saveError()) &&
(this.saveFieldErrors().length === 0 ||
this.saveFieldErrors().some((error) => !this.isInlineServerError(error)))
);
}
fieldError(field: AmendmentField): string {
const model = this.amendmentModel();
const invalid =
(field === 'effectiveDate' &&
(!model.effectiveDate ||
model.effectiveDate < browserToday() ||
model.effectiveDate < this.lease().startDate ||
model.effectiveDate > this.lease().endDate ||
(model.endDate !== '' && model.effectiveDate > model.endDate))) ||
(field === 'tenantIds' && !model.tenantIds.length) ||
(field === 'endDate' &&
(!model.endDate ||
model.endDate < this.lease().startDate ||
(model.effectiveDate !== '' && model.endDate < model.effectiveDate))) ||
(field === 'monthlyRent' && model.monthlyRent <= 0) ||
(field === 'monthlyCharges' && model.monthlyCharges < 0) ||
(field === 'dueDay' &&
(!Number.isInteger(model.dueDay) || model.dueDay < 1 || model.dueDay > 28));
if ((!invalid || !this.isTouched(field)) && !this.hasKnownServerError(field)) {
return '';
}
return {
effectiveDate:
'La prise d’effet doit être comprise entre aujourd’hui et la fin du bail, sans dépasser sa nouvelle fin.',
tenantIds: 'Sélectionnez au moins un locataire, sans doublon.',
endDate: 'La fin du bail ne peut pas précéder sa prise d’effet ni son début.',
monthlyRent: 'Le loyer mensuel doit être strictement positif.',
monthlyCharges: 'Les charges mensuelles doivent être positives ou nulles.',
dueDay: 'Le jour d’échéance doit être un entier entre 1 et 28.',
}[field];
}
private hasKnownServerError(field: AmendmentField): boolean {
return this.saveFieldErrors().some(
(error) => matchesField(field, error.field) && serverErrorCodes[field].has(error.code),
);
}
private isTouched(field: AmendmentField): boolean {
switch (field) {
case 'effectiveDate':
return this.amendmentForm.effectiveDate().touched();
case 'tenantIds':
return this.amendmentForm.tenantIds().touched();
case 'endDate':
return this.amendmentForm.endDate().touched();
case 'monthlyRent':
return this.amendmentForm.monthlyRent().touched();
case 'monthlyCharges':
return this.amendmentForm.monthlyCharges().touched();
case 'dueDay':
return this.amendmentForm.dueDay().touched();
}
}
private isInlineServerError(error: FieldValidationError): boolean {
return amendmentFields.some(
(field) => matchesField(field, error.field) && serverErrorCodes[field].has(error.code),
);
}
private focusFirstInvalid(): void {
const field = amendmentFields.find((item) => this.fieldError(item));
this.element.nativeElement.querySelector<HTMLElement>(`#${field ?? ''}`)?.focus();
}
private reset(lease: Lease): void {
this.amendmentModel.set({
effectiveDate: '',
tenantIds: lease.tenantIds,
endDate: lease.endDate,
monthlyRent: lease.monthlyRent,
monthlyCharges: lease.monthlyCharges,
dueDay: lease.dueDay,
chargeMethod: lease.chargeMethod,
indexFlatRate: lease.indexFlatRate,
});
this.guarantors.set(lease.guarantors);
}
}
function matchesField(field: AmendmentField, path: string): boolean {
return (
path === field ||
(field === 'tenantIds' && path === 'tenantIdsDistinct') ||
(field === 'endDate' && path === 'endDateOnOrAfterEffectiveDate')
);
}
function browserToday(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -177,6 +177,11 @@
}
</button>
} @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') {
@if (lease().status === 'ACTIVE') {
<button mat-button type="button" (click)="amendmentRequested.emit(lease())">
Préparer un avenant
</button>
}
<button
mat-button
type="button"
@@ -31,6 +31,7 @@ export class LeaseCard {
readonly pdfRequested = output<Lease>();
readonly dossierRequested = output<Lease>();
readonly editRequested = output<Lease>();
readonly amendmentRequested = output<Lease>();
readonly finalizationRequested = output<string>();
readonly leaseEndRequested = output<string>();
readonly inventoryRequested = output<string>();
@@ -0,0 +1,83 @@
import { FieldValidationError } from '../../services/http-error-message';
import { LeaseInput } from '../../services/lease.service';
export type LeaseCoreField =
| 'apartmentId'
| 'tenantIds'
| 'leaseType'
| 'startDate'
| 'monthlyRent'
| 'monthlyCharges'
| 'securityDeposit'
| 'dueDay';
export const leaseCoreFields: LeaseCoreField[] = [
'apartmentId',
'tenantIds',
'leaseType',
'startDate',
'monthlyRent',
'monthlyCharges',
'securityDeposit',
'dueDay',
];
const serverErrorCodes: Record<LeaseCoreField, ReadonlySet<string>> = {
apartmentId: new Set(['not_blank']),
tenantIds: new Set(['not_empty', 'assert_true']),
leaseType: new Set(['not_null']),
startDate: new Set(['not_null']),
monthlyRent: new Set(['positive']),
monthlyCharges: new Set(['positive_or_zero']),
securityDeposit: new Set(['positive_or_zero']),
dueDay: new Set(['min', 'max']),
};
export function leaseFieldError(
model: Pick<LeaseInput, LeaseCoreField>,
field: LeaseCoreField,
touched: boolean,
errors: FieldValidationError[],
): string {
if ((!invalid(model, field) || !touched) && !hasKnownServerError(field, errors)) return '';
return {
apartmentId: 'Sélectionnez un appartement.',
tenantIds: 'Sélectionnez au moins un locataire, sans doublon.',
leaseType: 'Sélectionnez un type de bail.',
startDate: 'Renseignez la date d’entrée.',
monthlyRent: 'Le loyer mensuel doit être strictement positif.',
monthlyCharges: 'Les charges mensuelles doivent être positives ou nulles.',
securityDeposit: 'Le dépôt de garantie doit être positif ou nul.',
dueDay: 'Le jour d’échéance doit être un entier entre 1 et 28.',
}[field];
}
export function isInlineLeaseServerError(error: FieldValidationError): boolean {
return leaseCoreFields.some(
(field) => matchesField(field, error.field) && serverErrorCodes[field].has(error.code),
);
}
function hasKnownServerError(field: LeaseCoreField, errors: FieldValidationError[]): boolean {
return errors.some(
(error) => matchesField(field, error.field) && serverErrorCodes[field].has(error.code),
);
}
function matchesField(field: LeaseCoreField, path: string): boolean {
return path === field || (field === 'tenantIds' && path === 'tenantIdsDistinct');
}
function invalid(model: Pick<LeaseInput, LeaseCoreField>, field: LeaseCoreField): boolean {
return (
(field === 'apartmentId' && !model.apartmentId) ||
(field === 'tenantIds' && !model.tenantIds.length) ||
(field === 'leaseType' && !model.leaseType) ||
(field === 'startDate' && !model.startDate) ||
(field === 'monthlyRent' && model.monthlyRent <= 0) ||
(field === 'monthlyCharges' && model.monthlyCharges < 0) ||
(field === 'securityDeposit' && model.securityDeposit < 0) ||
(field === 'dueDay' &&
(!Number.isInteger(model.dueDay) || model.dueDay < 1 || model.dueDay > 28))
);
}
@@ -22,7 +22,7 @@
(ngSubmit)="save()"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
@if (showSaveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
<p class="visually-hidden" role="status" aria-live="polite">
@@ -40,23 +40,38 @@
<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"
(selectionChange)="fieldWasChanged('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>
}
</mat-select></mat-form-field
>
</mat-select>
@if (fieldError('apartmentId')) {
<mat-error>{{ fieldError('apartmentId') }}</mat-error>
}
</mat-form-field>
<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
(selectionChange)="fieldWasChanged('tenantIds')"
>
@for (tenant of tenants(); track tenant.id) {
<mat-option [value]="tenant.id"
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
>
}
</mat-select></mat-form-field
>
</mat-select>
@if (fieldError('tenantIds')) {
<mat-error>{{ fieldError('tenantIds') }}</mat-error>
}
</mat-form-field>
</div>
</section>
<section class="form-section">
@@ -70,11 +85,18 @@
<div class="form-grid three-columns">
<mat-form-field appearance="outline"
><mat-label>Type de bail</mat-label
><mat-select id="leaseType" [formField]="leaseForm.leaseType">
><mat-select
id="leaseType"
[formField]="leaseForm.leaseType"
(selectionChange)="fieldWasChanged('leaseType')"
>
<mat-option value="STANDARD">Location meublée classique</mat-option>
<mat-option value="STUDENT">Location meublée étudiante</mat-option>
</mat-select></mat-form-field
>
</mat-select>
@if (fieldError('leaseType')) {
<mat-error>{{ fieldError('leaseType') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Date d'entrée</mat-label
><input
@@ -82,9 +104,10 @@
type="date"
id="startDate"
[formField]="leaseForm.startDate"
(change)="fieldWasChanged('startDate')"
/>
@if (leaseForm.startDate().touched() && leaseForm.startDate().invalid()) {
<mat-error>{{ leaseForm.startDate().errors()[0]?.message }}</mat-error>
@if (fieldError('startDate')) {
<mat-error>{{ fieldError('startDate') }}</mat-error>
}
</mat-form-field>
<div class="lease-duration" aria-live="polite">
@@ -94,6 +117,9 @@
</output>
<small>{{ leaseDuration().renewalLabel }}</small>
</div>
@if (overlapWarning()) {
<p class="lease-warning" role="note">{{ overlapWarning() }}</p>
}
<mat-form-field appearance="outline"
><mat-label>Loyer mensuel</mat-label
><input
@@ -101,8 +127,13 @@
type="number"
id="monthlyRent"
[formField]="leaseForm.monthlyRent"
/><span matTextSuffix>€</span></mat-form-field
>
step="0.01"
(change)="fieldWasChanged('monthlyRent')"
/><span matTextSuffix>€</span>
@if (fieldError('monthlyRent')) {
<mat-error>{{ fieldError('monthlyRent') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Charges mensuelles</mat-label
><input
@@ -110,8 +141,13 @@
type="number"
id="monthlyCharges"
[formField]="leaseForm.monthlyCharges"
/><span matTextSuffix>€</span></mat-form-field
>
step="0.01"
(change)="fieldWasChanged('monthlyCharges')"
/><span matTextSuffix>€</span>
@if (fieldError('monthlyCharges')) {
<mat-error>{{ fieldError('monthlyCharges') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Dépôt de garantie</mat-label
><input
@@ -119,11 +155,20 @@
type="number"
id="securityDeposit"
[formField]="leaseForm.securityDeposit"
/><span matTextSuffix>€</span></mat-form-field
>
step="0.01"
(change)="fieldWasChanged('securityDeposit')"
/><span matTextSuffix>€</span>
@if (fieldError('securityDeposit')) {
<mat-error>{{ fieldError('securityDeposit') }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Mode de récupération des charges</mat-label
><mat-select id="chargeMethod" [formField]="leaseForm.chargeMethod">
><mat-select
id="chargeMethod"
[formField]="leaseForm.chargeMethod"
(selectionChange)="fieldWasChanged('chargeMethod')"
>
<mat-option value="FLAT_RATE">Forfait de charges</mat-option>
<mat-option value="PROVISION"
>Provision avec régularisation annuelle</mat-option
@@ -137,8 +182,13 @@
type="number"
id="dueDay"
[formField]="leaseForm.dueDay"
/><mat-hint>Entre le 1er et le 28 du mois</mat-hint></mat-form-field
>
step="1"
(change)="fieldWasChanged('dueDay')"
/><mat-hint>Entre le 1er et le 28 du mois</mat-hint>
@if (fieldError('dueDay')) {
<mat-error>{{ fieldError('dueDay') }}</mat-error>
}
</mat-form-field>
@if (leaseModel().chargeMethod === 'FLAT_RATE') {
<mat-checkbox id="indexFlatRate" [formField]="leaseForm.indexFlatRate">
Indexer le forfait de charges avec le loyer
@@ -51,7 +51,7 @@ describe('LeaseForm', () => {
expect(fixture.nativeElement.textContent).toContain('reconductible tacitement');
});
it('allows overlapping draft dates but leaves active dates unavailable', () => {
it('warns about an active overlap without blocking the draft save', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
@@ -92,13 +92,115 @@ describe('LeaseForm', () => {
status: 'ACTIVE',
},
]);
fixture.componentInstance.leaseModel.set({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
leaseType: 'STANDARD',
startDate: '2026-02-15',
monthlyRent: 800,
monthlyCharges: 0,
securityDeposit: 0,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
legalTerms: emptyLeaseLegalTerms(),
});
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
expect(fixture.componentInstance.overlapWarning()).toContain('chevauche');
fixture.componentInstance.save();
expect(submitted).toHaveBeenCalled();
});
it('accepts centime rent and zero charges or deposit, but blocks negative amounts', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentInstance.leaseModel.set({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
leaseType: 'STANDARD',
startDate: '2026-09-01',
monthlyRent: 0.01,
monthlyCharges: 0,
securityDeposit: 0,
dueDay: 1,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
legalTerms: emptyLeaseLegalTerms(),
});
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
fixture.componentInstance.save();
expect(submitted).toHaveBeenCalled();
fixture.componentInstance.leaseModel.update((lease) => ({
...lease,
apartmentId: 'apartment-1',
monthlyCharges: -0.01,
securityDeposit: -1,
}));
fixture.componentInstance.save();
expect(submitted).toHaveBeenCalledTimes(1);
});
expect(fixture.componentInstance.dateFilter('2026-01-15')).toBe(true);
expect(fixture.componentInstance.dateFilter('2026-02-15')).toBe(false);
it('keeps values and focuses the first server-invalid core control', async () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentInstance.leaseModel.set({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
leaseType: 'STANDARD',
startDate: '2026-09-01',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
legalTerms: emptyLeaseLegalTerms(),
});
fixture.detectChanges();
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'monthlyCharges', code: 'positive_or_zero' },
{ field: 'dueDay', code: 'max' },
]);
fixture.detectChanges();
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(fixture.componentInstance.leaseModel().monthlyCharges).toBe(50);
expect(fixture.nativeElement.textContent).toContain('charges mensuelles doivent être');
expect(document.activeElement?.id).toBe('monthlyCharges');
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'dueDay', code: 'max' }]);
fixture.detectChanges();
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(document.activeElement?.id).toBe('dueDay');
});
it('maps and clears the duplicate-tenant validation alias through the tenant control', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'tenantIdsDistinct', code: 'assert_true' },
]);
const changed = vi.fn();
fixture.componentInstance.fieldChanged.subscribe(changed);
fixture.detectChanges();
expect(fixture.componentInstance.fieldError('tenantIds')).toContain('sans doublon');
fixture.componentInstance.fieldWasChanged('tenantIds');
expect(changed).toHaveBeenCalledWith('tenantIds');
expect(changed).toHaveBeenCalledWith('tenantIdsDistinct');
});
it('loads a draft for editing', () => {
@@ -1,4 +1,14 @@
import { Component, computed, effect, input, output, signal, viewChild } from '@angular/core';
import {
Component,
computed,
effect,
ElementRef,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, max, min, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@@ -23,6 +33,13 @@ import { GuarantorForm } from '../guarantor-form/guarantor-form';
import { LeaseClauseEditor } from '../lease-clause-editor/lease-clause-editor';
import { LeaseLegalTermsEditor } from '../lease-legal-terms/lease-legal-terms';
import { normalizeLeaseLegalTerms } from '../../services/lease-legal-terms';
import { FieldValidationError } from '../../services/http-error-message';
import {
isInlineLeaseServerError,
leaseCoreFields,
leaseFieldError,
LeaseCoreField,
} from './lease-form-validation';
const frenchDate = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
@@ -51,14 +68,17 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
styleUrl: './lease-form.css',
})
export class LeaseForm {
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
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 saveFieldErrors = input<FieldValidationError[]>([]);
readonly cancelled = output<void>();
readonly submitted = output<LeaseInput>();
readonly fieldChanged = output<string>();
readonly clauseEditor = viewChild(LeaseClauseEditor);
readonly legalTermsEditor = viewChild(LeaseLegalTermsEditor);
@@ -81,21 +101,20 @@ export class LeaseForm {
required(f.tenantIds);
required(f.leaseType);
required(f.startDate);
validate(f.startDate, ({ value }) =>
!value() || this.dateFilter(value())
? null
: {
kind: 'occupied-period',
message: 'Cette date est comprise dans la période d’un bail déjà finalisé.',
},
);
required(f.monthlyRent);
min(f.monthlyRent, 1);
min(f.monthlyRent, Number.MIN_VALUE);
required(f.monthlyCharges);
min(f.monthlyCharges, 0);
required(f.securityDeposit);
min(f.securityDeposit, 0);
required(f.dueDay);
min(f.dueDay, 1);
max(f.dueDay, 28);
validate(f.dueDay, ({ value }) =>
Number.isInteger(value())
? null
: { kind: 'whole-day', message: 'Le jour d’échéance doit être un nombre entier.' },
);
});
readonly guarantors = signal<Guarantor[]>([]);
@@ -122,6 +141,23 @@ export class LeaseForm {
: 'Bail étudiant de neuf mois, non reconductible.',
};
});
readonly overlapWarning = computed(() => {
const { apartmentId, startDate } = this.leaseModel();
const endDate = this.leaseDuration().endDate;
if (!apartmentId || !startDate || !endDate) return '';
const overlappingLease = this.leases().find(
(lease) =>
lease.id !== this.lease()?.id &&
lease.status !== 'DRAFT' &&
lease.apartmentId === apartmentId &&
startDate <= lease.endDate &&
endDate >= lease.startDate,
);
return overlappingLease
? 'Cette période chevauche un bail déjà finalisé. Vérifiez la disponibilité avant de finaliser ce brouillon.'
: '';
});
private previousServerErrorSignature = '';
constructor() {
effect(() => {
@@ -143,20 +179,19 @@ export class LeaseForm {
this.guarantors.set(lease.guarantors);
this.specialClauses.set(lease.specialClauses ?? []);
});
effect(() => {
const errors = this.saveFieldErrors();
const signature = errors
.map((error) => `${error.field}:${error.code}`)
.sort()
.join('|');
if (signature && signature !== this.previousServerErrorSignature) {
queueMicrotask(() => this.focusFirstInvalid());
}
this.previousServerErrorSignature = signature;
});
}
readonly dateFilter = (date: string): boolean => {
const aptId = this.leaseModel().apartmentId;
if (!aptId) return true;
return !this.leases().some(
(lease) =>
lease.status !== 'DRAFT' &&
lease.apartmentId === aptId &&
date >= lease.startDate &&
date <= lease.endDate,
);
};
addGuarantor(guarantor: Guarantor): void {
this.guarantors.update((guarantors) => [...guarantors, guarantor]);
}
@@ -175,6 +210,7 @@ export class LeaseForm {
this.legalTermsEditor()?.validate() === false ||
this.clauseEditor()?.validate() === false
) {
queueMicrotask(() => this.focusFirstInvalid());
return;
}
const modelVal = this.leaseModel();
@@ -196,6 +232,59 @@ export class LeaseForm {
});
}
fieldWasChanged(field: string): void {
this.fieldChanged.emit(field);
if (field === 'tenantIds') this.fieldChanged.emit('tenantIdsDistinct');
}
showSaveError(): boolean {
return (
Boolean(this.saveError()) &&
(this.saveFieldErrors().length === 0 ||
this.saveFieldErrors().some((error) => !this.isInlineServerError(error)))
);
}
fieldError(field: LeaseCoreField): string {
return leaseFieldError(
this.leaseModel(),
field,
this.isTouched(field),
this.saveFieldErrors(),
);
}
private isTouched(field: LeaseCoreField): boolean {
switch (field) {
case 'apartmentId':
return this.leaseForm.apartmentId().touched();
case 'tenantIds':
return this.leaseForm.tenantIds().touched();
case 'leaseType':
return this.leaseForm.leaseType().touched();
case 'startDate':
return this.leaseForm.startDate().touched();
case 'monthlyRent':
return this.leaseForm.monthlyRent().touched();
case 'monthlyCharges':
return this.leaseForm.monthlyCharges().touched();
case 'securityDeposit':
return this.leaseForm.securityDeposit().touched();
case 'dueDay':
return this.leaseForm.dueDay().touched();
}
}
private isInlineServerError(error: FieldValidationError): boolean {
return isInlineLeaseServerError(error);
}
private focusFirstInvalid(): void {
const field = leaseCoreFields.find((item) => this.fieldError(item));
const control = field && this.element.nativeElement.querySelector<HTMLElement>(`#${field}`);
control?.focus();
}
private formatCivilDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
return frenchDate.format(new Date(Date.UTC(year, month - 1, day)));
@@ -1,4 +1,4 @@
@if (!showForm()) {
@if (!showForm() && !amendingLease()) {
<section class="page-section">
<header class="page-heading">
<div>
@@ -85,6 +85,7 @@
(pdfRequested)="workspace.downloadLeasePdf($event)"
(dossierRequested)="workspace.downloadLeaseDossier($event)"
(editRequested)="openForm($event)"
(amendmentRequested)="openAmendment($event)"
(finalizationRequested)="openFinalization($event)"
(leaseEndRequested)="openLeaseEnd($event)"
(inventoryRequested)="openInventory($event)"
@@ -97,7 +98,7 @@
}
</div>
</section>
} @else {
} @else if (showForm()) {
<app-lease-form
[apartments]="workspace.apartments()"
[tenants]="workspace.tenants()"
@@ -105,7 +106,20 @@
[lease]="editingLease() ?? undefined"
[saving]="workspace.leaseSaving(editingLease()?.id)"
[saveError]="workspace.leaseSaveError(editingLease()?.id)"
[saveFieldErrors]="workspace.leaseSaveFieldErrors(editingLease()?.id)"
(cancelled)="closeForm()"
(fieldChanged)="workspace.clearLeaseSaveFieldError($event, editingLease()?.id)"
(submitted)="saveLease($event)"
/>
} @else if (amendingLease(); as lease) {
<app-lease-amendment-form
[lease]="lease"
[tenants]="workspace.tenants()"
[saving]="workspace.leaseSaving(lease.id)"
[saveError]="workspace.leaseSaveError(lease.id)"
[saveFieldErrors]="workspace.leaseSaveFieldErrors(lease.id)"
(cancelled)="closeForm()"
(fieldChanged)="workspace.clearLeaseSaveFieldError($event, lease.id)"
(submitted)="saveAmendment($event)"
/>
}
@@ -22,12 +22,15 @@ describe('LeasesPage', () => {
loadLeases: vi.fn(),
loadRentDues: vi.fn(),
saveLease: vi.fn(),
amendLease: vi.fn(),
deleteLease: vi.fn(),
downloadLeasePdf: vi.fn(),
downloadLeaseDossier: vi.fn(),
leaseSaving: vi.fn(() => false),
leaseSaveError: vi.fn(() => ''),
leaseSaveFieldErrors: vi.fn(() => []),
clearLeaseSaveError: vi.fn(),
clearLeaseSaveFieldError: vi.fn(),
leaseDownloading: vi.fn(() => false),
leaseDownloadError: vi.fn(() => ''),
leaseRevision: vi.fn(),
@@ -3,13 +3,14 @@ import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { WorkspaceService } from '../../services/workspace.service';
import { Lease, LeaseInput, RentDue } from '../../services/lease.service';
import { Lease, LeaseAmendmentInput, LeaseInput, RentDue } from '../../services/lease.service';
import { LeaseAmendmentForm } from '../lease-amendment-form/lease-amendment-form';
import { LeaseCard } from '../lease-card/lease-card';
import { LeaseForm } from '../lease-form/lease-form';
@Component({
selector: 'app-leases-page',
imports: [MatButtonModule, MatProgressBarModule, LeaseCard, LeaseForm],
imports: [MatButtonModule, MatProgressBarModule, LeaseCard, LeaseForm, LeaseAmendmentForm],
templateUrl: './leases-page.html',
styleUrl: './leases-page.css',
})
@@ -18,6 +19,7 @@ export class LeasesPage {
private readonly router = inject(Router);
readonly showForm = signal(false);
readonly editingLease = signal<Lease | null>(null);
readonly amendingLease = signal<Lease | null>(null);
readonly rentDuesByLease = computed(() => {
const rentDuesByLease = new Map<string, RentDue[]>();
for (const rentDue of this.workspace.rentDues()) {
@@ -57,6 +59,7 @@ export class LeasesPage {
}
openForm(lease: Lease | null = null): void {
this.amendingLease.set(null);
this.editingLease.set(lease);
this.workspace.clearLeaseSaveError(lease?.id);
this.showForm.set(true);
@@ -64,11 +67,25 @@ export class LeasesPage {
closeForm(): void {
this.showForm.set(false);
this.editingLease.set(null);
this.amendingLease.set(null);
}
saveLease(lease: LeaseInput): void {
this.workspace.saveLease(lease, this.editingLease()?.id, () => this.closeForm());
}
openAmendment(lease: Lease): void {
this.showForm.set(false);
this.editingLease.set(null);
this.workspace.clearLeaseSaveError(lease.id);
this.amendingLease.set(lease);
}
saveAmendment(amendment: LeaseAmendmentInput): void {
const leaseId = this.amendingLease()?.id;
if (!leaseId) return;
this.workspace.amendLease(leaseId, amendment, () => this.closeForm());
}
rentDuesFor(lease: Lease): RentDue[] {
return lease.id ? (this.rentDuesByLease().get(lease.id) ?? []) : [];
}
@@ -2,7 +2,12 @@ import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { emptyLeaseLegalTerms, LeaseInput, LeaseService } from './lease.service';
import {
emptyLeaseLegalTerms,
LeaseAmendmentInput,
LeaseInput,
LeaseService,
} from './lease.service';
describe('LeaseService', () => {
const http = {
@@ -43,6 +48,19 @@ describe('LeaseService', () => {
service.refreshRentDues().subscribe();
service.createLease(lease).subscribe();
service.updateLease('lease-1', lease).subscribe();
const amendment: LeaseAmendmentInput = {
effectiveDate: '2026-10-01',
timeZone: 'Europe/Paris',
tenantIds: ['tenant-1'],
guarantors: [],
endDate: '2027-08-31',
monthlyRent: 900,
monthlyCharges: 50,
dueDay: 5,
chargeMethod: 'FLAT_RATE',
indexFlatRate: false,
};
service.amendLease('lease-1', amendment).subscribe();
service.deleteLease('lease-1').subscribe();
service.downloadPdf('lease-1').subscribe();
service.createRevision('lease-1').subscribe();
@@ -88,6 +106,7 @@ describe('LeaseService', () => {
expect(http.get).not.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/amendments', amendment);
expect(http.delete).toHaveBeenCalledWith('/api/leases/lease-1');
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/pdf', {
responseType: 'blob',
@@ -134,6 +134,19 @@ export interface Lease extends LeaseInput {
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
}
export interface LeaseAmendmentInput {
effectiveDate: string;
timeZone: string;
tenantIds: string[];
guarantors: Guarantor[];
endDate: string;
monthlyRent: number;
monthlyCharges: number;
dueDay: number;
chargeMethod: ChargeMethod;
indexFlatRate: boolean;
}
export interface RentDue {
id: string;
leaseId: string;
@@ -317,6 +330,10 @@ export class LeaseService {
return this.http.put<Lease>(`/api/leases/${id}`, lease);
}
amendLease(id: string, amendment: LeaseAmendmentInput): Observable<Lease> {
return this.http.post<Lease>(`/api/leases/${id}/amendments`, amendment);
}
deleteLease(id: string): Observable<void> {
return this.http.delete<void>(`/api/leases/${id}`);
}
+24 -1
View File
@@ -7,7 +7,14 @@ import {
import { Apartment, ApartmentService } from './apartment.service';
import { FileDownloadService } from './file-download.service';
import { map, switchMap, tap } from 'rxjs';
import { Lease, LeaseInput, LeaseRevisionMetadata, LeaseService, RentDue } from './lease.service';
import {
Lease,
LeaseAmendmentInput,
LeaseInput,
LeaseRevisionMetadata,
LeaseService,
RentDue,
} from './lease.service';
import { RentPayment, RentPaymentService } from './rent-payment.service';
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
@@ -255,6 +262,14 @@ export class WorkspaceService {
);
}
amendLease(id: string, amendment: LeaseAmendmentInput, onSuccess?: () => void): void {
this.leaseResource.save(
this.resourceKey(id),
this.leaseService.amendLease(id, amendment),
onSuccess,
);
}
leaseSaving(id?: string): boolean {
return this.leaseResource.savingKeys().has(this.resourceKey(id));
}
@@ -263,10 +278,18 @@ export class WorkspaceService {
return this.leaseResource.saveErrors()[this.resourceKey(id)] ?? '';
}
leaseSaveFieldErrors(id?: string): FieldValidationError[] {
return this.leaseResource.saveFieldErrors()[this.resourceKey(id)] ?? [];
}
clearLeaseSaveError(id?: string): void {
this.leaseResource.clearSaveError(this.resourceKey(id));
}
clearLeaseSaveFieldError(field: string, id?: string): void {
this.leaseResource.clearSaveFieldError(this.resourceKey(id), field);
}
deleteLease(id: string): void {
this.requestConfirmation(
{