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

@@ -281,27 +281,38 @@ export class FurnitureService {
await this.access.require(projectId, userId, 'edit'); await this.access.require(projectId, userId, 'edit');
await this.owned(this.furniture.requirements, projectId, requirementId); await this.owned(this.furniture.requirements, projectId, requirementId);
await this.validateOptionLinks(projectId, dto.budgetCategoryId); await this.validateOptionLinks(projectId, dto.budgetCategoryId);
const totalPrice = this.total(dto); return this.dataSource.transaction(async (manager) => {
const saved = await this.furniture.options.save( const options = manager.getRepository(FurnitureOptionEntity);
this.furniture.options.create({ const saved = await options.save(
...this.optionValues(dto), options.create({
...this.optionValues(dto),
projectId,
requirementId,
totalPrice: this.total(dto),
currentlySelected: false,
createdByUserId: userId,
}),
);
await manager
.getRepository(FurnitureRequirementEntity)
.update(
{ id: requirementId, projectId },
{ status: FurnitureRequirementStatus.HasOptions },
);
await this.refreshAutomaticScenarios(projectId, manager);
await this.activity(
projectId, projectId,
requirementId, userId,
totalPrice, 'furniture.option-created',
currentlySelected: false, {
createdByUserId: userId, requirementId,
}), optionId: saved.id,
); name: saved.name,
await this.furniture.requirements.update( },
{ id: requirementId, projectId }, manager,
{ status: FurnitureRequirementStatus.HasOptions }, );
); return saved;
await this.activity(projectId, userId, 'furniture.option-created', {
requirementId,
optionId: saved.id,
name: saved.name,
}); });
return saved;
} }
async updateOption( async updateOption(
projectId: string, projectId: string,
@@ -725,12 +736,27 @@ export class FurnitureService {
} }
async deleteScenario(projectId: string, id: string, userId: string) { async deleteScenario(projectId: string, id: string, userId: string) {
await this.access.require(projectId, userId, 'edit'); await this.access.require(projectId, userId, 'edit');
const scenario = await this.owned(this.furniture.scenarios, projectId, id); await this.owned(this.furniture.scenarios, projectId, id);
if (scenario.isDefault) await this.dataSource.transaction(async (manager) => {
this.conflict( const scenarios = manager.getRepository(FurnitureScenarioEntity);
'Das Standardszenario kann nicht archiviert werden. Legen Sie zuerst ein anderes Standardszenario fest.', const scenario = await scenarios.findOneByOrFail({ id, projectId });
await scenarios.softDelete({ id, projectId });
if (scenario.isDefault) {
const replacement = await scenarios.findOne({
where: { projectId, deletedAt: IsNull() },
order: { createdAt: 'ASC' },
});
if (replacement)
await scenarios.update({ id: replacement.id }, { isDefault: true });
}
await this.activity(
projectId,
userId,
'furniture.scenario-deleted',
{ scenarioId: id },
manager,
); );
await this.furniture.scenarios.softDelete({ id, projectId }); });
} }
async updateSelections( async updateSelections(
projectId: string, projectId: string,
@@ -1134,6 +1160,35 @@ export class FurnitureService {
} }
await selectionRepo.save(selections); await selectionRepo.save(selections);
} }
private async refreshAutomaticScenarios(
projectId: string,
manager: EntityManager,
) {
const scenarios = await manager
.getRepository(FurnitureScenarioEntity)
.find({
where: { projectId, deletedAt: IsNull() },
});
const selectionRepo = manager.getRepository(
FurnitureScenarioSelectionEntity,
);
for (const scenario of scenarios) {
if (
![
FurnitureScenarioType.Budget,
FurnitureScenarioType.Preferred,
FurnitureScenarioType.Premium,
FurnitureScenarioType.Existing,
].includes(scenario.type)
)
continue;
await selectionRepo.delete({ projectId, scenarioId: scenario.id });
await this.autoSelections(projectId, scenario.id, scenario.type, manager);
scenario.version += 1;
await manager.getRepository(FurnitureScenarioEntity).save(scenario);
}
}
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 | null) => const decimal = (value?: number | null) =>

View File

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

View File

