generated from bastian/boilerplate
guide
This commit is contained in:
@@ -11,10 +11,44 @@ import type {
|
||||
} from './hauspilot-api.service';
|
||||
import { HauspilotApiService } from './hauspilot-api.service';
|
||||
import { FurniturePlanningComponent } from './furniture-planning.component';
|
||||
import { ToastService } from '../../shared/ui';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
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 () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FurniturePlanningComponent],
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { HauspilotApiService } from './hauspilot-api.service';
|
||||
import { conflictMessage } from './project-workspace.helpers';
|
||||
import { FurnitureGridComponent } from './furniture-grid.component';
|
||||
import { ToastService } from '../../shared/ui';
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
seating: 'Sitzmöbel',
|
||||
@@ -521,40 +522,13 @@ const statusLabels: Record<string, string> = {
|
||||
min="0"
|
||||
step="0.01"
|
||||
formControlName="unitPrice" /></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>
|
||||
><label>Menge<input type="number" min="1" formControlName="quantity" /></label>
|
||||
<output class="calculated"
|
||||
>Gesamtpreis:
|
||||
<strong>{{
|
||||
calculatedTotal() | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
|
||||
}}</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
|
||||
>Verfügbarkeit<select formControlName="availability">
|
||||
<option value="unknown">Unbekannt</option>
|
||||
@@ -578,24 +552,60 @@ const statusLabels: Record<string, string> = {
|
||||
><label class="check"
|
||||
><input type="checkbox" formControlName="existingItem" /> Bereits vorhanden</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>
|
||||
<details class="advanced-fields wide">
|
||||
<summary>Erweiterte Informationen</summary>
|
||||
<p class="ui-meta">Diese Angaben sind vollständig optional.</p>
|
||||
<div class="advanced-grid">
|
||||
<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>
|
||||
<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">
|
||||
<button class="ui-button" type="submit" [disabled]="saving()">Speichern</button
|
||||
><button class="ui-button ui-button--ghost" type="button" (click)="closeOptionForm()">
|
||||
@@ -1069,6 +1079,21 @@ const statusLabels: Record<string, string> = {
|
||||
.calculated {
|
||||
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 {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -1087,6 +1112,9 @@ const statusLabels: Record<string, string> = {
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 40rem) {
|
||||
.advanced-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.actions .ui-button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
@@ -1115,6 +1143,7 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
@ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>;
|
||||
@ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>;
|
||||
private readonly api = inject(HauspilotApiService);
|
||||
private readonly toasts = inject(ToastService);
|
||||
readonly requirements = signal<FurnitureRequirement[]>([]);
|
||||
readonly scenarios = signal<FurnitureScenario[]>([]);
|
||||
readonly summary = signal<FurnitureSummary | null>(null);
|
||||
@@ -1185,24 +1214,19 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
retailer: new FormControl('', { nonNullable: true }),
|
||||
productUrl: new FormControl('', { nonNullable: true }),
|
||||
articleNumber: new FormControl('', { nonNullable: true }),
|
||||
unitPrice: new FormControl(0, {
|
||||
nonNullable: true,
|
||||
unitPrice: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
quantity: new FormControl(1, {
|
||||
nonNullable: true,
|
||||
quantity: new FormControl<number | null>(1, {
|
||||
validators: [(control) => Validators.min(1)(control)],
|
||||
}),
|
||||
shippingCost: new FormControl(0, {
|
||||
nonNullable: true,
|
||||
shippingCost: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
additionalCost: new FormControl(0, {
|
||||
nonNullable: true,
|
||||
additionalCost: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
discount: new FormControl(0, {
|
||||
nonNullable: true,
|
||||
discount: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
width: new FormControl<number | null>(null),
|
||||
@@ -1251,15 +1275,15 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
});
|
||||
readonly calculatedTotal = computed(() => {
|
||||
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(
|
||||
0,
|
||||
acquisition +
|
||||
v.shippingCost +
|
||||
v.additionalCost +
|
||||
(v.shippingCost ?? 0) +
|
||||
(v.additionalCost ?? 0) +
|
||||
v.movingCost +
|
||||
v.refurbishmentCost -
|
||||
v.discount,
|
||||
(v.discount ?? 0),
|
||||
);
|
||||
});
|
||||
ngOnChanges() {
|
||||
@@ -1342,7 +1366,11 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
if (!keepFlow) this.guidedPlanning.set(false);
|
||||
}
|
||||
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);
|
||||
const existing = this.editingRequirement();
|
||||
const value = this.requirementForm.getRawValue();
|
||||
@@ -1381,7 +1409,11 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
if (!keepFlow) this.guidedPlanning.set(false);
|
||||
}
|
||||
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(
|
||||
(entry) => entry.id === this.optionTargetForm.controls.requirementId.value,
|
||||
);
|
||||
@@ -1400,11 +1432,11 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
retailer: '',
|
||||
productUrl: '',
|
||||
articleNumber: '',
|
||||
unitPrice: 0,
|
||||
unitPrice: null,
|
||||
quantity: r.requiredQuantity,
|
||||
shippingCost: 0,
|
||||
additionalCost: 0,
|
||||
discount: 0,
|
||||
shippingCost: null,
|
||||
additionalCost: null,
|
||||
discount: null,
|
||||
width: null,
|
||||
height: null,
|
||||
depth: null,
|
||||
@@ -1467,12 +1499,25 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
}
|
||||
saveOption() {
|
||||
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);
|
||||
const value = this.optionForm.getRawValue();
|
||||
const body = {
|
||||
...value,
|
||||
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,
|
||||
currency: 'EUR',
|
||||
};
|
||||
@@ -1532,7 +1577,11 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
if (!keepFlow) this.guidedPlanning.set(false);
|
||||
}
|
||||
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);
|
||||
const value = this.scenarioForm.getRawValue();
|
||||
this.api
|
||||
@@ -1631,7 +1680,16 @@ export class FurniturePlanningComponent implements OnChanges {
|
||||
this.openOptionPicker();
|
||||
}
|
||||
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 scenario = this.scenarios().find((entry) => entry.id === value.scenarioId);
|
||||
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.';
|
||||
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>) {
|
||||
queueMicrotask(() => {
|
||||
if (dialog && !dialog.nativeElement.open) dialog.nativeElement.showModal();
|
||||
|
||||
Reference in New Issue
Block a user