refactor: address loading review findings (#18)
This commit is contained in:
1 parent
ec3319494b
commit
0f68a91c5d
26 files changed
+610
-367
No files matched your search
@@ -27,17 +27,6 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
fieldset {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.primary-action mat-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -26,7 +26,10 @@
|
|||||||
@if (error()) {
|
@if (error()) {
|
||||||
<div class="alert-error" role="alert">{{ error() }}</div>
|
<div class="alert-error" role="alert">{{ error() }}</div>
|
||||||
}
|
}
|
||||||
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
<p class="visually-hidden" role="status" aria-live="polite">
|
||||||
|
{{ saving() ? 'Enregistrement en cours.' : '' }}
|
||||||
|
</p>
|
||||||
|
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<mat-form-field appearance="outline"
|
<mat-form-field appearance="outline"
|
||||||
><mat-label>Nom de l'appartement</mat-label
|
><mat-label>Nom de l'appartement</mat-label
|
||||||
@@ -77,7 +80,7 @@
|
|||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button mat-button type="button" (click)="closed.emit()">Annuler</button
|
<button mat-button type="button" (click)="closed.emit()">Annuler</button
|
||||||
><button mat-flat-button type="submit" class="primary-action">
|
><button mat-flat-button type="submit" class="primary-action">
|
||||||
<span aria-live="polite">
|
<span>
|
||||||
@if (saving()) {
|
@if (saving()) {
|
||||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('ApartmentForm', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBe(true);
|
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBe(true);
|
||||||
|
expect(fixture.nativeElement.querySelector('[role=status]').closest('fieldset')).toBeNull();
|
||||||
expect(fixture.nativeElement.textContent).toContain('Enregistrement…');
|
expect(fixture.nativeElement.textContent).toContain('Enregistrement…');
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'Impossible d’enregistrer cet appartement.',
|
'Impossible d’enregistrer cet appartement.',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { Apartment } from '../../services/apartment.service';
|
import { Apartment } from '../../services/apartment.service';
|
||||||
import { LoadState } from '../../services/workspace.service';
|
import { LoadState } from '../../services/workspace-resource';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-apartment-list',
|
selector: 'app-apartment-list',
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<app-apartment-form
|
<app-apartment-form
|
||||||
[apartment]="editingApartment()"
|
[apartment]="editingApartment()"
|
||||||
[saving]="workspace.apartmentSaving()"
|
[saving]="workspace.apartmentSaving(editingApartment()?.id)"
|
||||||
[saveError]="workspace.apartmentSaveError()"
|
[saveError]="workspace.apartmentSaveError(editingApartment()?.id)"
|
||||||
(closed)="closeForm()"
|
(closed)="closeForm()"
|
||||||
(saved)="saveApartment($event)"
|
(saved)="saveApartment($event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ describe('ApartmentsPage', () => {
|
|||||||
const workspace = {
|
const workspace = {
|
||||||
apartments: signal<Apartment[]>([]),
|
apartments: signal<Apartment[]>([]),
|
||||||
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||||
apartmentSaving: signal(false),
|
apartmentSaving: vi.fn(() => false),
|
||||||
apartmentSaveError: signal(''),
|
apartmentSaveError: vi.fn(() => ''),
|
||||||
|
clearApartmentSaveError: vi.fn(),
|
||||||
deletingApartmentIds: signal<ReadonlySet<string>>(new Set()),
|
deletingApartmentIds: signal<ReadonlySet<string>>(new Set()),
|
||||||
apartmentDeleteErrors: signal<Record<string, string>>({}),
|
apartmentDeleteErrors: signal<Record<string, string>>({}),
|
||||||
loadApartments: () => undefined,
|
loadApartments: () => undefined,
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export class ApartmentsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openAddForm(): void {
|
openAddForm(): void {
|
||||||
this.workspace.apartmentSaveError.set('');
|
this.workspace.clearApartmentSaveError();
|
||||||
this.editingApartment.set(null);
|
this.editingApartment.set(null);
|
||||||
this.showForm.set(true);
|
this.showForm.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
openEditForm(apartment: Apartment): void {
|
openEditForm(apartment: Apartment): void {
|
||||||
this.workspace.apartmentSaveError.set('');
|
this.workspace.clearApartmentSaveError(apartment.id);
|
||||||
this.editingApartment.set(apartment);
|
this.editingApartment.set(apartment);
|
||||||
this.showForm.set(true);
|
this.showForm.set(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,30 @@ import { LeaseCard } from './lease-card';
|
|||||||
describe('LeaseCard', () => {
|
describe('LeaseCard', () => {
|
||||||
it('resolves apartment, tenants, and every status label', () => {
|
it('resolves apartment, tenants, and every status label', () => {
|
||||||
const fixture = TestBed.createComponent(LeaseCard);
|
const fixture = TestBed.createComponent(LeaseCard);
|
||||||
fixture.componentRef.setInput('apartments', [{ id: 'apartment-1', name: 'T2', address: '', purchasePrice: 0, description: '' }]);
|
fixture.componentRef.setInput('apartments', [
|
||||||
|
{ id: 'apartment-1', name: 'T2', address: '', purchasePrice: 0, description: '' },
|
||||||
|
]);
|
||||||
fixture.componentRef.setInput('tenants', [
|
fixture.componentRef.setInput('tenants', [
|
||||||
{ id: 'tenant-1', firstName: 'Camille', lastName: 'Martin', email: '', phone: '', address: '' },
|
{
|
||||||
|
id: 'tenant-1',
|
||||||
|
firstName: 'Camille',
|
||||||
|
lastName: 'Martin',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
},
|
||||||
{ id: 'tenant-2', firstName: 'Alex', lastName: 'Durand', email: '', phone: '', address: '' },
|
{ id: 'tenant-2', firstName: 'Alex', lastName: 'Durand', email: '', phone: '', address: '' },
|
||||||
]);
|
]);
|
||||||
fixture.componentRef.setInput('lease', {
|
fixture.componentRef.setInput('lease', {
|
||||||
apartmentId: 'apartment-1', tenantIds: ['tenant-1', 'missing', 'tenant-2'], guarantors: [], startDate: '', endDate: '', monthlyRent: 0, monthlyCharges: 0, securityDeposit: 0, status: 'DRAFT',
|
apartmentId: 'apartment-1',
|
||||||
|
tenantIds: ['tenant-1', 'missing', 'tenant-2'],
|
||||||
|
guarantors: [],
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
monthlyRent: 0,
|
||||||
|
monthlyCharges: 0,
|
||||||
|
securityDeposit: 0,
|
||||||
|
status: 'DRAFT',
|
||||||
});
|
});
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
@@ -19,8 +36,41 @@ describe('LeaseCard', () => {
|
|||||||
expect(component.statusLabel()).toBe('Bail en préparation');
|
expect(component.statusLabel()).toBe('Bail en préparation');
|
||||||
fixture.componentRef.setInput('lease', { ...component.lease(), status: 'ACTIVE' });
|
fixture.componentRef.setInput('lease', { ...component.lease(), status: 'ACTIVE' });
|
||||||
expect(component.statusLabel()).toBe('Bail actif');
|
expect(component.statusLabel()).toBe('Bail actif');
|
||||||
fixture.componentRef.setInput('lease', { ...component.lease(), apartmentId: 'missing', status: 'ARCHIVED' });
|
fixture.componentRef.setInput('lease', {
|
||||||
|
...component.lease(),
|
||||||
|
apartmentId: 'missing',
|
||||||
|
status: 'ARCHIVED',
|
||||||
|
});
|
||||||
expect(component.apartmentName()).toBe('Bien inconnu');
|
expect(component.apartmentName()).toBe('Bien inconnu');
|
||||||
expect(component.statusLabel()).toBe('Bail archivé');
|
expect(component.statusLabel()).toBe('Bail archivé');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows local Bail PDF download progress and failure', () => {
|
||||||
|
const fixture = TestBed.createComponent(LeaseCard);
|
||||||
|
fixture.componentRef.setInput('apartments', []);
|
||||||
|
fixture.componentRef.setInput('tenants', []);
|
||||||
|
fixture.componentRef.setInput('lease', {
|
||||||
|
id: 'lease-1',
|
||||||
|
apartmentId: 'apartment-1',
|
||||||
|
tenantIds: [],
|
||||||
|
guarantors: [],
|
||||||
|
startDate: '2026-01-01',
|
||||||
|
endDate: '2027-01-01',
|
||||||
|
monthlyRent: 800,
|
||||||
|
monthlyCharges: 50,
|
||||||
|
securityDeposit: 800,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('downloading', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const download = fixture.nativeElement.querySelector('.card-actions button');
|
||||||
|
expect(download.disabled).toBe(true);
|
||||||
|
expect(download.textContent).toContain('Téléchargement…');
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('downloading', false);
|
||||||
|
fixture.componentRef.setInput('downloadError', 'Impossible de télécharger ce bail.');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Impossible de télécharger ce bail.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -28,17 +28,6 @@
|
|||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
fieldset {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.primary-action mat-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.guarantor-section {
|
.guarantor-section {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,10 @@
|
|||||||
@if (error()) {
|
@if (error()) {
|
||||||
<div class="alert-error" role="alert">{{ error() }}</div>
|
<div class="alert-error" role="alert">{{ error() }}</div>
|
||||||
}
|
}
|
||||||
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
<p class="visually-hidden" role="status" aria-live="polite">
|
||||||
|
{{ saving() ? 'Enregistrement en cours.' : '' }}
|
||||||
|
</p>
|
||||||
|
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<span>01</span>
|
<span>01</span>
|
||||||
@@ -143,7 +146,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||||
<section class="guarantor-section">
|
<section class="guarantor-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<span>03</span>
|
<span>03</span>
|
||||||
@@ -171,7 +174,7 @@
|
|||||||
class="primary-action"
|
class="primary-action"
|
||||||
[disabled]="saving()"
|
[disabled]="saving()"
|
||||||
>
|
>
|
||||||
<span aria-live="polite">
|
<span>
|
||||||
@if (saving()) {
|
@if (saving()) {
|
||||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -60,8 +60,8 @@
|
|||||||
[tenants]="workspace.tenants()"
|
[tenants]="workspace.tenants()"
|
||||||
[deleting]="!!lease.id && workspace.deletingLeaseIds().has(lease.id)"
|
[deleting]="!!lease.id && workspace.deletingLeaseIds().has(lease.id)"
|
||||||
[deleteError]="lease.id ? (workspace.leaseDeleteErrors()[lease.id] ?? '') : ''"
|
[deleteError]="lease.id ? (workspace.leaseDeleteErrors()[lease.id] ?? '') : ''"
|
||||||
[downloading]="!!lease.id && workspace.downloadingLeaseIds().has(lease.id)"
|
[downloading]="!!lease.id && workspace.leaseDownloading(lease.id)"
|
||||||
[downloadError]="lease.id ? (workspace.leaseDownloadErrors()[lease.id] ?? '') : ''"
|
[downloadError]="lease.id ? workspace.leaseDownloadError(lease.id) : ''"
|
||||||
(pdfRequested)="workspace.downloadLeasePdf($event)"
|
(pdfRequested)="workspace.downloadLeasePdf($event)"
|
||||||
(deleteRequested)="workspace.deleteLease($event)"
|
(deleteRequested)="workspace.deleteLease($event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ describe('LeasesPage', () => {
|
|||||||
saveLease: vi.fn(),
|
saveLease: vi.fn(),
|
||||||
deleteLease: vi.fn(),
|
deleteLease: vi.fn(),
|
||||||
downloadLeasePdf: vi.fn(),
|
downloadLeasePdf: vi.fn(),
|
||||||
leaseSaving: signal(false),
|
leaseSaving: vi.fn(() => false),
|
||||||
leaseSaveError: signal(''),
|
leaseSaveError: vi.fn(() => ''),
|
||||||
|
clearLeaseSaveError: vi.fn(),
|
||||||
|
leaseDownloading: vi.fn(() => false),
|
||||||
|
leaseDownloadError: vi.fn(() => ''),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class LeasesPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openForm(): void {
|
openForm(): void {
|
||||||
this.workspace.leaseSaveError.set('');
|
this.workspace.clearLeaseSaveError();
|
||||||
this.showForm.set(true);
|
this.showForm.set(true);
|
||||||
}
|
}
|
||||||
closeForm(): void {
|
closeForm(): void {
|
||||||
|
|||||||
@@ -41,6 +41,23 @@ describe('TenantCard', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('Envoi : 50 %');
|
expect(fixture.nativeElement.textContent).toContain('Envoi : 50 %');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to indeterminate upload feedback when no total is available', () => {
|
||||||
|
const fixture = TestBed.createComponent(TenantCard);
|
||||||
|
fixture.componentRef.setInput('tenant', {
|
||||||
|
id: 'tenant-1',
|
||||||
|
firstName: 'Camille',
|
||||||
|
lastName: 'Martin',
|
||||||
|
email: 'camille@example.com',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('uploading', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Envoi…');
|
||||||
|
expect(fixture.nativeElement.querySelector('mat-progress-bar')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows progress only on the selected attachment download', () => {
|
it('shows progress only on the selected attachment download', () => {
|
||||||
const fixture = TestBed.createComponent(TenantCard);
|
const fixture = TestBed.createComponent(TenantCard);
|
||||||
fixture.componentRef.setInput('tenant', {
|
fixture.componentRef.setInput('tenant', {
|
||||||
|
|||||||
@@ -26,12 +26,6 @@
|
|||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
fieldset {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.alert-error {
|
.alert-error {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
padding: 13px 15px;
|
padding: 13px 15px;
|
||||||
@@ -40,11 +34,6 @@ fieldset {
|
|||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.primary-action mat-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
@if (saveError()) {
|
@if (saveError()) {
|
||||||
<div class="alert-error" role="alert">{{ saveError() }}</div>
|
<div class="alert-error" role="alert">{{ saveError() }}</div>
|
||||||
}
|
}
|
||||||
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
<p class="visually-hidden" role="status" aria-live="polite">
|
||||||
|
{{ saving() ? 'Enregistrement en cours.' : '' }}
|
||||||
|
</p>
|
||||||
|
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<mat-form-field appearance="outline"
|
<mat-form-field appearance="outline"
|
||||||
><mat-label>Prénom</mat-label
|
><mat-label>Prénom</mat-label
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button
|
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button
|
||||||
><button mat-flat-button type="submit" class="primary-action">
|
><button mat-flat-button type="submit" class="primary-action">
|
||||||
<span aria-live="polite">
|
<span>
|
||||||
@if (saving()) {
|
@if (saving()) {
|
||||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
[attachmentError]="
|
[attachmentError]="
|
||||||
tenant.id ? (workspace.tenantAttachmentErrors()[tenant.id] ?? '') : ''
|
tenant.id ? (workspace.tenantAttachmentErrors()[tenant.id] ?? '') : ''
|
||||||
"
|
"
|
||||||
[uploading]="workspace.uploadingTenantIds().has(tenant.id!)"
|
[uploading]="tenant.id ? workspace.uploadingTenantIds().has(tenant.id) : false"
|
||||||
[uploadProgress]="
|
[uploadProgress]="
|
||||||
tenant.id ? (workspace.tenantUploadProgress()[tenant.id] ?? null) : null
|
tenant.id ? (workspace.tenantUploadProgress()[tenant.id] ?? null) : null
|
||||||
"
|
"
|
||||||
@@ -83,8 +83,8 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<app-tenant-form
|
<app-tenant-form
|
||||||
[tenant]="editingTenant()"
|
[tenant]="editingTenant()"
|
||||||
[saving]="workspace.tenantSaving()"
|
[saving]="workspace.tenantSaving(editingTenant()?.id)"
|
||||||
[saveError]="workspace.tenantSaveError()"
|
[saveError]="workspace.tenantSaveError(editingTenant()?.id)"
|
||||||
(cancelled)="closeForm()"
|
(cancelled)="closeForm()"
|
||||||
(submitted)="saveTenant($event)"
|
(submitted)="saveTenant($event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { signal } from '@angular/core';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { TenantsPage } from './tenants-page';
|
import { TenantsPage } from './tenants-page';
|
||||||
import { WorkspaceService } from '../../services/workspace.service';
|
import { WorkspaceService } from '../../services/workspace.service';
|
||||||
@@ -9,7 +8,7 @@ describe('TenantsPage', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: WorkspaceService,
|
provide: WorkspaceService,
|
||||||
useValue: { loadTenants: () => undefined, tenantSaveError: signal('') },
|
useValue: { loadTenants: () => undefined, clearTenantSaveError: () => undefined },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ export class TenantsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openAddForm(): void {
|
openAddForm(): void {
|
||||||
this.workspace.tenantSaveError.set('');
|
this.workspace.clearTenantSaveError();
|
||||||
this.editingTenant.set(null);
|
this.editingTenant.set(null);
|
||||||
this.showForm.set(true);
|
this.showForm.set(true);
|
||||||
}
|
}
|
||||||
openEditForm(tenant: Tenant): void {
|
openEditForm(tenant: Tenant): void {
|
||||||
this.workspace.tenantSaveError.set('');
|
this.workspace.clearTenantSaveError(tenant.id);
|
||||||
this.editingTenant.set(tenant);
|
this.editingTenant.set(tenant);
|
||||||
this.showForm.set(true);
|
this.showForm.set(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FileDownloadService {
|
||||||
|
readonly pendingKeys = signal<ReadonlySet<string>>(new Set());
|
||||||
|
readonly errors = signal<Record<string, string>>({});
|
||||||
|
|
||||||
|
isPending(key: string): boolean {
|
||||||
|
return this.pendingKeys().has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(key: string): string {
|
||||||
|
return this.errors()[key] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
download(key: string, request: Observable<Blob>, fileName: string, errorMessage: string): void {
|
||||||
|
if (this.pendingKeys().has(key)) return;
|
||||||
|
this.pendingKeys.update((keys) => new Set(keys).add(key));
|
||||||
|
this.errors.update((errors) => this.withoutKey(errors, key));
|
||||||
|
request
|
||||||
|
.pipe(finalize(() => this.pendingKeys.update((keys) => this.without(keys, key))))
|
||||||
|
.subscribe({
|
||||||
|
next: (blob) => this.save(blob, fileName),
|
||||||
|
error: () => this.errors.update((errors) => ({ ...errors, [key]: errorMessage })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private without(values: ReadonlySet<string>, value: string): ReadonlySet<string> {
|
||||||
|
const next = new Set(values);
|
||||||
|
next.delete(value);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
|
||||||
|
const next = { ...record };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { computed, Injectable, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
interface UserResponse {
|
interface UserResponse {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
@@ -13,7 +13,7 @@ export type SessionStatus = 'checking' | 'authenticated' | 'anonymous' | 'error'
|
|||||||
export class SessionService {
|
export class SessionService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
readonly status = signal<SessionStatus>('checking');
|
readonly status = signal<SessionStatus>('checking');
|
||||||
readonly isLoggedIn = signal(false);
|
readonly isLoggedIn = computed(() => this.status() === 'authenticated');
|
||||||
readonly username = signal('');
|
readonly username = signal('');
|
||||||
readonly email = signal('');
|
readonly email = signal('');
|
||||||
|
|
||||||
@@ -21,13 +21,11 @@ export class SessionService {
|
|||||||
this.status.set('checking');
|
this.status.set('checking');
|
||||||
this.http.get<UserResponse>('/api/user').subscribe({
|
this.http.get<UserResponse>('/api/user').subscribe({
|
||||||
next: (user) => {
|
next: (user) => {
|
||||||
this.isLoggedIn.set(user.authenticated);
|
|
||||||
this.status.set(user.authenticated ? 'authenticated' : 'anonymous');
|
this.status.set(user.authenticated ? 'authenticated' : 'anonymous');
|
||||||
this.username.set(user.authenticated ? user.username || 'User' : '');
|
this.username.set(user.authenticated ? user.username || 'User' : '');
|
||||||
this.email.set(user.authenticated ? user.email || '' : '');
|
this.email.set(user.authenticated ? user.email || '' : '');
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.isLoggedIn.set(false);
|
|
||||||
this.status.set('error');
|
this.status.set('error');
|
||||||
this.username.set('');
|
this.username.set('');
|
||||||
this.email.set('');
|
this.email.set('');
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { HttpEventType } from '@angular/common/http';
|
||||||
|
import { computed, Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { finalize } from 'rxjs';
|
||||||
|
import { FileDownloadService } from './file-download.service';
|
||||||
|
import { TenantAttachment, TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TenantAttachmentTransferService {
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
private readonly downloads = inject(FileDownloadService);
|
||||||
|
readonly errors = signal<Record<string, string>>({});
|
||||||
|
readonly uploadingTenantIds = signal<ReadonlySet<string>>(new Set());
|
||||||
|
readonly uploadProgress = signal<Record<string, number | null>>({});
|
||||||
|
readonly downloadingIds = computed<ReadonlySet<string>>(() => {
|
||||||
|
const prefix = 'attachment:';
|
||||||
|
return new Set(
|
||||||
|
[...this.downloads.pendingKeys()]
|
||||||
|
.filter((key) => key.startsWith(prefix))
|
||||||
|
.map((key) => key.slice(prefix.length)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
readonly downloadErrors = computed<Record<string, string>>(() => {
|
||||||
|
const prefix = 'attachment:';
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(this.downloads.errors())
|
||||||
|
.filter(([key]) => key.startsWith(prefix))
|
||||||
|
.map(([key, error]) => [key.slice(prefix.length), error]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
upload(tenantId: string, file: File, onUploaded: (attachment: TenantAttachment) => void): void {
|
||||||
|
if (this.uploadingTenantIds().has(tenantId)) return;
|
||||||
|
this.errors.update((errors) => this.withoutKey(errors, tenantId));
|
||||||
|
this.uploadingTenantIds.update((ids) => new Set(ids).add(tenantId));
|
||||||
|
this.uploadProgress.update((progress) => ({ ...progress, [tenantId]: null }));
|
||||||
|
this.tenantService
|
||||||
|
.uploadAttachment(tenantId, file)
|
||||||
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
this.uploadingTenantIds.update((ids) => this.without(ids, tenantId));
|
||||||
|
this.uploadProgress.update((progress) => this.withoutKey(progress, tenantId));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
if (event.type === HttpEventType.UploadProgress) {
|
||||||
|
const percent = event.total ? Math.round((event.loaded / event.total) * 100) : null;
|
||||||
|
this.uploadProgress.update((progress) => ({ ...progress, [tenantId]: percent }));
|
||||||
|
} else if (event.type === HttpEventType.Response && event.body) {
|
||||||
|
onUploaded(event.body);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
const message = this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.');
|
||||||
|
this.errors.update((errors) => ({ ...errors, [tenantId]: message }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
download(tenantId: string, attachment: { id: string; fileName: string }): void {
|
||||||
|
this.downloads.download(
|
||||||
|
this.downloadKey(attachment.id),
|
||||||
|
this.tenantService.downloadAttachment(tenantId, attachment.id),
|
||||||
|
attachment.fileName,
|
||||||
|
'Impossible de télécharger cette pièce jointe.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloading(attachmentId: string): boolean {
|
||||||
|
return this.downloads.isPending(this.downloadKey(attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadError(attachmentId: string): string {
|
||||||
|
return this.downloads.error(this.downloadKey(attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private downloadKey(attachmentId: string): string {
|
||||||
|
return `attachment:${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private without(values: ReadonlySet<string>, value: string): ReadonlySet<string> {
|
||||||
|
const next = new Set(values);
|
||||||
|
next.delete(value);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
|
||||||
|
const next = { ...record };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorMessage(error: unknown, fallback: string): string {
|
||||||
|
return typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'error' in error &&
|
||||||
|
typeof error.error === 'object' &&
|
||||||
|
error.error !== null &&
|
||||||
|
'message' in error.error &&
|
||||||
|
typeof error.error.message === 'string'
|
||||||
|
? error.error.message
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface LoadState {
|
||||||
|
loaded: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceMessages {
|
||||||
|
load: string;
|
||||||
|
save: string;
|
||||||
|
delete: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceResource<T extends { id?: string }> {
|
||||||
|
readonly items = signal<T[]>([]);
|
||||||
|
readonly loadState = signal<LoadState>({ loaded: false, loading: false, error: '' });
|
||||||
|
readonly savingKeys = signal<ReadonlySet<string>>(new Set());
|
||||||
|
readonly saveErrors = signal<Record<string, string>>({});
|
||||||
|
readonly deletingIds = signal<ReadonlySet<string>>(new Set());
|
||||||
|
readonly deleteErrors = signal<Record<string, string>>({});
|
||||||
|
private revision = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly loadRequest: () => Observable<T[]>,
|
||||||
|
private readonly messages: ResourceMessages,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
if (this.loadState().loading) return;
|
||||||
|
const revision = this.revision;
|
||||||
|
this.loadState.update((state) => ({ ...state, loading: true, error: '' }));
|
||||||
|
this.loadRequest().subscribe({
|
||||||
|
next: (items) => {
|
||||||
|
if (revision === this.revision) this.items.set(items);
|
||||||
|
this.loadState.set({ loaded: true, loading: false, error: '' });
|
||||||
|
if (revision !== this.revision) this.load();
|
||||||
|
},
|
||||||
|
error: () =>
|
||||||
|
this.loadState.update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: this.messages.load,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
save(key: string, request: Observable<T>, onSuccess?: () => void): void {
|
||||||
|
if (this.savingKeys().has(key)) return;
|
||||||
|
this.savingKeys.update((keys) => new Set(keys).add(key));
|
||||||
|
this.saveErrors.update((errors) => this.withoutKey(errors, key));
|
||||||
|
request
|
||||||
|
.pipe(finalize(() => this.savingKeys.update((keys) => this.without(keys, key))))
|
||||||
|
.subscribe({
|
||||||
|
next: (saved) => {
|
||||||
|
this.upsert(saved);
|
||||||
|
onSuccess?.();
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: () => this.saveErrors.update((errors) => ({ ...errors, [key]: this.messages.save })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string, request: Observable<void>): void {
|
||||||
|
if (this.deletingIds().has(id)) return;
|
||||||
|
this.deletingIds.update((ids) => new Set(ids).add(id));
|
||||||
|
this.deleteErrors.update((errors) => this.withoutKey(errors, id));
|
||||||
|
request
|
||||||
|
.pipe(finalize(() => this.deletingIds.update((ids) => this.without(ids, id))))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.confirmChange((items) => items.filter((item) => item.id !== id));
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: () =>
|
||||||
|
this.deleteErrors.update((errors) => ({ ...errors, [id]: this.messages.delete })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(saved: T): void {
|
||||||
|
this.confirmChange((items) =>
|
||||||
|
saved.id && items.some((item) => item.id === saved.id)
|
||||||
|
? items.map((item) => (item.id === saved.id ? saved : item))
|
||||||
|
: [...items, saved],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, updateItem: (item: T) => T): void {
|
||||||
|
this.confirmChange((items) => items.map((item) => (item.id === id ? updateItem(item) : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSaveError(key: string): void {
|
||||||
|
this.saveErrors.update((errors) => this.withoutKey(errors, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmChange(updateItems: (items: T[]) => T[]): void {
|
||||||
|
this.revision += 1;
|
||||||
|
this.items.update(updateItems);
|
||||||
|
this.loadState.update((state) => ({ ...state, loaded: true, error: '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private without(values: ReadonlySet<string>, value: string): ReadonlySet<string> {
|
||||||
|
const next = new Set(values);
|
||||||
|
next.delete(value);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withoutKey<V>(record: Record<string, V>, key: string): Record<string, V> {
|
||||||
|
const next = { ...record };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,14 @@ describe('WorkspaceService', () => {
|
|||||||
service.loadApartments();
|
service.loadApartments();
|
||||||
expect(service.apartments()).toEqual([apartment]);
|
expect(service.apartments()).toEqual([apartment]);
|
||||||
expect(service.apartmentLoadState()).toEqual({ loaded: true, loading: true, error: '' });
|
expect(service.apartmentLoadState()).toEqual({ loaded: true, loading: true, error: '' });
|
||||||
|
|
||||||
|
refreshResponse.error(new Error('offline'));
|
||||||
|
expect(service.apartments()).toEqual([apartment]);
|
||||||
|
expect(service.apartmentLoadState()).toEqual({
|
||||||
|
loaded: true,
|
||||||
|
loading: false,
|
||||||
|
error: 'Impossible de charger les appartements.',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a confirmed apartment save visible while reconciliation is pending', () => {
|
it('keeps a confirmed apartment save visible while reconciliation is pending', () => {
|
||||||
@@ -120,13 +128,13 @@ describe('WorkspaceService', () => {
|
|||||||
const saved = vi.fn();
|
const saved = vi.fn();
|
||||||
|
|
||||||
service.saveApartment(updatedApartment, apartment.id, saved);
|
service.saveApartment(updatedApartment, apartment.id, saved);
|
||||||
expect(service.apartmentSaving()).toBe(true);
|
expect(service.apartmentSaving(apartment.id)).toBe(true);
|
||||||
|
|
||||||
saveResponse.next(updatedApartment);
|
saveResponse.next(updatedApartment);
|
||||||
saveResponse.complete();
|
saveResponse.complete();
|
||||||
expect(saved).toHaveBeenCalled();
|
expect(saved).toHaveBeenCalled();
|
||||||
expect(service.apartments()).toEqual([updatedApartment]);
|
expect(service.apartments()).toEqual([updatedApartment]);
|
||||||
expect(service.apartmentSaving()).toBe(false);
|
expect(service.apartmentSaving(apartment.id)).toBe(false);
|
||||||
expect(service.apartmentLoadState().loading).toBe(true);
|
expect(service.apartmentLoadState().loading).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +152,7 @@ describe('WorkspaceService', () => {
|
|||||||
service.loadApartments();
|
service.loadApartments();
|
||||||
service.saveApartment(updatedApartment, apartment.id);
|
service.saveApartment(updatedApartment, apartment.id);
|
||||||
expect(service.apartments()).toEqual([updatedApartment]);
|
expect(service.apartments()).toEqual([updatedApartment]);
|
||||||
|
expect(service.apartmentLoadState().loaded).toBe(true);
|
||||||
|
|
||||||
staleRefresh.next([apartment]);
|
staleRefresh.next([apartment]);
|
||||||
staleRefresh.complete();
|
staleRefresh.complete();
|
||||||
@@ -151,6 +160,28 @@ describe('WorkspaceService', () => {
|
|||||||
expect(apartments.getApartments).toHaveBeenCalledTimes(2);
|
expect(apartments.getApartments).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scopes concurrent apartment saves and failures to their records', () => {
|
||||||
|
const firstSave = new Subject<typeof apartment>();
|
||||||
|
const secondApartment = { ...apartment, id: 'apartment-2', name: 'T3' };
|
||||||
|
const secondSave = new Subject<typeof secondApartment>();
|
||||||
|
apartments.updateApartment.mockReturnValueOnce(firstSave).mockReturnValueOnce(secondSave);
|
||||||
|
const service = TestBed.inject(WorkspaceService);
|
||||||
|
const firstConfirmed = vi.fn();
|
||||||
|
|
||||||
|
service.saveApartment(apartment, apartment.id, firstConfirmed);
|
||||||
|
service.saveApartment(secondApartment, secondApartment.id);
|
||||||
|
expect(service.apartmentSaving(apartment.id)).toBe(true);
|
||||||
|
expect(service.apartmentSaving(secondApartment.id)).toBe(true);
|
||||||
|
|
||||||
|
firstSave.error(new Error('offline'));
|
||||||
|
expect(firstConfirmed).not.toHaveBeenCalled();
|
||||||
|
expect(service.apartmentSaving(apartment.id)).toBe(false);
|
||||||
|
expect(service.apartmentSaveError(apartment.id)).toBe(
|
||||||
|
'Impossible d’enregistrer cet appartement.',
|
||||||
|
);
|
||||||
|
expect(service.apartmentSaving(secondApartment.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('removes a confirmed deletion locally before reconciliation finishes', () => {
|
it('removes a confirmed deletion locally before reconciliation finishes', () => {
|
||||||
const deleteResponse = new Subject<void>();
|
const deleteResponse = new Subject<void>();
|
||||||
const refreshResponse = new Subject<(typeof apartment)[]>();
|
const refreshResponse = new Subject<(typeof apartment)[]>();
|
||||||
@@ -170,6 +201,20 @@ describe('WorkspaceService', () => {
|
|||||||
expect(service.apartmentLoadState().loading).toBe(true);
|
expect(service.apartmentLoadState().loading).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves a record and exposes a local error when deletion fails', () => {
|
||||||
|
apartments.deleteApartment.mockReturnValueOnce(throwError(() => new Error('offline')));
|
||||||
|
const service = TestBed.inject(WorkspaceService);
|
||||||
|
service.apartments.set([apartment]);
|
||||||
|
|
||||||
|
service.deleteApartment(apartment.id);
|
||||||
|
|
||||||
|
expect(service.apartments()).toEqual([apartment]);
|
||||||
|
expect(service.deletingApartmentIds().has(apartment.id)).toBe(false);
|
||||||
|
expect(service.apartmentDeleteErrors()[apartment.id]).toBe(
|
||||||
|
'Impossible de supprimer cet appartement.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports attachment upload progress and keeps the confirmed attachment', () => {
|
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' };
|
||||||
const uploadEvents = new Subject<HttpEvent<typeof attachment>>();
|
const uploadEvents = new Subject<HttpEvent<typeof attachment>>();
|
||||||
@@ -180,6 +225,8 @@ describe('WorkspaceService', () => {
|
|||||||
service.tenants.set([tenant]);
|
service.tenants.set([tenant]);
|
||||||
|
|
||||||
service.uploadTenantAttachment(tenant.id, new File(['id'], 'id.pdf'));
|
service.uploadTenantAttachment(tenant.id, new File(['id'], 'id.pdf'));
|
||||||
|
service.uploadTenantAttachment(tenant.id, new File(['duplicate'], 'duplicate.pdf'));
|
||||||
|
expect(tenants.uploadAttachment).toHaveBeenCalledTimes(1);
|
||||||
expect(service.uploadingTenantIds().has(tenant.id)).toBe(true);
|
expect(service.uploadingTenantIds().has(tenant.id)).toBe(true);
|
||||||
expect(service.tenantUploadProgress()[tenant.id]).toBeNull();
|
expect(service.tenantUploadProgress()[tenant.id]).toBeNull();
|
||||||
|
|
||||||
|
|||||||
@@ -1,332 +1,192 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpEventType } from '@angular/common/http';
|
|
||||||
import { finalize } from 'rxjs';
|
|
||||||
import { Apartment, ApartmentService } from './apartment.service';
|
import { Apartment, ApartmentService } from './apartment.service';
|
||||||
|
import { FileDownloadService } from './file-download.service';
|
||||||
import { Lease, LeaseService } from './lease.service';
|
import { Lease, LeaseService } from './lease.service';
|
||||||
import { Tenant, TenantService } from './tenant.service';
|
import { Tenant, TenantService } from './tenant.service';
|
||||||
|
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
|
||||||
|
import { WorkspaceResource } from './workspace-resource';
|
||||||
|
|
||||||
export interface LoadState {
|
const newResourceKey = 'new';
|
||||||
loaded: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialLoadState: LoadState = { loaded: false, loading: false, error: '' };
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
private readonly apartmentService = inject(ApartmentService);
|
private readonly apartmentService = inject(ApartmentService);
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
private readonly leaseService = inject(LeaseService);
|
private readonly leaseService = inject(LeaseService);
|
||||||
private apartmentRevision = 0;
|
private readonly downloads = inject(FileDownloadService);
|
||||||
private tenantRevision = 0;
|
private readonly attachmentTransfers = inject(TenantAttachmentTransferService);
|
||||||
private leaseRevision = 0;
|
|
||||||
readonly apartments = signal<Apartment[]>([]);
|
private readonly apartmentResource = new WorkspaceResource(
|
||||||
readonly apartmentLoadState = signal<LoadState>(initialLoadState);
|
() => this.apartmentService.getApartments(),
|
||||||
readonly apartmentSaving = signal(false);
|
{
|
||||||
readonly apartmentSaveError = signal('');
|
load: 'Impossible de charger les appartements.',
|
||||||
readonly deletingApartmentIds = signal<ReadonlySet<string>>(new Set());
|
save: 'Impossible d’enregistrer cet appartement.',
|
||||||
readonly apartmentDeleteErrors = signal<Record<string, string>>({});
|
delete: 'Impossible de supprimer cet appartement.',
|
||||||
readonly tenants = signal<Tenant[]>([]);
|
},
|
||||||
readonly tenantLoadState = signal<LoadState>(initialLoadState);
|
);
|
||||||
readonly tenantSaving = signal(false);
|
private readonly tenantResource = new WorkspaceResource(() => this.tenantService.getTenants(), {
|
||||||
readonly tenantSaveError = signal('');
|
load: 'Impossible de charger les locataires.',
|
||||||
readonly deletingTenantIds = signal<ReadonlySet<string>>(new Set());
|
save: 'Impossible d’enregistrer ce locataire.',
|
||||||
readonly tenantDeleteErrors = signal<Record<string, string>>({});
|
delete: 'Impossible de supprimer ce locataire.',
|
||||||
readonly leases = signal<Lease[]>([]);
|
});
|
||||||
readonly leaseLoadState = signal<LoadState>(initialLoadState);
|
private readonly leaseResource = new WorkspaceResource(() => this.leaseService.getLeases(), {
|
||||||
readonly leaseSaving = signal(false);
|
load: 'Impossible de charger les baux.',
|
||||||
readonly leaseSaveError = signal('');
|
save: 'Impossible d’enregistrer ce bail.',
|
||||||
readonly deletingLeaseIds = signal<ReadonlySet<string>>(new Set());
|
delete: 'Impossible de supprimer ce bail.',
|
||||||
readonly leaseDeleteErrors = signal<Record<string, string>>({});
|
});
|
||||||
readonly tenantAttachmentErrors = signal<Record<string, string>>({});
|
|
||||||
readonly uploadingTenantIds = signal<ReadonlySet<string>>(new Set());
|
readonly apartments = this.apartmentResource.items;
|
||||||
readonly tenantUploadProgress = signal<Record<string, number | null>>({});
|
readonly apartmentLoadState = this.apartmentResource.loadState;
|
||||||
readonly downloadingAttachmentIds = signal<ReadonlySet<string>>(new Set());
|
readonly deletingApartmentIds = this.apartmentResource.deletingIds;
|
||||||
readonly attachmentDownloadErrors = signal<Record<string, string>>({});
|
readonly apartmentDeleteErrors = this.apartmentResource.deleteErrors;
|
||||||
readonly downloadingLeaseIds = signal<ReadonlySet<string>>(new Set());
|
readonly tenants = this.tenantResource.items;
|
||||||
readonly leaseDownloadErrors = signal<Record<string, string>>({});
|
readonly tenantLoadState = this.tenantResource.loadState;
|
||||||
|
readonly deletingTenantIds = this.tenantResource.deletingIds;
|
||||||
|
readonly tenantDeleteErrors = this.tenantResource.deleteErrors;
|
||||||
|
readonly leases = this.leaseResource.items;
|
||||||
|
readonly leaseLoadState = this.leaseResource.loadState;
|
||||||
|
readonly deletingLeaseIds = this.leaseResource.deletingIds;
|
||||||
|
readonly leaseDeleteErrors = this.leaseResource.deleteErrors;
|
||||||
|
|
||||||
|
readonly tenantAttachmentErrors = this.attachmentTransfers.errors;
|
||||||
|
readonly uploadingTenantIds = this.attachmentTransfers.uploadingTenantIds;
|
||||||
|
readonly tenantUploadProgress = this.attachmentTransfers.uploadProgress;
|
||||||
|
readonly downloadingAttachmentIds = this.attachmentTransfers.downloadingIds;
|
||||||
|
readonly attachmentDownloadErrors = this.attachmentTransfers.downloadErrors;
|
||||||
|
|
||||||
loadApartments(): void {
|
loadApartments(): void {
|
||||||
if (this.apartmentLoadState().loading) return;
|
this.apartmentResource.load();
|
||||||
const revision = this.apartmentRevision;
|
|
||||||
this.apartmentLoadState.update((state) => ({ ...state, loading: true, error: '' }));
|
|
||||||
this.apartmentService.getApartments().subscribe({
|
|
||||||
next: (data) => {
|
|
||||||
if (revision === this.apartmentRevision) this.apartments.set(data);
|
|
||||||
this.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
|
|
||||||
if (revision !== this.apartmentRevision) this.loadApartments();
|
|
||||||
},
|
|
||||||
error: () =>
|
|
||||||
this.apartmentLoadState.update((state) => ({
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
error: 'Impossible de charger les appartements.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveApartment(apartment: Apartment, id?: string, onSuccess?: () => void): void {
|
saveApartment(apartment: Apartment, id?: string, onSuccess?: () => void): void {
|
||||||
if (this.apartmentSaving()) return;
|
this.apartmentResource.save(
|
||||||
this.apartmentSaving.set(true);
|
this.resourceKey(id),
|
||||||
this.apartmentSaveError.set('');
|
id
|
||||||
(id
|
|
||||||
? this.apartmentService.updateApartment(id, apartment)
|
? this.apartmentService.updateApartment(id, apartment)
|
||||||
: this.apartmentService.createApartment(apartment)
|
: this.apartmentService.createApartment(apartment),
|
||||||
)
|
onSuccess,
|
||||||
.pipe(finalize(() => this.apartmentSaving.set(false)))
|
|
||||||
.subscribe({
|
|
||||||
next: (savedApartment) => {
|
|
||||||
this.apartmentRevision += 1;
|
|
||||||
this.apartments.update((items) => this.mergeById(items, savedApartment));
|
|
||||||
onSuccess?.();
|
|
||||||
this.loadApartments();
|
|
||||||
},
|
|
||||||
error: () => this.apartmentSaveError.set('Impossible d’enregistrer cet appartement.'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
deleteApartment(id: string): void {
|
|
||||||
if (
|
|
||||||
this.deletingApartmentIds().has(id) ||
|
|
||||||
!confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
this.deletingApartmentIds.update((ids) => new Set(ids).add(id));
|
|
||||||
this.apartmentDeleteErrors.update((errors) => this.withoutKey(errors, id));
|
|
||||||
this.apartmentService
|
|
||||||
.deleteApartment(id)
|
|
||||||
.pipe(finalize(() => this.deletingApartmentIds.update((ids) => this.without(ids, id))))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.apartmentRevision += 1;
|
|
||||||
this.apartments.update((items) => items.filter((item) => item.id !== id));
|
|
||||||
this.loadApartments();
|
|
||||||
},
|
|
||||||
error: () =>
|
|
||||||
this.apartmentDeleteErrors.update((errors) => ({
|
|
||||||
...errors,
|
|
||||||
[id]: 'Impossible de supprimer cet appartement.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
loadTenants(): void {
|
|
||||||
if (this.tenantLoadState().loading) return;
|
|
||||||
const revision = this.tenantRevision;
|
|
||||||
this.tenantLoadState.update((state) => ({ ...state, loading: true, error: '' }));
|
|
||||||
this.tenantService.getTenants().subscribe({
|
|
||||||
next: (data) => {
|
|
||||||
if (revision === this.tenantRevision) this.tenants.set(data);
|
|
||||||
this.tenantLoadState.set({ loaded: true, loading: false, error: '' });
|
|
||||||
if (revision !== this.tenantRevision) this.loadTenants();
|
|
||||||
},
|
|
||||||
error: () =>
|
|
||||||
this.tenantLoadState.update((state) => ({
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
error: 'Impossible de charger les locataires.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
saveTenant(tenant: Tenant, id?: string, onSuccess?: () => void): void {
|
|
||||||
if (this.tenantSaving()) return;
|
|
||||||
this.tenantSaving.set(true);
|
|
||||||
this.tenantSaveError.set('');
|
|
||||||
(id ? this.tenantService.updateTenant(id, tenant) : this.tenantService.createTenant(tenant))
|
|
||||||
.pipe(finalize(() => this.tenantSaving.set(false)))
|
|
||||||
.subscribe({
|
|
||||||
next: (savedTenant) => {
|
|
||||||
this.tenantRevision += 1;
|
|
||||||
this.tenants.update((items) => this.mergeById(items, savedTenant));
|
|
||||||
onSuccess?.();
|
|
||||||
this.loadTenants();
|
|
||||||
},
|
|
||||||
error: () => this.tenantSaveError.set('Impossible d’enregistrer ce locataire.'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
deleteTenant(id: string): void {
|
|
||||||
if (
|
|
||||||
this.deletingTenantIds().has(id) ||
|
|
||||||
!confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?')
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
this.deletingTenantIds.update((ids) => new Set(ids).add(id));
|
|
||||||
this.tenantDeleteErrors.update((errors) => this.withoutKey(errors, id));
|
|
||||||
this.tenantService
|
|
||||||
.deleteTenant(id)
|
|
||||||
.pipe(finalize(() => this.deletingTenantIds.update((ids) => this.without(ids, id))))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.tenantRevision += 1;
|
|
||||||
this.tenants.update((items) => items.filter((item) => item.id !== id));
|
|
||||||
this.loadTenants();
|
|
||||||
},
|
|
||||||
error: () =>
|
|
||||||
this.tenantDeleteErrors.update((errors) => ({
|
|
||||||
...errors,
|
|
||||||
[id]: 'Impossible de supprimer ce locataire.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
uploadTenantAttachment(tenantId: string, file: File): void {
|
|
||||||
if (this.uploadingTenantIds().has(tenantId)) return;
|
|
||||||
this.tenantAttachmentErrors.update((errors) => this.withoutKey(errors, tenantId));
|
|
||||||
this.uploadingTenantIds.update((ids) => new Set(ids).add(tenantId));
|
|
||||||
this.tenantUploadProgress.update((progress) => ({ ...progress, [tenantId]: null }));
|
|
||||||
this.tenantService
|
|
||||||
.uploadAttachment(tenantId, file)
|
|
||||||
.pipe(
|
|
||||||
finalize(() => {
|
|
||||||
this.uploadingTenantIds.update((ids) => this.without(ids, tenantId));
|
|
||||||
this.tenantUploadProgress.update((progress) => this.withoutKey(progress, tenantId));
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (event) => {
|
|
||||||
if (event.type === HttpEventType.UploadProgress) {
|
|
||||||
const percent = event.total ? Math.round((event.loaded / event.total) * 100) : null;
|
|
||||||
this.tenantUploadProgress.update((progress) => ({ ...progress, [tenantId]: percent }));
|
|
||||||
} else if (event.type === HttpEventType.Response && event.body) {
|
|
||||||
this.tenantRevision += 1;
|
|
||||||
this.tenants.update((items) =>
|
|
||||||
items.map((item) =>
|
|
||||||
item.id === tenantId
|
|
||||||
? { ...item, attachments: this.mergeById(item.attachments ?? [], event.body!) }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
this.loadTenants();
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
error: (error: unknown) => {
|
apartmentSaving(id?: string): boolean {
|
||||||
const message = this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.');
|
return this.apartmentResource.savingKeys().has(this.resourceKey(id));
|
||||||
this.tenantAttachmentErrors.update((errors) => ({ ...errors, [tenantId]: message }));
|
}
|
||||||
},
|
|
||||||
|
apartmentSaveError(id?: string): string {
|
||||||
|
return this.apartmentResource.saveErrors()[this.resourceKey(id)] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
clearApartmentSaveError(id?: string): void {
|
||||||
|
this.apartmentResource.clearSaveError(this.resourceKey(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteApartment(id: string): void {
|
||||||
|
if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?'))
|
||||||
|
this.apartmentResource.delete(id, this.apartmentService.deleteApartment(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTenants(): void {
|
||||||
|
this.tenantResource.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTenant(tenant: Tenant, id?: string, onSuccess?: () => void): void {
|
||||||
|
this.tenantResource.save(
|
||||||
|
this.resourceKey(id),
|
||||||
|
id ? this.tenantService.updateTenant(id, tenant) : this.tenantService.createTenant(tenant),
|
||||||
|
onSuccess,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantSaving(id?: string): boolean {
|
||||||
|
return this.tenantResource.savingKeys().has(this.resourceKey(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantSaveError(id?: string): string {
|
||||||
|
return this.tenantResource.saveErrors()[this.resourceKey(id)] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTenantSaveError(id?: string): void {
|
||||||
|
this.tenantResource.clearSaveError(this.resourceKey(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTenant(id: string): void {
|
||||||
|
if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?'))
|
||||||
|
this.tenantResource.delete(id, this.tenantService.deleteTenant(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadTenantAttachment(tenantId: string, file: File): void {
|
||||||
|
this.attachmentTransfers.upload(tenantId, file, (attachment) => {
|
||||||
|
this.tenantResource.update(tenantId, (tenant) => ({
|
||||||
|
...tenant,
|
||||||
|
attachments: [
|
||||||
|
...(tenant.attachments ?? []).filter((item) => item.id !== attachment.id),
|
||||||
|
attachment,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
this.tenantResource.load();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadTenantAttachment(tenantId: string, attachment: { id: string; fileName: string }): void {
|
downloadTenantAttachment(tenantId: string, attachment: { id: string; fileName: string }): void {
|
||||||
if (this.downloadingAttachmentIds().has(attachment.id)) return;
|
this.attachmentTransfers.download(tenantId, attachment);
|
||||||
this.downloadingAttachmentIds.update((ids) => new Set(ids).add(attachment.id));
|
|
||||||
this.attachmentDownloadErrors.update((errors) => this.withoutKey(errors, attachment.id));
|
|
||||||
this.tenantService
|
|
||||||
.downloadAttachment(tenantId, attachment.id)
|
|
||||||
.pipe(
|
|
||||||
finalize(() =>
|
|
||||||
this.downloadingAttachmentIds.update((ids) => this.without(ids, attachment.id)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (blob) => this.saveBlob(blob, attachment.fileName),
|
|
||||||
error: () =>
|
|
||||||
this.attachmentDownloadErrors.update((errors) => ({
|
|
||||||
...errors,
|
|
||||||
[attachment.id]: 'Impossible de télécharger cette pièce jointe.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachmentDownloading(attachmentId: string): boolean {
|
||||||
|
return this.attachmentTransfers.downloading(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentDownloadError(attachmentId: string): string {
|
||||||
|
return this.attachmentTransfers.downloadError(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
loadLeases(): void {
|
loadLeases(): void {
|
||||||
if (this.leaseLoadState().loading) return;
|
this.leaseResource.load();
|
||||||
const revision = this.leaseRevision;
|
|
||||||
this.leaseLoadState.update((state) => ({ ...state, loading: true, error: '' }));
|
|
||||||
this.leaseService.getLeases().subscribe({
|
|
||||||
next: (data) => {
|
|
||||||
if (revision === this.leaseRevision) this.leases.set(data);
|
|
||||||
this.leaseLoadState.set({ loaded: true, loading: false, error: '' });
|
|
||||||
if (revision !== this.leaseRevision) this.loadLeases();
|
|
||||||
},
|
|
||||||
error: () =>
|
|
||||||
this.leaseLoadState.update((state) => ({
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
error: 'Impossible de charger les baux.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveLease(lease: Lease, onSuccess?: () => void): void {
|
saveLease(lease: Lease, onSuccess?: () => void): void {
|
||||||
if (this.leaseSaving()) return;
|
this.leaseResource.save(newResourceKey, this.leaseService.createLease(lease), onSuccess);
|
||||||
this.leaseSaving.set(true);
|
|
||||||
this.leaseSaveError.set('');
|
|
||||||
this.leaseService
|
|
||||||
.createLease(lease)
|
|
||||||
.pipe(finalize(() => this.leaseSaving.set(false)))
|
|
||||||
.subscribe({
|
|
||||||
next: (savedLease) => {
|
|
||||||
this.leaseRevision += 1;
|
|
||||||
this.leases.update((items) => this.mergeById(items, savedLease));
|
|
||||||
onSuccess?.();
|
|
||||||
this.loadLeases();
|
|
||||||
},
|
|
||||||
error: () => this.leaseSaveError.set('Impossible d’enregistrer ce bail.'),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
leaseSaving(): boolean {
|
||||||
|
return this.leaseResource.savingKeys().has(newResourceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaseSaveError(): string {
|
||||||
|
return this.leaseResource.saveErrors()[newResourceKey] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLeaseSaveError(): void {
|
||||||
|
this.leaseResource.clearSaveError(newResourceKey);
|
||||||
|
}
|
||||||
|
|
||||||
deleteLease(id: string): void {
|
deleteLease(id: string): void {
|
||||||
if (this.deletingLeaseIds().has(id) || !confirm('Êtes-vous sûr de vouloir supprimer ce bail ?'))
|
if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?'))
|
||||||
return;
|
this.leaseResource.delete(id, this.leaseService.deleteLease(id));
|
||||||
this.deletingLeaseIds.update((ids) => new Set(ids).add(id));
|
|
||||||
this.leaseDeleteErrors.update((errors) => this.withoutKey(errors, id));
|
|
||||||
this.leaseService
|
|
||||||
.deleteLease(id)
|
|
||||||
.pipe(finalize(() => this.deletingLeaseIds.update((ids) => this.without(ids, id))))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.leaseRevision += 1;
|
|
||||||
this.leases.update((items) => items.filter((item) => item.id !== id));
|
|
||||||
this.loadLeases();
|
|
||||||
},
|
|
||||||
error: () =>
|
|
||||||
this.leaseDeleteErrors.update((errors) => ({
|
|
||||||
...errors,
|
|
||||||
[id]: 'Impossible de supprimer ce bail.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadLeasePdf(lease: Lease): void {
|
downloadLeasePdf(lease: Lease): void {
|
||||||
if (!lease.id || this.downloadingLeaseIds().has(lease.id)) return;
|
if (!lease.id) return;
|
||||||
const id = lease.id;
|
this.downloads.download(
|
||||||
this.downloadingLeaseIds.update((ids) => new Set(ids).add(id));
|
this.leaseDownloadKey(lease.id),
|
||||||
this.leaseDownloadErrors.update((errors) => this.withoutKey(errors, id));
|
this.leaseService.downloadPdf(lease.id),
|
||||||
this.leaseService
|
`bail-${lease.id}.pdf`,
|
||||||
.downloadPdf(id)
|
'Impossible de télécharger ce bail.',
|
||||||
.pipe(finalize(() => this.downloadingLeaseIds.update((ids) => this.without(ids, id))))
|
);
|
||||||
.subscribe({
|
|
||||||
next: (blob) => this.saveBlob(blob, `bail-${id}.pdf`),
|
|
||||||
error: () =>
|
|
||||||
this.leaseDownloadErrors.update((errors) => ({
|
|
||||||
...errors,
|
|
||||||
[id]: 'Impossible de télécharger ce bail.',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
private saveBlob(blob: Blob, fileName: string): void {
|
|
||||||
const url = URL.createObjectURL(blob);
|
leaseDownloading(leaseId: string): boolean {
|
||||||
const link = document.createElement('a');
|
return this.downloads.isPending(this.leaseDownloadKey(leaseId));
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
private mergeById<T extends { id?: string }>(items: T[], saved: T): T[] {
|
|
||||||
return saved.id && items.some((item) => item.id === saved.id)
|
leaseDownloadError(leaseId: string): string {
|
||||||
? items.map((item) => (item.id === saved.id ? saved : item))
|
return this.downloads.error(this.leaseDownloadKey(leaseId));
|
||||||
: [...items, saved];
|
|
||||||
}
|
}
|
||||||
private without(ids: ReadonlySet<string>, id: string): ReadonlySet<string> {
|
|
||||||
const next = new Set(ids);
|
private resourceKey(id?: string): string {
|
||||||
next.delete(id);
|
return id ?? newResourceKey;
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
private withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
|
|
||||||
const next = { ...record };
|
private leaseDownloadKey(leaseId: string): string {
|
||||||
delete next[key];
|
return `lease:${leaseId}`;
|
||||||
return next;
|
|
||||||
}
|
|
||||||
private errorMessage(error: unknown, fallback: string): string {
|
|
||||||
return typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'error' in error &&
|
|
||||||
typeof error.error === 'object' &&
|
|
||||||
error.error !== null &&
|
|
||||||
'message' in error.error &&
|
|
||||||
typeof error.error.message === 'string'
|
|
||||||
? error.error.message
|
|
||||||
: fallback;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,30 @@ p {
|
|||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action mat-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.load-error,
|
.load-error,
|
||||||
.refresh-error {
|
.refresh-error {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
Reference in new issue
Block a user