generated from bastian/boilerplate
szenario
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user