szenarien

This commit is contained in:
Bastian Wagner
2026-07-21 09:07:11 +02:00
parent bf830f825d
commit bbe60752cd
6 changed files with 210 additions and 43 deletions

View File

@@ -97,13 +97,29 @@
<strong>{{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong>
<p>{{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen</p>
@if (canEdit) {
<button
class="ui-button ui-button--ghost"
type="button"
(click)="editAssignment(scenario.id)"
>
Auswahl bearbeiten
</button>
<div class="scenario-actions">
<button
class="ui-button ui-button--ghost"
type="button"
(click)="editAssignment(scenario.id)"
>
Auswahl
</button>
<button
class="ui-button ui-button--ghost"
type="button"
(click)="editScenario(scenario)"
>
Bearbeiten
</button>
<button
class="ui-button ui-button--danger"
type="button"
(click)="deleteScenario(scenario)"
>
Löschen
</button>
</div>
}
</article>
}
@@ -312,7 +328,7 @@
<form [formGroup]="scenarioForm" (ngSubmit)="saveScenario()">
<header>
<span class="eyebrow">Variante</span>
<h3>Szenario anlegen</h3>
<h3>{{ editingScenario() ? 'Szenario bearbeiten' : 'Szenario anlegen' }}</h3>
<p>Eine Vorauswahl kann später jederzeit angepasst werden.</p>
</header>
<label>Name *<input formControlName="name" placeholder="z. B. Wunschlösung" /></label>
@@ -324,6 +340,14 @@
<option value="existing">Vorhandene Möbel</option>
</select></label
>
<label
>Status<select formControlName="status">
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select></label
>
<label>Beschreibung<textarea rows="3" formControlName="description"></textarea></label>
<label class="check"
><input type="checkbox" formControlName="isDefault" /> Als Standard verwenden</label
>
@@ -331,12 +355,14 @@
<button class="ui-button ui-button--ghost" type="button" (click)="closeDialog('scenario')">
Abbrechen</button
><button class="ui-button ui-button--primary" type="submit" [disabled]="saving()">
Anlegen
{{ editingScenario() ? 'Änderungen speichern' : 'Anlegen' }}
</button>
</footer>
</form>
</dialog>
<ui-confirm-dialog />
<dialog #assignmentDialog class="editor-dialog" (cancel)="closeDialog('assignment')">
<form [formGroup]="assignmentForm" (ngSubmit)="saveAssignment()">
<header>

View File

@@ -64,6 +64,18 @@ p {
color: var(--color-text-secondary);
}
.scenario-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.scenario-actions .ui-button {
flex: 1 1 auto;
min-height: 2.5rem;
padding-inline: var(--space-4);
}
.empty-state {
display: grid;
place-items: center;

View File

@@ -4,7 +4,7 @@ import type { ElementRef, OnChanges } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { forkJoin } from 'rxjs';
import type { Observable } from 'rxjs';
import { ToastService } from '../../shared/ui';
import { ToastService, UiConfirmDialogComponent } from '../../shared/ui';
import { FurnitureGridComponent } from './furniture-grid.component';
import type {
FurnitureOption,
@@ -20,7 +20,7 @@ export type FurnitureSection = 'furniture' | 'requirements' | 'scenarios';
@Component({
selector: 'app-furniture-section',
standalone: true,
imports: [CurrencyPipe, FurnitureGridComponent, ReactiveFormsModule],
imports: [CurrencyPipe, FurnitureGridComponent, ReactiveFormsModule, UiConfirmDialogComponent],
templateUrl: './furniture-section.component.html',
styleUrl: './furniture-section.component.scss',
})
@@ -35,6 +35,7 @@ export class FurnitureSectionComponent implements OnChanges {
@ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>;
@ViewChild(UiConfirmDialogComponent) private confirmDialog?: UiConfirmDialogComponent;
private readonly api = inject(HauspilotApiService);
private readonly toasts = inject(ToastService);
@@ -45,6 +46,7 @@ export class FurnitureSectionComponent implements OnChanges {
readonly error = signal<string | null>(null);
readonly editingRequirement = signal<FurnitureRequirement | null>(null);
readonly editingOption = signal<FurnitureOption | null>(null);
readonly editingScenario = signal<FurnitureScenario | null>(null);
readonly optionRequirement = signal<FurnitureRequirement | null>(null);
readonly assignableRequirements = computed(() =>
this.requirements().filter((requirement) => this.optionsFor(requirement).length > 0),
@@ -123,6 +125,8 @@ export class FurnitureSectionComponent implements OnChanges {
validators: [(control) => Validators.required(control)],
}),
automaticSelection: new FormControl('budget', { nonNullable: true }),
description: new FormControl('', { nonNullable: true }),
status: new FormControl('draft', { nonNullable: true }),
isDefault: new FormControl(false, { nonNullable: true }),
});
readonly assignmentForm = new FormGroup({
@@ -344,25 +348,60 @@ export class FurnitureSectionComponent implements OnChanges {
}
newScenario(): void {
this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false });
this.editingScenario.set(null);
this.scenarioForm.reset({
name: '',
automaticSelection: 'budget',
description: '',
status: 'draft',
isDefault: false,
});
this.open(this.scenarioDialog);
}
editScenario(scenario: FurnitureScenario): void {
this.editingScenario.set(scenario);
this.scenarioForm.reset({
name: scenario.name,
automaticSelection: scenario.type,
description: scenario.description ?? '',
status: scenario.status,
isDefault: scenario.isDefault,
});
this.open(this.scenarioDialog);
}
saveScenario(): void {
if (this.scenarioForm.invalid) return this.invalid(this.scenarioForm);
const value = this.scenarioForm.getRawValue();
const existing = this.editingScenario();
const body = {
name: value.name,
description: value.description,
type: value.automaticSelection,
status: value.status,
automaticSelection: value.automaticSelection,
isDefault: value.isDefault,
};
this.save(
this.api.createFurnitureScenario(this.projectId, {
name: value.name,
type: value.automaticSelection,
status: 'draft',
automaticSelection: value.automaticSelection,
isDefault: value.isDefault,
}),
existing
? this.api.updateFurnitureScenario(this.projectId, existing, body)
: this.api.createFurnitureScenario(this.projectId, body),
() => this.close(this.scenarioDialog),
);
}
async deleteScenario(scenario: FurnitureScenario): Promise<void> {
const confirmed = await this.confirmDialog?.open({
title: 'Szenario löschen?',
description: `${scenario.name}“ und seine Möbelauswahl werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.`,
confirmLabel: 'Szenario löschen',
danger: true,
});
if (!confirmed) return;
this.save(this.api.deleteFurnitureScenario(this.projectId, scenario), () => undefined);
}
editAssignment(scenarioId = '', requirement?: FurnitureRequirement): void {
const scenario = this.scenarios().find(({ id }) => id === scenarioId) ?? this.scenarios()[0];
const target = requirement ?? this.assignableRequirements()[0];
@@ -446,6 +485,7 @@ export class FurnitureSectionComponent implements OnChanges {
dialog?.nativeElement.close();
this.editingRequirement.set(null);
this.editingOption.set(null);
this.editingScenario.set(null);
this.optionRequirement.set(null);
}

View File

@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HauspilotApiService } from './hauspilot-api.service';
import type { FurnitureScenario } from './hauspilot-api.service';
describe('HauspilotApiService', () => {
it('überträgt Pagination, Whitelist-Sortierung und Aufgabenfilter an das Backend', () => {
@@ -44,4 +45,28 @@ describe('HauspilotApiService', () => {
request.flush([]);
http.verify();
});
it('überträgt Szenarioänderungen mit Version und löscht das gewählte Szenario', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const service = TestBed.inject(HauspilotApiService);
const http = TestBed.inject(HttpTestingController);
const scenario = {
id: 'scenario-1',
version: 4,
} as FurnitureScenario;
service.updateFurnitureScenario('project-1', scenario, { name: 'Neu' }).subscribe();
const update = http.expectOne('/api/projects/project-1/furniture-scenarios/scenario-1');
expect(update.request.method).toBe('PATCH');
expect(update.request.body).toEqual({ name: 'Neu', version: 4 });
update.flush({});
service.deleteFurnitureScenario('project-1', scenario).subscribe();
const deletion = http.expectOne('/api/projects/project-1/furniture-scenarios/scenario-1');
expect(deletion.request.method).toBe('DELETE');
deletion.flush(null);
http.verify();
});
});

View File

@@ -573,6 +573,15 @@ export class HauspilotApiService {
createFurnitureScenario(id: string, body: object) {
return this.http.post<FurnitureScenario>(`${this.base}/${id}/furniture-scenarios`, body);
}
updateFurnitureScenario(id: string, scenario: FurnitureScenario, body: object) {
return this.http.patch<FurnitureScenario>(
`${this.base}/${id}/furniture-scenarios/${scenario.id}`,
{ ...body, version: scenario.version },
);
}
deleteFurnitureScenario(id: string, scenario: FurnitureScenario) {
return this.http.delete<void>(`${this.base}/${id}/furniture-scenarios/${scenario.id}`);
}
updateFurnitureScenarioSelections(
id: string,
scenario: FurnitureScenario,