generated from bastian/boilerplate
bedarfe
This commit is contained in:
Binary file not shown.
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
<details class="planning-details ui-card">
|
||||
<summary>Kosten und Planungsstand anzeigen</summary>
|
||||
<div class="metric-grid">
|
||||
<article class="ui-card metric">
|
||||
<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="ui-card metric">
|
||||
<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="ui-card metric">
|
||||
<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="ui-card metric">
|
||||
<span>Entscheidungen</span><strong>{{ data.withoutDecision }}</strong
|
||||
><small>{{ data.withoutOption }} ohne Alternative · {{ data.delayed }} verspätet</small>
|
||||
<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()"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<details class="legacy-cards">
|
||||
<summary>Kompakte Kartenansicht</summary>
|
||||
<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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()">
|
||||
@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]="['/projekte', projectId, item.id]"
|
||||
routerLinkActive="active"
|
||||
(click)="closeDrawerOnNavigation()"
|
||||
>{{ item.label }}</a
|
||||
>
|
||||
}
|
||||
<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">
|
||||
@for (item of nav; track item.path) {
|
||||
<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
|
||||
>
|
||||
{{ 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user