This commit is contained in:
Bastian Wagner
2026-07-20 11:20:41 +02:00
parent e62673ac11
commit 2f84a109e8
9 changed files with 1508 additions and 362 deletions

View File

@@ -4,12 +4,7 @@ import { of } from 'rxjs';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { AuthService } from '../../core/auth.service'; import { AuthService } from '../../core/auth.service';
import { FurnitureGridComponent } from './furniture-grid.component'; import { FurnitureGridComponent } from './furniture-grid.component';
import type { import type { FurnitureRequirement, PageResult } from './hauspilot-api.service';
FurnitureOption,
FurnitureRequirement,
FurnitureScenario,
PageResult,
} from './hauspilot-api.service';
import { HauspilotApiService } from './hauspilot-api.service'; import { HauspilotApiService } from './hauspilot-api.service';
const requirement: FurnitureRequirement = { const requirement: FurnitureRequirement = {
@@ -199,78 +194,7 @@ describe('FurnitureGridComponent', () => {
expect(calls[0]?.['sortBy']).toBe('sortOrder'); expect(calls[0]?.['sortBy']).toBe('sortOrder');
}); });
it('assigns an option to a scenario through the existing selection endpoint', async () => { it('opens the assignment dialog when a scenario cell is clicked', async () => {
const option: FurnitureOption = {
id: 'option-1',
version: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
requirementId: requirement.id,
name: 'Sofa Wunsch',
manufacturer: null,
model: null,
description: null,
retailer: null,
productUrl: null,
articleNumber: null,
unitPrice: '1000.00',
originalPrice: null,
shippingCost: '0.00',
additionalCost: '0.00',
discount: '0.00',
totalPrice: '1000.00',
currency: 'EUR',
quantity: 1,
width: null,
height: null,
depth: null,
weight: null,
color: null,
material: null,
deliveryDays: null,
expectedDeliveryDate: null,
availability: 'available',
favorite: true,
currentlySelected: false,
status: 'favorite',
notes: null,
budgetCategoryId: null,
existingItem: false,
movingCost: '0.00',
refurbishmentCost: '0.00',
deliveryStatus: 'not_ordered',
deliveredQuantity: 0,
orderNumber: null,
orderedAt: null,
};
const scenario: FurnitureScenario = {
id: 'scenario-1',
version: 3,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
name: 'Wunsch',
description: null,
type: 'preferred',
status: 'active',
isDefault: true,
total: '0.00',
selectedRequirements: 0,
openRequirements: 1,
byRoom: {},
selections: [],
};
const updateSelections = vi.fn(() =>
of({
...scenario,
version: 4,
total: option.totalPrice,
selections: [
{ requirementId: requirement.id, optionId: option.id, quantity: option.quantity },
],
}),
);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [FurnitureGridComponent], imports: [FurnitureGridComponent],
providers: [ providers: [
@@ -280,7 +204,6 @@ describe('FurnitureGridComponent', () => {
useValue: { useValue: {
furnitureRequirements: () => of(page), furnitureRequirements: () => of(page),
furnitureProjectOptions: () => of({ ...page, items: [] }), furnitureProjectOptions: () => of({ ...page, items: [] }),
updateFurnitureScenarioSelections: updateSelections,
}, },
}, },
{ provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } }, { provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } },
@@ -289,16 +212,12 @@ describe('FurnitureGridComponent', () => {
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.projectId = 'project-1'; component.projectId = 'project-1';
component.canEdit = true; component.canEdit = true;
component.scenarios = [scenario]; const assignment = vi.fn();
component.cellChanged({ component.assignScenario.subscribe(assignment);
oldValue: '', component.cellClicked({
newValue: option.id, data: requirement,
data: { ...requirement, options: [option] }, column: { getColId: () => 'scenario:scenario-1' },
column: { getColId: () => `scenario:${scenario.id}` },
node: { setDataValue: vi.fn() },
} as never); } as never);
expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [ expect(assignment).toHaveBeenCalledWith({ requirement, scenarioId: 'scenario-1' });
{ requirementId: requirement.id, optionId: option.id, quantity: 1 },
]);
}); });
}); });

View File

