This commit is contained in:
Bastian Wagner
2026-07-20 21:36:54 +02:00
parent af5297b335
commit 4214c2d436
5 changed files with 67 additions and 6 deletions

View File

@@ -36,3 +36,9 @@ export function sumMoney(values: readonly (string | number)[]): string {
const total = values.reduce<number>((sum, value) => sum + cents(value), 0); const total = values.reduce<number>((sum, value) => sum + cents(value), 0);
return `${Math.floor(total / 100)}.${String(total % 100).padStart(2, '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);
}

View File

@@ -34,7 +34,11 @@ import {
FurnitureScenarioStatus, FurnitureScenarioStatus,
FurnitureScenarioType, FurnitureScenarioType,
} from './entities/furniture.entities'; } from './entities/furniture.entities';
import { calculateFurnitureTotal, sumMoney } from './furniture-pricing'; import {
calculateFurnitureTotal,
formatOptionalMoney,
sumMoney,
} from './furniture-pricing';
import { FurnitureRepository } from './furniture.repository'; import { FurnitureRepository } from './furniture.repository';
import { RenovationRepository } from './renovation.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.', 'Dieser Datensatz wurde zwischenzeitlich geändert. Laden Sie die aktuellen Daten neu.',
); );
} }
private decimal(value?: number) { private decimal(value?: number | null) {
return value === undefined ? null : value.toFixed(2); return formatOptionalMoney(value);
} }
private validation(message: string): never { private validation(message: string): never {
throw new ApiError(ErrorCode.ValidationFailed, message, 400); throw new ApiError(ErrorCode.ValidationFailed, message, 400);

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { calculateFurnitureTotal, sumMoney } from '../furniture-pricing'; import {
calculateFurnitureTotal,
formatOptionalMoney,
sumMoney,
} from '../furniture-pricing';
describe('furniture price calculation', () => { describe('furniture price calculation', () => {
it('includes quantity, shipping and additional costs and subtracts discounts cent-exactly', () => { 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', () => { it('sums decimal money without binary floating point drift', () => {
expect(sumMoney(['0.10', '0.20', '1299.99'])).toBe('1300.29'); 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');
});
}); });

View File

@@ -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'; import { ToastService } from './toast.service';
@Component({ @Component({
selector: 'ui-toast-host', selector: 'ui-toast-host',
standalone: true, standalone: true,
template: ` template: `
<section class="ui-toast-region" aria-live="polite" aria-label="Meldungen"> <section
#region
class="ui-toast-region"
popover="manual"
aria-live="polite"
aria-label="Meldungen"
>
@for (toast of toasts.messages(); track toast.id) { @for (toast of toasts.messages(); track toast.id) {
<article class="ui-toast" [class]="toast.tone"> <article class="ui-toast" [class]="toast.tone">
<div> <div>
@@ -37,6 +44,11 @@ import { ToastService } from './toast.service';
bottom: var(--space-4); bottom: var(--space-4);
z-index: var(--z-toast); z-index: var(--z-toast);
width: min(26rem, calc(100vw - var(--space-7))); width: min(26rem, calc(100vw - var(--space-7)));
margin: 0;
padding: 0;
border: 0;
background: transparent;
overflow: visible;
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
} }
@@ -76,4 +88,24 @@ import { ToastService } from './toast.service';
}) })
export class UiToastHostComponent { export class UiToastHostComponent {
readonly toasts = inject(ToastService); readonly toasts = inject(ToastService);
private readonly region = viewChild<ElementRef<HTMLElement>>('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();
});
}
} }

View File

@@ -115,6 +115,15 @@ describe('shared UI components', () => {
expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('Gespeichert'); 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 () => { it('emits empty state and pagination actions', async () => {
await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents();
const fixture = TestBed.createComponent(UiTestHostComponent); const fixture = TestBed.createComponent(UiTestHostComponent);