diff --git a/apps/backend/src/renovation/furniture-pricing.ts b/apps/backend/src/renovation/furniture-pricing.ts index 8d2b6c8..c85cf23 100644 --- a/apps/backend/src/renovation/furniture-pricing.ts +++ b/apps/backend/src/renovation/furniture-pricing.ts @@ -36,3 +36,9 @@ export function sumMoney(values: readonly (string | number)[]): string { const total = values.reduce((sum, value) => sum + cents(value), 0); return `${Math.floor(total / 100)}.${String(total % 100).padStart(2, '0')}`; } + +export function formatOptionalMoney( + value: number | null | undefined, +): string | null { + return value == null ? null : value.toFixed(2); +} diff --git a/apps/backend/src/renovation/furniture.service.ts b/apps/backend/src/renovation/furniture.service.ts index 07f20cd..19a13dd 100644 --- a/apps/backend/src/renovation/furniture.service.ts +++ b/apps/backend/src/renovation/furniture.service.ts @@ -34,7 +34,11 @@ import { FurnitureScenarioStatus, FurnitureScenarioType, } from './entities/furniture.entities'; -import { calculateFurnitureTotal, sumMoney } from './furniture-pricing'; +import { + calculateFurnitureTotal, + formatOptionalMoney, + sumMoney, +} from './furniture-pricing'; import { FurnitureRepository } from './furniture.repository'; import { RenovationRepository } from './renovation.repository'; @@ -1317,8 +1321,8 @@ export class FurnitureService { 'Dieser Datensatz wurde zwischenzeitlich geƤndert. Laden Sie die aktuellen Daten neu.', ); } - private decimal(value?: number) { - return value === undefined ? null : value.toFixed(2); + private decimal(value?: number | null) { + return formatOptionalMoney(value); } private validation(message: string): never { throw new ApiError(ErrorCode.ValidationFailed, message, 400); diff --git a/apps/backend/src/renovation/tests/furniture-pricing.spec.ts b/apps/backend/src/renovation/tests/furniture-pricing.spec.ts index d43cb18..b150c90 100644 --- a/apps/backend/src/renovation/tests/furniture-pricing.spec.ts +++ b/apps/backend/src/renovation/tests/furniture-pricing.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { calculateFurnitureTotal, sumMoney } from '../furniture-pricing'; +import { + calculateFurnitureTotal, + formatOptionalMoney, + sumMoney, +} from '../furniture-pricing'; describe('furniture price calculation', () => { it('includes quantity, shipping and additional costs and subtracts discounts cent-exactly', () => { @@ -40,4 +44,10 @@ describe('furniture price calculation', () => { it('sums decimal money without binary floating point drift', () => { expect(sumMoney(['0.10', '0.20', '1299.99'])).toBe('1300.29'); }); + + it('formats optional scenario price overrides without crashing for missing values', () => { + expect(formatOptionalMoney(undefined)).toBeNull(); + expect(formatOptionalMoney(null)).toBeNull(); + expect(formatOptionalMoney(12.5)).toBe('12.50'); + }); }); diff --git a/apps/frontend/src/app/shared/ui/toast/toast-host.component.ts b/apps/frontend/src/app/shared/ui/toast/toast-host.component.ts index 4f17f17..79aac74 100644 --- a/apps/frontend/src/app/shared/ui/toast/toast-host.component.ts +++ b/apps/frontend/src/app/shared/ui/toast/toast-host.component.ts @@ -1,11 +1,18 @@ -import { Component, inject } from '@angular/core'; +import { Component, effect, inject, viewChild } from '@angular/core'; +import type { ElementRef } from '@angular/core'; import { ToastService } from './toast.service'; @Component({ selector: 'ui-toast-host', standalone: true, template: ` -
+
@for (toast of toasts.messages(); track toast.id) {
@@ -37,6 +44,11 @@ import { ToastService } from './toast.service'; bottom: var(--space-4); z-index: var(--z-toast); width: min(26rem, calc(100vw - var(--space-7))); + margin: 0; + padding: 0; + border: 0; + background: transparent; + overflow: visible; display: grid; gap: var(--space-3); } @@ -76,4 +88,24 @@ import { ToastService } from './toast.service'; }) export class UiToastHostComponent { readonly toasts = inject(ToastService); + private readonly region = viewChild>('region'); + + constructor() { + effect(() => { + const region = this.region()?.nativeElement; + const hasMessages = this.toasts.messages().length > 0; + if (!region || typeof region.showPopover !== 'function') return; + + const isOpen = region.matches(':popover-open'); + if (!hasMessages) { + if (isOpen) region.hidePopover(); + return; + } + + // Erneutes Oeffnen setzt den Toast im Top Layer auch vor einen bereits + // geoeffneten modalen Dialog. + if (isOpen) region.hidePopover(); + region.showPopover(); + }); + } } diff --git a/apps/frontend/src/app/shared/ui/ui-components.spec.ts b/apps/frontend/src/app/shared/ui/ui-components.spec.ts index 9862167..d9da1ae 100644 --- a/apps/frontend/src/app/shared/ui/ui-components.spec.ts +++ b/apps/frontend/src/app/shared/ui/ui-components.spec.ts @@ -115,6 +115,15 @@ describe('shared UI components', () => { expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('Gespeichert'); }); + it('places the toast region in the browser top layer', async () => { + await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UiTestHostComponent); + fixture.detectChanges(); + + const region = (fixture.nativeElement as HTMLElement).querySelector('.ui-toast-region'); + expect(region?.getAttribute('popover')).toBe('manual'); + }); + it('emits empty state and pagination actions', async () => { await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); const fixture = TestBed.createComponent(UiTestHostComponent);