@@ -64,6 +64,18 @@ p {
color: var(--color-text-secondary); 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 { .empty-state {
display: grid; display: grid;
place-items: center; 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 { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import type { Observable } 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 { FurnitureGridComponent } from './furniture-grid.component';
import type { import type {
FurnitureOption, FurnitureOption,
@@ -20,7 +20,7 @@ export type FurnitureSection = 'furniture' | 'requirements' | 'scenarios';
@Component({ @Component({
selector: 'app-furniture-section', selector: 'app-furniture-section',
standalone: true, standalone: true,
imports: [CurrencyPipe, FurnitureGridComponent, ReactiveFormsModule], imports: [CurrencyPipe, FurnitureGridComponent, ReactiveFormsModule, UiConfirmDialogComponent],
templateUrl: './furniture-section.component.html', templateUrl: './furniture-section.component.html',
styleUrl: './furniture-section.component.scss', styleUrl: './furniture-section.component.scss',
}) })
@@ -35,6 +35,7 @@ export class FurnitureSectionComponent implements OnChanges {
@ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>; @ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>; @ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>; @ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>;
@ViewChild(UiConfirmDialogComponent) private confirmDialog?: UiConfirmDialogComponent;
private readonly api = inject(HauspilotApiService); private readonly api = inject(HauspilotApiService);
private readonly toasts = inject(ToastService); private readonly toasts = inject(ToastService);
@@ -45,6 +46,7 @@ export class FurnitureSectionComponent implements OnChanges {
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly editingRequirement = signal<FurnitureRequirement | null>(null); readonly editingRequirement = signal<FurnitureRequirement | null>(null);
readonly editingOption = signal<FurnitureOption | null>(null); readonly editingOption = signal<FurnitureOption | null>(null);
readonly editingScenario = signal<FurnitureScenario | null>(null);
readonly optionRequirement = signal<FurnitureRequirement | null>(null); readonly optionRequirement = signal<FurnitureRequirement | null>(null);
readonly assignableRequirements = computed(() => readonly assignableRequirements = computed(() =>
this.requirements().filter((requirement) => this.optionsFor(requirement).length > 0), this.requirements().filter((requirement) => this.optionsFor(requirement).length > 0),
@@ -123,6 +125,8 @@ export class FurnitureSectionComponent implements OnChanges {
validators: [(control) => Validators.required(control)], validators: [(control) => Validators.required(control)],
}), }),
automaticSelection: new FormControl('budget', { nonNullable: true }), automaticSelection: new FormControl('budget', { nonNullable: true }),
description: new FormControl('', { nonNullable: true }),
status: new FormControl('draft', { nonNullable: true }),
isDefault: new FormControl(false, { nonNullable: true }), isDefault: new FormControl(false, { nonNullable: true }),
}); });
readonly assignmentForm = new FormGroup({ readonly assignmentForm = new FormGroup({
@@ -344,25 +348,60 @@ export class FurnitureSectionComponent implements OnChanges {
} }
newScenario(): void { 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); this.open(this.scenarioDialog);
} }
saveScenario(): void { saveScenario(): void {
if (this.scenarioForm.invalid) return this.invalid(this.scenarioForm); if (this.scenarioForm.invalid) return this.invalid(this.scenarioForm);
const value = this.scenarioForm.getRawValue(); 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.save(
this.api.createFurnitureScenario(this.projectId, { existing
name: value.name, ? this.api.updateFurnitureScenario(this.projectId, existing, body)
type: value.automaticSelection, : this.api.createFurnitureScenario(this.projectId, body),
status: 'draft',
automaticSelection: value.automaticSelection,
isDefault: value.isDefault,
}),
() => this.close(this.scenarioDialog), () => 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 { editAssignment(scenarioId = '', requirement?: FurnitureRequirement): void {
const scenario = this.scenarios().find(({ id }) => id === scenarioId) ?? this.scenarios()[0]; const scenario = this.scenarios().find(({ id }) => id === scenarioId) ?? this.scenarios()[0];
const target = requirement ?? this.assignableRequirements()[0]; const target = requirement ?? this.assignableRequirements()[0];
@@ -446,6 +485,7 @@ export class FurnitureSectionComponent implements OnChanges {
dialog?.nativeElement.close(); dialog?.nativeElement.close();
this.editingRequirement.set(null); this.editingRequirement.set(null);
this.editingOption.set(null); this.editingOption.set(null);
this.editingScenario.set(null);
this.optionRequirement.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 { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HauspilotApiService } from './hauspilot-api.service'; import { HauspilotApiService } from './hauspilot-api.service';
import type { FurnitureScenario } from './hauspilot-api.service';
describe('HauspilotApiService', () => { describe('HauspilotApiService', () => {
it('überträgt Pagination, Whitelist-Sortierung und Aufgabenfilter an das Backend', () => { it('überträgt Pagination, Whitelist-Sortierung und Aufgabenfilter an das Backend', () => {
@@ -44,4 +45,28 @@ describe('HauspilotApiService', () => {
request.flush([]); request.flush([]);
http.verify(); 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) { createFurnitureScenario(id: string, body: object) {
return this.http.post<FurnitureScenario>(`${this.base}/${id}/furniture-scenarios`, body); 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( updateFurnitureScenarioSelections(
id: string, id: string,
scenario: FurnitureScenario, scenario: FurnitureScenario,