diff --git a/frontend/bun.lock b/frontend/bun.lock
index a18a102..9bf637d 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -13,6 +13,8 @@
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
+ "@fortawesome/angular-fontawesome": "^5.1.0",
+ "@fortawesome/free-solid-svg-icons": "^7.3.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
},
@@ -245,6 +247,14 @@
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
+ "@fortawesome/angular-fontawesome": ["@fortawesome/angular-fontawesome@5.1.0", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.3.0", "tslib": "^2.8.1" }, "peerDependencies": { "@angular/core": "^22.0.0" } }, "sha512-22SF8aKzmOfeOKK2M8+hdGFw33p6S7kHWK5q/9bkqAl86SpcfIsfXVHMPhcyP0ZdiAMdeSjsuz76ye3gJbh25A=="],
+
+ "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.3.1", "", {}, "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA=="],
+
+ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.3.1", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.3.1" } }, "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q=="],
+
+ "@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@7.3.1", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.3.1" } }, "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg=="],
+
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
"@harperfast/extended-iterable": ["@harperfast/extended-iterable@1.0.3", "", {}, "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw=="],
diff --git a/frontend/package.json b/frontend/package.json
index 90c6729..f87fcc3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -23,6 +23,8 @@
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
+ "@fortawesome/angular-fontawesome": "^5.1.0",
+ "@fortawesome/free-solid-svg-icons": "^7.3.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts
index 6ebef3e..34c6d37 100644
--- a/frontend/src/app/app.ts
+++ b/frontend/src/app/app.ts
@@ -1,6 +1,7 @@
import { Component, computed, inject, OnInit } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatButtonModule } from '@angular/material/button';
+import { MatDialogModule } from '@angular/material/dialog';
import { RouterOutlet } from '@angular/router';
import { BrandMark } from './components/brand-mark';
import { SignInPage } from './components/sign-in-page/sign-in-page';
@@ -16,6 +17,7 @@ import { SessionService } from './services/session.service';
RouterOutlet,
MatProgressSpinnerModule,
MatButtonModule,
+ MatDialogModule,
],
templateUrl: './app.html',
styleUrl: './app.css',
diff --git a/frontend/src/app/components/confirmation-dialog/confirmation-dialog.css b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.css
new file mode 100644
index 0000000..dbd6263
--- /dev/null
+++ b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.css
@@ -0,0 +1,23 @@
+:host {
+ display: block;
+}
+
+.confirmation-dialog h2 {
+ margin-bottom: 8px;
+ color: var(--text);
+ font-size: 1.25rem;
+ letter-spacing: -0.025em;
+}
+
+.confirmation-dialog p {
+ margin: 0;
+ color: var(--text-muted);
+ line-height: 1.55;
+}
+
+.confirmation-dialog .confirm-action.mat-mdc-unelevated-button {
+ --mdc-filled-button-container-color: var(--danger);
+ --mdc-filled-button-label-text-color: var(--accent-contrast);
+ border-radius: 12px;
+ font-weight: 700;
+}
diff --git a/frontend/src/app/components/confirmation-dialog/confirmation-dialog.html b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.html
new file mode 100644
index 0000000..62fe262
--- /dev/null
+++ b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.html
@@ -0,0 +1,12 @@
+
+
{{ data.title }}
+
+ {{ data.message }}
+
+
+
+
+
+
diff --git a/frontend/src/app/components/confirmation-dialog/confirmation-dialog.spec.ts b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.spec.ts
new file mode 100644
index 0000000..f47603e
--- /dev/null
+++ b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.spec.ts
@@ -0,0 +1,44 @@
+import { TestBed } from '@angular/core/testing';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { ConfirmationDialog, ConfirmationDialogData } from './confirmation-dialog';
+
+describe('ConfirmationDialog', () => {
+ const data: ConfirmationDialogData = {
+ title: 'Supprimer cet appartement ?',
+ message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
+ confirmLabel: 'Supprimer',
+ };
+ const dialogRef = { close: vi.fn() };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: MAT_DIALOG_DATA, useValue: data },
+ { provide: MatDialogRef, useValue: dialogRef },
+ ],
+ });
+ });
+
+ it('shows the destructive action and lets the user cancel', () => {
+ const fixture = TestBed.createComponent(ConfirmationDialog);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('[mat-dialog-title]').textContent).toContain(
+ data.title,
+ );
+ expect(fixture.nativeElement.textContent).toContain(data.message);
+ expect(fixture.nativeElement.textContent).toContain(data.confirmLabel);
+
+ fixture.nativeElement.querySelector('button:first-of-type').click();
+ expect(dialogRef.close).toHaveBeenCalledWith(false);
+ });
+
+ it('returns true when the destructive action is selected', () => {
+ const fixture = TestBed.createComponent(ConfirmationDialog);
+ fixture.detectChanges();
+
+ fixture.nativeElement.querySelector('.confirm-action').click();
+ expect(dialogRef.close).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/frontend/src/app/components/confirmation-dialog/confirmation-dialog.ts b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.ts
new file mode 100644
index 0000000..0a17150
--- /dev/null
+++ b/frontend/src/app/components/confirmation-dialog/confirmation-dialog.ts
@@ -0,0 +1,28 @@
+import { Component, inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+
+export interface ConfirmationDialogData {
+ title: string;
+ message: string;
+ confirmLabel: string;
+}
+
+@Component({
+ selector: 'app-confirmation-dialog',
+ imports: [MatButtonModule, MatDialogModule],
+ templateUrl: './confirmation-dialog.html',
+ styleUrl: './confirmation-dialog.css',
+})
+export class ConfirmationDialog {
+ readonly data = inject(MAT_DIALOG_DATA);
+ private readonly dialogRef = inject(MatDialogRef);
+
+ 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();
+ });
+ }
}