);
+
+ cancel(): void {
+ this.dialogRef.close(false);
+ }
+
+ accept(): void {
+ this.dialogRef.close(true);
+ }
+}
diff --git a/frontend/src/app/components/tenant-card/tenant-card.css b/frontend/src/app/components/tenant-card/tenant-card.css
index c884ea4..af57670 100644
--- a/frontend/src/app/components/tenant-card/tenant-card.css
+++ b/frontend/src/app/components/tenant-card/tenant-card.css
@@ -69,14 +69,29 @@
background: var(--surface);
color: var(--text-muted);
}
+.attachment-item {
+ min-width: 0;
+}
+.attachment-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
.attachment-row.mat-mdc-button {
--mdc-text-button-label-text-color: var(--text);
- width: 100%;
+ flex: 1;
min-width: 0;
justify-content: flex-start;
padding: 0 5px;
font-size: 0.78rem;
}
+.attachment-delete.mat-mdc-button {
+ --mdc-text-button-label-text-color: var(--danger);
+ flex: 0 0 auto;
+ min-width: 32px;
+ padding: 0 5px;
+ font-size: 0.9rem;
+}
.upload-action {
display: block;
min-height: 40px;
diff --git a/frontend/src/app/components/tenant-card/tenant-card.html b/frontend/src/app/components/tenant-card/tenant-card.html
index f836269..a04c020 100644
--- a/frontend/src/app/components/tenant-card/tenant-card.html
+++ b/frontend/src/app/components/tenant-card/tenant-card.html
@@ -27,24 +27,60 @@
Pièces jointes{{ tenant().attachments?.length ?? 0 }}
@for (attachment of tenant().attachments ?? []; track attachment.id) {
-
- @if (attachmentDownloadErrors()[attachment.id]) {
- {{ attachmentDownloadErrors()[attachment.id] }}
- }
+ @if (attachmentDeleteErrors()[attachment.id]) {
+ {{ attachmentDeleteErrors()[attachment.id] }}
+ }
+
}
@if (tenant().id) {
@if (uploadError() || attachmentError()) {
diff --git a/frontend/src/app/components/tenant-card/tenant-card.spec.ts b/frontend/src/app/components/tenant-card/tenant-card.spec.ts
index 5bf2503..c1fbc4e 100644
--- a/frontend/src/app/components/tenant-card/tenant-card.spec.ts
+++ b/frontend/src/app/components/tenant-card/tenant-card.spec.ts
@@ -78,4 +78,44 @@ describe('TenantCard', () => {
expect(download.disabled).toBe(true);
expect(download.textContent).toContain('Téléchargement…');
});
+
+ it('shows a separate remove action for attachments', () => {
+ const fixture = TestBed.createComponent(TenantCard);
+ fixture.componentRef.setInput('tenant', {
+ id: 'tenant-1',
+ firstName: 'Camille',
+ lastName: 'Martin',
+ email: 'camille@example.com',
+ phone: '',
+ address: '',
+ attachments: [
+ { id: 'attachment-1', fileName: 'id.pdf', contentType: 'application/pdf', size: 42 },
+ ],
+ });
+ fixture.componentRef.setInput('deletingAttachmentIds', new Set(['attachment-1']));
+ fixture.detectChanges();
+
+ const remove = fixture.nativeElement.querySelector('.attachment-delete');
+ expect(remove.disabled).toBe(true);
+ expect(remove.textContent).toContain('Suppression…');
+ expect(remove.getAttribute('aria-label')).toBe('Retirer la pièce jointe id.pdf');
+ });
+
+ it('uses a Font Awesome cross icon for the remove action', () => {
+ const fixture = TestBed.createComponent(TenantCard);
+ fixture.componentRef.setInput('tenant', {
+ id: 'tenant-1',
+ firstName: 'Camille',
+ lastName: 'Martin',
+ email: 'camille@example.com',
+ phone: '',
+ address: '',
+ attachments: [
+ { id: 'attachment-1', fileName: 'id.pdf', contentType: 'application/pdf', size: 42 },
+ ],
+ });
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('.attachment-delete fa-icon')).not.toBeNull();
+ });
});
diff --git a/frontend/src/app/components/tenant-card/tenant-card.ts b/frontend/src/app/components/tenant-card/tenant-card.ts
index f6ba75d..ebe014e 100644
--- a/frontend/src/app/components/tenant-card/tenant-card.ts
+++ b/frontend/src/app/components/tenant-card/tenant-card.ts
@@ -1,4 +1,6 @@
import { Component, input, output, signal } from '@angular/core';
+import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
+import { faXmark } from '@fortawesome/free-solid-svg-icons';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@@ -11,23 +13,27 @@ export interface TenantAttachmentRequest {
@Component({
selector: 'app-tenant-card',
- imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
+ imports: [FontAwesomeModule, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
templateUrl: './tenant-card.html',
styleUrl: './tenant-card.css',
})
export class TenantCard {
+ readonly faXmark = faXmark;
readonly tenant = input.required();
readonly attachmentError = input('');
readonly uploading = input(false);
readonly uploadProgress = input(null);
readonly deleting = input(false);
readonly deleteError = input('');
+ readonly deletingAttachmentIds = input>(new Set());
+ readonly attachmentDeleteErrors = input>({});
readonly downloadingAttachmentIds = input>(new Set());
readonly attachmentDownloadErrors = input>({});
readonly editRequested = output();
readonly deleteRequested = output();
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
readonly attachmentDownloadRequested = output();
+ readonly attachmentDeleteRequested = output();
readonly uploadError = signal('');
selectFile(event: Event): void {
diff --git a/frontend/src/app/components/tenants-page/tenants-page.html b/frontend/src/app/components/tenants-page/tenants-page.html
index fe6edea..04d643f 100644
--- a/frontend/src/app/components/tenants-page/tenants-page.html
+++ b/frontend/src/app/components/tenants-page/tenants-page.html
@@ -63,6 +63,8 @@
"
[deleting]="!!tenant.id && workspace.deletingTenantIds().has(tenant.id)"
[deleteError]="tenant.id ? (workspace.tenantDeleteErrors()[tenant.id] ?? '') : ''"
+ [deletingAttachmentIds]="workspace.deletingAttachmentIds()"
+ [attachmentDeleteErrors]="workspace.attachmentDeleteErrors()"
[downloadingAttachmentIds]="workspace.downloadingAttachmentIds()"
[attachmentDownloadErrors]="workspace.attachmentDownloadErrors()"
(editRequested)="openEditForm($event)"
@@ -73,6 +75,9 @@
(attachmentDownloadRequested)="
workspace.downloadTenantAttachment($event.tenantId, $event.attachment)
"
+ (attachmentDeleteRequested)="
+ workspace.deleteTenantAttachment($event.tenantId, $event.attachment)
+ "
/>
}
diff --git a/frontend/src/app/services/tenant-attachment-transfer.service.ts b/frontend/src/app/services/tenant-attachment-transfer.service.ts
index 7f62c07..b9bfb63 100644
--- a/frontend/src/app/services/tenant-attachment-transfer.service.ts
+++ b/frontend/src/app/services/tenant-attachment-transfer.service.ts
@@ -12,6 +12,8 @@ export class TenantAttachmentTransferService {
readonly errors = signal>({});
readonly uploadingTenantIds = signal>(new Set());
readonly uploadProgress = signal>({});
+ readonly deletingIds = signal>(new Set());
+ readonly deleteErrors = signal>({});
readonly downloadingIds = computed>(() => {
const prefix = 'attachment:';
return new Set(
@@ -75,6 +77,23 @@ export class TenantAttachmentTransferService {
return this.downloads.error(this.downloadKey(attachmentId));
}
+ delete(tenantId: string, attachmentId: string, onDeleted: () => void): void {
+ if (this.deletingIds().has(attachmentId)) return;
+ this.deletingIds.update((ids) => new Set(ids).add(attachmentId));
+ this.deleteErrors.update((errors) => withoutRecordKey(errors, attachmentId));
+ this.tenantService
+ .deleteAttachment(tenantId, attachmentId)
+ .pipe(finalize(() => this.deletingIds.update((ids) => withoutSetValue(ids, attachmentId))))
+ .subscribe({
+ next: () => onDeleted(),
+ error: () =>
+ this.deleteErrors.update((errors) => ({
+ ...errors,
+ [attachmentId]: 'Impossible de supprimer cette pièce jointe.',
+ })),
+ });
+ }
+
private downloadKey(attachmentId: string): string {
return `attachment:${attachmentId}`;
}
diff --git a/frontend/src/app/services/tenant.service.spec.ts b/frontend/src/app/services/tenant.service.spec.ts
index e110429..4ad921d 100644
--- a/frontend/src/app/services/tenant.service.spec.ts
+++ b/frontend/src/app/services/tenant.service.spec.ts
@@ -46,6 +46,7 @@ describe('TenantService', () => {
service.uploadAttachment('tenant-1', file).subscribe();
service.downloadAttachment('tenant-1', 'attachment-1').subscribe();
+ service.deleteAttachment('tenant-1', 'attachment-1').subscribe();
expect(http.post).toHaveBeenCalledWith(
'/api/tenants/tenant-1/attachments',
@@ -55,5 +56,6 @@ describe('TenantService', () => {
expect(http.get).toHaveBeenCalledWith('/api/tenants/tenant-1/attachments/attachment-1', {
responseType: 'blob',
});
+ expect(http.delete).toHaveBeenCalledWith('/api/tenants/tenant-1/attachments/attachment-1');
});
});
diff --git a/frontend/src/app/services/tenant.service.ts b/frontend/src/app/services/tenant.service.ts
index 779a3be..70b66f9 100644
--- a/frontend/src/app/services/tenant.service.ts
+++ b/frontend/src/app/services/tenant.service.ts
@@ -53,4 +53,8 @@ export class TenantService {
responseType: 'blob',
});
}
+
+ deleteAttachment(tenantId: string, attachmentId: string): Observable {
+ return this.http.delete(`/api/tenants/${tenantId}/attachments/${attachmentId}`);
+ }
}
diff --git a/frontend/src/app/services/workspace.service.spec.ts b/frontend/src/app/services/workspace.service.spec.ts
index c4dd5cc..c889b2d 100644
--- a/frontend/src/app/services/workspace.service.spec.ts
+++ b/frontend/src/app/services/workspace.service.spec.ts
@@ -1,4 +1,5 @@
import { TestBed } from '@angular/core/testing';
+import { MatDialog } from '@angular/material/dialog';
import { HttpEvent, HttpEventType, HttpResponse } from '@angular/common/http';
import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
@@ -6,6 +7,7 @@ import { ApartmentService } from './apartment.service';
import { LeaseService } from './lease.service';
import { TenantService } from './tenant.service';
import { WorkspaceService } from './workspace.service';
+import { ConfirmationDialog } from '../components/confirmation-dialog/confirmation-dialog';
describe('WorkspaceService', () => {
const apartment = {
@@ -48,6 +50,7 @@ describe('WorkspaceService', () => {
deleteTenant: vi.fn(),
uploadAttachment: vi.fn(),
downloadAttachment: vi.fn(),
+ deleteAttachment: vi.fn(),
};
const leases = {
getLeases: vi.fn(),
@@ -55,6 +58,9 @@ describe('WorkspaceService', () => {
deleteLease: vi.fn(),
downloadPdf: vi.fn(),
};
+ const dialog = {
+ open: vi.fn(),
+ };
beforeEach(() => {
vi.clearAllMocks();
@@ -68,14 +74,12 @@ describe('WorkspaceService', () => {
tenants.deleteTenant.mockReturnValue(of(undefined));
tenants.uploadAttachment.mockReturnValue(of({ id: 'attachment-1', fileName: 'id.pdf' }));
tenants.downloadAttachment.mockReturnValue(of(new Blob(['identity'])));
+ tenants.deleteAttachment.mockReturnValue(of(undefined));
leases.getLeases.mockReturnValue(of([lease]));
leases.createLease.mockReturnValue(of(lease));
leases.deleteLease.mockReturnValue(of(undefined));
leases.downloadPdf.mockReturnValue(of(new Blob(['pdf'])));
- vi.stubGlobal(
- 'confirm',
- vi.fn(() => true),
- );
+ dialog.open.mockImplementation(() => ({ afterClosed: () => of(true) }));
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() });
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
TestBed.configureTestingModule({
@@ -84,6 +88,7 @@ describe('WorkspaceService', () => {
{ provide: ApartmentService, useValue: apartments },
{ provide: TenantService, useValue: tenants },
{ provide: LeaseService, useValue: leases },
+ { provide: MatDialog, useValue: dialog },
],
});
});
@@ -236,7 +241,12 @@ describe('WorkspaceService', () => {
});
it('reports attachment upload progress and keeps the confirmed attachment', () => {
- const attachment = { id: 'attachment-1', fileName: 'id.pdf' };
+ const attachment = {
+ id: 'attachment-1',
+ fileName: 'id.pdf',
+ contentType: 'application/pdf',
+ size: 42,
+ };
const uploadEvents = new Subject>();
const refreshResponse = new Subject<(typeof tenant)[]>();
tenants.uploadAttachment.mockReturnValueOnce(uploadEvents);
@@ -277,6 +287,32 @@ describe('WorkspaceService', () => {
);
});
+ it('removes a confirmed attachment locally before reconciliation finishes', () => {
+ const attachment = {
+ id: 'attachment-1',
+ fileName: 'id.pdf',
+ contentType: 'application/pdf',
+ size: 42,
+ };
+ const deleteResponse = new Subject();
+ const refreshResponse = new Subject<(typeof tenant)[]>();
+ const tenantWithAttachment = { ...tenant, attachments: [attachment] };
+ tenants.deleteAttachment.mockReturnValueOnce(deleteResponse);
+ tenants.getTenants.mockReturnValueOnce(refreshResponse);
+ const service = TestBed.inject(WorkspaceService);
+ service.tenants.set([tenantWithAttachment]);
+
+ service.deleteTenantAttachment(tenant.id, attachment);
+ expect(service.deletingAttachmentIds().has(attachment.id)).toBe(true);
+ expect(service.tenants()[0].attachments).toEqual([attachment]);
+
+ deleteResponse.next();
+ deleteResponse.complete();
+ expect(service.tenants()[0].attachments).toEqual([]);
+ expect(service.deletingAttachmentIds().has(attachment.id)).toBe(false);
+ expect(service.tenantLoadState().loading).toBe(true);
+ });
+
it('loads, creates, updates, and deletes every workspace resource', () => {
const service = TestBed.inject(WorkspaceService);
service.loadApartments();
@@ -299,11 +335,33 @@ describe('WorkspaceService', () => {
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
});
- it('only deletes confirmed resources and handles attachment downloads', () => {
+ it('opens a confirmation pop-in before deleting a resource', () => {
const service = TestBed.inject(WorkspaceService);
- const confirmation = vi.mocked(confirm);
- confirmation.mockReturnValueOnce(false);
+ const closed = new Subject();
+
+ dialog.open.mockReturnValueOnce({ afterClosed: () => closed });
service.deleteApartment(apartment.id);
+
+ expect(dialog.open).toHaveBeenCalledWith(
+ ConfirmationDialog,
+ expect.objectContaining({
+ role: 'alertdialog',
+ data: {
+ title: 'Supprimer cet appartement ?',
+ message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
+ confirmLabel: 'Supprimer',
+ },
+ }),
+ );
+ expect(apartments.deleteApartment).not.toHaveBeenCalled();
+
+ closed.next(false);
+ closed.complete();
+ expect(apartments.deleteApartment).not.toHaveBeenCalled();
+ });
+
+ it('only deletes resources confirmed in the pop-in and handles attachment downloads', () => {
+ const service = TestBed.inject(WorkspaceService);
service.deleteTenant(tenant.id);
service.deleteLease(lease.id);
service.uploadTenantAttachment('tenant-1', new File(['id'], 'id.pdf'));
@@ -311,7 +369,6 @@ describe('WorkspaceService', () => {
service.downloadLeasePdf(lease);
service.downloadLeasePdf({ ...lease, id: undefined });
- expect(apartments.deleteApartment).not.toHaveBeenCalled();
expect(tenants.deleteTenant).toHaveBeenCalledWith('tenant-1');
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
diff --git a/frontend/src/app/services/workspace.service.ts b/frontend/src/app/services/workspace.service.ts
index 96c9363..f766427 100644
--- a/frontend/src/app/services/workspace.service.ts
+++ b/frontend/src/app/services/workspace.service.ts
@@ -1,8 +1,13 @@
import { Injectable, inject } from '@angular/core';
+import { MatDialog } from '@angular/material/dialog';
+import {
+ ConfirmationDialog,
+ ConfirmationDialogData,
+} from '../components/confirmation-dialog/confirmation-dialog';
import { Apartment, ApartmentService } from './apartment.service';
import { FileDownloadService } from './file-download.service';
import { Lease, LeaseService } from './lease.service';
-import { Tenant, TenantService } from './tenant.service';
+import { Tenant, TenantAttachment, TenantService } from './tenant.service';
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
import { WorkspaceResource } from './workspace-resource';
@@ -10,6 +15,7 @@ const newResourceKey = 'new';
@Injectable({ providedIn: 'root' })
export class WorkspaceService {
+ private readonly dialog = inject(MatDialog);
private readonly apartmentService = inject(ApartmentService);
private readonly tenantService = inject(TenantService);
private readonly leaseService = inject(LeaseService);
@@ -51,6 +57,8 @@ export class WorkspaceService {
readonly tenantAttachmentErrors = this.attachmentTransfers.errors;
readonly uploadingTenantIds = this.attachmentTransfers.uploadingTenantIds;
readonly tenantUploadProgress = this.attachmentTransfers.uploadProgress;
+ readonly deletingAttachmentIds = this.attachmentTransfers.deletingIds;
+ readonly attachmentDeleteErrors = this.attachmentTransfers.deleteErrors;
readonly downloadingAttachmentIds = this.attachmentTransfers.downloadingIds;
readonly attachmentDownloadErrors = this.attachmentTransfers.downloadErrors;
@@ -81,8 +89,14 @@ export class WorkspaceService {
}
deleteApartment(id: string): void {
- if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?'))
- this.apartmentResource.delete(id, this.apartmentService.deleteApartment(id));
+ this.requestConfirmation(
+ {
+ title: 'Supprimer cet appartement ?',
+ message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
+ confirmLabel: 'Supprimer',
+ },
+ () => this.apartmentResource.delete(id, this.apartmentService.deleteApartment(id)),
+ );
}
loadTenants(): void {
@@ -110,8 +124,14 @@ export class WorkspaceService {
}
deleteTenant(id: string): void {
- if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?'))
- this.tenantResource.delete(id, this.tenantService.deleteTenant(id));
+ this.requestConfirmation(
+ {
+ title: 'Supprimer ce locataire ?',
+ message: 'Êtes-vous sûr de vouloir supprimer ce locataire ?',
+ confirmLabel: 'Supprimer',
+ },
+ () => this.tenantResource.delete(id, this.tenantService.deleteTenant(id)),
+ );
}
uploadTenantAttachment(tenantId: string, file: File): void {
@@ -139,6 +159,24 @@ export class WorkspaceService {
return this.attachmentTransfers.downloadError(attachmentId);
}
+ deleteTenantAttachment(tenantId: string, attachment: TenantAttachment): void {
+ this.requestConfirmation(
+ {
+ title: 'Retirer cette pièce jointe ?',
+ message: `Êtes-vous sûr de vouloir retirer « ${attachment.fileName} » ?`,
+ confirmLabel: 'Retirer',
+ },
+ () =>
+ this.attachmentTransfers.delete(tenantId, attachment.id, () => {
+ this.tenantResource.update(tenantId, (tenant) => ({
+ ...tenant,
+ attachments: (tenant.attachments ?? []).filter((item) => item.id !== attachment.id),
+ }));
+ this.tenantResource.load();
+ }),
+ );
+ }
+
loadLeases(): void {
this.leaseResource.load();
}
@@ -160,8 +198,14 @@ export class WorkspaceService {
}
deleteLease(id: string): void {
- if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?'))
- this.leaseResource.delete(id, this.leaseService.deleteLease(id));
+ this.requestConfirmation(
+ {
+ title: 'Supprimer ce bail ?',
+ message: 'Êtes-vous sûr de vouloir supprimer ce bail ?',
+ confirmLabel: 'Supprimer',
+ },
+ () => this.leaseResource.delete(id, this.leaseService.deleteLease(id)),
+ );
}
downloadLeasePdf(lease: Lease): void {
@@ -189,4 +233,18 @@ export class WorkspaceService {
private leaseDownloadKey(leaseId: string): string {
return `lease:${leaseId}`;
}
+
+ private requestConfirmation(data: ConfirmationDialogData, onConfirmed: () => void): void {
+ this.dialog
+ .open(ConfirmationDialog, {
+ data,
+ role: 'alertdialog',
+ width: 'min(420px, calc(100vw - 32px))',
+ maxWidth: 'calc(100vw - 32px)',
+ })
+ .afterClosed()
+ .subscribe((confirmed) => {
+ if (confirmed) onConfirmed();
+ });
+ }
}