From 5c19f244fcc287b7e793d4b911d7c5f236ee7970 Mon Sep 17 00:00:00 2001 From: Nessar Date: Thu, 16 Jul 2026 20:27:52 +0200 Subject: [PATCH] Replace browser confirmations with confirmation dialog --- frontend/bun.lock | 10 +++ frontend/package.json | 2 + frontend/src/app/app.ts | 2 + .../confirmation-dialog.css | 23 ++++++ .../confirmation-dialog.html | 12 +++ .../confirmation-dialog.spec.ts | 44 +++++++++++ .../confirmation-dialog.ts | 28 +++++++ .../components/tenant-card/tenant-card.css | 17 ++++- .../components/tenant-card/tenant-card.html | 68 +++++++++++++---- .../tenant-card/tenant-card.spec.ts | 40 ++++++++++ .../app/components/tenant-card/tenant-card.ts | 8 +- .../components/tenants-page/tenants-page.html | 5 ++ .../tenant-attachment-transfer.service.ts | 19 +++++ .../src/app/services/tenant.service.spec.ts | 2 + frontend/src/app/services/tenant.service.ts | 4 + .../app/services/workspace.service.spec.ts | 75 ++++++++++++++++--- .../src/app/services/workspace.service.ts | 72 ++++++++++++++++-- 17 files changed, 397 insertions(+), 34 deletions(-) create mode 100644 frontend/src/app/components/confirmation-dialog/confirmation-dialog.css create mode 100644 frontend/src/app/components/confirmation-dialog/confirmation-dialog.html create mode 100644 frontend/src/app/components/confirmation-dialog/confirmation-dialog.spec.ts create mode 100644 frontend/src/app/components/confirmation-dialog/confirmation-dialog.ts 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]) { + } - - @if (attachmentDownloadErrors()[attachment.id]) { - - } + @if (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(); + }); + } }