This commit is contained in:
Bastian Wagner
2026-07-20 21:10:47 +02:00
parent 1baf8b6231
commit 8ec1f477d8
3 changed files with 182 additions and 79 deletions

View File

@@ -1120,8 +1120,8 @@ export class FurnitureService {
} }
private optionValues(dto: CreateFurnitureOptionDto) { private optionValues(dto: CreateFurnitureOptionDto) {
const text = (value?: string) => value?.trim() || null; const text = (value?: string) => value?.trim() || null;
const decimal = (value?: number) => const decimal = (value?: number | null) =>
value === undefined ? null : value.toFixed(2); value === undefined || value === null ? null : value.toFixed(2);
return { return {
name: dto.name?.trim() || 'Unbenannter Möbelvorschlag', name: dto.name?.trim() || 'Unbenannter Möbelvorschlag',
manufacturer: text(dto.manufacturer), manufacturer: text(dto.manufacturer),
@@ -1130,13 +1130,13 @@ export class FurnitureService {
retailer: text(dto.retailer), retailer: text(dto.retailer),
productUrl: text(dto.productUrl), productUrl: text(dto.productUrl),
articleNumber: text(dto.articleNumber), articleNumber: text(dto.articleNumber),
unitPrice: dto.unitPrice.toFixed(2), unitPrice: (dto.unitPrice ?? 0).toFixed(2),
originalPrice: decimal(dto.originalPrice), originalPrice: decimal(dto.originalPrice),
shippingCost: dto.shippingCost.toFixed(2), shippingCost: (dto.shippingCost ?? 0).toFixed(2),
additionalCost: dto.additionalCost.toFixed(2), additionalCost: (dto.additionalCost ?? 0).toFixed(2),
discount: dto.discount.toFixed(2), discount: (dto.discount ?? 0).toFixed(2),
currency: dto.currency.toUpperCase(), currency: dto.currency.toUpperCase(),
quantity: dto.quantity, quantity: dto.quantity ?? 1,
width: decimal(dto.width), width: decimal(dto.width),
height: decimal(dto.height), height: decimal(dto.height),
depth: decimal(dto.depth), depth: decimal(dto.depth),
@@ -1154,15 +1154,19 @@ export class FurnitureService {
budgetCategoryId: dto.budgetCategoryId ?? null, budgetCategoryId: dto.budgetCategoryId ?? null,
existingItem: dto.existingItem, existingItem: dto.existingItem,
estimatedCurrentValue: decimal(dto.estimatedCurrentValue), estimatedCurrentValue: decimal(dto.estimatedCurrentValue),
movingCost: dto.movingCost.toFixed(2), movingCost: (dto.movingCost ?? 0).toFixed(2),
refurbishmentCost: dto.refurbishmentCost.toFixed(2), refurbishmentCost: (dto.refurbishmentCost ?? 0).toFixed(2),
currentLocation: text(dto.currentLocation), currentLocation: text(dto.currentLocation),
condition: dto.condition ?? null, condition: dto.condition ?? null,
}; };
} }
private total(dto: CreateFurnitureOptionDto) { private total(dto: CreateFurnitureOptionDto) {
try { try {
return calculateFurnitureTotal(dto); return calculateFurnitureTotal({
...dto,
unitPrice: dto.unitPrice ?? 0,
quantity: dto.quantity ?? 1,
});
} catch { } catch {
this.validation( this.validation(
'Der Rabatt darf die berechneten Gesamtkosten nicht überschreiten.', 'Der Rabatt darf die berechneten Gesamtkosten nicht überschreiten.',

View File

@@ -11,10 +11,44 @@ import type {
} from './hauspilot-api.service'; } from './hauspilot-api.service';
import { HauspilotApiService } from './hauspilot-api.service'; import { HauspilotApiService } from './hauspilot-api.service';
import { FurniturePlanningComponent } from './furniture-planning.component'; import { FurniturePlanningComponent } from './furniture-planning.component';
import { ToastService } from '../../shared/ui';
registerLocaleData(localeDe); registerLocaleData(localeDe);
describe('FurniturePlanningComponent', () => { describe('FurniturePlanningComponent', () => {
it('groups optional furniture details in a collapsed advanced section', async () => {
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
providers: [provideHttpClient()],
}).compileComponents();
const fixture = TestBed.createComponent(FurniturePlanningComponent);
fixture.componentInstance.showOptionForm.set(true);
fixture.detectChanges();
const host: unknown = fixture.nativeElement;
if (!(host instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.');
const details = host.querySelector('details.advanced-fields');
expect(details?.querySelector('summary')?.textContent).toContain('Erweiterte Informationen');
expect(details?.textContent).toContain('Versand');
expect(details?.textContent).toContain('Erwartete Lieferung');
expect(details?.hasAttribute('open')).toBe(false);
});
it('shows a toast naming a missing required furniture selection', async () => {
const show = vi.fn();
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
providers: [provideHttpClient(), { provide: ToastService, useValue: { show } }],
}).compileComponents();
const component = TestBed.createComponent(FurniturePlanningComponent).componentInstance;
component.chooseOptionRequirement();
expect(show).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Bitte einen Möbelbedarf auswählen.' }),
);
});
it('allows optional furniture and delivery fields to stay empty', async () => { it('allows optional furniture and delivery fields to stay empty', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent], imports: [FurniturePlanningComponent],

View File

@@ -13,6 +13,7 @@ import type {
import { HauspilotApiService } from './hauspilot-api.service'; import { HauspilotApiService } from './hauspilot-api.service';
import { conflictMessage } from './project-workspace.helpers'; import { conflictMessage } from './project-workspace.helpers';
import { FurnitureGridComponent } from './furniture-grid.component'; import { FurnitureGridComponent } from './furniture-grid.component';
import { ToastService } from '../../shared/ui';
const categoryLabels: Record<string, string> = { const categoryLabels: Record<string, string> = {
seating: 'Sitzmöbel', seating: 'Sitzmöbel',
@@ -521,40 +522,13 @@ const statusLabels: Record<string, string> = {
min="0" min="0"
step="0.01" step="0.01"
formControlName="unitPrice" /></label formControlName="unitPrice" /></label
><label>Menge<input type="number" min="1" formControlName="quantity" /></label ><label>Menge<input type="number" min="1" formControlName="quantity" /></label>
><label
>Versand<input
type="number"
min="0"
step="0.01"
formControlName="shippingCost" /></label
><label
>Zusatzkosten<input
type="number"
min="0"
step="0.01"
formControlName="additionalCost" /></label
><label
>Rabatt<input type="number" min="0" step="0.01" formControlName="discount"
/></label>
<output class="calculated" <output class="calculated"
>Gesamtpreis: >Gesamtpreis:
<strong>{{ <strong>{{
calculatedTotal() | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' calculatedTotal() | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
}}</strong></output }}</strong></output
> >
<label>Breite (cm)<input type="number" min="0" formControlName="width" /></label
><label>Höhe (cm)<input type="number" min="0" formControlName="height" /></label
><label>Tiefe (cm)<input type="number" min="0" formControlName="depth" /></label
><label>Farbe<input formControlName="color" /></label
><label>Material<input formControlName="material" /></label
><label
>Lieferzeit (Tage)<input type="number" min="0" formControlName="deliveryDays" /></label
><label
>Erwartete Lieferung (optional)<input
type="date"
formControlName="expectedDeliveryDate"
/></label>
<label <label
>Verfügbarkeit<select formControlName="availability"> >Verfügbarkeit<select formControlName="availability">
<option value="unknown">Unbekannt</option> <option value="unknown">Unbekannt</option>
@@ -578,24 +552,60 @@ const statusLabels: Record<string, string> = {
><label class="check" ><label class="check"
><input type="checkbox" formControlName="existingItem" /> Bereits vorhanden</label ><input type="checkbox" formControlName="existingItem" /> Bereits vorhanden</label
> >
@if (optionForm.controls.existingItem.value) { <details class="advanced-fields wide">
<label <summary>Erweiterte Informationen</summary>
>Umzugskosten<input <p class="ui-meta">Diese Angaben sind vollständig optional.</p>
type="number" <div class="advanced-grid">
min="0" <label
step="0.01" >Versand<input
formControlName="movingCost" /></label type="number"
><label min="0"
>Aufbereitungskosten<input step="0.01"
type="number" formControlName="shippingCost" /></label
min="0" ><label
step="0.01" >Zusatzkosten<input
formControlName="refurbishmentCost" /></label type="number"
><label>Aktueller Standort<input formControlName="currentLocation" /></label> min="0"
} step="0.01"
<label class="wide" formControlName="additionalCost" /></label
>Beschreibung<textarea rows="3" formControlName="description"></textarea></label ><label
><label class="wide">Notizen<textarea rows="3" formControlName="notes"></textarea></label> >Rabatt<input type="number" min="0" step="0.01" formControlName="discount"
/></label>
<label>Breite (cm)<input type="number" min="0" formControlName="width" /></label
><label>Höhe (cm)<input type="number" min="0" formControlName="height" /></label
><label>Tiefe (cm)<input type="number" min="0" formControlName="depth" /></label
><label>Farbe<input formControlName="color" /></label
><label>Material<input formControlName="material" /></label
><label
>Lieferzeit (Tage)<input
type="number"
min="0"
formControlName="deliveryDays" /></label
><label
>Erwartete Lieferung<input type="date" formControlName="expectedDeliveryDate"
/></label>
@if (optionForm.controls.existingItem.value) {
<label
>Umzugskosten<input
type="number"
min="0"
step="0.01"
formControlName="movingCost" /></label
><label
>Aufbereitungskosten<input
type="number"
min="0"
step="0.01"
formControlName="refurbishmentCost" /></label
><label>Aktueller Standort<input formControlName="currentLocation" /></label>
}
<label class="wide"
>Beschreibung<textarea rows="3" formControlName="description"></textarea></label
><label class="wide"
>Notizen<textarea rows="3" formControlName="notes"></textarea>
</label>
</div>
</details>
<div class="actions"> <div class="actions">
<button class="ui-button" type="submit" [disabled]="saving()">Speichern</button <button class="ui-button" type="submit" [disabled]="saving()">Speichern</button
><button class="ui-button ui-button--ghost" type="button" (click)="closeOptionForm()"> ><button class="ui-button ui-button--ghost" type="button" (click)="closeOptionForm()">
@@ -1069,6 +1079,21 @@ const statusLabels: Record<string, string> = {
.calculated { .calculated {
align-self: center; align-self: center;
} }
.advanced-fields {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.advanced-fields summary {
cursor: pointer;
font-weight: var(--font-weight-semibold);
}
.advanced-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-4);
}
.scenario-grid article { .scenario-grid article {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -1087,6 +1112,9 @@ const statusLabels: Record<string, string> = {
text-align: center; text-align: center;
} }
@media (max-width: 40rem) { @media (max-width: 40rem) {
.advanced-grid {
grid-template-columns: 1fr;
}
.actions .ui-button { .actions .ui-button {
flex: 1 1 100%; flex: 1 1 100%;
} }
@@ -1115,6 +1143,7 @@ export class FurniturePlanningComponent implements OnChanges {
@ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>; @ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>; @ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>;
private readonly api = inject(HauspilotApiService); private readonly api = inject(HauspilotApiService);
private readonly toasts = inject(ToastService);
readonly requirements = signal<FurnitureRequirement[]>([]); readonly requirements = signal<FurnitureRequirement[]>([]);
readonly scenarios = signal<FurnitureScenario[]>([]); readonly scenarios = signal<FurnitureScenario[]>([]);
readonly summary = signal<FurnitureSummary | null>(null); readonly summary = signal<FurnitureSummary | null>(null);
@@ -1185,24 +1214,19 @@ export class FurniturePlanningComponent implements OnChanges {
retailer: new FormControl('', { nonNullable: true }), retailer: new FormControl('', { nonNullable: true }),
productUrl: new FormControl('', { nonNullable: true }), productUrl: new FormControl('', { nonNullable: true }),
articleNumber: new FormControl('', { nonNullable: true }), articleNumber: new FormControl('', { nonNullable: true }),
unitPrice: new FormControl(0, { unitPrice: new FormControl<number | null>(null, {
nonNullable: true,
validators: [(control) => Validators.min(0)(control)], validators: [(control) => Validators.min(0)(control)],
}), }),
quantity: new FormControl(1, { quantity: new FormControl<number | null>(1, {
nonNullable: true,
validators: [(control) => Validators.min(1)(control)], validators: [(control) => Validators.min(1)(control)],
}), }),
shippingCost: new FormControl(0, { shippingCost: new FormControl<number | null>(null, {
nonNullable: true,
validators: [(control) => Validators.min(0)(control)], validators: [(control) => Validators.min(0)(control)],
}), }),
additionalCost: new FormControl(0, { additionalCost: new FormControl<number | null>(null, {
nonNullable: true,
validators: [(control) => Validators.min(0)(control)], validators: [(control) => Validators.min(0)(control)],
}), }),
discount: new FormControl(0, { discount: new FormControl<number | null>(null, {
nonNullable: true,
validators: [(control) => Validators.min(0)(control)], validators: [(control) => Validators.min(0)(control)],
}), }),
width: new FormControl<number | null>(null), width: new FormControl<number | null>(null),
@@ -1251,15 +1275,15 @@ export class FurniturePlanningComponent implements OnChanges {
}); });
readonly calculatedTotal = computed(() => { readonly calculatedTotal = computed(() => {
const v = this.optionForm.getRawValue(); const v = this.optionForm.getRawValue();
const acquisition = v.existingItem ? 0 : v.unitPrice * v.quantity; const acquisition = v.existingItem ? 0 : (v.unitPrice ?? 0) * (v.quantity ?? 1);
return Math.max( return Math.max(
0, 0,
acquisition + acquisition +
v.shippingCost + (v.shippingCost ?? 0) +
v.additionalCost + (v.additionalCost ?? 0) +
v.movingCost + v.movingCost +
v.refurbishmentCost - v.refurbishmentCost -
v.discount, (v.discount ?? 0),
); );
}); });
ngOnChanges() { ngOnChanges() {
@@ -1342,7 +1366,11 @@ export class FurniturePlanningComponent implements OnChanges {
if (!keepFlow) this.guidedPlanning.set(false); if (!keepFlow) this.guidedPlanning.set(false);
} }
saveRequirement() { saveRequirement() {
if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched(); if (this.requirementForm.invalid) {
this.requirementForm.markAllAsTouched();
this.validationToast('Bitte eine gültige Menge und nichtnegative Zahlen eingeben.');
return;
}
this.saving.set(true); this.saving.set(true);
const existing = this.editingRequirement(); const existing = this.editingRequirement();
const value = this.requirementForm.getRawValue(); const value = this.requirementForm.getRawValue();
@@ -1381,7 +1409,11 @@ export class FurniturePlanningComponent implements OnChanges {
if (!keepFlow) this.guidedPlanning.set(false); if (!keepFlow) this.guidedPlanning.set(false);
} }
chooseOptionRequirement() { chooseOptionRequirement() {
if (this.optionTargetForm.invalid) return this.optionTargetForm.markAllAsTouched(); if (this.optionTargetForm.invalid) {
this.optionTargetForm.markAllAsTouched();
this.validationToast('Bitte einen Möbelbedarf auswählen.');
return;
}
const requirement = this.requirements().find( const requirement = this.requirements().find(
(entry) => entry.id === this.optionTargetForm.controls.requirementId.value, (entry) => entry.id === this.optionTargetForm.controls.requirementId.value,
); );
@@ -1400,11 +1432,11 @@ export class FurniturePlanningComponent implements OnChanges {
retailer: '', retailer: '',
productUrl: '', productUrl: '',
articleNumber: '', articleNumber: '',
unitPrice: 0, unitPrice: null,
quantity: r.requiredQuantity, quantity: r.requiredQuantity,
shippingCost: 0, shippingCost: null,
additionalCost: 0, additionalCost: null,
discount: 0, discount: null,
width: null, width: null,
height: null, height: null,
depth: null, depth: null,
@@ -1467,12 +1499,25 @@ export class FurniturePlanningComponent implements OnChanges {
} }
saveOption() { saveOption() {
const requirement = this.optionRequirement(); const requirement = this.optionRequirement();
if (!requirement || this.optionForm.invalid) return this.optionForm.markAllAsTouched(); if (!requirement) {
this.validationToast('Bitte zuerst einen Möbelbedarf auswählen.');
return;
}
if (this.optionForm.invalid) {
this.optionForm.markAllAsTouched();
this.validationToast('Bitte ungültige Preis-, Mengen- oder Maßangaben korrigieren.');
return;
}
this.saving.set(true); this.saving.set(true);
const value = this.optionForm.getRawValue(); const value = this.optionForm.getRawValue();
const body = { const body = {
...value, ...value,
name: value.name.trim() || undefined, name: value.name.trim() || undefined,
unitPrice: value.unitPrice ?? undefined,
quantity: value.quantity ?? undefined,
shippingCost: value.shippingCost ?? undefined,
additionalCost: value.additionalCost ?? undefined,
discount: value.discount ?? undefined,
expectedDeliveryDate: value.expectedDeliveryDate || undefined, expectedDeliveryDate: value.expectedDeliveryDate || undefined,
currency: 'EUR', currency: 'EUR',
}; };
@@ -1532,7 +1577,11 @@ export class FurniturePlanningComponent implements OnChanges {
if (!keepFlow) this.guidedPlanning.set(false); if (!keepFlow) this.guidedPlanning.set(false);
} }
createScenario() { createScenario() {
if (this.scenarioForm.invalid) return this.scenarioForm.markAllAsTouched(); if (this.scenarioForm.invalid) {
this.scenarioForm.markAllAsTouched();
this.validationToast('Bitte einen Namen für das Szenario eingeben.');
return;
}
this.saving.set(true); this.saving.set(true);
const value = this.scenarioForm.getRawValue(); const value = this.scenarioForm.getRawValue();
this.api this.api
@@ -1631,7 +1680,16 @@ export class FurniturePlanningComponent implements OnChanges {
this.openOptionPicker(); this.openOptionPicker();
} }
saveAssignment() { saveAssignment() {
if (this.assignmentForm.invalid) return this.assignmentForm.markAllAsTouched(); if (this.assignmentForm.invalid) {
this.assignmentForm.markAllAsTouched();
const message = !this.assignmentForm.controls.scenarioId.value
? 'Bitte ein Szenario auswählen.'
: !this.assignmentForm.controls.requirementId.value
? 'Bitte einen Möbelbedarf auswählen.'
: 'Bitte einen Möbelvorschlag auswählen.';
this.validationToast(message);
return;
}
const value = this.assignmentForm.getRawValue(); const value = this.assignmentForm.getRawValue();
const scenario = this.scenarios().find((entry) => entry.id === value.scenarioId); const scenario = this.scenarios().find((entry) => entry.id === value.scenarioId);
const requirement = this.requirements().find((entry) => entry.id === value.requirementId); const requirement = this.requirements().find((entry) => entry.id === value.requirementId);
@@ -1688,6 +1746,13 @@ export class FurniturePlanningComponent implements OnChanges {
: 'Die Möbelplanung konnte nicht gespeichert werden.'; : 'Die Möbelplanung konnte nicht gespeichert werden.';
this.error.set(conflictMessage(status, fallback)); this.error.set(conflictMessage(status, fallback));
} }
private validationToast(message: string): void {
this.toasts.show({
tone: 'warning',
title: 'Angabe fehlt oder ist ungültig',
message,
});
}
private openDialog(dialog?: ElementRef<HTMLDialogElement>) { private openDialog(dialog?: ElementRef<HTMLDialogElement>) {
queueMicrotask(() => { queueMicrotask(() => {
if (dialog && !dialog.nativeElement.open) dialog.nativeElement.showModal(); if (dialog && !dialog.nativeElement.open) dialog.nativeElement.showModal();