feat: manage tenants and leases (#4)

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent 26ab037084
commit 8603b98714
42 files changed
+3089 -18

No files matched your search

+19
View File
@@ -39,6 +39,25 @@
(apartmentSaved)="saveApartment($event)"
/>
}
@if (activeTab() === 'tenants') {
<app-tenants-page
[tenants]="tenants()"
(tenantSaved)="saveTenant($event)"
(deleteRequested)="deleteTenant($event)"
(attachmentUploadRequested)="uploadTenantAttachment($event)"
(attachmentDownloadRequested)="downloadTenantAttachment($event)"
/>
}
@if (activeTab() === 'leases') {
<app-leases-page
[leases]="leases()"
[apartments]="apartments()"
[tenants]="tenants()"
(leaseSaved)="saveLease($event)"
(deleteRequested)="deleteLease($event)"
(pdfRequested)="downloadLeasePdf($event)"
/>
}
@if (activeTab() === 'settings') {
<app-settings-page
[username]="username"
+100
View File
@@ -2,12 +2,16 @@ import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { HttpClient } from '@angular/common/http';
import { ApartmentService } from './services/apartment.service';
import { TenantService } from './services/tenant.service';
import { LeaseService } from './services/lease.service';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
describe('App', () => {
let mockHttpClient: any;
let mockApartmentService: any;
let mockTenantService: any;
let mockLeaseService: any;
let mockUserResponse: { authenticated: boolean; username: string; email: string };
let originalLocation: any;
let locationMock: { href: string; origin: string };
@@ -42,6 +46,23 @@ describe('App', () => {
deleteApartment: () => of(undefined),
};
mockTenantService = {
getTenants: vi.fn().mockReturnValue(of([])),
createTenant: vi.fn().mockImplementation((tenant) => of({ id: 'tenant-2', ...tenant })),
updateTenant: vi.fn().mockImplementation((id, tenant) => of({ id, ...tenant })),
deleteTenant: vi.fn().mockReturnValue(of(undefined)),
uploadAttachment: vi.fn().mockReturnValue(of({ id: 'attachment-1' })),
downloadAttachment: vi.fn().mockReturnValue(of(new Blob(['attachment']))),
};
mockLeaseService = {
getLeases: vi.fn().mockReturnValue(of([])),
createLease: vi.fn().mockImplementation((lease) => of({ id: 'lease-1', ...lease })),
updateLease: vi.fn().mockImplementation((id, lease) => of({ id, ...lease })),
deleteLease: vi.fn().mockReturnValue(of(undefined)),
downloadPdf: vi.fn().mockReturnValue(of(new Blob(['pdf']))),
};
originalLocation = window.location;
delete (window as any).location;
locationMock = {
@@ -55,6 +76,8 @@ describe('App', () => {
providers: [
{ provide: HttpClient, useValue: mockHttpClient },
{ provide: ApartmentService, useValue: mockApartmentService },
{ provide: TenantService, useValue: mockTenantService },
{ provide: LeaseService, useValue: mockLeaseService },
],
}).compileComponents();
});
@@ -286,4 +309,81 @@ describe('App', () => {
await fixture.whenStable();
expect(deleteSpy).not.toHaveBeenCalled();
});
it('should orchestrate tenant mutations and attachment upload', () => {
const app = TestBed.createComponent(App).componentInstance;
const tenant = {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
};
const file = new File(['identity'], 'identite.pdf');
app.saveTenant({ tenant });
app.saveTenant({ tenant, tenantId: 'tenant-1' });
app.uploadTenantAttachment({ tenantId: 'tenant-1', file });
expect(mockTenantService.createTenant).toHaveBeenCalledWith(tenant);
expect(mockTenantService.updateTenant).toHaveBeenCalledWith('tenant-1', tenant);
expect(mockTenantService.uploadAttachment).toHaveBeenCalledWith('tenant-1', file);
expect(mockTenantService.getTenants).toHaveBeenCalledTimes(3);
});
it('should orchestrate lease creation and confirmed deletions', () => {
const app = TestBed.createComponent(App).componentInstance;
const lease = {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'ACTIVE' as const,
};
vi.spyOn(window, 'confirm').mockReturnValue(true);
app.saveLease(lease);
app.deleteLease('lease-1');
app.deleteTenant('tenant-1');
expect(mockLeaseService.createLease).toHaveBeenCalledWith(lease);
expect(mockLeaseService.deleteLease).toHaveBeenCalledWith('lease-1');
expect(mockTenantService.deleteTenant).toHaveBeenCalledWith('tenant-1');
});
it('should download tenant attachments and lease PDFs', () => {
const app = TestBed.createComponent(App).componentInstance;
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
app.downloadTenantAttachment({
tenantId: 'tenant-1',
attachment: { id: 'attachment-1', fileName: 'identite.pdf' },
});
app.downloadLeasePdf({
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'ACTIVE',
});
expect(mockTenantService.downloadAttachment).toHaveBeenCalled();
expect(mockLeaseService.downloadPdf).toHaveBeenCalledWith('lease-1');
expect(createObjectUrl).toHaveBeenCalledTimes(2);
expect(click).toHaveBeenCalledTimes(2);
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:test');
});
});
+95
View File
@@ -12,6 +12,10 @@ import { DashboardPage } from './components/dashboard-page/dashboard-page';
import { ApartmentsPage } from './components/apartments-page/apartments-page';
import { ApartmentSaveRequest } from './components/apartments-page/apartments-page';
import { SettingsPage } from './components/settings-page/settings-page';
import { Tenant, TenantService } from './services/tenant.service';
import { Lease, LeaseService } from './services/lease.service';
import { TenantsPage, TenantSaveRequest } from './components/tenants-page/tenants-page';
import { LeasesPage } from './components/leases-page/leases-page';
@Component({
selector: 'app-root',
@@ -21,6 +25,8 @@ import { SettingsPage } from './components/settings-page/settings-page';
WorkspaceNavigation,
DashboardPage,
ApartmentsPage,
TenantsPage,
LeasesPage,
SettingsPage,
],
templateUrl: './app.html',
@@ -33,6 +39,8 @@ export class App implements OnInit {
// Tab & CRUD States
readonly activeTab = signal<WorkspaceTab>('dashboard');
readonly apartments = signal<Apartment[]>([]);
readonly tenants = signal<Tenant[]>([]);
readonly leases = signal<Lease[]>([]);
private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>('');
private readonly _email = signal<string>('');
@@ -40,6 +48,8 @@ export class App implements OnInit {
private readonly http = inject(HttpClient);
private readonly apartmentService = inject(ApartmentService);
private readonly tenantService = inject(TenantService);
private readonly leaseService = inject(LeaseService);
protected readonly themeService = inject(ThemeService);
readonly themePreference = this.themeService.preference;
@@ -47,6 +57,8 @@ export class App implements OnInit {
effect(() => {
if (this.isLoggedIn) {
this.loadApartments();
this.loadTenants();
this.loadLeases();
}
});
}
@@ -139,4 +151,87 @@ export class App implements OnInit {
});
}
}
loadTenants(): void {
this.tenantService.getTenants().subscribe({
next: (data) => this.tenants.set(data),
error: (err) => console.error('Error loading tenants', err),
});
}
saveTenant({ tenant, tenantId }: TenantSaveRequest): void {
const request = tenantId
? this.tenantService.updateTenant(tenantId, tenant)
: this.tenantService.createTenant(tenant);
request.subscribe({
next: () => this.loadTenants(),
error: (err) => console.error('Error saving tenant', err),
});
}
deleteTenant(id: string): void {
if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?')) {
this.tenantService.deleteTenant(id).subscribe({
next: () => this.loadTenants(),
error: (err) => console.error('Error deleting tenant', err),
});
}
}
uploadTenantAttachment(request: { tenantId: string; file: File }): void {
this.tenantService.uploadAttachment(request.tenantId, request.file).subscribe({
next: () => this.loadTenants(),
error: (err) => console.error('Error uploading tenant attachment', err),
});
}
downloadTenantAttachment(request: {
tenantId: string;
attachment: { id: string; fileName: string };
}): void {
this.tenantService.downloadAttachment(request.tenantId, request.attachment.id).subscribe({
next: (blob) => this.saveBlob(blob, request.attachment.fileName),
error: (err) => console.error('Error downloading tenant attachment', err),
});
}
loadLeases(): void {
this.leaseService.getLeases().subscribe({
next: (data) => this.leases.set(data),
error: (err) => console.error('Error loading leases', err),
});
}
saveLease(lease: Lease): void {
this.leaseService.createLease(lease).subscribe({
next: () => this.loadLeases(),
error: (err) => console.error('Error saving lease', err),
});
}
deleteLease(id: string): void {
if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?')) {
this.leaseService.deleteLease(id).subscribe({
next: () => this.loadLeases(),
error: (err) => console.error('Error deleting lease', err),
});
}
}
downloadLeasePdf(lease: Lease): void {
if (!lease.id) return;
this.leaseService.downloadPdf(lease.id).subscribe({
next: (blob) => this.saveBlob(blob, `bail-${lease.id}.pdf`),
error: (err) => console.error('Error downloading lease PDF', err),
});
}
private saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
}
}
@@ -0,0 +1,240 @@
: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,
.lease-card,
.lease-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);
}
.lease-grid {
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;
align-items: flex-end;
justify-content: space-between;
gap: 28px;
}
.page-heading .primary-action {
width: auto;
margin-top: 0;
}
.lease-grid,
.form-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;
}
}
@@ -0,0 +1,259 @@
@if (!showForm()) {
<section class="page-section">
<header class="page-heading">
<div>
<p class="eyebrow">Location</p>
<h1>Mes baux</h1>
<p>Les baux actifs, leurs parties et leurs conditions financières.</p>
</div>
<button mat-flat-button type="button" class="primary-action" (click)="openForm()">
<span aria-hidden="true">+</span> Créer un bail
</button>
</header>
@if (leases().length === 0) {
<article class="empty-state">
<p class="eyebrow">Mise en location</p>
<h2>Aucun bail enregistré</h2>
<p>Créez un bail dès qu’un appartement et un locataire sont prêts.</p>
<button mat-stroked-button type="button" (click)="openForm()">Créer un bail</button>
</article>
} @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>
}
</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 le locataire.</p>
</div>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Appartement</mat-label
><select
matNativeControl
name="apartmentId"
[ngModel]="apartmentId()"
(ngModelChange)="apartmentId.set($event)"
required
>
<option value=""></option>
@for (apartment of apartments(); track apartment.id) {
<option [value]="apartment.id">{{ apartment.name }}</option>
}
</select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Locataire</mat-label
><select
matNativeControl
name="tenantId"
[ngModel]="tenantId()"
(ngModelChange)="tenantId.set($event)"
required
>
<option value=""></option>
@for (tenant of tenants(); track tenant.id) {
<option [value]="tenant.id">{{ tenant.firstName }} {{ tenant.lastName }}</option>
}
</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
type="date"
name="startDate"
[ngModel]="startDate()"
(ngModelChange)="startDate.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Date de fin</mat-label
><input
matInput
type="date"
name="endDate"
[ngModel]="endDate()"
(ngModelChange)="endDate.set($event)"
required
/></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)"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone du garant</mat-label
><input
matInput
name="guarantorPhone"
[ngModel]="guarantorPhone()"
(ngModelChange)="guarantorPhone.set($event)"
/></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>
}
@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { Lease } from '../../services/lease.service';
import { LeasesPage } from './leases-page';
describe('LeasesPage', () => {
function createComponent(): LeasesPage {
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.tenantId.set('tenant-1');
component.startDate.set('2026-09-01');
component.endDate.set('2027-08-31');
component.monthlyRent.set(850);
component.save();
expect(savedLease?.tenantIds).toEqual(['tenant-1']);
expect(savedLease?.guarantors[0].lastName).toBe('Durand');
});
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('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: '' },
]);
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']);
});
});
@@ -0,0 +1,142 @@
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 { Apartment } from '../../services/apartment.service';
import { Guarantor, Lease } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
@Component({
selector: 'app-leases-page',
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './leases-page.html',
styleUrl: './leases-page.css',
})
export class LeasesPage {
readonly leases = input.required<Lease[]>();
readonly apartments = input.required<Apartment[]>();
readonly tenants = input.required<Tenant[]>();
readonly leaseSaved = output<Lease>();
readonly deleteRequested = output<string>();
readonly pdfRequested = output<Lease>();
readonly showForm = signal(false);
readonly apartmentId = signal('');
readonly tenantId = signal('');
readonly startDate = signal('');
readonly endDate = signal('');
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;
}
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.tenantId() ||
!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;
}
this.leaseSaved.emit({
apartmentId: this.apartmentId(),
tenantIds: [this.tenantId()],
guarantors: this.guarantors(),
startDate: this.startDate(),
endDate: 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.tenantId.set('');
this.startDate.set('');
this.endDate.set('');
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('');
}
}
@@ -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('');
}
}
@@ -21,7 +21,7 @@
.navigation-items {
display: grid;
height: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.navigation-item.mat-mdc-button {
@@ -47,6 +47,48 @@
</svg>
<span>Appartements</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'tenants'"
[attr.aria-current]="activeTab() === 'tenants' ? 'page' : null"
(click)="tabSelected.emit('tenants')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<circle cx="9" cy="8" r="3" />
<path
d="M3.5 20v-2.5A4.5 4.5 0 0 1 8 13h2a4.5 4.5 0 0 1 4.5 4.5V20M16 5.5a3 3 0 0 1 0 5.8M17 14a4.5 4.5 0 0 1 3.5 4.4V20"
/>
</svg>
<span>Locataires</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'leases'"
[attr.aria-current]="activeTab() === 'leases' ? 'page' : null"
(click)="tabSelected.emit('leases')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M6 3h9l4 4v14H6z" />
<path d="M14 3v5h5M9 12h7M9 16h7" />
</svg>
<span>Baux</span>
</button>
<button
mat-button
type="button"
@@ -2,7 +2,7 @@ import { Component, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BrandMark } from '../brand-mark';
export type WorkspaceTab = 'dashboard' | 'apartments' | 'settings';
export type WorkspaceTab = 'dashboard' | 'apartments' | 'tenants' | 'leases' | 'settings';
@Component({
selector: 'app-workspace-navigation',
@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { Lease, LeaseService } from './lease.service';
describe('LeaseService', () => {
const http = {
get: vi.fn().mockReturnValue(of([])),
post: vi.fn().mockReturnValue(of({})),
put: vi.fn().mockReturnValue(of({})),
delete: vi.fn().mockReturnValue(of(undefined)),
};
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [LeaseService, { provide: HttpClient, useValue: http }],
});
});
it('uses lease CRUD and PDF endpoints', () => {
const service = TestBed.inject(LeaseService);
const lease: Lease = {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'ACTIVE',
};
service.getLeases().subscribe();
service.createLease(lease).subscribe();
service.updateLease('lease-1', lease).subscribe();
service.deleteLease('lease-1').subscribe();
service.downloadPdf('lease-1').subscribe();
expect(http.get).toHaveBeenCalledWith('/api/leases');
expect(http.post).toHaveBeenCalledWith('/api/leases', lease);
expect(http.put).toHaveBeenCalledWith('/api/leases/lease-1', lease);
expect(http.delete).toHaveBeenCalledWith('/api/leases/lease-1');
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/pdf', {
responseType: 'blob',
});
});
});
@@ -0,0 +1,51 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface Guarantor {
id?: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
}
export interface Lease {
id?: string;
landlordId?: string;
apartmentId: string;
tenantIds: string[];
guarantors: Guarantor[];
startDate: string;
endDate: string;
monthlyRent: number;
monthlyCharges: number;
securityDeposit: number;
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
}
@Injectable({ providedIn: 'root' })
export class LeaseService {
private readonly http = inject(HttpClient);
getLeases(): Observable<Lease[]> {
return this.http.get<Lease[]>('/api/leases');
}
createLease(lease: Lease): Observable<Lease> {
return this.http.post<Lease>('/api/leases', lease);
}
updateLease(id: string, lease: Lease): Observable<Lease> {
return this.http.put<Lease>(`/api/leases/${id}`, lease);
}
deleteLease(id: string): Observable<void> {
return this.http.delete<void>(`/api/leases/${id}`);
}
downloadPdf(id: string): Observable<Blob> {
return this.http.get(`/api/leases/${id}/pdf`, { responseType: 'blob' });
}
}
@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { TenantService } from './tenant.service';
describe('TenantService', () => {
const http = {
get: vi.fn().mockReturnValue(of([])),
post: vi.fn().mockReturnValue(of({})),
put: vi.fn().mockReturnValue(of({})),
delete: vi.fn().mockReturnValue(of(undefined)),
};
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [TenantService, { provide: HttpClient, useValue: http }],
});
});
it('uses the tenant CRUD endpoints', () => {
const service = TestBed.inject(TenantService);
const tenant = {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '+33612345678',
address: '10 rue des Lilas',
};
service.getTenants().subscribe();
service.createTenant(tenant).subscribe();
service.updateTenant('tenant-1', tenant).subscribe();
service.deleteTenant('tenant-1').subscribe();
expect(http.get).toHaveBeenCalledWith('/api/tenants');
expect(http.post).toHaveBeenCalledWith('/api/tenants', tenant);
expect(http.put).toHaveBeenCalledWith('/api/tenants/tenant-1', tenant);
expect(http.delete).toHaveBeenCalledWith('/api/tenants/tenant-1');
});
it('uploads and downloads tenant attachments', () => {
const service = TestBed.inject(TenantService);
const file = new File(['identity'], 'identite.pdf', { type: 'application/pdf' });
service.uploadAttachment('tenant-1', file).subscribe();
service.downloadAttachment('tenant-1', 'attachment-1').subscribe();
expect(http.post).toHaveBeenCalledWith(
'/api/tenants/tenant-1/attachments',
expect.any(FormData),
);
expect(http.get).toHaveBeenCalledWith('/api/tenants/tenant-1/attachments/attachment-1', {
responseType: 'blob',
});
});
});
@@ -0,0 +1,54 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface TenantAttachment {
id: string;
fileName: string;
contentType: string;
size: number;
}
export interface Tenant {
id?: string;
landlordId?: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
attachments?: TenantAttachment[];
}
@Injectable({ providedIn: 'root' })
export class TenantService {
private readonly http = inject(HttpClient);
getTenants(): Observable<Tenant[]> {
return this.http.get<Tenant[]>('/api/tenants');
}
createTenant(tenant: Tenant): Observable<Tenant> {
return this.http.post<Tenant>('/api/tenants', tenant);
}
updateTenant(id: string, tenant: Tenant): Observable<Tenant> {
return this.http.put<Tenant>(`/api/tenants/${id}`, tenant);
}
deleteTenant(id: string): Observable<void> {
return this.http.delete<void>(`/api/tenants/${id}`);
}
uploadAttachment(tenantId: string, file: File): Observable<TenantAttachment> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<TenantAttachment>(`/api/tenants/${tenantId}/attachments`, formData);
}
downloadAttachment(tenantId: string, attachmentId: string): Observable<Blob> {
return this.http.get(`/api/tenants/${tenantId}/attachments/${attachmentId}`, {
responseType: 'blob',
});
}
}