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 { AuthService } from '../../core/auth.service';
import { FurnitureGridComponent } from './furniture-grid.component';
import type {
FurnitureOption,
FurnitureRequirement,
FurnitureScenario,
PageResult,
} from './hauspilot-api.service';
import type { FurnitureRequirement, PageResult } from './hauspilot-api.service';
import { HauspilotApiService } from './hauspilot-api.service';
const requirement: FurnitureRequirement = {
@@ -199,78 +194,7 @@ describe('FurnitureGridComponent', () => {
expect(calls[0]?.['sortBy']).toBe('sortOrder');
});
it('assigns an option to a scenario through the existing selection endpoint', 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 },
],
}),
);
it('opens the assignment dialog when a scenario cell is clicked', async () => {
await TestBed.configureTestingModule({
imports: [FurnitureGridComponent],
providers: [
@@ -280,7 +204,6 @@ describe('FurnitureGridComponent', () => {
useValue: {
furnitureRequirements: () => of(page),
furnitureProjectOptions: () => of({ ...page, items: [] }),
updateFurnitureScenarioSelections: updateSelections,
},
},
{ provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } },
@@ -289,16 +212,12 @@ describe('FurnitureGridComponent', () => {
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.projectId = 'project-1';
component.canEdit = true;
component.scenarios = [scenario];
component.cellChanged({
oldValue: '',
newValue: option.id,
data: { ...requirement, options: [option] },
column: { getColId: () => `scenario:${scenario.id}` },
node: { setDataValue: vi.fn() },
const assignment = vi.fn();
component.assignScenario.subscribe(assignment);
component.cellClicked({
data: requirement,
column: { getColId: () => 'scenario:scenario-1' },
} as never);
expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [
{ requirementId: requirement.id, optionId: option.id, quantity: 1 },
]);
expect(assignment).toHaveBeenCalledWith({ requirement, scenarioId: 'scenario-1' });
});
});

View File

@@ -61,7 +61,7 @@ const requirementPriorities: Record<string, GridValuePresentation> = {
const requirementStatuses: Record<string, GridValuePresentation> = {
identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' },
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' },
selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' },
ordered: { icon: '▣', label: 'Bestellt', tone: 'info' },
@@ -172,8 +172,8 @@ const deliveryStatuses: Record<string, GridValuePresentation> = {
}
@if (view() === 'scenarios') {
<p class="scenario-help">
Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus.
„Nicht zugewiesen“ lässt den Bedarf im Szenario offen.
Klicken Sie auf eine Szenariozelle, um passende Möbel in Ruhe zu vergleichen und Ihre
Auswahl anschließend zu übernehmen.
</p>
}
<ag-grid-angular
@@ -330,6 +330,10 @@ export class FurnitureGridComponent implements OnChanges {
@Output() editRequirement = new EventEmitter<FurnitureRequirement>();
@Output() addOption = new EventEmitter<FurnitureRequirement>();
@Output() editOption = new EventEmitter<FurnitureOption>();
@Output() assignScenario = new EventEmitter<{
requirement: FurnitureRequirement;
scenarioId: string;
}>();
@Output() dataChanged = new EventEmitter<void>();
private readonly api = inject(HauspilotApiService);
private readonly auth = inject(AuthService);
@@ -352,7 +356,7 @@ export class FurnitureGridComponent implements OnChanges {
delayedOnly = false;
readonly views: Array<{ id: View; label: string }> = [
{ id: 'requirements', label: 'Bedarfe' },
{ id: 'options', label: 'Alternativen' },
{ id: 'options', label: 'Möbelvorschläge' },
{ id: 'orders', label: 'Bestellungen' },
{ id: 'scenarios', label: 'Szenarien' },
];
@@ -432,11 +436,6 @@ export class FurnitureGridComponent implements OnChanges {
}
cellChanged(event: CellValueChangedEvent<FurnitureRow>) {
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;
this.savingRow.set(row.id);
this.error.set(null);
@@ -464,6 +463,11 @@ export class FurnitureGridComponent implements OnChanges {
if (!event.data) return;
const id = event.column.getColId();
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 === 'addOption') this.addOption.emit(event.data);
return;
@@ -543,7 +547,7 @@ export class FurnitureGridComponent implements OnChanges {
valueFormatter: (p) => money(p.value),
valueParser: (p) => parseGermanNumber(p.newValue),
},
{ field: 'optionCount', headerName: 'Alternativen' },
{ field: 'optionCount', headerName: 'Möbelvorschläge' },
{
field: 'cheapestOption',
headerName: 'Günstigste',
@@ -586,7 +590,7 @@ export class FurnitureGridComponent implements OnChanges {
},
{
colId: 'addOption',
headerName: 'Alternative',
headerName: 'Möbelvorschlag',
valueGetter: () => (this.canEdit ? ' hinzufügen' : 'anzeigen'),
sortable: false,
},
@@ -734,28 +738,18 @@ export class FurnitureGridComponent implements OnChanges {
const option = row.options.find((entry) => entry.id === params.value);
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) =>
params.value
? {
color: 'var(--color-success)',
backgroundColor: 'var(--color-success-subtle)',
fontWeight: '650',
cursor: this.canEdit ? 'pointer' : 'default',
}
: {
color: 'var(--color-warning)',
backgroundColor: 'var(--color-warning-subtle)',
cursor: this.canEdit ? 'pointer' : 'default',
},
minWidth: 245,
}),
@@ -803,63 +797,6 @@ export class FurnitureGridComponent implements OnChanges {
private scenarioId(columnId: string) {
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) {
return {
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 { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
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';
registerLocaleData(localeDe);
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 () => {
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
@@ -77,4 +135,134 @@ describe('FurniturePlanningComponent', () => {
expect(showModal).toHaveBeenCalledOnce();
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> = {
identified: 'Bedarf erkannt',
research: 'Recherche',
has_options: 'Alternativen vorhanden',
has_options: 'Möbelvorschläge vorhanden',
decision_open: 'Entscheidung offen',
selected: 'Ausgewählt',
ordered: 'Bestellt',
@@ -52,14 +52,126 @@ const statusLabels: Record<string, string> = {
<div class="section-head">
<div>
<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>
@if (canEdit) {
<button class="ui-button" type="button" (click)="newRequirement()">
Möbelbedarf hinzufügen
</button>
}
</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()) {
<p class="error" role="alert">{{ error() }}</p>
}
@@ -67,65 +179,36 @@ const statusLabels: Record<string, string> = {
<p role="status">Möbelplanung wird geladen …</p>
}
@if (summary(); as data) {
<div class="metric-grid">
<article class="ui-card metric">
<span>Ausgewählte Möbel</span
><strong>{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
><small>{{ data.selected }} ausgewählt</small>
</article>
<article class="ui-card metric">
<span>Günstigste Variante</span
><strong>{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
><small>{{ data.openPrices }} Preise noch offen</small>
</article>
<article class="ui-card metric">
<span>Tatsächliche Ausgaben</span
><strong>{{
data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
}}</strong
><small>Verknüpfte Ausgaben, nicht doppelt gezählt</small>
</article>
<article class="ui-card metric">
<span>Entscheidungen</span><strong>{{ data.withoutDecision }}</strong
><small>{{ data.withoutOption }} ohne Alternative · {{ data.delayed }} verspätet</small>
</article>
</div>
<details class="planning-details ui-card">
<summary>Kosten und Planungsstand anzeigen</summary>
<div class="metric-grid">
<article class="metric">
<span>Ausgewählte Möbel</span
><strong>{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
><small>{{ data.selected }} ausgewählt</small>
</article>
<article class="metric">
<span>Günstigste Variante</span
><strong>{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
><small>{{ data.openPrices }} Preise noch offen</small>
</article>
<article class="metric">
<span>Tatsächliche Ausgaben</span
><strong>{{
data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
}}</strong
><small>Verknüpfte Ausgaben, nicht doppelt gezählt</small>
</article>
<article class="metric">
<span>Offene Entscheidungen</span><strong>{{ data.withoutDecision }}</strong
><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
#requirementDialog
class="editor-dialog"
@@ -201,22 +284,19 @@ const statusLabels: Record<string, string> = {
}
</dialog>
<div #furnitureGridHost>
<app-furniture-grid
#furnitureGrid
[projectId]="projectId"
[rooms]="rooms"
[canEdit]="canEdit"
[scenarios]="scenarios()"
(editRequirement)="editRequirement($event)"
(addOption)="newOption($event)"
(editOption)="editOptionFromGrid($event)"
(dataChanged)="load()"
/>
</div>
<details class="legacy-cards">
<summary>Kompakte Kartenansicht</summary>
<section class="needs-overview" aria-labelledby="needs-overview-title">
<div class="section-head">
<div>
<span class="eyebrow">Ihre Planung</span>
<h2 id="needs-overview-title">Bedarfe und Möbelvorschläge</h2>
<p>Jeder Bedarf zeigt die konkreten Möbel, die dafür infrage kommen.</p>
</div>
@if (canEdit) {
<button class="ui-button ui-button--secondary" type="button" (click)="newRequirement()">
Weiteren Bedarf anlegen
</button>
}
</div>
<div class="requirement-list">
@for (requirement of requirements(); track requirement.id) {
<article class="ui-card requirement">
@@ -238,7 +318,7 @@ const statusLabels: Record<string, string> = {
requirement.maximumBudget || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
}}</strong></span
><span
>Alternativen: <strong>{{ requirement.options.length }}</strong></span
>Möbelvorschläge: <strong>{{ requirement.options.length }}</strong></span
>
</div>
@if (requirement.description) {
@@ -252,8 +332,8 @@ const statusLabels: Record<string, string> = {
>
{{
expandedRequirementId() === requirement.id
? 'Alternativen schließen'
: 'Alternativen vergleichen'
? 'Möbelvorschläge schließen'
: 'Möbelvorschläge ansehen'
}}
</button>
@if (canEdit) {
@@ -268,14 +348,14 @@ const statusLabels: Record<string, string> = {
type="button"
(click)="newOption(requirement)"
>
Alternative hinzufügen
Möbelvorschlag hinzufügen
</button>
}
</div>
@if (expandedRequirementId() === requirement.id) {
<div class="comparison" aria-label="Alternativenvergleich">
<div class="comparison" aria-label="Vergleich der Möbelvorschläge">
@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) {
<article class="option" [class.selected]="option.currentlySelected">
@@ -392,6 +472,27 @@ const statusLabels: Record<string, string> = {
</button>
</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>
<dialog
@@ -403,7 +504,7 @@ const statusLabels: Record<string, string> = {
@if (showOptionForm()) {
<form class="ui-card form-grid" [formGroup]="optionForm" (ngSubmit)="saveOption()">
<h3 id="option-form-title">
{{ editingOption() ? 'Alternative bearbeiten' : 'Alternative hinzufügen' }}
{{ editingOption() ? 'Möbelvorschlag bearbeiten' : 'Möbelvorschlag hinzufügen' }}
</h3>
<label>Name *<input formControlName="name" /></label
><label>Hersteller<input formControlName="manufacturer" /></label
@@ -500,42 +601,62 @@ const statusLabels: Record<string, string> = {
}
</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">
<div class="section-head">
<div>
<h2>Einrichtungsszenarien</h2>
<p>Budget-, Wunsch- und Premiumvarianten vergleichen.</p>
<h2>Einrichtungsvarianten</h2>
<p>
Ein Szenario ist eine komplette Variante: Für jeden Bedarf wählen Sie darin einen
Möbelvorschlag aus.
</p>
</div>
@if (canEdit) {
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
(click)="openScenarioGrid(furnitureGrid)"
>
Zuordnungen bearbeiten
</button>
<button class="ui-button" type="button" (click)="showScenarioForm.set(true)">
Szenario erstellen
<button class="ui-button" type="button" (click)="openScenarioForm()">
Neue Variante erstellen
</button>
</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">
@for (scenario of scenarios(); track scenario.id) {
<article>
@@ -549,6 +670,15 @@ const statusLabels: Record<string, string> = {
<p>
{{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen
</p>
@if (canEdit) {
<button
class="ui-button ui-button--ghost"
type="button"
(click)="openAssignment(scenario.id)"
>
Möbel zuordnen
</button>
}
</article>
} @empty {
<p>Noch kein Szenario vorhanden.</p>
@@ -574,6 +704,165 @@ const statusLabels: Record<string, string> = {
</div>
}
</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: [
`
@@ -604,6 +893,14 @@ const statusLabels: Record<string, string> = {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: var(--space-4);
}
.dialog-heading,
.dialog-notice {
display: grid;
gap: var(--space-1);
}
.dialog-form label small {
color: var(--color-text-muted);
}
.metric,
.requirement,
.scenarios {
@@ -675,6 +972,22 @@ const statusLabels: Record<string, string> = {
border: 0;
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 {
display: flex;
align-items: center;
@@ -776,7 +1089,9 @@ export class FurniturePlanningComponent implements OnChanges {
@Input() canEdit = false;
@ViewChild('requirementDialog') private requirementDialog?: 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);
readonly requirements = signal<FurnitureRequirement[]>([]);
readonly scenarios = signal<FurnitureScenario[]>([]);
@@ -789,12 +1104,35 @@ export class FurniturePlanningComponent implements OnChanges {
readonly expandedRequirementId = signal<string | null>(null);
readonly showRequirementForm = signal(false);
readonly showOptionForm = signal(false);
readonly showOptionPicker = signal(false);
readonly showScenarioForm = signal(false);
readonly showAssignmentForm = signal(false);
readonly guidedPlanning = signal(false);
readonly editingRequirement = signal<FurnitureRequirement | null>(null);
readonly editingOption = signal<FurnitureOption | null>(null);
readonly optionRequirement = signal<FurnitureRequirement | null>(null);
readonly categories = Object.entries(categoryLabels);
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({
search: new FormControl('', { nonNullable: true }),
roomId: new FormControl('', { nonNullable: true }),
@@ -878,6 +1216,26 @@ export class FurniturePlanningComponent implements OnChanges {
automaticSelection: new FormControl('budget', { 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(() => {
const v = this.optionForm.getRawValue();
const acquisition = v.existingItem ? 0 : v.unitPrice * v.quantity;
@@ -927,6 +1285,10 @@ export class FurniturePlanningComponent implements OnChanges {
status(value: string) {
return statusLabels[value] ?? value;
}
startGuidedPlanning() {
this.guidedPlanning.set(true);
this.newRequirement();
}
newRequirement() {
this.editingRequirement.set(null);
this.requirementForm.reset({
@@ -959,10 +1321,11 @@ export class FurniturePlanningComponent implements OnChanges {
this.showRequirementForm.set(true);
this.openDialog(this.requirementDialog);
}
closeRequirementForm() {
closeRequirementForm(keepFlow = false) {
this.requirementDialog?.nativeElement.close();
this.showRequirementForm.set(false);
this.editingRequirement.set(null);
if (!keepFlow) this.guidedPlanning.set(false);
}
saveRequirement() {
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());
request.subscribe({
next: () => {
next: (saved) => {
this.saving.set(false);
this.closeRequirementForm();
this.closeRequirementForm(true);
if (!existing && this.guidedPlanning()) this.newOption(saved);
this.load();
},
error: (e: unknown) => this.fail(e),
@@ -987,6 +1351,32 @@ export class FurniturePlanningComponent implements OnChanges {
toggleOptions(r: FurnitureRequirement) {
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) {
this.optionRequirement.set(r);
this.editingOption.set(null);
@@ -1050,11 +1440,12 @@ export class FurniturePlanningComponent implements OnChanges {
this.showOptionForm.set(true);
this.openDialog(this.optionDialog);
}
closeOptionForm() {
closeOptionForm(keepFlow = false) {
this.optionDialog?.nativeElement.close();
this.showOptionForm.set(false);
this.editingOption.set(null);
this.optionRequirement.set(null);
if (!keepFlow) this.guidedPlanning.set(false);
}
editOptionFromGrid(option: FurnitureOption) {
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.createFurnitureOption(this.projectId, requirement.id, body);
request.subscribe({
next: () => {
next: (saved) => {
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();
},
error: (e: unknown) => this.fail(e),
@@ -1103,14 +1503,18 @@ export class FurniturePlanningComponent implements OnChanges {
.deliverFurnitureOption(this.projectId, o, o.quantity)
.subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) });
}
openScenarioGrid(grid: FurnitureGridComponent) {
grid.setView('scenarios');
queueMicrotask(() =>
this.furnitureGridHost?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }),
);
openScenarioForm() {
this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false });
this.showScenarioForm.set(true);
this.openDialog(this.scenarioDialog);
}
closeScenarioForm(keepFlow = false) {
this.scenarioDialog?.nativeElement.close();
this.showScenarioForm.set(false);
if (!keepFlow) this.guidedPlanning.set(false);
}
createScenario() {
if (this.scenarioForm.invalid) return;
if (this.scenarioForm.invalid) return this.scenarioForm.markAllAsTouched();
this.saving.set(true);
const value = this.scenarioForm.getRawValue();
this.api
@@ -1122,15 +1526,128 @@ export class FurniturePlanningComponent implements OnChanges {
isDefault: value.isDefault,
})
.subscribe({
next: () => {
next: (saved) => {
this.saving.set(false);
this.showScenarioForm.set(false);
this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false });
this.closeScenarioForm(true);
this.scenarios.update((current) => [...current, saved]);
if (this.guidedPlanning()) this.openAssignment(saved.id);
this.load();
},
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) {
this.saving.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 type { OnInit } from '@angular/core';
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 { ApiErrorBody, ProjectDto, ProjectMemberDto } from '@boilerplate/api-client';
import { ApiClientService } from '@boilerplate/api-client';
@@ -30,17 +30,6 @@ import type {
Room,
} 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({
standalone: true,
imports: [
@@ -48,7 +37,6 @@ const sections = [
DatePipe,
DecimalPipe,
ReactiveFormsModule,
RouterLink,
UiEmptyStateComponent,
UiPageHeaderComponent,
UiStatusBadgeComponent,
@@ -57,22 +45,11 @@ const sections = [
FurniturePlanningComponent,
],
template: `
<a routerLink="/projekte">Zurück zu den Projekten</a>
@if (project(); as project) {
<ui-page-header
[title]="project.name"
[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()) {
<p role="status">Projektdaten werden geladen …</p>
}
@@ -680,25 +657,6 @@ const sections = [
display: grid;
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,
.card-grid {
display: grid;
@@ -775,7 +733,6 @@ export class ProjectWorkspacePageComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
projectId = '';
readonly navigation = sections;
readonly section = signal('uebersicht');
readonly project = signal<ProjectDto | 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 { provideRouter } from '@angular/router';
import { Router, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { ApiClientService } from '@boilerplate/api-client';
import { AppShellComponent } from './app-shell';
@Component({ standalone: true, template: '' })
class EmptyPageComponent {}
describe('AppShellComponent', () => {
beforeEach(() => sessionStorage.clear());
it('hides navigation entries without matching permissions', async () => {
await TestBed.configureTestingModule({
imports: [AppShellComponent],
@@ -99,4 +104,59 @@ describe('AppShellComponent', () => {
fixture.detectChanges();
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 { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
import type { Permission } from '@boilerplate/api-client';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { AuthService } from '../core/auth.service';
import { NotificationPanelComponent } from './notification-panel';
@@ -12,6 +14,23 @@ interface NavItem {
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({
selector: 'app-shell',
standalone: true,
@@ -30,7 +49,28 @@ interface NavItem {
@if (auth.user()) {
<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()) {
<button
class="ui-icon-button notification-button"
@@ -68,24 +108,78 @@ interface NavItem {
></button>
}
<aside class="sidebar" [class.open]="drawerOpen()">
<nav aria-label="Hauptnavigation">
@for (item of nav; track item.path) {
@if (visible(item)) {
@if (currentProjectId(); as projectId) {
<div class="sidebar-context">
<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
[routerLink]="item.path"
[routerLink]="['/projekte', projectId, item.id]"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
(click)="closeDrawerOnNavigation()"
>{{ item.label }}</a
>
{{ item.label }}
</a>
}
}
</nav>
<a
[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>
<main class="content">
<nav class="breadcrumbs">Start / {{ title() }}</nav>
<nav class="breadcrumbs">{{ breadcrumb() }}</nav>
<router-outlet />
</main>
} @else {
@@ -131,6 +225,53 @@ interface NavItem {
}
.brand {
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 {
margin-left: auto;
@@ -190,20 +331,56 @@ interface NavItem {
transform: translateX(-100%);
transition: transform var(--transition-base) ease;
z-index: var(--z-drawer);
overflow-y: auto;
}
.sidebar.open {
transform: translateX(0);
}
nav a {
.sidebar nav a {
display: block;
padding: var(--space-4) var(--space-5);
color: var(--color-text-primary);
text-decoration: none;
min-height: var(--touch-target);
}
nav a.active {
.sidebar nav a.active {
background: var(--color-primary-subtle);
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 {
min-width: 0;
@@ -229,22 +406,55 @@ interface NavItem {
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 {
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 notifications = inject(NotificationStore);
readonly drawerOpen = signal(false);
readonly notificationPanelOpen = signal(false);
readonly title = computed(
() => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard',
readonly projectTabs = signal<ProjectTab[]>(this.restoreProjectTabs());
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: 'Projekte', path: '/projekte', 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: 'Sicherheit', path: '/account/security' },
{
@@ -253,14 +463,23 @@ export class AppShellComponent {
permission: 'notifications.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 Rollen', path: '/admin/roles', permission: 'roles.read' },
{ label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' },
];
readonly nav: NavItem[] = [...this.workspaceNav, ...this.accountNav, ...this.adminNav];
constructor() {
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(() => {
if (this.auth.user()) {
this.notifications.startPolling();
@@ -275,6 +494,20 @@ export class AppShellComponent {
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 {
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
@@ -295,6 +528,80 @@ export class AppShellComponent {
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')
closeOverlays(): void {
this.drawerOpen.set(false);

View File

@@ -108,6 +108,267 @@
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) {
.ui-page-header {
grid-template-columns: minmax(0, 1fr) auto;