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,27 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.form-grid mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
.add-guarantor.mat-mdc-outlined-button {
|
||||
--mdc-outlined-button-label-text-color: var(--accent);
|
||||
--mdc-outlined-button-outline-color: var(--border-strong);
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.full-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.add-guarantor.mat-mdc-outlined-button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<form
|
||||
class="guarantor-form"
|
||||
#guarantorForm="ngForm"
|
||||
ngNativeValidate
|
||||
(ngSubmit)="save(guarantorForm)"
|
||||
>
|
||||
<div class="form-grid guarantor-fields">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="guarantorFirstName"
|
||||
[ngModel]="firstName()"
|
||||
(ngModelChange)="firstName.set($event)"
|
||||
required
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="guarantorLastName"
|
||||
[ngModel]="lastName()"
|
||||
(ngModelChange)="lastName.set($event)"
|
||||
required
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="email"
|
||||
name="guarantorEmail"
|
||||
[ngModel]="email()"
|
||||
(ngModelChange)="email.set($event)"
|
||||
required
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="tel"
|
||||
name="guarantorPhone"
|
||||
[ngModel]="phone()"
|
||||
(ngModelChange)="phone.set($event)"
|
||||
pattern="^(?:(?:0\d(?:[ .-]?\d){8})|(?:(?:\+|00)\d(?:[ .()-]?\d){7,14}))$"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="guarantorAddress"
|
||||
[ngModel]="address()"
|
||||
(ngModelChange)="address.set($event)"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<button mat-stroked-button type="submit" class="add-guarantor">Ajouter le garant</button>
|
||||
</form>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, output, signal } from '@angular/core';
|
||||
import { FormsModule, NgForm } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { Guarantor } from '../../services/lease.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantor-form',
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
templateUrl: './guarantor-form.html',
|
||||
styleUrl: './guarantor-form.css',
|
||||
})
|
||||
export class GuarantorForm {
|
||||
readonly submitted = output<Guarantor>();
|
||||
readonly firstName = signal('');
|
||||
readonly lastName = signal('');
|
||||
readonly email = signal('');
|
||||
readonly phone = signal('');
|
||||
readonly address = signal('');
|
||||
|
||||
save(form: NgForm): void {
|
||||
if (form.invalid) {
|
||||
form.control.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitted.emit({
|
||||
firstName: this.firstName().trim(),
|
||||
lastName: this.lastName().trim(),
|
||||
email: this.email().trim(),
|
||||
phone: this.phone().trim(),
|
||||
address: this.address().trim(),
|
||||
});
|
||||
this.firstName.set('');
|
||||
this.lastName.set('');
|
||||
this.email.set('');
|
||||
this.phone.set('');
|
||||
this.address.set('');
|
||||
form.resetForm();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.lease-card {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.lease-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lease-topline strong {
|
||||
margin-left: auto;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--surface-accent);
|
||||
}
|
||||
.lease-card h2 {
|
||||
margin: 25px 0 6px;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.tenant-name {
|
||||
color: var(--accent);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.terms {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.terms dt,
|
||||
.guarantor-summary span {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.terms dd {
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.guarantor-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
padding: 13px;
|
||||
border-radius: 13px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.guarantor-summary span {
|
||||
width: 100%;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.card-actions .mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--accent);
|
||||
}
|
||||
.card-actions .danger-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<article class="lease-card">
|
||||
<div class="lease-topline">
|
||||
<span class="status-dot"></span><span>{{ statusLabel() }}</span
|
||||
><strong>{{ lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
|
||||
</div>
|
||||
<h2>{{ apartmentName() }}</h2>
|
||||
<p class="tenant-name">{{ tenantNames() }}</p>
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>Période</dt>
|
||||
<dd>{{ lease().startDate }} → {{ lease().endDate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ lease().monthlyCharges.toLocaleString('fr-FR') }} € / mois</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dépôt</dt>
|
||||
<dd>{{ lease().securityDeposit.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (lease().guarantors.length) {
|
||||
<div class="guarantor-summary">
|
||||
<span>Garant</span>
|
||||
@for (guarantor of lease().guarantors; track $index) {
|
||||
<strong>{{ guarantor.firstName }} {{ guarantor.lastName }}</strong>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="card-actions">
|
||||
<button mat-button type="button" (click)="pdfRequested.emit(lease())">
|
||||
Télécharger le bail
|
||||
</button>
|
||||
@if (lease().id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
(click)="deleteRequested.emit(lease().id!)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Apartment } from '../../services/apartment.service';
|
||||
import { Lease } from '../../services/lease.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-card',
|
||||
imports: [MatButtonModule],
|
||||
templateUrl: './lease-card.html',
|
||||
styleUrl: './lease-card.css',
|
||||
})
|
||||
export class LeaseCard {
|
||||
readonly lease = input.required<Lease>();
|
||||
readonly apartments = input.required<Apartment[]>();
|
||||
readonly tenants = input.required<Tenant[]>();
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly pdfRequested = output<Lease>();
|
||||
|
||||
apartmentName(): string {
|
||||
return (
|
||||
this.apartments().find((apartment) => apartment.id === this.lease().apartmentId)?.name ??
|
||||
'Bien inconnu'
|
||||
);
|
||||
}
|
||||
|
||||
tenantNames(): string {
|
||||
return this.lease()
|
||||
.tenantIds.map((id) => this.tenants().find((tenant) => tenant.id === id))
|
||||
.filter((tenant): tenant is Tenant => Boolean(tenant))
|
||||
.map((tenant) => `${tenant.firstName} ${tenant.lastName}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
statusLabel(): string {
|
||||
return { DRAFT: 'Bail en préparation', ACTIVE: 'Bail actif', ARCHIVED: 'Bail archivé' }[
|
||||
this.lease().status
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,12 @@
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.empty-state,
|
||||
.lease-card,
|
||||
.lease-form {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.empty-state {
|
||||
padding: 46px 22px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.empty-state > p:not(.eyebrow) {
|
||||
color: var(--text-muted);
|
||||
@@ -38,162 +34,6 @@
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.lease-card {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.lease-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lease-topline strong {
|
||||
margin-left: auto;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--surface-accent);
|
||||
}
|
||||
.lease-card h2 {
|
||||
margin: 25px 0 6px;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.tenant-name {
|
||||
color: var(--accent);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.terms {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.terms dt {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.terms dd {
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.guarantor-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
padding: 13px;
|
||||
border-radius: 13px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.guarantor-summary span {
|
||||
width: 100%;
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.card-actions .mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--accent);
|
||||
}
|
||||
.card-actions .danger-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
}
|
||||
.back-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--text-muted);
|
||||
margin: 0 0 20px -16px;
|
||||
}
|
||||
.lease-form {
|
||||
padding: 20px;
|
||||
}
|
||||
.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;
|
||||
color: var(--text-muted);
|
||||
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);
|
||||
}
|
||||
.add-guarantor.mat-mdc-outlined-button {
|
||||
--mdc-outlined-button-label-text-color: var(--accent);
|
||||
--mdc-outlined-button-outline-color: var(--border-strong);
|
||||
width: 100%;
|
||||
}
|
||||
.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) {
|
||||
.page-heading {
|
||||
display: flex;
|
||||
@@ -205,35 +45,12 @@
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
.lease-grid,
|
||||
.form-grid {
|
||||
.lease-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.three-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.full-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.lease-form {
|
||||
padding: 30px;
|
||||
}
|
||||
.add-guarantor.mat-mdc-outlined-button {
|
||||
width: auto;
|
||||
}
|
||||
.form-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-actions button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.lease-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.form-page {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
@@ -20,243 +20,22 @@
|
||||
} @else {
|
||||
<div class="lease-grid">
|
||||
@for (lease of leases(); track lease.id) {
|
||||
<article class="lease-card">
|
||||
<div class="lease-topline">
|
||||
<span class="status-dot"></span><span>{{ statusLabel(lease.status) }}</span
|
||||
><strong>{{ lease.monthlyRent.toLocaleString('fr-FR') }} €</strong>
|
||||
</div>
|
||||
<h2>{{ apartmentName(lease) }}</h2>
|
||||
<p class="tenant-name">{{ tenantNames(lease) }}</p>
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>Période</dt>
|
||||
<dd>{{ lease.startDate }} → {{ lease.endDate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ lease.monthlyCharges.toLocaleString('fr-FR') }} € / mois</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dépôt</dt>
|
||||
<dd>{{ lease.securityDeposit.toLocaleString('fr-FR') }} €</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (lease.guarantors.length) {
|
||||
<div class="guarantor-summary">
|
||||
<span>Garant</span>
|
||||
@for (guarantor of lease.guarantors; track $index) {
|
||||
<strong>{{ guarantor.firstName }} {{ guarantor.lastName }}</strong>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="card-actions">
|
||||
<button mat-button type="button" (click)="pdfRequested.emit(lease)">
|
||||
Télécharger le bail
|
||||
</button>
|
||||
@if (lease.id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
(click)="deleteRequested.emit(lease.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
<app-lease-card
|
||||
[lease]="lease"
|
||||
[apartments]="apartments()"
|
||||
[tenants]="tenants()"
|
||||
(pdfRequested)="pdfRequested.emit($event)"
|
||||
(deleteRequested)="deleteRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<section class="form-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<button mat-button type="button" class="back-action" (click)="closeForm()">← Retour</button>
|
||||
<p class="eyebrow">Nouveau bail</p>
|
||||
<h1>Créer un bail</h1>
|
||||
<p>Associez le bien, le locataire et les éventuels garants.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form class="lease-form" (submit)="save(); $event.preventDefault()">
|
||||
@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"
|
||||
/><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"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-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>
|
||||
}
|
||||
<div class="form-grid guarantor-fields">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="guarantorFirstName"
|
||||
[ngModel]="guarantorFirstName()"
|
||||
(ngModelChange)="guarantorFirstName.set($event)"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="guarantorLastName"
|
||||
[ngModel]="guarantorLastName()"
|
||||
(ngModelChange)="guarantorLastName.set($event)"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="email"
|
||||
name="guarantorEmail"
|
||||
[ngModel]="guarantorEmail()"
|
||||
(ngModelChange)="guarantorEmail.set($event)"
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="tel"
|
||||
name="guarantorPhone"
|
||||
[ngModel]="guarantorPhone()"
|
||||
(ngModelChange)="guarantorPhone.set($event)"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse du garant</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="guarantorAddress"
|
||||
[ngModel]="guarantorAddress()"
|
||||
(ngModelChange)="guarantorAddress.set($event)"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<button mat-stroked-button type="button" class="add-guarantor" (click)="addGuarantor()">
|
||||
Ajouter le garant
|
||||
</button>
|
||||
</section>
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="closeForm()">Annuler</button
|
||||
><button mat-flat-button type="submit" class="primary-action">Enregistrer le bail</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<app-lease-form
|
||||
[apartments]="apartments()"
|
||||
[tenants]="tenants()"
|
||||
(cancelled)="closeForm()"
|
||||
(submitted)="leaseSaved.emit($event); closeForm()"
|
||||
/>
|
||||
}
|
||||
@@ -1,104 +1,17 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Lease } from '../../services/lease.service';
|
||||
import { LeasesPage } from './leases-page';
|
||||
|
||||
describe('LeasesPage', () => {
|
||||
function createComponent(): LeasesPage {
|
||||
it('opens and closes the focused lease form module', () => {
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
fixture.componentRef.setInput('leases', []);
|
||||
fixture.componentRef.setInput('apartments', [
|
||||
{ id: 'apartment-1', name: 'Studio', address: 'Lyon', purchasePrice: 1, description: '' },
|
||||
]);
|
||||
fixture.componentRef.setInput('tenants', [
|
||||
{
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
},
|
||||
]);
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
|
||||
it('adds a guarantor and emits a valid lease', () => {
|
||||
const component = createComponent();
|
||||
let savedLease: Lease | undefined;
|
||||
component.leaseSaved.subscribe((lease) => (savedLease = lease));
|
||||
|
||||
component.guarantorFirstName.set('Alex');
|
||||
component.guarantorLastName.set('Durand');
|
||||
component.guarantorEmail.set('alex@example.com');
|
||||
component.addGuarantor();
|
||||
expect(component.guarantors()).toHaveLength(1);
|
||||
|
||||
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.save();
|
||||
|
||||
expect(savedLease?.tenantIds).toEqual(['tenant-1']);
|
||||
expect(savedLease?.guarantors[0].lastName).toBe('Durand');
|
||||
expect(savedLease?.startDate).toBe('2026-09-01');
|
||||
});
|
||||
|
||||
it('rejects incomplete lease and guarantor drafts', () => {
|
||||
const component = createComponent();
|
||||
|
||||
component.addGuarantor();
|
||||
expect(component.error()).toContain('garant');
|
||||
component.save();
|
||||
expect(component.error()).toContain('Sélectionnez');
|
||||
});
|
||||
|
||||
it('rejects malformed guarantor contact details', () => {
|
||||
const component = createComponent();
|
||||
component.guarantorFirstName.set('Alex');
|
||||
component.guarantorLastName.set('Durand');
|
||||
component.guarantorEmail.set('alex-at-example.com');
|
||||
|
||||
component.addGuarantor();
|
||||
expect(component.error()).toContain('e-mail valide');
|
||||
|
||||
component.guarantorEmail.set('alex@example.com');
|
||||
component.guarantorPhone.set('not-a-phone');
|
||||
component.addGuarantor();
|
||||
expect(component.error()).toContain('numéro français');
|
||||
});
|
||||
|
||||
it('resets the form and resolves display labels', () => {
|
||||
const component = createComponent();
|
||||
component.monthlyRent.set(900);
|
||||
component.guarantors.set([
|
||||
{ firstName: 'Alex', lastName: 'Durand', email: 'alex@example.com', phone: '', address: '' },
|
||||
]);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.openForm();
|
||||
|
||||
expect(component.showForm()).toBe(true);
|
||||
expect(component.monthlyRent()).toBe(0);
|
||||
expect(component.guarantors()).toEqual([]);
|
||||
expect(component.apartmentName({ apartmentId: 'apartment-1' } as Lease)).toBe('Studio');
|
||||
expect(component.tenantNames({ tenantIds: ['tenant-1'] } as Lease)).toBe('Camille Martin');
|
||||
expect(component.statusLabel('DRAFT')).toBe('Bail en préparation');
|
||||
expect(component.statusLabel('ACTIVE')).toBe('Bail actif');
|
||||
expect(component.statusLabel('ARCHIVED')).toBe('Bail archivé');
|
||||
component.closeForm();
|
||||
expect(component.showForm()).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a guarantor', () => {
|
||||
const component = createComponent();
|
||||
component.guarantors.set([
|
||||
{ firstName: 'Alex', lastName: 'Durand', email: 'alex@example.com', phone: '', address: '' },
|
||||
{ firstName: 'Jo', lastName: 'Petit', email: 'jo@example.com', phone: '', address: '' },
|
||||
]);
|
||||
|
||||
component.removeGuarantor(0);
|
||||
|
||||
expect(component.guarantors().map((guarantor) => guarantor.firstName)).toEqual(['Jo']);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,14 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { Apartment } from '../../services/apartment.service';
|
||||
import { Guarantor, Lease } from '../../services/lease.service';
|
||||
import { Lease } from '../../services/lease.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
import { isValidEmail, isValidPhone } from '../../shared/contact-validation';
|
||||
import { LeaseCard } from '../lease-card/lease-card';
|
||||
import { LeaseForm } from '../lease-form/lease-form';
|
||||
|
||||
@Component({
|
||||
selector: 'app-leases-page',
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
],
|
||||
imports: [MatButtonModule, LeaseCard, LeaseForm],
|
||||
templateUrl: './leases-page.html',
|
||||
styleUrl: './leases-page.css',
|
||||
})
|
||||
@@ -30,141 +19,12 @@ export class LeasesPage {
|
||||
readonly leaseSaved = output<Lease>();
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly pdfRequested = output<Lease>();
|
||||
|
||||
readonly showForm = signal(false);
|
||||
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 guarantorFirstName = signal('');
|
||||
readonly guarantorLastName = signal('');
|
||||
readonly guarantorEmail = signal('');
|
||||
readonly guarantorPhone = signal('');
|
||||
readonly guarantorAddress = signal('');
|
||||
readonly error = signal('');
|
||||
|
||||
openForm(): void {
|
||||
this.reset();
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
this.showForm.set(false);
|
||||
}
|
||||
|
||||
addGuarantor(): void {
|
||||
if (
|
||||
!this.guarantorFirstName().trim() ||
|
||||
!this.guarantorLastName().trim() ||
|
||||
!this.guarantorEmail().trim()
|
||||
) {
|
||||
this.error.set('Le prénom, le nom et l’e-mail du garant sont obligatoires.');
|
||||
return;
|
||||
}
|
||||
if (!isValidEmail(this.guarantorEmail())) {
|
||||
this.error.set('Saisissez une adresse e-mail valide pour le garant.');
|
||||
return;
|
||||
}
|
||||
if (!isValidPhone(this.guarantorPhone())) {
|
||||
this.error.set('Saisissez un numéro français ou international valide pour le garant.');
|
||||
return;
|
||||
}
|
||||
this.guarantors.update((guarantors) => [
|
||||
...guarantors,
|
||||
{
|
||||
firstName: this.guarantorFirstName().trim(),
|
||||
lastName: this.guarantorLastName().trim(),
|
||||
email: this.guarantorEmail().trim(),
|
||||
phone: this.guarantorPhone().trim(),
|
||||
address: this.guarantorAddress().trim(),
|
||||
},
|
||||
]);
|
||||
this.guarantorFirstName.set('');
|
||||
this.guarantorLastName.set('');
|
||||
this.guarantorEmail.set('');
|
||||
this.guarantorPhone.set('');
|
||||
this.guarantorAddress.set('');
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
removeGuarantor(index: number): void {
|
||||
this.guarantors.update((guarantors) =>
|
||||
guarantors.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (
|
||||
!this.apartmentId() ||
|
||||
this.tenantIds().length === 0 ||
|
||||
!this.startDate() ||
|
||||
!this.endDate() ||
|
||||
this.monthlyRent() <= 0
|
||||
) {
|
||||
this.error.set('Sélectionnez un bien et un locataire, puis complétez les dates et le loyer.');
|
||||
return;
|
||||
}
|
||||
if (this.endDate()! < this.startDate()!) {
|
||||
this.error.set('La date de fin doit être postérieure à la date de début.');
|
||||
return;
|
||||
}
|
||||
this.leaseSaved.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',
|
||||
});
|
||||
this.closeForm();
|
||||
}
|
||||
|
||||
apartmentName(lease: Lease): string {
|
||||
return (
|
||||
this.apartments().find((apartment) => apartment.id === lease.apartmentId)?.name ??
|
||||
'Bien inconnu'
|
||||
);
|
||||
}
|
||||
|
||||
tenantNames(lease: Lease): string {
|
||||
return lease.tenantIds
|
||||
.map((id) => this.tenants().find((tenant) => tenant.id === id))
|
||||
.filter((tenant): tenant is Tenant => Boolean(tenant))
|
||||
.map((tenant) => `${tenant.firstName} ${tenant.lastName}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
statusLabel(status: Lease['status']): string {
|
||||
return { DRAFT: 'Bail en préparation', ACTIVE: 'Bail actif', ARCHIVED: 'Bail archivé' }[status];
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.apartmentId.set('');
|
||||
this.tenantIds.set([]);
|
||||
this.startDate.set(null);
|
||||
this.endDate.set(null);
|
||||
this.monthlyRent.set(0);
|
||||
this.monthlyCharges.set(0);
|
||||
this.securityDeposit.set(0);
|
||||
this.guarantors.set([]);
|
||||
this.guarantorFirstName.set('');
|
||||
this.guarantorLastName.set('');
|
||||
this.guarantorEmail.set('');
|
||||
this.guarantorPhone.set('');
|
||||
this.guarantorAddress.set('');
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
private toIsoDate(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.tenant-card {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.tenant-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.tenant-identity h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.tenant-avatar {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
place-items: center;
|
||||
border-radius: 15px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
.contact-list {
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.contact-list div {
|
||||
min-width: 0;
|
||||
}
|
||||
.contact-list dt {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.contact-list dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.attachments {
|
||||
padding: 15px;
|
||||
border-radius: 15px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.section-title span {
|
||||
display: grid;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.attachment-row.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--text);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0 5px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.upload-action {
|
||||
display: block;
|
||||
min-height: 40px;
|
||||
padding: 11px 5px 4px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.upload-action input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
.upload-action small {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.attachment-error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card-actions .mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--accent);
|
||||
}
|
||||
.card-actions .danger-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<article class="tenant-card">
|
||||
<div class="tenant-identity">
|
||||
<span class="tenant-avatar"
|
||||
>{{ tenant().firstName.charAt(0) }}{{ tenant().lastName.charAt(0) }}</span
|
||||
>
|
||||
<div>
|
||||
<p class="property-label">Locataire</p>
|
||||
<h2>{{ tenant().firstName }} {{ tenant().lastName }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="contact-list">
|
||||
<div>
|
||||
<dt>E-mail</dt>
|
||||
<dd>{{ tenant().email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Téléphone</dt>
|
||||
<dd>{{ tenant().phone || 'Non renseigné' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Adresse</dt>
|
||||
<dd>{{ tenant().address || 'Non renseignée' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="attachments">
|
||||
<div class="section-title">
|
||||
<strong>Pièces jointes</strong><span>{{ tenant().attachments?.length ?? 0 }}</span>
|
||||
</div>
|
||||
@for (attachment of tenant().attachments ?? []; track attachment.id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="attachment-row"
|
||||
[attr.aria-label]="'Télécharger la pièce jointe ' + attachment.fileName"
|
||||
(click)="attachmentDownloadRequested.emit({ tenantId: tenant().id!, attachment })"
|
||||
>
|
||||
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
|
||||
</button>
|
||||
}
|
||||
@if (tenant().id) {
|
||||
@if (uploadError() || attachmentError()) {
|
||||
<p class="attachment-error" role="alert">{{ uploadError() || attachmentError() }}</p>
|
||||
}
|
||||
<label class="upload-action"
|
||||
><input
|
||||
type="file"
|
||||
[attr.aria-label]="
|
||||
'Ajouter une pièce jointe pour ' + tenant().firstName + ' ' + tenant().lastName
|
||||
"
|
||||
(change)="selectFile($event)"
|
||||
/>+ Ajouter une pièce jointe <small>· 250 Mo maximum</small></label
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button mat-button type="button" (click)="editRequested.emit(tenant())">Modifier</button>
|
||||
@if (tenant().id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
(click)="deleteRequested.emit(tenant().id!)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TenantCard } from './tenant-card';
|
||||
|
||||
describe('TenantCard', () => {
|
||||
it('rejects files larger than the upload limit before emitting', () => {
|
||||
const fixture = TestBed.createComponent(TenantCard);
|
||||
fixture.componentRef.setInput('tenant', {
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
const file = new File(['identity'], 'archive.zip');
|
||||
Object.defineProperty(file, 'size', { value: 250 * 1024 * 1024 + 1 });
|
||||
const input = document.createElement('input');
|
||||
Object.defineProperty(input, 'files', { value: { item: () => file } });
|
||||
|
||||
fixture.componentInstance.selectFile({ target: input } as unknown as Event);
|
||||
|
||||
expect(fixture.componentInstance.uploadError()).toContain('250 Mo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Tenant, TenantAttachment } from '../../services/tenant.service';
|
||||
|
||||
export interface TenantAttachmentRequest {
|
||||
tenantId: string;
|
||||
attachment: TenantAttachment;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tenant-card',
|
||||
imports: [MatButtonModule],
|
||||
templateUrl: './tenant-card.html',
|
||||
styleUrl: './tenant-card.css',
|
||||
})
|
||||
export class TenantCard {
|
||||
readonly tenant = input.required<Tenant>();
|
||||
readonly attachmentError = input('');
|
||||
readonly editRequested = output<Tenant>();
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
|
||||
readonly attachmentDownloadRequested = output<TenantAttachmentRequest>();
|
||||
readonly uploadError = signal('');
|
||||
|
||||
selectFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0);
|
||||
const tenantId = this.tenant().id;
|
||||
if (file && file.size > 250 * 1024 * 1024) {
|
||||
this.uploadError.set('Une pièce jointe ne peut pas dépasser 250 Mo.');
|
||||
} else if (tenantId && file) {
|
||||
this.uploadError.set('');
|
||||
this.attachmentUploadRequested.emit({ tenantId, file });
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
: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 {
|
||||
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;
|
||||
}
|
||||
.entity-form {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.form-grid mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.full-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.entity-form {
|
||||
padding: 30px;
|
||||
}
|
||||
.form-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-actions button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.form-page {
|
||||
max-width: 900px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<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">Dossier locataire</p>
|
||||
<h1>{{ tenant() ? 'Modifier le locataire' : 'Ajouter un locataire' }}</h1>
|
||||
<p>Renseignez les coordonnées nécessaires à la gestion du bail.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form class="entity-form" #tenantForm="ngForm" ngNativeValidate (ngSubmit)="save(tenantForm)">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="firstName"
|
||||
[ngModel]="firstName()"
|
||||
(ngModelChange)="firstName.set($event)"
|
||||
required
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="lastName"
|
||||
[ngModel]="lastName()"
|
||||
(ngModelChange)="lastName.set($event)"
|
||||
required
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="email"
|
||||
name="email"
|
||||
[ngModel]="email()"
|
||||
(ngModelChange)="email.set($event)"
|
||||
required
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="tel"
|
||||
name="phone"
|
||||
[ngModel]="phone()"
|
||||
(ngModelChange)="phone.set($event)"
|
||||
pattern="^(?:(?:0\d(?:[ .-]?\d){8})|(?:(?:\+|00)\d(?:[ .()-]?\d){7,14}))$"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse complète</mat-label
|
||||
><input matInput name="address" [ngModel]="address()" (ngModelChange)="address.set($event)"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button
|
||||
><button mat-flat-button type="submit" class="primary-action">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TenantForm } from './tenant-form';
|
||||
|
||||
describe('TenantForm', () => {
|
||||
it('emits a trimmed tenant through its small interface', () => {
|
||||
const fixture = TestBed.createComponent(TenantForm);
|
||||
const component = fixture.componentInstance;
|
||||
component.firstName.set(' Camille ');
|
||||
component.lastName.set(' Martin ');
|
||||
component.email.set(' camille@example.com ');
|
||||
let firstName = '';
|
||||
component.submitted.subscribe((request) => (firstName = request.tenant.firstName));
|
||||
|
||||
component.save({ invalid: false } as never);
|
||||
|
||||
expect(firstName).toBe('Camille');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Component, effect, input, output, signal } from '@angular/core';
|
||||
import { FormsModule, NgForm } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
|
||||
export interface TenantSaveRequest {
|
||||
tenant: Tenant;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tenant-form',
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
templateUrl: './tenant-form.html',
|
||||
styleUrl: './tenant-form.css',
|
||||
})
|
||||
export class TenantForm {
|
||||
readonly tenant = input<Tenant | null>(null);
|
||||
readonly cancelled = output<void>();
|
||||
readonly submitted = output<TenantSaveRequest>();
|
||||
|
||||
readonly firstName = signal('');
|
||||
readonly lastName = signal('');
|
||||
readonly email = signal('');
|
||||
readonly phone = signal('');
|
||||
readonly address = signal('');
|
||||
|
||||
constructor() {
|
||||
effect(() => this.fillForm(this.tenant()));
|
||||
}
|
||||
|
||||
save(form: NgForm): void {
|
||||
if (form.invalid) {
|
||||
form.control.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitted.emit({
|
||||
tenant: {
|
||||
firstName: this.firstName().trim(),
|
||||
lastName: this.lastName().trim(),
|
||||
email: this.email().trim(),
|
||||
phone: this.phone().trim(),
|
||||
address: this.address().trim(),
|
||||
},
|
||||
tenantId: this.tenant()?.id,
|
||||
});
|
||||
}
|
||||
|
||||
private fillForm(tenant: Tenant | null): void {
|
||||
this.firstName.set(tenant?.firstName ?? '');
|
||||
this.lastName.set(tenant?.lastName ?? '');
|
||||
this.email.set(tenant?.email ?? '');
|
||||
this.phone.set(tenant?.phone ?? '');
|
||||
this.address.set(tenant?.address ?? '');
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,12 @@
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.empty-state,
|
||||
.tenant-card,
|
||||
.entity-form {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.empty-state {
|
||||
padding: 46px 22px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.empty-state > p:not(.eyebrow) {
|
||||
color: var(--text-muted);
|
||||
@@ -38,144 +34,6 @@
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.tenant-card {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.tenant-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.tenant-identity h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.tenant-avatar {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
place-items: center;
|
||||
border-radius: 15px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
.contact-list {
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.contact-list div {
|
||||
min-width: 0;
|
||||
}
|
||||
.contact-list dt {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.contact-list dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.attachments {
|
||||
padding: 15px;
|
||||
border-radius: 15px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.section-title span {
|
||||
display: grid;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.attachment-row.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--text);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0 5px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.upload-action {
|
||||
display: block;
|
||||
min-height: 40px;
|
||||
padding: 11px 5px 4px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.upload-action input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
.upload-action small {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.attachment-error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card-actions .mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--accent);
|
||||
}
|
||||
.card-actions .danger-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
}
|
||||
.back-action.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--text-muted);
|
||||
margin: 0 0 20px -16px;
|
||||
}
|
||||
.entity-form {
|
||||
padding: 20px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.form-grid mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.form-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.page-heading {
|
||||
display: flex;
|
||||
@@ -187,30 +45,13 @@
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
.tenant-grid,
|
||||
.form-grid {
|
||||
.tenant-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.full-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.entity-form {
|
||||
padding: 30px;
|
||||
}
|
||||
.form-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-actions button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.tenant-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.form-page {
|
||||
max-width: 900px;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
<span aria-hidden="true">+</span> Ajouter un locataire
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (tenants().length === 0) {
|
||||
<article class="empty-state">
|
||||
<p class="eyebrow">Premier dossier</p>
|
||||
@@ -23,148 +22,22 @@
|
||||
} @else {
|
||||
<div class="tenant-grid">
|
||||
@for (tenant of tenants(); track tenant.id) {
|
||||
<article class="tenant-card">
|
||||
<div class="tenant-identity">
|
||||
<span class="tenant-avatar"
|
||||
>{{ tenant.firstName.charAt(0) }}{{ tenant.lastName.charAt(0) }}</span
|
||||
>
|
||||
<div>
|
||||
<p class="property-label">Locataire</p>
|
||||
<h2>{{ tenant.firstName }} {{ tenant.lastName }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="contact-list">
|
||||
<div>
|
||||
<dt>E-mail</dt>
|
||||
<dd>{{ tenant.email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Téléphone</dt>
|
||||
<dd>{{ tenant.phone || 'Non renseigné' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Adresse</dt>
|
||||
<dd>{{ tenant.address || 'Non renseignée' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="attachments">
|
||||
<div class="section-title">
|
||||
<strong>Pièces jointes</strong><span>{{ tenant.attachments?.length ?? 0 }}</span>
|
||||
</div>
|
||||
@for (attachment of tenant.attachments ?? []; track attachment.id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="attachment-row"
|
||||
[attr.aria-label]="'Télécharger la pièce jointe ' + attachment.fileName"
|
||||
(click)="attachmentDownloadRequested.emit({ tenantId: tenant.id!, attachment })"
|
||||
>
|
||||
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
|
||||
</button>
|
||||
}
|
||||
@if (tenant.id) {
|
||||
@if (attachmentUploadError() || attachmentError()) {
|
||||
<p class="attachment-error" role="alert">
|
||||
{{ attachmentUploadError() || attachmentError() }}
|
||||
</p>
|
||||
}
|
||||
<label class="upload-action">
|
||||
<input
|
||||
type="file"
|
||||
[attr.aria-label]="
|
||||
'Ajouter une pièce jointe pour ' + tenant.firstName + ' ' + tenant.lastName
|
||||
"
|
||||
(change)="selectFile(tenant, $event)"
|
||||
/>
|
||||
+ Ajouter une pièce jointe <small>· 250 Mo maximum</small>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button mat-button type="button" (click)="openEditForm(tenant)">Modifier</button>
|
||||
@if (tenant.id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
(click)="deleteRequested.emit(tenant.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
<app-tenant-card
|
||||
[tenant]="tenant"
|
||||
[attachmentError]="attachmentError()"
|
||||
(editRequested)="openEditForm($event)"
|
||||
(deleteRequested)="deleteRequested.emit($event)"
|
||||
(attachmentUploadRequested)="attachmentUploadRequested.emit($event)"
|
||||
(attachmentDownloadRequested)="attachmentDownloadRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<section class="form-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<button mat-button type="button" class="back-action" (click)="closeForm()">← Retour</button>
|
||||
<p class="eyebrow">Dossier locataire</p>
|
||||
<h1>{{ editingTenant() ? 'Modifier le locataire' : 'Ajouter un locataire' }}</h1>
|
||||
<p>Renseignez les coordonnées nécessaires à la gestion du bail.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form class="entity-form" (submit)="save(); $event.preventDefault()">
|
||||
@if (error()) {
|
||||
<div class="alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="firstName"
|
||||
[ngModel]="firstName()"
|
||||
(ngModelChange)="firstName.set($event)"
|
||||
required
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="lastName"
|
||||
[ngModel]="lastName()"
|
||||
(ngModelChange)="lastName.set($event)"
|
||||
required
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="email"
|
||||
name="email"
|
||||
[ngModel]="email()"
|
||||
(ngModelChange)="email.set($event)"
|
||||
required
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="tel"
|
||||
name="phone"
|
||||
[ngModel]="phone()"
|
||||
(ngModelChange)="phone.set($event)"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse complète</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="address"
|
||||
[ngModel]="address()"
|
||||
(ngModelChange)="address.set($event)"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="closeForm()">Annuler</button
|
||||
><button mat-flat-button type="submit" class="primary-action">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<app-tenant-form
|
||||
[tenant]="editingTenant()"
|
||||
(cancelled)="closeForm()"
|
||||
(submitted)="tenantSaved.emit($event); closeForm()"
|
||||
/>
|
||||
}
|
||||
@@ -2,108 +2,26 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { TenantsPage } from './tenants-page';
|
||||
|
||||
describe('TenantsPage', () => {
|
||||
it('validates and emits a tenant draft', () => {
|
||||
it('opens and closes the focused tenant form module', () => {
|
||||
const fixture = TestBed.createComponent(TenantsPage);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
let savedName = '';
|
||||
component.tenantSaved.subscribe((request) => (savedName = request.tenant.firstName));
|
||||
|
||||
component.save();
|
||||
expect(component.error()).toContain('obligatoires');
|
||||
component.openAddForm();
|
||||
expect(component.showForm()).toBe(true);
|
||||
expect(component.editingTenant()).toBeNull();
|
||||
|
||||
component.firstName.set('Camille');
|
||||
component.lastName.set('Martin');
|
||||
component.email.set('camille@example.com');
|
||||
component.save();
|
||||
|
||||
expect(savedName).toBe('Camille');
|
||||
expect(component.showForm()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed tenant contact details', () => {
|
||||
const fixture = TestBed.createComponent(TenantsPage);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
component.firstName.set('Camille');
|
||||
component.lastName.set('Martin');
|
||||
component.email.set('camille-at-example.com');
|
||||
|
||||
component.save();
|
||||
expect(component.error()).toContain('e-mail valide');
|
||||
|
||||
component.email.set('camille@example.com');
|
||||
component.phone.set('invalid');
|
||||
component.save();
|
||||
expect(component.error()).toContain('numéro français');
|
||||
});
|
||||
|
||||
it('emits a selected attachment for an existing tenant', () => {
|
||||
const fixture = TestBed.createComponent(TenantsPage);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['identity'], 'identite.pdf', { type: 'application/pdf' });
|
||||
let uploadedFile: File | undefined;
|
||||
component.attachmentUploadRequested.subscribe((request) => (uploadedFile = request.file));
|
||||
const input = document.createElement('input');
|
||||
Object.defineProperty(input, 'files', { value: { item: () => file } });
|
||||
|
||||
component.selectFile(
|
||||
{
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
},
|
||||
{ target: input } as unknown as Event,
|
||||
);
|
||||
|
||||
expect(uploadedFile).toBe(file);
|
||||
});
|
||||
|
||||
it('rejects an attachment larger than 250 Mo before upload', () => {
|
||||
const fixture = TestBed.createComponent(TenantsPage);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
const file = new File(['identity'], 'archive.zip');
|
||||
Object.defineProperty(file, 'size', { value: 250 * 1024 * 1024 + 1 });
|
||||
const input = document.createElement('input');
|
||||
Object.defineProperty(input, 'files', { value: { item: () => file } });
|
||||
|
||||
component.selectFile(
|
||||
{ id: 'tenant-1', firstName: 'Camille', lastName: 'Martin', email: 'camille@example.com', phone: '', address: '' },
|
||||
{ target: input } as unknown as Event,
|
||||
);
|
||||
|
||||
expect(component.attachmentUploadError()).toContain('250 Mo');
|
||||
});
|
||||
|
||||
it('opens, fills, and closes the edit form', () => {
|
||||
const fixture = TestBed.createComponent(TenantsPage);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
const tenant = {
|
||||
component.openEditForm({
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '0600000000',
|
||||
address: 'Lyon',
|
||||
};
|
||||
|
||||
component.openEditForm(tenant);
|
||||
fixture.detectChanges();
|
||||
expect(component.showForm()).toBe(true);
|
||||
expect(component.firstName()).toBe('Camille');
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
expect(component.editingTenant()?.id).toBe('tenant-1');
|
||||
|
||||
component.closeForm();
|
||||
fixture.detectChanges();
|
||||
expect(component.showForm()).toBe(false);
|
||||
expect(component.firstName()).toBe('');
|
||||
|
||||
component.openAddForm();
|
||||
expect(component.showForm()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,14 @@
|
||||
import { Component, effect, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { isValidEmail, isValidPhone } from '../../shared/contact-validation';
|
||||
import { Tenant, TenantAttachment } from '../../services/tenant.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
import { TenantCard, TenantAttachmentRequest } from '../tenant-card/tenant-card';
|
||||
import { TenantForm, TenantSaveRequest } from '../tenant-form/tenant-form';
|
||||
|
||||
export interface TenantSaveRequest {
|
||||
tenant: Tenant;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface TenantAttachmentRequest {
|
||||
tenantId: string;
|
||||
attachment: TenantAttachment;
|
||||
}
|
||||
export type { TenantAttachmentRequest, TenantSaveRequest };
|
||||
|
||||
@Component({
|
||||
selector: 'app-tenants-page',
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
imports: [MatButtonModule, TenantCard, TenantForm],
|
||||
templateUrl: './tenants-page.html',
|
||||
styleUrl: './tenants-page.css',
|
||||
})
|
||||
@@ -32,77 +22,17 @@ export class TenantsPage {
|
||||
|
||||
readonly showForm = signal(false);
|
||||
readonly editingTenant = signal<Tenant | null>(null);
|
||||
readonly firstName = signal('');
|
||||
readonly lastName = signal('');
|
||||
readonly email = signal('');
|
||||
readonly phone = signal('');
|
||||
readonly address = signal('');
|
||||
readonly error = signal('');
|
||||
readonly attachmentUploadError = signal('');
|
||||
|
||||
constructor() {
|
||||
effect(() => this.fillForm(this.editingTenant()));
|
||||
}
|
||||
|
||||
openAddForm(): void {
|
||||
this.editingTenant.set(null);
|
||||
this.fillForm(null);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
openEditForm(tenant: Tenant): void {
|
||||
this.editingTenant.set(tenant);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
this.showForm.set(false);
|
||||
this.editingTenant.set(null);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.firstName().trim() || !this.lastName().trim() || !this.email().trim()) {
|
||||
this.error.set('Le prénom, le nom et l’e-mail sont obligatoires.');
|
||||
return;
|
||||
}
|
||||
if (!isValidEmail(this.email())) {
|
||||
this.error.set('Saisissez une adresse e-mail valide.');
|
||||
return;
|
||||
}
|
||||
if (!isValidPhone(this.phone())) {
|
||||
this.error.set('Saisissez un numéro français ou international valide.');
|
||||
return;
|
||||
}
|
||||
this.tenantSaved.emit({
|
||||
tenant: {
|
||||
firstName: this.firstName().trim(),
|
||||
lastName: this.lastName().trim(),
|
||||
email: this.email().trim(),
|
||||
phone: this.phone().trim(),
|
||||
address: this.address().trim(),
|
||||
},
|
||||
tenantId: this.editingTenant()?.id,
|
||||
});
|
||||
this.closeForm();
|
||||
}
|
||||
|
||||
selectFile(tenant: Tenant, event: Event): void {
|
||||
const file = (event.target as HTMLInputElement).files?.item(0);
|
||||
if (file && file.size > 250 * 1024 * 1024) {
|
||||
this.attachmentUploadError.set('Une pièce jointe ne peut pas dépasser 250 Mo.');
|
||||
} else if (tenant.id && file) {
|
||||
this.attachmentUploadError.set('');
|
||||
this.attachmentUploadRequested.emit({ tenantId: tenant.id, file });
|
||||
}
|
||||
(event.target as HTMLInputElement).value = '';
|
||||
}
|
||||
|
||||
private fillForm(tenant: Tenant | null): void {
|
||||
this.firstName.set(tenant?.firstName ?? '');
|
||||
this.lastName.set(tenant?.lastName ?? '');
|
||||
this.email.set(tenant?.email ?? '');
|
||||
this.phone.set(tenant?.phone ?? '');
|
||||
this.address.set(tenant?.address ?? '');
|
||||
this.error.set('');
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return emailPattern.test(email.trim());
|
||||
}
|
||||
|
||||
export function isValidPhone(phone: string): boolean {
|
||||
const compact = phone.trim().replace(/[\s()./-]/g, '');
|
||||
if (!compact) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const international = compact.startsWith('00') ? `+${compact.slice(2)}` : compact;
|
||||
return international.startsWith('+')
|
||||
? /^\+\d{8,15}$/.test(international)
|
||||
: /^0\d{9}$/.test(international);
|
||||
}
|
||||
Reference in new issue
Block a user