feat: manage tenants and leases (#4)
This commit is contained in:
1 parent
26ab037084
commit
8603b98714
42 files changed
+3089
-18
No files matched your search
@@ -0,0 +1,207 @@
|
||||
: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;
|
||||
}
|
||||
.page-heading .primary-action {
|
||||
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;
|
||||
}
|
||||
.empty-state > p:not(.eyebrow) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tenant-grid {
|
||||
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);
|
||||
}
|
||||
.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;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 28px;
|
||||
}
|
||||
.page-heading .primary-action {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
.tenant-grid,
|
||||
.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) {
|
||||
.tenant-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.form-page {
|
||||
max-width: 900px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
@if (!showForm()) {
|
||||
<section class="page-section">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Dossiers</p>
|
||||
<h1>Mes locataires</h1>
|
||||
<p>Contacts, pièces jointes et accès rapide aux baux associés.</p>
|
||||
</div>
|
||||
<button mat-flat-button type="button" class="primary-action" (click)="openAddForm()">
|
||||
<span aria-hidden="true">+</span> Ajouter un locataire
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (tenants().length === 0) {
|
||||
<article class="empty-state">
|
||||
<p class="eyebrow">Premier dossier</p>
|
||||
<h2>Aucun locataire enregistré</h2>
|
||||
<p>Centralisez ici les coordonnées et pièces jointes utiles de vos locataires.</p>
|
||||
<button mat-stroked-button type="button" (click)="openAddForm()">
|
||||
Ajouter un locataire
|
||||
</button>
|
||||
</article>
|
||||
} @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) {
|
||||
<label class="upload-action">
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,image/*"
|
||||
[attr.aria-label]="
|
||||
'Ajouter une pièce jointe pour ' + tenant.firstName + ' ' + tenant.lastName
|
||||
"
|
||||
(change)="selectFile(tenant, $event)"
|
||||
/>
|
||||
+ Ajouter une pièce jointe
|
||||
</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>
|
||||
}
|
||||
</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
|
||||
/></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)"
|
||||
/></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>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TenantsPage } from './tenants-page';
|
||||
|
||||
describe('TenantsPage', () => {
|
||||
it('validates and emits a tenant draft', () => {
|
||||
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.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('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('opens, fills, and closes the edit form', () => {
|
||||
const fixture = TestBed.createComponent(TenantsPage);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
const component = fixture.componentInstance;
|
||||
const tenant = {
|
||||
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');
|
||||
|
||||
component.closeForm();
|
||||
fixture.detectChanges();
|
||||
expect(component.showForm()).toBe(false);
|
||||
expect(component.firstName()).toBe('');
|
||||
|
||||
component.openAddForm();
|
||||
expect(component.showForm()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component, effect, 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 { Tenant, TenantAttachment } from '../../services/tenant.service';
|
||||
|
||||
export interface TenantSaveRequest {
|
||||
tenant: Tenant;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface TenantAttachmentRequest {
|
||||
tenantId: string;
|
||||
attachment: TenantAttachment;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tenants-page',
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
templateUrl: './tenants-page.html',
|
||||
styleUrl: './tenants-page.css',
|
||||
})
|
||||
export class TenantsPage {
|
||||
readonly tenants = input.required<Tenant[]>();
|
||||
readonly tenantSaved = output<TenantSaveRequest>();
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
|
||||
readonly attachmentDownloadRequested = output<TenantAttachmentRequest>();
|
||||
|
||||
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('');
|
||||
|
||||
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;
|
||||
}
|
||||
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 (tenant.id && file) {
|
||||
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('');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user