diff --git a/apps/backend/src/renovation/furniture.service.ts b/apps/backend/src/renovation/furniture.service.ts index 69eff1d..f383ac1 100644 --- a/apps/backend/src/renovation/furniture.service.ts +++ b/apps/backend/src/renovation/furniture.service.ts @@ -281,27 +281,38 @@ export class FurnitureService { await this.access.require(projectId, userId, 'edit'); await this.owned(this.furniture.requirements, projectId, requirementId); await this.validateOptionLinks(projectId, dto.budgetCategoryId); - const totalPrice = this.total(dto); - const saved = await this.furniture.options.save( - this.furniture.options.create({ - ...this.optionValues(dto), + return this.dataSource.transaction(async (manager) => { + const options = manager.getRepository(FurnitureOptionEntity); + const saved = await options.save( + 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, - requirementId, - totalPrice, - currentlySelected: false, - createdByUserId: userId, - }), - ); - await this.furniture.requirements.update( - { id: requirementId, projectId }, - { status: FurnitureRequirementStatus.HasOptions }, - ); - await this.activity(projectId, userId, 'furniture.option-created', { - requirementId, - optionId: saved.id, - name: saved.name, + userId, + 'furniture.option-created', + { + requirementId, + optionId: saved.id, + name: saved.name, + }, + manager, + ); + return saved; }); - return saved; } async updateOption( projectId: string, @@ -725,12 +736,27 @@ export class FurnitureService { } async deleteScenario(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'edit'); - const scenario = await this.owned(this.furniture.scenarios, projectId, id); - if (scenario.isDefault) - this.conflict( - 'Das Standardszenario kann nicht archiviert werden. Legen Sie zuerst ein anderes Standardszenario fest.', + await this.owned(this.furniture.scenarios, projectId, id); + await this.dataSource.transaction(async (manager) => { + const scenarios = manager.getRepository(FurnitureScenarioEntity); + 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( projectId: string, @@ -1134,6 +1160,35 @@ export class FurnitureService { } 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) { const text = (value?: string) => value?.trim() || null; const decimal = (value?: number | null) => diff --git a/apps/frontend/src/app/features/projects/furniture-section.component.html b/apps/frontend/src/app/features/projects/furniture-section.component.html index 0cbb86e..12cd2b4 100644 --- a/apps/frontend/src/app/features/projects/furniture-section.component.html +++ b/apps/frontend/src/app/features/projects/furniture-section.component.html @@ -97,13 +97,29 @@ {{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}

{{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen

@if (canEdit) { - +
+ + + +
} } @@ -312,7 +328,7 @@
Variante -

Szenario anlegen

+

{{ editingScenario() ? 'Szenario bearbeiten' : 'Szenario anlegen' }}

Eine Vorauswahl kann später jederzeit angepasst werden.

@@ -324,6 +340,14 @@ + + @@ -331,12 +355,14 @@
+ +
diff --git a/apps/frontend/src/app/features/projects/furniture-section.component.scss b/apps/frontend/src/app/features/projects/furniture-section.component.scss index b10bddb..a87ce22 100644 --- a/apps/frontend/src/app/features/projects/furniture-section.component.scss +++ b/apps/frontend/src/app/features/projects/furniture-section.component.scss @@ -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; diff --git a/apps/frontend/src/app/features/projects/furniture-section.component.ts b/apps/frontend/src/app/features/projects/furniture-section.component.ts index 3445b26..e8b77c9 100644 --- a/apps/frontend/src/app/features/projects/furniture-section.component.ts +++ b/apps/frontend/src/app/features/projects/furniture-section.component.ts @@ -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; @ViewChild('scenarioDialog') private scenarioDialog?: ElementRef; @ViewChild('assignmentDialog') private assignmentDialog?: ElementRef; + @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(null); readonly editingRequirement = signal(null); readonly editingOption = signal(null); + readonly editingScenario = signal(null); readonly optionRequirement = signal(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 { + 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); } diff --git a/apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts b/apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts index 93863d7..bbd3fb3 100644 --- a/apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts +++ b/apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts @@ -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(); + }); }); diff --git a/apps/frontend/src/app/features/projects/hauspilot-api.service.ts b/apps/frontend/src/app/features/projects/hauspilot-api.service.ts index 883304a..5d4af4e 100644 --- a/apps/frontend/src/app/features/projects/hauspilot-api.service.ts +++ b/apps/frontend/src/app/features/projects/hauspilot-api.service.ts @@ -573,6 +573,15 @@ export class HauspilotApiService { createFurnitureScenario(id: string, body: object) { return this.http.post(`${this.base}/${id}/furniture-scenarios`, body); } + updateFurnitureScenario(id: string, scenario: FurnitureScenario, body: object) { + return this.http.patch( + `${this.base}/${id}/furniture-scenarios/${scenario.id}`, + { ...body, version: scenario.version }, + ); + } + deleteFurnitureScenario(id: string, scenario: FurnitureScenario) { + return this.http.delete(`${this.base}/${id}/furniture-scenarios/${scenario.id}`); + } updateFurnitureScenarioSelections( id: string, scenario: FurnitureScenario,