@@ -61,7 +61,7 @@ const requirementPriorities: Record<string, GridValuePresentation> = {
const requirementStatuses: Record<string, GridValuePresentation> = { const requirementStatuses: Record<string, GridValuePresentation> = {
identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' }, identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' },
research: { icon: '⌕', label: 'Recherche', tone: 'info' }, research: { icon: '⌕', label: 'Recherche', tone: 'info' },
has_options: { icon: '≡', label: 'Alternativen vorhanden', tone: 'info' }, has_options: { icon: '≡', label: 'Möbelvorschläge vorhanden', tone: 'info' },
decision_open: { icon: '?', label: 'Entscheidung offen', tone: 'warning' }, decision_open: { icon: '?', label: 'Entscheidung offen', tone: 'warning' },
selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' }, selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' },
ordered: { icon: '▣', label: 'Bestellt', tone: 'info' }, ordered: { icon: '▣', label: 'Bestellt', tone: 'info' },
@@ -172,8 +172,8 @@ const deliveryStatuses: Record<string, GridValuePresentation> = {
} }
@if (view() === 'scenarios') { @if (view() === 'scenarios') {
<p class="scenario-help"> <p class="scenario-help">
Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus. Klicken Sie auf eine Szenariozelle, um passende Möbel in Ruhe zu vergleichen und Ihre
„Nicht zugewiesen“ lässt den Bedarf im Szenario offen. Auswahl anschließend zu übernehmen.
</p> </p>
} }
<ag-grid-angular <ag-grid-angular
@@ -330,6 +330,10 @@ export class FurnitureGridComponent implements OnChanges {
@Output() editRequirement = new EventEmitter<FurnitureRequirement>(); @Output() editRequirement = new EventEmitter<FurnitureRequirement>();
@Output() addOption = new EventEmitter<FurnitureRequirement>(); @Output() addOption = new EventEmitter<FurnitureRequirement>();
@Output() editOption = new EventEmitter<FurnitureOption>(); @Output() editOption = new EventEmitter<FurnitureOption>();
@Output() assignScenario = new EventEmitter<{
requirement: FurnitureRequirement;
scenarioId: string;
}>();
@Output() dataChanged = new EventEmitter<void>(); @Output() dataChanged = new EventEmitter<void>();
private readonly api = inject(HauspilotApiService); private readonly api = inject(HauspilotApiService);
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
@@ -352,7 +356,7 @@ export class FurnitureGridComponent implements OnChanges {
delayedOnly = false; delayedOnly = false;
readonly views: Array<{ id: View; label: string }> = [ readonly views: Array<{ id: View; label: string }> = [
{ id: 'requirements', label: 'Bedarfe' }, { id: 'requirements', label: 'Bedarfe' },
{ id: 'options', label: 'Alternativen' }, { id: 'options', label: 'Möbelvorschläge' },
{ id: 'orders', label: 'Bestellungen' }, { id: 'orders', label: 'Bestellungen' },
{ id: 'scenarios', label: 'Szenarien' }, { id: 'scenarios', label: 'Szenarien' },
]; ];
@@ -432,11 +436,6 @@ export class FurnitureGridComponent implements OnChanges {
} }
cellChanged(event: CellValueChangedEvent<FurnitureRow>) { cellChanged(event: CellValueChangedEvent<FurnitureRow>) {
if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return; if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return;
const scenarioId = this.scenarioId(event.column.getColId());
if (scenarioId && 'requiredQuantity' in event.data) {
this.saveScenarioSelection(event, scenarioId, event.data);
return;
}
const row = event.data; const row = event.data;
this.savingRow.set(row.id); this.savingRow.set(row.id);
this.error.set(null); this.error.set(null);
@@ -464,6 +463,11 @@ export class FurnitureGridComponent implements OnChanges {
if (!event.data) return; if (!event.data) return;
const id = event.column.getColId(); const id = event.column.getColId();
if ('requiredQuantity' in event.data) { if ('requiredQuantity' in event.data) {
const scenarioId = this.scenarioId(id);
if (scenarioId && this.canEdit) {
this.assignScenario.emit({ requirement: event.data, scenarioId });
return;
}
if (id === 'actions') this.editRequirement.emit(event.data); if (id === 'actions') this.editRequirement.emit(event.data);
if (id === 'addOption') this.addOption.emit(event.data); if (id === 'addOption') this.addOption.emit(event.data);
return; return;
@@ -543,7 +547,7 @@ export class FurnitureGridComponent implements OnChanges {
valueFormatter: (p) => money(p.value), valueFormatter: (p) => money(p.value),
valueParser: (p) => parseGermanNumber(p.newValue), valueParser: (p) => parseGermanNumber(p.newValue),
}, },
{ field: 'optionCount', headerName: 'Alternativen' }, { field: 'optionCount', headerName: 'Möbelvorschläge' },
{ {
field: 'cheapestOption', field: 'cheapestOption',
headerName: 'Günstigste', headerName: 'Günstigste',
@@ -586,7 +590,7 @@ export class FurnitureGridComponent implements OnChanges {
}, },
{ {
colId: 'addOption', colId: 'addOption',
headerName: 'Alternative', headerName: 'Möbelvorschlag',
valueGetter: () => (this.canEdit ? ' hinzufügen' : 'anzeigen'), valueGetter: () => (this.canEdit ? ' hinzufügen' : 'anzeigen'),
sortable: false, sortable: false,
}, },
@@ -734,28 +738,18 @@ export class FurnitureGridComponent implements OnChanges {
const option = row.options.find((entry) => entry.id === params.value); const option = row.options.find((entry) => entry.id === params.value);
return option ? `${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen'; return option ? `${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen';
}, },
editable: (params) =>
this.canEdit &&
!!params.data &&
'requiredQuantity' in params.data &&
this.selectableOptions(params.data).length > 0,
cellEditor: 'agSelectCellEditor',
cellEditorParams: (params: { data?: FurnitureRow }) => ({
values:
params.data && 'requiredQuantity' in params.data
? ['', ...this.selectableOptions(params.data).map((option) => option.id)]
: [''],
}),
cellStyle: (params) => cellStyle: (params) =>
params.value params.value
? { ? {
color: 'var(--color-success)', color: 'var(--color-success)',
backgroundColor: 'var(--color-success-subtle)', backgroundColor: 'var(--color-success-subtle)',
fontWeight: '650', fontWeight: '650',
cursor: this.canEdit ? 'pointer' : 'default',
} }
: { : {
color: 'var(--color-warning)', color: 'var(--color-warning)',
backgroundColor: 'var(--color-warning-subtle)', backgroundColor: 'var(--color-warning-subtle)',
cursor: this.canEdit ? 'pointer' : 'default',
}, },
minWidth: 245, minWidth: 245,
}), }),
@@ -803,63 +797,6 @@ export class FurnitureGridComponent implements OnChanges {
private scenarioId(columnId: string) { private scenarioId(columnId: string) {
return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null; return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null;
} }
private selectableOptions(requirement: FurnitureRequirement) {
return requirement.options.filter(
(option) =>
!['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) &&
!['unavailable', 'discontinued'].includes(option.availability),
);
}
private saveScenarioSelection(
event: CellValueChangedEvent<FurnitureRow>,
scenarioId: string,
requirement: FurnitureRequirement,
) {
const scenario = this.scenarios.find((entry) => entry.id === scenarioId);
if (!scenario) {
this.rollbackCell(event);
return;
}
const optionId = typeof event.newValue === 'string' ? event.newValue : '';
const option = optionId
? this.selectableOptions(requirement).find((entry) => entry.id === optionId)
: undefined;
if (optionId && !option) {
this.rollbackCell(event);
this.error.set('Diese Alternative kann dem Szenario nicht zugewiesen werden.');
return;
}
const selections = scenario.selections
.filter((selection) => selection.requirementId !== requirement.id)
.map((selection) => ({ ...selection }));
if (option)
selections.push({
requirementId: requirement.id,
optionId: option.id,
quantity: option.quantity,
});
this.savingRow.set(requirement.id);
this.error.set(null);
this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({
next: (saved) => {
this.scenarios = this.scenarios.map((entry) => (entry.id === saved.id ? saved : entry));
this.savingRow.set(null);
this.refreshColumns();
this.dataChanged.emit();
},
error: (error: unknown) => {
this.rollbackCell(event);
this.savingRow.set(null);
this.fail(error);
if (this.status(error) === 409) this.load(this.page());
},
});
}
private rollbackCell(event: CellValueChangedEvent<FurnitureRow>) {
this.rollback = true;
event.node.setDataValue(event.column.getColId(), event.oldValue);
this.rollback = false;
}
private optionBody(row: FurnitureOption) { private optionBody(row: FurnitureOption) {
return { return {
name: row.name, name: row.name,

View File

@@ -1,9 +1,67 @@
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type {
FurnitureOption,
FurnitureRequirement,
FurnitureScenario,
} from './hauspilot-api.service';
import { HauspilotApiService } from './hauspilot-api.service';
import { FurniturePlanningComponent } from './furniture-planning.component'; import { FurniturePlanningComponent } from './furniture-planning.component';
registerLocaleData(localeDe);
describe('FurniturePlanningComponent', () => { describe('FurniturePlanningComponent', () => {
it('explains the planning concepts and recommends the next useful step', async () => {
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
providers: [provideHttpClient()],
}).compileComponents();
const fixture = TestBed.createComponent(FurniturePlanningComponent);
fixture.componentInstance.canEdit = true;
fixture.detectChanges();
const host: unknown = fixture.nativeElement;
if (!(host instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.');
expect(host.querySelector('.concept-flow')?.textContent).toContain('Was wird wo benötigt?');
expect(host.querySelector('.concept-flow')?.textContent).toContain(
'Was kommt konkret infrage?',
);
expect(host.querySelector('.next-action button')?.textContent).toContain(
'Ersten Bedarf anlegen',
);
fixture.componentInstance.requirements.set([
{
id: 'requirement-1',
version: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
roomId: 'room-1',
name: 'Sofa',
description: null,
category: 'seating',
priority: 'normal',
requiredQuantity: 1,
status: 'identified',
responsibleUserId: null,
maximumBudget: null,
sortOrder: 0,
options: [],
},
]);
fixture.detectChanges();
expect(fixture.componentInstance.nextStep()).toBe('option');
expect(host.querySelector('.next-action button')?.textContent).toContain(
'Möbelvorschlag hinzufügen',
);
});
it('calculates option totals including quantity, shipping, extras and discounts', async () => { it('calculates option totals including quantity, shipping, extras and discounts', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent], imports: [FurniturePlanningComponent],
@@ -77,4 +135,134 @@ describe('FurniturePlanningComponent', () => {
expect(showModal).toHaveBeenCalledOnce(); expect(showModal).toHaveBeenCalledOnce();
expect(fixture.componentInstance.showRequirementForm()).toBe(true); expect(fixture.componentInstance.showRequirementForm()).toBe(true);
}); });
it('guides users through an explicit scenario assignment', async () => {
const option = {
id: 'option-1',
version: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
requirementId: 'requirement-1',
name: 'Wunschsofa',
manufacturer: 'Nordmöbel',
model: null,
description: null,
retailer: 'Wohnwelt',
productUrl: null,
articleNumber: null,
unitPrice: '1000.00',
originalPrice: null,
shippingCost: '0.00',
additionalCost: '0.00',
discount: '0.00',
totalPrice: '1000.00',
currency: 'EUR',
quantity: 1,
width: null,
height: null,
depth: null,
weight: null,
color: null,
material: null,
deliveryDays: null,
expectedDeliveryDate: null,
availability: 'available',
favorite: false,
currentlySelected: false,
status: 'idea',
notes: null,
budgetCategoryId: null,
existingItem: false,
movingCost: '0.00',
refurbishmentCost: '0.00',
deliveryStatus: 'not_ordered',
deliveredQuantity: 0,
orderNumber: null,
orderedAt: null,
} satisfies FurnitureOption;
const requirement = {
id: 'requirement-1',
version: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
roomId: 'room-1',
name: 'Sofa',
description: null,
category: 'seating',
priority: 'normal',
requiredQuantity: 1,
status: 'identified',
responsibleUserId: null,
maximumBudget: null,
sortOrder: 0,
options: [option],
} satisfies FurnitureRequirement;
const scenario = {
id: 'scenario-1',
version: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
name: 'Wunsch',
description: null,
type: 'preferred',
status: 'draft',
isDefault: true,
total: '0.00',
selectedRequirements: 0,
openRequirements: 1,
byRoom: {},
selections: [],
} satisfies FurnitureScenario;
const updateSelections = vi.fn(() =>
of({
...scenario,
version: 2,
selections: [{ requirementId: requirement.id, optionId: option.id, quantity: 1 }],
}),
);
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
providers: [
provideHttpClient(),
{
provide: HauspilotApiService,
useValue: {
updateFurnitureScenarioSelections: updateSelections,
furnitureRequirements: () =>
of({ items: [requirement], page: 1, pageSize: 50, totalItems: 1, totalPages: 1 }),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(FurniturePlanningComponent);
const component = fixture.componentInstance;
component.projectId = 'project-1';
component.requirements.set([requirement]);
component.scenarios.set([scenario]);
vi.spyOn(component, 'load').mockImplementation(() => undefined);
component.openAssignment(scenario.id, requirement.id, option.id);
fixture.detectChanges();
const host: unknown = fixture.nativeElement;
if (!(host instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.');
const choice = host.querySelector('.option-choice');
expect(choice?.textContent).toContain('Wunschsofa');
expect(choice?.textContent).toContain('Nordmöbel');
expect(choice?.textContent).toContain('Wohnwelt');
const radio = choice?.querySelector('input[type="radio"]');
expect(radio instanceof HTMLInputElement && radio.checked).toBe(true);
const dialog = host.querySelector('#assignment-form-title')?.closest('dialog');
if (!(dialog instanceof HTMLDialogElement)) throw new Error('Zuordnungsdialog fehlt.');
Object.defineProperty(dialog, 'close', { value: vi.fn() });
component.saveAssignment();
expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [
{ requirementId: requirement.id, optionId: option.id, quantity: 1 },
]);
expect(component.assignedRequirements()).toBe(1);
expect(component.showAssignmentForm()).toBe(false);
});
}); });

View File

@@ -34,7 +34,7 @@ const categoryLabels: Record<string, string> = {
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
identified: 'Bedarf erkannt', identified: 'Bedarf erkannt',
research: 'Recherche', research: 'Recherche',
has_options: 'Alternativen vorhanden', has_options: 'Möbelvorschläge vorhanden',
decision_open: 'Entscheidung offen', decision_open: 'Entscheidung offen',
selected: 'Ausgewählt', selected: 'Ausgewählt',
ordered: 'Bestellt', ordered: 'Bestellt',
@@ -52,14 +52,126 @@ const statusLabels: Record<string, string> = {
<div class="section-head"> <div class="section-head">
<div> <div>
<h2>Möbel & Einrichtung</h2> <h2>Möbel & Einrichtung</h2>
<p>Bedarfe, Produktalternativen und Einrichtungsszenarien gemeinsam planen.</p> <p>Planen Sie vom benötigten Möbel bis zur fertigen Einrichtungsvariante.</p>
</div> </div>
@if (canEdit) {
<button class="ui-button" type="button" (click)="newRequirement()">
Möbelbedarf hinzufügen
</button>
}
</div> </div>
<section class="planning-guide ui-card" aria-labelledby="planning-guide-title">
<div class="guide-intro">
<span class="eyebrow">Geführte Planung</span>
<h3 id="planning-guide-title">Was gehört hier zusammen?</h3>
<p>
Sie notieren zuerst, <strong>was fehlt</strong>, sammeln dafür konkrete
<strong>Möbelvorschläge</strong> und stellen daraus ein
<strong>Szenario</strong> zusammen.
</p>
<div class="concept-flow" aria-label="Begriffe der Möbelplanung">
<article>
<span aria-hidden="true">1</span>
<div><strong>Bedarf</strong><small>Was wird wo benötigt?</small></div>
</article>
<span class="flow-arrow" aria-hidden="true">→</span>
<article>
<span aria-hidden="true">2</span>
<div><strong>Möbelvorschlag</strong><small>Was kommt konkret infrage?</small></div>
</article>
<span class="flow-arrow" aria-hidden="true">→</span>
<article>
<span aria-hidden="true">3</span>
<div>
<strong>Szenario</strong><small>Welche Vorschläge wählen wir zusammen?</small>
</div>
</article>
</div>
</div>
<ol class="guide-steps">
<li [class.complete]="requirements().length > 0" [class.current]="nextStep() === 'need'">
<span class="step-number" aria-hidden="true">1</span>
<div>
<strong>Bedarf notieren</strong
><small>{{ requirements().length }} Bedarfe erfasst</small>
</div>
</li>
<li
[class.complete]="
requirementsWithOptions() === requirements().length && requirements().length > 0
"
[class.current]="nextStep() === 'option'"
>
<span class="step-number" aria-hidden="true">2</span>
<div>
<strong>Möbelvorschläge sammeln</strong
><small
>{{ requirementsWithOptions() }} von {{ requirements().length }} Bedarfen haben
Vorschläge</small
>
</div>
</li>
<li
[class.complete]="assignedRequirements() > 0"
[class.current]="nextStep() === 'scenario' || nextStep() === 'assign'"
>
<span class="step-number" aria-hidden="true">3</span>
<div>
<strong>Szenario zusammenstellen</strong
><small>{{ assignedRequirements() }} Bedarfe sind einem Szenario zugeordnet</small>
</div>
</li>
</ol>
<div class="next-action" aria-live="polite">
<div>
<span class="eyebrow">Als Nächstes</span>
@switch (nextStep()) {
@case ('need') {
<strong>Notieren Sie den ersten Möbelbedarf</strong>
<small
>Zum Beispiel „Sofa im Wohnzimmer“ ein Produkt müssen Sie noch nicht
kennen.</small
>
}
@case ('option') {
<strong>Ergänzen Sie einen konkreten Möbelvorschlag</strong>
<small
>Das kann ein neues Produkt oder ein bereits vorhandenes Möbelstück sein.</small
>
}
@case ('scenario') {
<strong>Erstellen Sie Ihre erste Einrichtungsvariante</strong>
<small>Ein Szenario bündelt je Bedarf genau einen ausgewählten Vorschlag.</small>
}
@default {
<strong>Vervollständigen oder vergleichen Sie Ihre Szenarien</strong>
<small
>Ordnen Sie offene Bedarfe zu oder probieren Sie eine weitere Variante aus.</small
>
}
}
</div>
@if (canEdit) {
@switch (nextStep()) {
@case ('need') {
<button class="ui-button" type="button" (click)="startGuidedPlanning()">
Ersten Bedarf anlegen
</button>
}
@case ('option') {
<button class="ui-button" type="button" (click)="openOptionPicker()">
Möbelvorschlag hinzufügen
</button>
}
@case ('scenario') {
<button class="ui-button" type="button" (click)="openScenarioForm()">
Szenario erstellen
</button>
}
@default {
<button class="ui-button" type="button" (click)="openAssignment()">
Möbel im Szenario auswählen
</button>
}
}
}
</div>
</section>
@if (error()) { @if (error()) {
<p class="error" role="alert">{{ error() }}</p> <p class="error" role="alert">{{ error() }}</p>
} }
@@ -67,65 +179,36 @@ const statusLabels: Record<string, string> = {
<p role="status">Möbelplanung wird geladen …</p> <p role="status">Möbelplanung wird geladen …</p>
} }
@if (summary(); as data) { @if (summary(); as data) {
<div class="metric-grid"> <details class="planning-details ui-card">
<article class="ui-card metric"> <summary>Kosten und Planungsstand anzeigen</summary>
<span>Ausgewählte Möbel</span <div class="metric-grid">
><strong>{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong <article class="metric">
><small>{{ data.selected }} ausgewählt</small> <span>Ausgewählte Möbel</span
</article> ><strong>{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
<article class="ui-card metric"> ><small>{{ data.selected }} ausgewählt</small>
<span>Günstigste Variante</span </article>
><strong>{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong <article class="metric">
><small>{{ data.openPrices }} Preise noch offen</small> <span>Günstigste Variante</span
</article> ><strong>{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
<article class="ui-card metric"> ><small>{{ data.openPrices }} Preise noch offen</small>
<span>Tatsächliche Ausgaben</span </article>
><strong>{{ <article class="metric">
data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' <span>Tatsächliche Ausgaben</span
}}</strong ><strong>{{
><small>Verknüpfte Ausgaben, nicht doppelt gezählt</small> data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
</article> }}</strong
<article class="ui-card metric"> ><small>Verknüpfte Ausgaben, nicht doppelt gezählt</small>
<span>Entscheidungen</span><strong>{{ data.withoutDecision }}</strong </article>
><small>{{ data.withoutOption }} ohne Alternative · {{ data.delayed }} verspätet</small> <article class="metric">
</article> <span>Offene Entscheidungen</span><strong>{{ data.withoutDecision }}</strong
</div> ><small
>{{ data.withoutOption }} ohne Möbelvorschlag · {{ data.delayed }} verspätet</small
>
</article>
</div>
</details>
} }
<form class="filters ui-card" [formGroup]="filterForm" (ngSubmit)="load(1)">
<label>Suche<input formControlName="search" placeholder="Möbel oder Händler" /></label>
<label
>Raum<select formControlName="roomId">
<option value="">Alle Räume</option>
@for (room of rooms; track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
<label
>Status<select formControlName="status">
<option value="">Alle Status</option>
@for (entry of requirementStatuses; track entry[0]) {
<option [value]="entry[0]">{{ entry[1] }}</option>
}
</select></label
>
<label
>Sortierung<select formControlName="sortBy">
<option value="sortOrder">Reihenfolge</option>
<option value="name">Name</option>
<option value="room">Raum</option>
<option value="price">Preis</option>
<option value="priority">Priorität</option>
<option value="updatedAt">Zuletzt geändert</option>
</select></label
>
<button class="ui-button ui-button--secondary" type="submit">Anwenden</button>
<button class="ui-button ui-button--ghost" type="button" (click)="resetFilters()">
Zurücksetzen
</button>
</form>
<dialog <dialog
#requirementDialog #requirementDialog
class="editor-dialog" class="editor-dialog"
@@ -201,22 +284,19 @@ const statusLabels: Record<string, string> = {
} }
</dialog> </dialog>
<div #furnitureGridHost> <section class="needs-overview" aria-labelledby="needs-overview-title">
<app-furniture-grid <div class="section-head">
#furnitureGrid <div>
[projectId]="projectId" <span class="eyebrow">Ihre Planung</span>
[rooms]="rooms" <h2 id="needs-overview-title">Bedarfe und Möbelvorschläge</h2>
[canEdit]="canEdit" <p>Jeder Bedarf zeigt die konkreten Möbel, die dafür infrage kommen.</p>
[scenarios]="scenarios()" </div>
(editRequirement)="editRequirement($event)" @if (canEdit) {
(addOption)="newOption($event)" <button class="ui-button ui-button--secondary" type="button" (click)="newRequirement()">
(editOption)="editOptionFromGrid($event)" Weiteren Bedarf anlegen
(dataChanged)="load()" </button>
/> }
</div> </div>
<details class="legacy-cards">
<summary>Kompakte Kartenansicht</summary>
<div class="requirement-list"> <div class="requirement-list">
@for (requirement of requirements(); track requirement.id) { @for (requirement of requirements(); track requirement.id) {
<article class="ui-card requirement"> <article class="ui-card requirement">
@@ -238,7 +318,7 @@ const statusLabels: Record<string, string> = {
requirement.maximumBudget || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' requirement.maximumBudget || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
}}</strong></span }}</strong></span
><span ><span
>Alternativen: <strong>{{ requirement.options.length }}</strong></span >Möbelvorschläge: <strong>{{ requirement.options.length }}</strong></span
> >
</div> </div>
@if (requirement.description) { @if (requirement.description) {
@@ -252,8 +332,8 @@ const statusLabels: Record<string, string> = {
> >
{{ {{
expandedRequirementId() === requirement.id expandedRequirementId() === requirement.id
? 'Alternativen schließen' ? 'Möbelvorschläge schließen'
: 'Alternativen vergleichen' : 'Möbelvorschläge ansehen'
}} }}
</button> </button>
@if (canEdit) { @if (canEdit) {
@@ -268,14 +348,14 @@ const statusLabels: Record<string, string> = {
type="button" type="button"
(click)="newOption(requirement)" (click)="newOption(requirement)"
> >
Alternative hinzufügen Möbelvorschlag hinzufügen
</button> </button>
} }
</div> </div>
@if (expandedRequirementId() === requirement.id) { @if (expandedRequirementId() === requirement.id) {
<div class="comparison" aria-label="Alternativenvergleich"> <div class="comparison" aria-label="Vergleich der Möbelvorschläge">
@if (!requirement.options.length) { @if (!requirement.options.length) {
<p>Noch keine Alternative erfasst.</p> <p>Noch kein Möbelvorschlag erfasst.</p>
} }
@for (option of requirement.options; track option.id) { @for (option of requirement.options; track option.id) {
<article class="option" [class.selected]="option.currentlySelected"> <article class="option" [class.selected]="option.currentlySelected">
@@ -392,6 +472,27 @@ const statusLabels: Record<string, string> = {
</button> </button>
</nav> </nav>
} }
</section>
<details class="planning-details ui-card">
<summary>Detailtabelle und Bestellungen öffnen</summary>
<p class="detail-description">
Für umfangreiche Planungen: Bedarfe filtern, Daten direkt bearbeiten, Bestellungen prüfen
und Szenarien tabellarisch vergleichen.
</p>
<div #furnitureGridHost>
<app-furniture-grid
[projectId]="projectId"
[rooms]="rooms"
[canEdit]="canEdit"
[scenarios]="scenarios()"
(editRequirement)="editRequirement($event)"
(addOption)="newOption($event)"
(editOption)="editOptionFromGrid($event)"
(assignScenario)="openScenarioAssignment($event.scenarioId, $event.requirement)"
(dataChanged)="load()"
/>
</div>
</details> </details>
<dialog <dialog
@@ -403,7 +504,7 @@ const statusLabels: Record<string, string> = {
@if (showOptionForm()) { @if (showOptionForm()) {
<form class="ui-card form-grid" [formGroup]="optionForm" (ngSubmit)="saveOption()"> <form class="ui-card form-grid" [formGroup]="optionForm" (ngSubmit)="saveOption()">
<h3 id="option-form-title"> <h3 id="option-form-title">
{{ editingOption() ? 'Alternative bearbeiten' : 'Alternative hinzufügen' }} {{ editingOption() ? 'Möbelvorschlag bearbeiten' : 'Möbelvorschlag hinzufügen' }}
</h3> </h3>
<label>Name *<input formControlName="name" /></label <label>Name *<input formControlName="name" /></label
><label>Hersteller<input formControlName="manufacturer" /></label ><label>Hersteller<input formControlName="manufacturer" /></label
@@ -500,42 +601,62 @@ const statusLabels: Record<string, string> = {
} }
</dialog> </dialog>
<dialog
#optionPickerDialog
class="editor-dialog"
aria-labelledby="option-target-title"
(cancel)="closeOptionPicker()"
>
@if (showOptionPicker()) {
<form
class="ui-card dialog-form"
[formGroup]="optionTargetForm"
(ngSubmit)="chooseOptionRequirement()"
>
<div class="dialog-heading">
<span class="eyebrow">Schritt 2 von 3</span>
<h3 id="option-target-title">Für welchen Bedarf ist das Möbel?</h3>
<p>
So bleibt jede Produktalternative direkt dem richtigen Raum und Bedarf zugeordnet.
</p>
</div>
<label
>Bedarf *<select formControlName="requirementId">
<option value="">Bitte wählen</option>
@for (requirement of requirements(); track requirement.id) {
<option [value]="requirement.id">
{{ requirement.name }} · {{ roomName(requirement.roomId) }}
</option>
}
</select></label
>
<div class="actions">
<button class="ui-button" type="submit">Weiter zum Möbel</button>
<button class="ui-button ui-button--ghost" type="button" (click)="closeOptionPicker()">
Abbrechen
</button>
</div>
</form>
}
</dialog>
<section class="ui-card scenarios"> <section class="ui-card scenarios">
<div class="section-head"> <div class="section-head">
<div> <div>
<h2>Einrichtungsszenarien</h2> <h2>Einrichtungsvarianten</h2>
<p>Budget-, Wunsch- und Premiumvarianten vergleichen.</p> <p>
Ein Szenario ist eine komplette Variante: Für jeden Bedarf wählen Sie darin einen
Möbelvorschlag aus.
</p>
</div> </div>
@if (canEdit) { @if (canEdit) {
<div class="actions"> <div class="actions">
<button <button class="ui-button" type="button" (click)="openScenarioForm()">
class="ui-button ui-button--secondary" Neue Variante erstellen
type="button"
(click)="openScenarioGrid(furnitureGrid)"
>
Zuordnungen bearbeiten
</button>
<button class="ui-button" type="button" (click)="showScenarioForm.set(true)">
Szenario erstellen
</button> </button>
</div> </div>
} }
</div> </div>
@if (showScenarioForm()) {
<form class="inline-form" [formGroup]="scenarioForm" (ngSubmit)="createScenario()">
<label>Name<input formControlName="name" /></label
><label
>Vorauswahl<select formControlName="automaticSelection">
<option value="budget">Günstig</option>
<option value="preferred">Bevorzugt</option>
<option value="premium">Premium</option>
<option value="existing">Bestand</option>
</select></label
><label class="check"
><input type="checkbox" formControlName="isDefault" /> Standardszenario</label
><button class="ui-button" type="submit" [disabled]="saving()">Erstellen</button>
</form>
}
<div class="scenario-grid"> <div class="scenario-grid">
@for (scenario of scenarios(); track scenario.id) { @for (scenario of scenarios(); track scenario.id) {
<article> <article>
@@ -549,6 +670,15 @@ const statusLabels: Record<string, string> = {
<p> <p>
{{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen {{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen
</p> </p>
@if (canEdit) {
<button
class="ui-button ui-button--ghost"
type="button"
(click)="openAssignment(scenario.id)"
>
Möbel zuordnen
</button>
}
</article> </article>
} @empty { } @empty {
<p>Noch kein Szenario vorhanden.</p> <p>Noch kein Szenario vorhanden.</p>
@@ -574,6 +704,165 @@ const statusLabels: Record<string, string> = {
</div> </div>
} }
</section> </section>
<dialog
#scenarioDialog
class="editor-dialog"
aria-labelledby="scenario-form-title"
(cancel)="closeScenarioForm()"
>
@if (showScenarioForm()) {
<form class="ui-card dialog-form" [formGroup]="scenarioForm" (ngSubmit)="createScenario()">
<div class="dialog-heading">
<span class="eyebrow">Einrichtungsvariante</span>
<h3 id="scenario-form-title">Neues Szenario erstellen</h3>
<p>
Die Vorauswahl befüllt das Szenario automatisch. Sie können jede Zuordnung danach
ändern.
</p>
</div>
<label>Name *<input formControlName="name" placeholder="z. B. Wunschlösung" /></label>
<label
>Automatische Vorauswahl<select formControlName="automaticSelection">
<option value="budget">Günstigste Möbelvorschläge</option>
<option value="preferred">Bevorzugte Möbelvorschläge</option>
<option value="premium">Hochwertigste Möbelvorschläge</option>
<option value="existing">Vorhandene Möbel</option></select
><small
>Damit erhalten Sie sofort eine erste, anpassbare Zusammenstellung.</small
></label
>
<label class="check"
><input type="checkbox" formControlName="isDefault" /> Als Standardszenario
verwenden</label
>
<div class="actions">
<button class="ui-button" type="submit" [disabled]="saving()">
{{ saving() ? 'Erstellt …' : 'Szenario erstellen' }}
</button>
<button class="ui-button ui-button--ghost" type="button" (click)="closeScenarioForm()">
Abbrechen
</button>
</div>
</form>
}
</dialog>
<dialog
#assignmentDialog
class="editor-dialog"
aria-labelledby="assignment-form-title"
(cancel)="closeAssignment()"
>
@if (showAssignmentForm()) {
<form
class="ui-card dialog-form"
[formGroup]="assignmentForm"
(ngSubmit)="saveAssignment()"
>
<div class="dialog-heading">
<span class="eyebrow">Schritt 3 von 3</span>
<h3 id="assignment-form-title">Möbel für ein Szenario auswählen</h3>
<p>
Wählen Sie zuerst die Variante und den Bedarf, danach den passenden Möbelvorschlag.
</p>
</div>
@if (!scenarios().length) {
<div class="dialog-notice" role="status">
<strong>Es fehlt noch ein Szenario.</strong
><span>Erstellen Sie zuerst eine Einrichtungsvariante.</span>
</div>
} @else if (!assignableRequirements().length) {
<div class="dialog-notice" role="status">
<strong>Es fehlt noch ein Möbelvorschlag.</strong
><span>Erfassen Sie für mindestens einen Bedarf ein konkretes Möbel.</span>
</div>
} @else {
<label
>Szenario *<select formControlName="scenarioId">
<option value="">Bitte wählen</option>
@for (scenario of scenarios(); track scenario.id) {
<option [value]="scenario.id">{{ scenario.name }}</option>
}
</select></label
>
<label
>Bedarf *<select
formControlName="requirementId"
(change)="assignmentRequirementChanged()"
>
<option value="">Bitte wählen</option>
@for (requirement of assignableRequirements(); track requirement.id) {
<option [value]="requirement.id">
{{ requirement.name }} · {{ roomName(requirement.roomId) }}
</option>
}
</select></label
>
<fieldset class="option-selection">
<legend>Möbelvorschlag auswählen *</legend>
<div class="option-choice-grid">
@for (option of assignmentOptions(); track option.id) {
<label
class="option-choice"
[class.option-choice--selected]="
assignmentForm.controls.optionId.value === option.id
"
>
<input type="radio" formControlName="optionId" [value]="option.id" />
<span class="option-choice__content">
<span class="option-choice__head">
<strong>{{ option.name }}</strong>
<strong class="price">{{
option.totalPrice | currency: option.currency : 'symbol' : '1.2-2' : 'de'
}}</strong>
</span>
<span class="markers">
@if (option.favorite) {
<span>★ Favorit</span>
}
@if (option.existingItem) {
<span>↺ Bereits vorhanden</span>
}
<span>{{ optionAvailability(option.availability) }}</span>
</span>
<dl class="option-facts">
<dt>Hersteller / Modell</dt>
<dd>{{ optionManufacturer(option) }}</dd>
<dt>Händler</dt>
<dd>{{ option.retailer || 'Nicht angegeben' }}</dd>
<dt>Maße B × H × T</dt>
<dd>{{ optionDimensions(option) }}</dd>
<dt>Lieferzeit</dt>
<dd>{{ optionDelivery(option) }}</dd>
</dl>
</span>
</label>
}
</div>
</fieldset>
}
<div class="actions">
@if (!scenarios().length) {
<button class="ui-button" type="button" (click)="continueWithScenario()">
Szenario erstellen
</button>
} @else if (!assignableRequirements().length) {
<button class="ui-button" type="button" (click)="continueWithOption()">
Möbelvorschlag hinzufügen
</button>
} @else {
<button class="ui-button" type="submit" [disabled]="saving()">
{{ saving() ? 'Wird zugeordnet …' : 'Jetzt zuordnen' }}
</button>
}
<button class="ui-button ui-button--ghost" type="button" (click)="closeAssignment()">
Abbrechen
</button>
</div>
</form>
}
</dialog>
`, `,
styles: [ styles: [
` `
@@ -604,6 +893,14 @@ const statusLabels: Record<string, string> = {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: var(--space-4); gap: var(--space-4);
} }
.dialog-heading,
.dialog-notice {
display: grid;
gap: var(--space-1);
}
.dialog-form label small {
color: var(--color-text-muted);
}
.metric, .metric,
.requirement, .requirement,
.scenarios { .scenarios {
@@ -675,6 +972,22 @@ const statusLabels: Record<string, string> = {
border: 0; border: 0;
padding: var(--space-6); padding: var(--space-6);
} }
.dialog-form {
display: grid;
gap: var(--space-5);
border: 0;
padding: var(--space-6);
}
.dialog-heading {
gap: var(--space-2);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.dialog-notice {
padding: var(--space-4);
border-left: 0.25rem solid var(--color-info);
background: var(--color-info-subtle);
}
.check { .check {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -776,7 +1089,9 @@ export class FurniturePlanningComponent implements OnChanges {
@Input() canEdit = false; @Input() canEdit = false;
@ViewChild('requirementDialog') private requirementDialog?: ElementRef<HTMLDialogElement>; @ViewChild('requirementDialog') private requirementDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>; @ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('furnitureGridHost') private furnitureGridHost?: ElementRef<HTMLElement>; @ViewChild('optionPickerDialog') private optionPickerDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>;
@ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>;
private readonly api = inject(HauspilotApiService); private readonly api = inject(HauspilotApiService);
readonly requirements = signal<FurnitureRequirement[]>([]); readonly requirements = signal<FurnitureRequirement[]>([]);
readonly scenarios = signal<FurnitureScenario[]>([]); readonly scenarios = signal<FurnitureScenario[]>([]);
@@ -789,12 +1104,35 @@ export class FurniturePlanningComponent implements OnChanges {
readonly expandedRequirementId = signal<string | null>(null); readonly expandedRequirementId = signal<string | null>(null);
readonly showRequirementForm = signal(false); readonly showRequirementForm = signal(false);
readonly showOptionForm = signal(false); readonly showOptionForm = signal(false);
readonly showOptionPicker = signal(false);
readonly showScenarioForm = signal(false); readonly showScenarioForm = signal(false);
readonly showAssignmentForm = signal(false);
readonly guidedPlanning = signal(false);
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 optionRequirement = signal<FurnitureRequirement | null>(null); readonly optionRequirement = signal<FurnitureRequirement | null>(null);
readonly categories = Object.entries(categoryLabels); readonly categories = Object.entries(categoryLabels);
readonly requirementStatuses = Object.entries(statusLabels); readonly requirementStatuses = Object.entries(statusLabels);
readonly requirementsWithOptions = computed(
() => this.requirements().filter((requirement) => requirement.options.length > 0).length,
);
readonly assignedRequirements = computed(
() =>
new Set(
this.scenarios().flatMap((scenario) =>
scenario.selections.map((selection) => selection.requirementId),
),
).size,
);
readonly nextStep = computed<'need' | 'option' | 'scenario' | 'assign'>(() => {
if (!this.requirements().length) return 'need';
if (this.requirementsWithOptions() < this.requirements().length) return 'option';
if (!this.scenarios().length) return 'scenario';
return 'assign';
});
readonly assignableRequirements = computed(() =>
this.requirements().filter((requirement) => this.selectableOptions(requirement).length > 0),
);
readonly filterForm = new FormGroup({ readonly filterForm = new FormGroup({
search: new FormControl('', { nonNullable: true }), search: new FormControl('', { nonNullable: true }),
roomId: new FormControl('', { nonNullable: true }), roomId: new FormControl('', { nonNullable: true }),
@@ -878,6 +1216,26 @@ export class FurniturePlanningComponent implements OnChanges {
automaticSelection: new FormControl('budget', { nonNullable: true }), automaticSelection: new FormControl('budget', { nonNullable: true }),
isDefault: new FormControl(false, { nonNullable: true }), isDefault: new FormControl(false, { nonNullable: true }),
}); });
readonly optionTargetForm = new FormGroup({
requirementId: new FormControl('', {
nonNullable: true,
validators: [(control) => Validators.required(control)],
}),
});
readonly assignmentForm = new FormGroup({
scenarioId: new FormControl('', {
nonNullable: true,
validators: [(control) => Validators.required(control)],
}),
requirementId: new FormControl('', {
nonNullable: true,
validators: [(control) => Validators.required(control)],
}),
optionId: new FormControl('', {
nonNullable: true,
validators: [(control) => Validators.required(control)],
}),
});
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 * v.quantity;
@@ -927,6 +1285,10 @@ export class FurniturePlanningComponent implements OnChanges {
status(value: string) { status(value: string) {
return statusLabels[value] ?? value; return statusLabels[value] ?? value;
} }
startGuidedPlanning() {
this.guidedPlanning.set(true);
this.newRequirement();
}
newRequirement() { newRequirement() {
this.editingRequirement.set(null); this.editingRequirement.set(null);
this.requirementForm.reset({ this.requirementForm.reset({
@@ -959,10 +1321,11 @@ export class FurniturePlanningComponent implements OnChanges {
this.showRequirementForm.set(true); this.showRequirementForm.set(true);
this.openDialog(this.requirementDialog); this.openDialog(this.requirementDialog);
} }
closeRequirementForm() { closeRequirementForm(keepFlow = false) {
this.requirementDialog?.nativeElement.close(); this.requirementDialog?.nativeElement.close();
this.showRequirementForm.set(false); this.showRequirementForm.set(false);
this.editingRequirement.set(null); this.editingRequirement.set(null);
if (!keepFlow) this.guidedPlanning.set(false);
} }
saveRequirement() { saveRequirement() {
if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched(); if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched();
@@ -976,9 +1339,10 @@ export class FurniturePlanningComponent implements OnChanges {
) )
: this.api.createFurnitureRequirement(this.projectId, this.requirementForm.getRawValue()); : this.api.createFurnitureRequirement(this.projectId, this.requirementForm.getRawValue());
request.subscribe({ request.subscribe({
next: () => { next: (saved) => {
this.saving.set(false); this.saving.set(false);
this.closeRequirementForm(); this.closeRequirementForm(true);
if (!existing && this.guidedPlanning()) this.newOption(saved);
this.load(); this.load();
}, },
error: (e: unknown) => this.fail(e), error: (e: unknown) => this.fail(e),
@@ -987,6 +1351,32 @@ export class FurniturePlanningComponent implements OnChanges {
toggleOptions(r: FurnitureRequirement) { toggleOptions(r: FurnitureRequirement) {
this.expandedRequirementId.set(this.expandedRequirementId() === r.id ? null : r.id); this.expandedRequirementId.set(this.expandedRequirementId() === r.id ? null : r.id);
} }
openOptionPicker() {
if (!this.requirements().length) {
this.startGuidedPlanning();
return;
}
this.guidedPlanning.set(true);
this.optionTargetForm.reset({
requirementId: this.requirements().length === 1 ? (this.requirements()[0]?.id ?? '') : '',
});
this.showOptionPicker.set(true);
this.openDialog(this.optionPickerDialog);
}
closeOptionPicker(keepFlow = false) {
this.optionPickerDialog?.nativeElement.close();
this.showOptionPicker.set(false);
if (!keepFlow) this.guidedPlanning.set(false);
}
chooseOptionRequirement() {
if (this.optionTargetForm.invalid) return this.optionTargetForm.markAllAsTouched();
const requirement = this.requirements().find(
(entry) => entry.id === this.optionTargetForm.controls.requirementId.value,
);
if (!requirement) return;
this.closeOptionPicker(true);
this.newOption(requirement);
}
newOption(r: FurnitureRequirement) { newOption(r: FurnitureRequirement) {
this.optionRequirement.set(r); this.optionRequirement.set(r);
this.editingOption.set(null); this.editingOption.set(null);
@@ -1050,11 +1440,12 @@ export class FurniturePlanningComponent implements OnChanges {
this.showOptionForm.set(true); this.showOptionForm.set(true);
this.openDialog(this.optionDialog); this.openDialog(this.optionDialog);
} }
closeOptionForm() { closeOptionForm(keepFlow = false) {
this.optionDialog?.nativeElement.close(); this.optionDialog?.nativeElement.close();
this.showOptionForm.set(false); this.showOptionForm.set(false);
this.editingOption.set(null); this.editingOption.set(null);
this.optionRequirement.set(null); this.optionRequirement.set(null);
if (!keepFlow) this.guidedPlanning.set(false);
} }
editOptionFromGrid(option: FurnitureOption) { editOptionFromGrid(option: FurnitureOption) {
this.api.furnitureRequirement(this.projectId, option.requirementId).subscribe({ this.api.furnitureRequirement(this.projectId, option.requirementId).subscribe({
@@ -1072,9 +1463,18 @@ export class FurniturePlanningComponent implements OnChanges {
? this.api.updateFurnitureOption(this.projectId, existing, body) ? this.api.updateFurnitureOption(this.projectId, existing, body)
: this.api.createFurnitureOption(this.projectId, requirement.id, body); : this.api.createFurnitureOption(this.projectId, requirement.id, body);
request.subscribe({ request.subscribe({
next: () => { next: (saved) => {
this.saving.set(false); this.saving.set(false);
this.closeOptionForm(); this.closeOptionForm(true);
if (!existing && this.guidedPlanning()) {
this.requirements.update((current) => {
const updated = { ...requirement, options: [...requirement.options, saved] };
return current.some((entry) => entry.id === requirement.id)
? current.map((entry) => (entry.id === requirement.id ? updated : entry))
: [...current, updated];
});
this.openAssignment(undefined, requirement.id, saved.id);
}
this.load(); this.load();
}, },
error: (e: unknown) => this.fail(e), error: (e: unknown) => this.fail(e),
@@ -1103,14 +1503,18 @@ export class FurniturePlanningComponent implements OnChanges {
.deliverFurnitureOption(this.projectId, o, o.quantity) .deliverFurnitureOption(this.projectId, o, o.quantity)
.subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) }); .subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) });
} }
openScenarioGrid(grid: FurnitureGridComponent) { openScenarioForm() {
grid.setView('scenarios'); this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false });
queueMicrotask(() => this.showScenarioForm.set(true);
this.furnitureGridHost?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }), this.openDialog(this.scenarioDialog);
); }
closeScenarioForm(keepFlow = false) {
this.scenarioDialog?.nativeElement.close();
this.showScenarioForm.set(false);
if (!keepFlow) this.guidedPlanning.set(false);
} }
createScenario() { createScenario() {
if (this.scenarioForm.invalid) return; if (this.scenarioForm.invalid) return this.scenarioForm.markAllAsTouched();
this.saving.set(true); this.saving.set(true);
const value = this.scenarioForm.getRawValue(); const value = this.scenarioForm.getRawValue();
this.api this.api
@@ -1122,15 +1526,128 @@ export class FurniturePlanningComponent implements OnChanges {
isDefault: value.isDefault, isDefault: value.isDefault,
}) })
.subscribe({ .subscribe({
next: () => { next: (saved) => {
this.saving.set(false); this.saving.set(false);
this.showScenarioForm.set(false); this.closeScenarioForm(true);
this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false }); this.scenarios.update((current) => [...current, saved]);
if (this.guidedPlanning()) this.openAssignment(saved.id);
this.load(); this.load();
}, },
error: (e: unknown) => this.fail(e), error: (e: unknown) => this.fail(e),
}); });
} }
openAssignment(scenarioId = '', requirementId = '', optionId = '') {
const scenario =
this.scenarios().find((entry) => entry.id === scenarioId) ??
this.scenarios().find((entry) => entry.isDefault) ??
this.scenarios()[0];
const requirement =
this.assignableRequirements().find((entry) => entry.id === requirementId) ??
this.assignableRequirements()[0];
const selection = scenario?.selections.find((entry) => entry.requirementId === requirement?.id);
this.assignmentForm.reset({
scenarioId: scenario?.id ?? '',
requirementId: requirement?.id ?? '',
optionId: optionId || selection?.optionId || '',
});
this.showAssignmentForm.set(true);
this.openDialog(this.assignmentDialog);
}
openScenarioAssignment(scenarioId: string, requirement: FurnitureRequirement) {
this.requirements.update((current) =>
current.some((entry) => entry.id === requirement.id)
? current.map((entry) => (entry.id === requirement.id ? requirement : entry))
: [...current, requirement],
);
this.openAssignment(scenarioId, requirement.id);
}
closeAssignment(keepFlow = false) {
this.assignmentDialog?.nativeElement.close();
this.showAssignmentForm.set(false);
if (!keepFlow) this.guidedPlanning.set(false);
}
assignmentOptions() {
const requirement = this.requirements().find(
(entry) => entry.id === this.assignmentForm.controls.requirementId.value,
);
return requirement ? this.selectableOptions(requirement) : [];
}
optionManufacturer(option: FurnitureOption) {
return [option.manufacturer, option.model].filter(Boolean).join(' · ') || 'Nicht angegeben';
}
optionDimensions(option: FurnitureOption) {
if (option.width === null && option.height === null && option.depth === null)
return 'Nicht angegeben';
return `${option.width ?? ''} × ${option.height ?? ''} × ${option.depth ?? ''} cm`;
}
optionDelivery(option: FurnitureOption) {
return option.deliveryDays === null ? 'Nicht angegeben' : `${option.deliveryDays} Tage`;
}
optionAvailability(value: string) {
const labels: Record<string, string> = {
unknown: 'Verfügbarkeit unbekannt',
available: 'Verfügbar',
limited: 'Begrenzt verfügbar',
unavailable: 'Nicht verfügbar',
discontinued: 'Nicht mehr erhältlich',
};
return labels[value] ?? value;
}
assignmentRequirementChanged() {
const scenario = this.scenarios().find(
(entry) => entry.id === this.assignmentForm.controls.scenarioId.value,
);
const selection = scenario?.selections.find(
(entry) => entry.requirementId === this.assignmentForm.controls.requirementId.value,
);
this.assignmentForm.controls.optionId.setValue(selection?.optionId ?? '');
}
continueWithScenario() {
this.guidedPlanning.set(true);
this.closeAssignment(true);
this.openScenarioForm();
}
continueWithOption() {
this.guidedPlanning.set(true);
this.closeAssignment(true);
this.openOptionPicker();
}
saveAssignment() {
if (this.assignmentForm.invalid) return this.assignmentForm.markAllAsTouched();
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);
const option = requirement?.options.find((entry) => entry.id === value.optionId);
if (!scenario || !requirement || !option) return;
const selections = scenario.selections
.filter((selection) => selection.requirementId !== requirement.id)
.map((selection) => ({ ...selection }));
selections.push({
requirementId: requirement.id,
optionId: option.id,
quantity: option.quantity,
});
this.saving.set(true);
this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({
next: (saved) => {
this.saving.set(false);
this.scenarios.update((current) =>
current.map((entry) => (entry.id === saved.id ? saved : entry)),
);
this.guidedPlanning.set(false);
this.closeAssignment();
this.load();
},
error: (error: unknown) => this.fail(error),
});
}
private selectableOptions(requirement: FurnitureRequirement) {
return requirement.options.filter(
(option) =>
!['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) &&
!['unavailable', 'discontinued'].includes(option.availability),
);
}
private fail(error: unknown) { private fail(error: unknown) {
this.saving.set(false); this.saving.set(false);
this.loading.set(false); this.loading.set(false);

View File

@@ -2,7 +2,7 @@ import { CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import type { OnInit } from '@angular/core'; import type { OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import type { Observable } from 'rxjs'; import type { Observable } from 'rxjs';
import type { ApiErrorBody, ProjectDto, ProjectMemberDto } from '@boilerplate/api-client'; import type { ApiErrorBody, ProjectDto, ProjectMemberDto } from '@boilerplate/api-client';
import { ApiClientService } from '@boilerplate/api-client'; import { ApiClientService } from '@boilerplate/api-client';
@@ -30,17 +30,6 @@ import type {
Room, Room,
} from './hauspilot-api.service'; } from './hauspilot-api.service';
const sections = [
['uebersicht', 'Übersicht'],
['raeume', 'Räume'],
['aufgaben', 'Aufgaben'],
['zeitplan', 'Zeitplan'],
['budget', 'Budget'],
['moebel', 'Möbel & Einrichtung'],
['dokumente', 'Dokumente'],
['aktivitaeten', 'Aktivitäten'],
] as const;
@Component({ @Component({
standalone: true, standalone: true,
imports: [ imports: [
@@ -48,7 +37,6 @@ const sections = [
DatePipe, DatePipe,
DecimalPipe, DecimalPipe,
ReactiveFormsModule, ReactiveFormsModule,
RouterLink,
UiEmptyStateComponent, UiEmptyStateComponent,
UiPageHeaderComponent, UiPageHeaderComponent,
UiStatusBadgeComponent, UiStatusBadgeComponent,
@@ -57,22 +45,11 @@ const sections = [
FurniturePlanningComponent, FurniturePlanningComponent,
], ],
template: ` template: `
<a routerLink="/projekte">Zurück zu den Projekten</a>
@if (project(); as project) { @if (project(); as project) {
<ui-page-header <ui-page-header
[title]="project.name" [title]="project.name"
[description]="project.description || 'Renovierung und Umzug gemeinsam planen'" [description]="project.description || 'Renovierung und Umzug gemeinsam planen'"
/> />
<nav class="project-nav" aria-label="Projektbereiche">
@for (item of navigation; track item[0]) {
<a
[routerLink]="['/projekte', projectId, item[0]]"
[class.active]="section() === item[0]"
>{{ item[1] }}</a
>
}
<a [routerLink]="['/projekte', projectId]">Mitglieder</a>
</nav>
@if (loading()) { @if (loading()) {
<p role="status">Projektdaten werden geladen …</p> <p role="status">Projektdaten werden geladen …</p>
} }
@@ -680,25 +657,6 @@ const sections = [
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
} }
.project-nav {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-block: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.project-nav a {
white-space: nowrap;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
text-decoration: none;
}
.project-nav a.active {
background: var(--color-primary-subtle);
color: var(--color-primary);
font-weight: 600;
}
.metric-grid, .metric-grid,
.card-grid { .card-grid {
display: grid; display: grid;
@@ -775,7 +733,6 @@ export class ProjectWorkspacePageComponent implements OnInit {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
projectId = ''; projectId = '';
readonly navigation = sections;
readonly section = signal('uebersicht'); readonly section = signal('uebersicht');
readonly project = signal<ProjectDto | null>(null); readonly project = signal<ProjectDto | null>(null);
readonly dashboard = signal<ProjectDashboard | null>(null); readonly dashboard = signal<ProjectDashboard | null>(null);

View File

@@ -1,10 +1,15 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { Router, provideRouter } from '@angular/router';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { ApiClientService } from '@boilerplate/api-client'; import { ApiClientService } from '@boilerplate/api-client';
import { AppShellComponent } from './app-shell'; import { AppShellComponent } from './app-shell';
@Component({ standalone: true, template: '' })
class EmptyPageComponent {}
describe('AppShellComponent', () => { describe('AppShellComponent', () => {
beforeEach(() => sessionStorage.clear());
it('hides navigation entries without matching permissions', async () => { it('hides navigation entries without matching permissions', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AppShellComponent], imports: [AppShellComponent],
@@ -99,4 +104,59 @@ describe('AppShellComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.sidebar.open')).toBeNull(); expect((fixture.nativeElement as HTMLElement).querySelector('.sidebar.open')).toBeNull();
}); });
it('shows an opened project as a tab and its contents in the sidebar', async () => {
await TestBed.configureTestingModule({
imports: [AppShellComponent],
providers: [
provideRouter([{ path: 'projekte/:id/:section', component: EmptyPageComponent }]),
{
provide: ApiClientService,
useValue: {
me: () =>
of({
id: 'u1',
name: 'Ada',
email: null,
active: true,
lastLoginAt: null,
settings: { tablePageSize: 20, sidebarExpanded: true },
roles: [
{
id: 'r1',
name: 'user',
protected: true,
permissions: [{ id: 'projects.use', description: 'projects.use' }],
},
],
}),
project: () =>
of({
id: 'project-1',
name: 'Wohnung Budapest',
description: null,
role: 'owner',
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
}),
unreadNotificationCount: () => of({ count: 0 }),
notifications: () => of({ items: [], total: 0, page: 1, pageSize: 20, unreadCount: 0 }),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/projekte/project-1/moebel');
await fixture.whenStable();
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.project-tab')?.textContent).toContain('Wohnung Budapest');
expect(host.querySelector('.sidebar-context')?.textContent).toContain('Wohnung Budapest');
expect(host.querySelector('.project-navigation')?.textContent).toContain('Möbel & Einrichtung');
expect(host.querySelector('.project-navigation a.active')?.textContent).toContain(
'Möbel & Einrichtung',
);
});
}); });

View File

@@ -1,6 +1,8 @@
import { Component, HostListener, computed, effect, inject, signal } from '@angular/core'; import { Component, HostListener, computed, effect, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { Permission } from '@boilerplate/api-client'; import { NavigationEnd, RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
import { ApiClientService, type Permission } from '@boilerplate/api-client';
import { filter } from 'rxjs';
import { NotificationStore } from '../core/notification.store'; import { NotificationStore } from '../core/notification.store';
import { AuthService } from '../core/auth.service'; import { AuthService } from '../core/auth.service';
import { NotificationPanelComponent } from './notification-panel'; import { NotificationPanelComponent } from './notification-panel';
@@ -12,6 +14,23 @@ interface NavItem {
permission?: Permission; permission?: Permission;
} }
interface ProjectTab {
id: string;
name: string;
path: string;
}
const projectSections = [
{ id: 'uebersicht', label: 'Übersicht' },
{ id: 'raeume', label: 'Räume' },
{ id: 'aufgaben', label: 'Aufgaben' },
{ id: 'zeitplan', label: 'Zeitplan' },
{ id: 'budget', label: 'Budget' },
{ id: 'moebel', label: 'Möbel & Einrichtung' },
{ id: 'dokumente', label: 'Dokumente' },
{ id: 'aktivitaeten', label: 'Aktivitäten' },
] as const;
@Component({ @Component({
selector: 'app-shell', selector: 'app-shell',
standalone: true, standalone: true,
@@ -30,7 +49,28 @@ interface NavItem {
@if (auth.user()) { @if (auth.user()) {
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" /> <ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
} }
<strong class="brand">HausPilot</strong> <a class="brand" routerLink="/">HausPilot</a>
@if (projectTabs().length) {
<nav class="project-tabs" aria-label="Geöffnete Projekte">
@for (tab of projectTabs(); track tab.id) {
<div class="project-tab" [class.active]="currentProjectId() === tab.id">
<a
[routerLink]="tab.path"
[attr.aria-current]="currentProjectId() === tab.id ? 'page' : null"
>
<span>{{ tab.name }}</span>
</a>
<button
type="button"
[attr.aria-label]="tab.name + ' schließen'"
(click)="closeProjectTab($event, tab.id)"
>
×
</button>
</div>
}
</nav>
}
@if (auth.user()) { @if (auth.user()) {
<button <button
class="ui-icon-button notification-button" class="ui-icon-button notification-button"
@@ -68,24 +108,78 @@ interface NavItem {
></button> ></button>
} }
<aside class="sidebar" [class.open]="drawerOpen()"> <aside class="sidebar" [class.open]="drawerOpen()">
<nav aria-label="Hauptnavigation"> @if (currentProjectId(); as projectId) {
@for (item of nav; track item.path) { <div class="sidebar-context">
@if (visible(item)) { <span class="sidebar-eyebrow">Aktuelles Projekt</span>
<strong>{{ currentProjectName() }}</strong>
</div>
<nav class="project-navigation" aria-label="Projektinhalte">
@for (item of projectNavigation; track item.id) {
<a <a
[routerLink]="item.path" [routerLink]="['/projekte', projectId, item.id]"
routerLinkActive="active" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
(click)="closeDrawerOnNavigation()" (click)="closeDrawerOnNavigation()"
>{{ item.label }}</a
> >
{{ item.label }}
</a>
} }
} <a
</nav> [routerLink]="['/projekte', projectId]"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
(click)="closeDrawerOnNavigation()"
>Mitglieder & Zugriff</a
>
</nav>
<nav class="sidebar-secondary" aria-label="Allgemeine Navigation">
<a routerLink="/projekte" (click)="closeDrawerOnNavigation()">← Alle Projekte</a>
<a routerLink="/notifications" (click)="closeDrawerOnNavigation()"
>Benachrichtigungen</a
>
</nav>
} @else {
<nav aria-label="Hauptnavigation">
<span class="nav-heading">Arbeitsbereich</span>
@for (item of workspaceNav; track item.path) {
@if (visible(item)) {
<a
[routerLink]="item.path"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
(click)="closeDrawerOnNavigation()"
>{{ item.label }}</a
>
}
}
<span class="nav-heading">Mein Konto</span>
@for (item of accountNav; track item.path) {
@if (visible(item)) {
<a
[routerLink]="item.path"
routerLinkActive="active"
(click)="closeDrawerOnNavigation()"
>{{ item.label }}</a
>
}
}
@if (hasVisibleAdminNavigation()) {
<span class="nav-heading">Administration</span>
@for (item of adminNav; track item.path) {
@if (visible(item)) {
<a
[routerLink]="item.path"
routerLinkActive="active"
(click)="closeDrawerOnNavigation()"
>{{ item.label }}</a
>
}
}
}
</nav>
}
</aside> </aside>
<main class="content"> <main class="content">
<nav class="breadcrumbs">Start / {{ title() }}</nav> <nav class="breadcrumbs">{{ breadcrumb() }}</nav>
<router-outlet /> <router-outlet />
</main> </main>
} @else { } @else {
@@ -131,6 +225,53 @@ interface NavItem {
} }
.brand { .brand {
white-space: nowrap; white-space: nowrap;
color: var(--color-text-primary);
font-weight: var(--font-weight-bold);
text-decoration: none;
}
.project-tabs {
display: flex;
flex: 1 1 auto;
align-self: stretch;
gap: var(--space-2);
min-width: 0;
overflow-x: auto;
padding-top: var(--space-3);
}
.project-tab {
display: flex;
align-items: center;
min-width: 9rem;
max-width: 15rem;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--color-background);
}
.project-tab.active {
background: var(--color-surface);
border-color: var(--color-border-strong);
box-shadow: inset 0 0.2rem var(--color-primary);
}
.project-tab a {
min-width: 0;
flex: 1;
overflow: hidden;
padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4);
color: var(--color-text-primary);
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-tab button {
width: 2rem;
height: 2rem;
flex: 0 0 auto;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
} }
.logout { .logout {
margin-left: auto; margin-left: auto;
@@ -190,20 +331,56 @@ interface NavItem {
transform: translateX(-100%); transform: translateX(-100%);
transition: transform var(--transition-base) ease; transition: transform var(--transition-base) ease;
z-index: var(--z-drawer); z-index: var(--z-drawer);
overflow-y: auto;
} }
.sidebar.open { .sidebar.open {
transform: translateX(0); transform: translateX(0);
} }
nav a { .sidebar nav a {
display: block; display: block;
padding: var(--space-4) var(--space-5); padding: var(--space-4) var(--space-5);
color: var(--color-text-primary); color: var(--color-text-primary);
text-decoration: none; text-decoration: none;
min-height: var(--touch-target); min-height: var(--touch-target);
} }
nav a.active { .sidebar nav a.active {
background: var(--color-primary-subtle); background: var(--color-primary-subtle);
border-left: 4px solid var(--color-primary); border-left: 4px solid var(--color-primary);
font-weight: var(--font-weight-semibold);
}
.sidebar-context {
display: grid;
gap: var(--space-2);
padding: var(--space-5);
border-bottom: 1px solid var(--color-border);
background: var(--color-primary-subtle);
}
.sidebar-context strong {
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-eyebrow,
.nav-heading {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.nav-heading {
display: block;
padding: var(--space-5) var(--space-5) var(--space-2);
}
.project-navigation {
padding-block: var(--space-2);
}
.sidebar-secondary {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.sidebar-secondary a {
color: var(--color-text-secondary);
} }
.content { .content {
min-width: 0; min-width: 0;
@@ -229,22 +406,55 @@ interface NavItem {
padding: var(--space-7); padding: var(--space-7);
} }
} }
@media (max-width: 40rem) {
.topbar {
gap: var(--space-2);
padding-inline: var(--space-3);
}
.brand {
display: none;
}
.project-tab {
min-width: 8rem;
}
.logout {
display: none;
}
}
`, `,
], ],
}) })
export class AppShellComponent { export class AppShellComponent {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly projectsApi = inject(ApiClientService);
private readonly loadingProjectIds = new Set<string>();
private readonly projectTabsStorageKey = 'hauspilot:open-projects';
readonly auth = inject(AuthService); readonly auth = inject(AuthService);
readonly notifications = inject(NotificationStore); readonly notifications = inject(NotificationStore);
readonly drawerOpen = signal(false); readonly drawerOpen = signal(false);
readonly notificationPanelOpen = signal(false); readonly notificationPanelOpen = signal(false);
readonly title = computed( readonly projectTabs = signal<ProjectTab[]>(this.restoreProjectTabs());
() => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard', readonly currentProjectId = signal<string | null>(null);
readonly currentProjectSection = signal<string | null>(null);
private readonly pageTitle = signal('Dashboard');
readonly projectNavigation = projectSections;
readonly currentProjectName = computed(
() => this.projectTabs().find((tab) => tab.id === this.currentProjectId())?.name ?? 'Projekt',
); );
readonly nav: NavItem[] = [ readonly title = computed(() => this.pageTitle());
readonly breadcrumb = computed(() => {
const projectId = this.currentProjectId();
if (!projectId) return `Start / ${this.title()}`;
const section = projectSections.find((item) => item.id === this.currentProjectSection());
return `${this.currentProjectName()} / ${section?.label ?? 'Mitglieder & Zugriff'}`;
});
readonly workspaceNav: NavItem[] = [
{ label: 'Dashboard', path: '/' }, { label: 'Dashboard', path: '/' },
{ label: 'Projekte', path: '/projekte', permission: 'projects.use' }, { label: 'Projekte', path: '/projekte', permission: 'projects.use' },
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' }, { label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
{ label: 'Items', path: '/items', permission: 'items.read' },
];
readonly accountNav: NavItem[] = [
{ label: 'Profil', path: '/profil' }, { label: 'Profil', path: '/profil' },
{ label: 'Sicherheit', path: '/account/security' }, { label: 'Sicherheit', path: '/account/security' },
{ {
@@ -253,14 +463,23 @@ export class AppShellComponent {
permission: 'notifications.readOwn', permission: 'notifications.readOwn',
}, },
{ label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' }, { label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' },
{ label: 'Items', path: '/items', permission: 'items.read' }, ];
readonly adminNav: NavItem[] = [
{ label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' }, { label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' },
{ label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' }, { label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' },
{ label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' }, { label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' },
]; ];
readonly nav: NavItem[] = [...this.workspaceNav, ...this.accountNav, ...this.adminNav];
constructor() { constructor() {
this.auth.loadMe(); this.auth.loadMe();
this.syncProjectContext(this.router.url);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe((event) => this.syncProjectContext(event.urlAfterRedirects));
effect(() => { effect(() => {
if (this.auth.user()) { if (this.auth.user()) {
this.notifications.startPolling(); this.notifications.startPolling();
@@ -275,6 +494,20 @@ export class AppShellComponent {
return !item.permission || this.auth.has(item.permission); return !item.permission || this.auth.has(item.permission);
} }
hasVisibleAdminNavigation(): boolean {
return this.adminNav.some((item) => this.visible(item));
}
closeProjectTab(event: MouseEvent, projectId: string): void {
event.preventDefault();
event.stopPropagation();
const wasCurrent = this.currentProjectId() === projectId;
const remaining = this.projectTabs().filter((tab) => tab.id !== projectId);
this.projectTabs.set(remaining);
this.persistProjectTabs();
if (wasCurrent) void this.router.navigateByUrl(remaining.at(-1)?.path ?? '/projekte');
}
loginHref(): string { loginHref(): string {
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/'; const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`; return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
@@ -295,6 +528,80 @@ export class AppShellComponent {
this.drawerOpen.set(false); this.drawerOpen.set(false);
} }
private syncProjectContext(url: string): void {
this.pageTitle.set(
this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard',
);
const match = /^\/projekte\/([^/?]+)(?:\/([^/?]+))?/.exec(url);
if (!match?.[1]) {
this.currentProjectId.set(null);
this.currentProjectSection.set(null);
return;
}
const projectId = decodeURIComponent(match[1]);
const section = match[2] ? decodeURIComponent(match[2]) : null;
const path = section
? `/projekte/${encodeURIComponent(projectId)}/${encodeURIComponent(section)}`
: `/projekte/${encodeURIComponent(projectId)}`;
this.currentProjectId.set(projectId);
this.currentProjectSection.set(section);
const existing = this.projectTabs().find((tab) => tab.id === projectId);
if (existing) {
this.upsertProjectTab({ ...existing, path });
return;
}
this.upsertProjectTab({ id: projectId, name: 'Projekt wird geladen …', path });
if (this.loadingProjectIds.has(projectId)) return;
this.loadingProjectIds.add(projectId);
this.projectsApi.project(projectId).subscribe({
next: (project) => {
const current = this.projectTabs().find((tab) => tab.id === projectId);
if (current) this.upsertProjectTab({ ...current, name: project.name });
},
error: () => {
const current = this.projectTabs().find((tab) => tab.id === projectId);
if (current) this.upsertProjectTab({ ...current, name: 'Unbekanntes Projekt' });
this.loadingProjectIds.delete(projectId);
},
complete: () => this.loadingProjectIds.delete(projectId),
});
}
private upsertProjectTab(tab: ProjectTab): void {
const tabs = this.projectTabs();
const next = tabs.some((entry) => entry.id === tab.id)
? tabs.map((entry) => (entry.id === tab.id ? tab : entry))
: [...tabs, tab].slice(-8);
this.projectTabs.set(next);
this.persistProjectTabs();
}
private restoreProjectTabs(): ProjectTab[] {
if (typeof sessionStorage === 'undefined') return [];
try {
const value: unknown = JSON.parse(sessionStorage.getItem(this.projectTabsStorageKey) ?? '[]');
if (!Array.isArray(value)) return [];
return value.filter((entry: unknown): entry is ProjectTab => this.isProjectTab(entry));
} catch {
return [];
}
}
private persistProjectTabs(): void {
if (typeof sessionStorage !== 'undefined')
sessionStorage.setItem(this.projectTabsStorageKey, JSON.stringify(this.projectTabs()));
}
private isProjectTab(value: unknown): value is ProjectTab {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate['id'] === 'string' &&
typeof candidate['name'] === 'string' &&
typeof candidate['path'] === 'string'
);
}
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
closeOverlays(): void { closeOverlays(): void {
this.drawerOpen.set(false); this.drawerOpen.set(false);

View File

@@ -108,6 +108,267 @@
border-color: var(--color-danger); border-color: var(--color-danger);
} }
.planning-guide {
display: grid;
gap: var(--space-6);
padding: var(--space-6);
background: linear-gradient(135deg, var(--color-primary-subtle), var(--color-surface) 55%);
border-color: var(--color-border-strong);
}
.guide-intro {
display: grid;
gap: var(--space-3);
}
.concept-flow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
gap: var(--space-3);
align-items: center;
margin-top: var(--space-2);
}
.concept-flow article {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: center;
min-height: 5rem;
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.concept-flow article > span {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
color: var(--color-primary);
font-weight: var(--font-weight-bold);
background: var(--color-primary-subtle);
border-radius: var(--radius-pill);
}
.concept-flow article div,
.next-action > div {
display: grid;
gap: var(--space-1);
}
.concept-flow small,
.next-action small,
.detail-description {
color: var(--color-text-muted);
}
.flow-arrow {
color: var(--color-text-muted);
font-size: var(--font-size-xl);
}
.eyebrow {
color: var(--color-primary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.guide-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.guide-steps li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: center;
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.guide-steps li.current {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
.guide-steps li.complete .step-number {
color: var(--color-success);
background: var(--color-success-subtle);
}
.step-number {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
color: var(--color-primary);
font-weight: var(--font-weight-bold);
background: var(--color-primary-subtle);
border-radius: var(--radius-pill);
}
.guide-steps div {
display: grid;
gap: var(--space-1);
}
.guide-steps small {
color: var(--color-text-muted);
}
.next-action {
display: flex;
gap: var(--space-5);
align-items: center;
justify-content: space-between;
padding: var(--space-5);
background: var(--color-surface);
border: 1px solid var(--color-primary);
border-radius: var(--radius-lg);
}
.planning-details {
display: block;
padding: var(--space-4) var(--space-5);
}
.planning-details summary {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.planning-details[open] summary {
margin-bottom: var(--space-4);
}
.planning-details .metric-grid {
margin-top: var(--space-4);
}
.detail-description {
margin-bottom: var(--space-4);
}
.option-selection {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.option-selection legend {
margin-bottom: var(--space-3);
color: var(--color-text-secondary);
}
.option-choice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--space-3);
}
.option-choice {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition:
border-color var(--transition-fast) ease,
background var(--transition-fast) ease;
}
.option-choice:hover {
border-color: var(--color-border-strong);
}
.option-choice--selected {
background: var(--color-primary-subtle);
border-color: var(--color-primary);
}
.option-choice input {
width: 1.25rem;
min-height: auto;
margin-top: var(--space-1);
accent-color: var(--color-primary);
}
.option-choice__content,
.option-choice__head {
display: grid;
gap: var(--space-3);
}
.option-choice__head {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.option-facts {
display: grid;
grid-template-columns: minmax(7rem, auto) minmax(0, 1fr);
gap: var(--space-2) var(--space-3);
margin: 0;
font-size: var(--font-size-sm);
}
.option-facts dt {
color: var(--color-text-muted);
}
.option-facts dd {
margin: 0;
text-align: right;
}
@media (max-width: 40rem) {
.planning-guide {
grid-template-columns: 1fr;
padding: var(--space-4);
}
.guide-steps li {
grid-template-columns: auto minmax(0, 1fr);
}
.concept-flow,
.guide-steps {
grid-template-columns: 1fr;
}
.flow-arrow {
justify-self: center;
transform: rotate(90deg);
}
.next-action {
align-items: stretch;
flex-direction: column;
}
.next-action .ui-button {
width: 100%;
}
}
@media (min-width: 48rem) { @media (min-width: 48rem) {
.ui-page-header { .ui-page-header {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;