Align core lease and amendment validation (#49)
This commit is contained in:
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}`);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user