refactor: split tenant and lease page modules
This commit is contained in:
1 parent
ebfc62a5e2
commit
3ca8514231
27 files changed
+1199
-1143
No files matched your search
@@ -0,0 +1,124 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.page-heading {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.page-heading h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: clamp(2rem, 7vw, 3.2rem);
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1.02;
|
||||
}
|
||||
.page-heading p:last-child,
|
||||
.section-heading p {
|
||||
margin-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.back-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--text-muted);
|
||||
margin: 0 0 20px -16px;
|
||||
}
|
||||
.lease-form,
|
||||
.guarantor-section {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.guarantor-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.form-section + .form-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-heading > span {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.section-heading h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.section-heading p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.form-grid mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
.guarantor-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 7px 5px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.guarantor-chip .mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
}
|
||||
.alert-error {
|
||||
margin-bottom: 18px;
|
||||
padding: 13px 15px;
|
||||
border-radius: 12px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.form-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.three-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.lease-form,
|
||||
.guarantor-section {
|
||||
padding: 30px;
|
||||
}
|
||||
.form-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-actions button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.form-page {
|
||||
max-width: 980px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<section class="form-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<button mat-button type="button" class="back-action" (click)="cancelled.emit()">
|
||||
← Retour
|
||||
</button>
|
||||
<p class="eyebrow">Nouveau bail</p>
|
||||
<h1>Créer un bail</h1>
|
||||
<p>Associez le bien, les locataires et les éventuels garants.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
id="lease-details"
|
||||
class="lease-form"
|
||||
#leaseForm="ngForm"
|
||||
ngNativeValidate
|
||||
(ngSubmit)="save(leaseForm)"
|
||||
>
|
||||
@if (error()) {
|
||||
<div class="alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
<section class="form-section">
|
||||
<div class="section-heading">
|
||||
<span>01</span>
|
||||
<div>
|
||||
<h2>Parties du bail</h2>
|
||||
<p>Choisissez le logement et les locataires.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Appartement</mat-label
|
||||
><mat-select
|
||||
name="apartmentId"
|
||||
[ngModel]="apartmentId()"
|
||||
(ngModelChange)="apartmentId.set($event)"
|
||||
required
|
||||
><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-form-field appearance="outline"
|
||||
><mat-label>Locataires</mat-label
|
||||
><mat-select
|
||||
name="tenantIds"
|
||||
[ngModel]="tenantIds()"
|
||||
(ngModelChange)="tenantIds.set($event)"
|
||||
multiple
|
||||
required
|
||||
>
|
||||
@for (tenant of tenants(); track tenant.id) {
|
||||
<mat-option [value]="tenant.id"
|
||||
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select></mat-form-field
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section">
|
||||
<div class="section-heading">
|
||||
<span>02</span>
|
||||
<div>
|
||||
<h2>Conditions</h2>
|
||||
<p>Définissez la période et les montants mensuels.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid three-columns">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Date de début</mat-label
|
||||
><input
|
||||
matInput
|
||||
[matDatepicker]="startDatePicker"
|
||||
name="startDate"
|
||||
[ngModel]="startDate()"
|
||||
(ngModelChange)="startDate.set($event)"
|
||||
required /><mat-datepicker-toggle
|
||||
matIconSuffix
|
||||
[for]="startDatePicker" /><mat-datepicker #startDatePicker
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Date de fin</mat-label
|
||||
><input
|
||||
matInput
|
||||
[matDatepicker]="endDatePicker"
|
||||
name="endDate"
|
||||
[ngModel]="endDate()"
|
||||
(ngModelChange)="endDate.set($event)"
|
||||
required /><mat-datepicker-toggle matIconSuffix [for]="endDatePicker" /><mat-datepicker
|
||||
#endDatePicker
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Loyer mensuel</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
name="monthlyRent"
|
||||
[ngModel]="monthlyRent()"
|
||||
(ngModelChange)="monthlyRent.set($event)"
|
||||
min="1"
|
||||
required
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Charges mensuelles</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
name="monthlyCharges"
|
||||
[ngModel]="monthlyCharges()"
|
||||
(ngModelChange)="monthlyCharges.set($event)"
|
||||
min="0"
|
||||
required
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Dépôt de garantie</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
name="securityDeposit"
|
||||
[ngModel]="securityDeposit()"
|
||||
(ngModelChange)="securityDeposit.set($event)"
|
||||
min="0"
|
||||
required
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
<section class="guarantor-section">
|
||||
<div class="section-heading">
|
||||
<span>03</span>
|
||||
<div>
|
||||
<h2>Garants</h2>
|
||||
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
|
||||
</div>
|
||||
</div>
|
||||
@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" (click)="cancelled.emit()">Annuler</button
|
||||
><button mat-flat-button type="submit" form="lease-details" class="primary-action">
|
||||
Enregistrer le bail
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||
import { LeaseForm } from './lease-form';
|
||||
|
||||
describe('LeaseForm', () => {
|
||||
it('serializes selected dates as ISO values', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
component.apartmentId.set('apartment-1');
|
||||
component.tenantIds.set(['tenant-1']);
|
||||
component.startDate.set(new Date(2026, 8, 1));
|
||||
component.endDate.set(new Date(2027, 7, 31));
|
||||
component.monthlyRent.set(850);
|
||||
component.monthlyCharges.set(50);
|
||||
component.securityDeposit.set(850);
|
||||
let startDate = '';
|
||||
component.submitted.subscribe((lease) => (startDate = lease.startDate));
|
||||
|
||||
component.save({ invalid: false } as never);
|
||||
|
||||
expect(startDate).toBe('2026-09-01');
|
||||
});
|
||||
|
||||
it('keeps guarantors in the lease form module', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentInstance.addGuarantor({
|
||||
firstName: 'Alex',
|
||||
lastName: 'Durand',
|
||||
email: 'alex@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
|
||||
expect(fixture.componentInstance.guarantors()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { FormsModule, NgForm } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { Apartment } from '../../services/apartment.service';
|
||||
import { Guarantor, Lease } from '../../services/lease.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
import { GuarantorForm } from '../guarantor-form/guarantor-form';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
GuarantorForm,
|
||||
],
|
||||
templateUrl: './lease-form.html',
|
||||
styleUrl: './lease-form.css',
|
||||
})
|
||||
export class LeaseForm {
|
||||
readonly apartments = input.required<Apartment[]>();
|
||||
readonly tenants = input.required<Tenant[]>();
|
||||
readonly cancelled = output<void>();
|
||||
readonly submitted = output<Lease>();
|
||||
readonly apartmentId = signal('');
|
||||
readonly tenantIds = signal<string[]>([]);
|
||||
readonly startDate = signal<Date | null>(null);
|
||||
readonly endDate = signal<Date | null>(null);
|
||||
readonly monthlyRent = signal(0);
|
||||
readonly monthlyCharges = signal(0);
|
||||
readonly securityDeposit = signal(0);
|
||||
readonly guarantors = signal<Guarantor[]>([]);
|
||||
readonly error = signal('');
|
||||
|
||||
addGuarantor(guarantor: Guarantor): void {
|
||||
this.guarantors.update((guarantors) => [...guarantors, guarantor]);
|
||||
}
|
||||
|
||||
removeGuarantor(index: number): void {
|
||||
this.guarantors.update((guarantors) =>
|
||||
guarantors.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
}
|
||||
|
||||
save(form: NgForm): void {
|
||||
if (form.invalid) {
|
||||
form.control.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
if (this.endDate()! < this.startDate()!) {
|
||||
this.error.set('La date de fin doit être postérieure à la date de début.');
|
||||
return;
|
||||
}
|
||||
this.submitted.emit({
|
||||
apartmentId: this.apartmentId(),
|
||||
tenantIds: this.tenantIds(),
|
||||
guarantors: this.guarantors(),
|
||||
startDate: this.toIsoDate(this.startDate()!),
|
||||
endDate: this.toIsoDate(this.endDate()!),
|
||||
monthlyRent: this.monthlyRent(),
|
||||
monthlyCharges: this.monthlyCharges(),
|
||||
securityDeposit: this.securityDeposit(),
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
}
|
||||
|
||||
private toIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user