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 { vi } from 'vitest';
|
||||||
import { AuthService } from '../../core/auth.service';
|
import { AuthService } from '../../core/auth.service';
|
||||||
import { FurnitureGridComponent } from './furniture-grid.component';
|
import { FurnitureGridComponent } from './furniture-grid.component';
|
||||||
import type {
|
import type { FurnitureRequirement, PageResult } from './hauspilot-api.service';
|
||||||
FurnitureOption,
|
|
||||||
FurnitureRequirement,
|
|
||||||
FurnitureScenario,
|
|
||||||
PageResult,
|
|
||||||
} from './hauspilot-api.service';
|
|
||||||
import { HauspilotApiService } from './hauspilot-api.service';
|
import { HauspilotApiService } from './hauspilot-api.service';
|
||||||
|
|
||||||
const requirement: FurnitureRequirement = {
|
const requirement: FurnitureRequirement = {
|
||||||
@@ -199,78 +194,7 @@ describe('FurnitureGridComponent', () => {
|
|||||||
expect(calls[0]?.['sortBy']).toBe('sortOrder');
|
expect(calls[0]?.['sortBy']).toBe('sortOrder');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('assigns an option to a scenario through the existing selection endpoint', async () => {
|
it('opens the assignment dialog when a scenario cell is clicked', async () => {
|
||||||
const option: FurnitureOption = {
|
|
||||||
id: 'option-1',
|
|
||||||
version: 1,
|
|
||||||
createdAt: '2026-01-01',
|
|
||||||
updatedAt: '2026-01-01',
|
|
||||||
projectId: 'project-1',
|
|
||||||
requirementId: requirement.id,
|
|
||||||
name: 'Sofa Wunsch',
|
|
||||||
manufacturer: null,
|
|
||||||
model: null,
|
|
||||||
description: null,
|
|
||||||
retailer: null,
|
|
||||||
productUrl: null,
|
|
||||||
articleNumber: null,
|
|
||||||
unitPrice: '1000.00',
|
|
||||||
originalPrice: null,
|
|
||||||
shippingCost: '0.00',
|
|
||||||
additionalCost: '0.00',
|
|
||||||
discount: '0.00',
|
|
||||||
totalPrice: '1000.00',
|
|
||||||
currency: 'EUR',
|
|
||||||
quantity: 1,
|
|
||||||
width: null,
|
|
||||||
height: null,
|
|
||||||
depth: null,
|
|
||||||
weight: null,
|
|
||||||
color: null,
|
|
||||||
material: null,
|
|
||||||
deliveryDays: null,
|
|
||||||
expectedDeliveryDate: null,
|
|
||||||
availability: 'available',
|
|
||||||
favorite: true,
|
|
||||||
currentlySelected: false,
|
|
||||||
status: 'favorite',
|
|
||||||
notes: null,
|
|
||||||
budgetCategoryId: null,
|
|
||||||
existingItem: false,
|
|
||||||
movingCost: '0.00',
|
|
||||||
refurbishmentCost: '0.00',
|
|
||||||
deliveryStatus: 'not_ordered',
|
|
||||||
deliveredQuantity: 0,
|
|
||||||
orderNumber: null,
|
|
||||||
orderedAt: null,
|
|
||||||
};
|
|
||||||
const scenario: FurnitureScenario = {
|
|
||||||
id: 'scenario-1',
|
|
||||||
version: 3,
|
|
||||||
createdAt: '2026-01-01',
|
|
||||||
updatedAt: '2026-01-01',
|
|
||||||
projectId: 'project-1',
|
|
||||||
name: 'Wunsch',
|
|
||||||
description: null,
|
|
||||||
type: 'preferred',
|
|
||||||
status: 'active',
|
|
||||||
isDefault: true,
|
|
||||||
total: '0.00',
|
|
||||||
selectedRequirements: 0,
|
|
||||||
openRequirements: 1,
|
|
||||||
byRoom: {},
|
|
||||||
selections: [],
|
|
||||||
};
|
|
||||||
const updateSelections = vi.fn(() =>
|
|
||||||
of({
|
|
||||||
...scenario,
|
|
||||||
version: 4,
|
|
||||||
total: option.totalPrice,
|
|
||||||
selections: [
|
|
||||||
{ requirementId: requirement.id, optionId: option.id, quantity: option.quantity },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [FurnitureGridComponent],
|
imports: [FurnitureGridComponent],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -280,7 +204,6 @@ describe('FurnitureGridComponent', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
furnitureRequirements: () => of(page),
|
furnitureRequirements: () => of(page),
|
||||||
furnitureProjectOptions: () => of({ ...page, items: [] }),
|
furnitureProjectOptions: () => of({ ...page, items: [] }),
|
||||||
updateFurnitureScenarioSelections: updateSelections,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } },
|
{ provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } },
|
||||||
@@ -289,16 +212,12 @@ describe('FurnitureGridComponent', () => {
|
|||||||
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
|
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
|
||||||
component.projectId = 'project-1';
|
component.projectId = 'project-1';
|
||||||
component.canEdit = true;
|
component.canEdit = true;
|
||||||
component.scenarios = [scenario];
|
const assignment = vi.fn();
|
||||||
component.cellChanged({
|
component.assignScenario.subscribe(assignment);
|
||||||
oldValue: '',
|
component.cellClicked({
|
||||||
newValue: option.id,
|
data: requirement,
|
||||||
data: { ...requirement, options: [option] },
|
column: { getColId: () => 'scenario:scenario-1' },
|
||||||
column: { getColId: () => `scenario:${scenario.id}` },
|
|
||||||
node: { setDataValue: vi.fn() },
|
|
||||||
} as never);
|
} as never);
|
||||||
expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [
|
expect(assignment).toHaveBeenCalledWith({ requirement, scenarioId: 'scenario-1' });
|
||||||
{ requirementId: requirement.id, optionId: option.id, quantity: 1 },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const requirementPriorities: Record<string, GridValuePresentation> = {
|
|||||||
const requirementStatuses: Record<string, GridValuePresentation> = {
|
const requirementStatuses: Record<string, GridValuePresentation> = {
|
||||||
identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' },
|
identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' },
|
||||||
research: { icon: '⌕', label: 'Recherche', tone: 'info' },
|
research: { icon: '⌕', label: 'Recherche', tone: 'info' },
|
||||||
has_options: { icon: '≡', label: 'Alternativen vorhanden', tone: 'info' },
|
has_options: { icon: '≡', label: 'Möbelvorschläge vorhanden', tone: 'info' },
|
||||||
decision_open: { icon: '?', label: 'Entscheidung offen', tone: 'warning' },
|
decision_open: { icon: '?', label: 'Entscheidung offen', tone: 'warning' },
|
||||||
selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' },
|
selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' },
|
||||||
ordered: { icon: '▣', label: 'Bestellt', tone: 'info' },
|
ordered: { icon: '▣', label: 'Bestellt', tone: 'info' },
|
||||||
@@ -172,8 +172,8 @@ const deliveryStatuses: Record<string, GridValuePresentation> = {
|
|||||||
}
|
}
|
||||||
@if (view() === 'scenarios') {
|
@if (view() === 'scenarios') {
|
||||||
<p class="scenario-help">
|
<p class="scenario-help">
|
||||||
Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus.
|
Klicken Sie auf eine Szenariozelle, um passende Möbel in Ruhe zu vergleichen und Ihre
|
||||||
„Nicht zugewiesen“ lässt den Bedarf im Szenario offen.
|
Auswahl anschließend zu übernehmen.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<ag-grid-angular
|
<ag-grid-angular
|
||||||
@@ -330,6 +330,10 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
@Output() editRequirement = new EventEmitter<FurnitureRequirement>();
|
@Output() editRequirement = new EventEmitter<FurnitureRequirement>();
|
||||||
@Output() addOption = new EventEmitter<FurnitureRequirement>();
|
@Output() addOption = new EventEmitter<FurnitureRequirement>();
|
||||||
@Output() editOption = new EventEmitter<FurnitureOption>();
|
@Output() editOption = new EventEmitter<FurnitureOption>();
|
||||||
|
@Output() assignScenario = new EventEmitter<{
|
||||||
|
requirement: FurnitureRequirement;
|
||||||
|
scenarioId: string;
|
||||||
|
}>();
|
||||||
@Output() dataChanged = new EventEmitter<void>();
|
@Output() dataChanged = new EventEmitter<void>();
|
||||||
private readonly api = inject(HauspilotApiService);
|
private readonly api = inject(HauspilotApiService);
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
@@ -352,7 +356,7 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
delayedOnly = false;
|
delayedOnly = false;
|
||||||
readonly views: Array<{ id: View; label: string }> = [
|
readonly views: Array<{ id: View; label: string }> = [
|
||||||
{ id: 'requirements', label: 'Bedarfe' },
|
{ id: 'requirements', label: 'Bedarfe' },
|
||||||
{ id: 'options', label: 'Alternativen' },
|
{ id: 'options', label: 'Möbelvorschläge' },
|
||||||
{ id: 'orders', label: 'Bestellungen' },
|
{ id: 'orders', label: 'Bestellungen' },
|
||||||
{ id: 'scenarios', label: 'Szenarien' },
|
{ id: 'scenarios', label: 'Szenarien' },
|
||||||
];
|
];
|
||||||
@@ -432,11 +436,6 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
cellChanged(event: CellValueChangedEvent<FurnitureRow>) {
|
cellChanged(event: CellValueChangedEvent<FurnitureRow>) {
|
||||||
if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return;
|
if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return;
|
||||||
const scenarioId = this.scenarioId(event.column.getColId());
|
|
||||||
if (scenarioId && 'requiredQuantity' in event.data) {
|
|
||||||
this.saveScenarioSelection(event, scenarioId, event.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const row = event.data;
|
const row = event.data;
|
||||||
this.savingRow.set(row.id);
|
this.savingRow.set(row.id);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
@@ -464,6 +463,11 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
if (!event.data) return;
|
if (!event.data) return;
|
||||||
const id = event.column.getColId();
|
const id = event.column.getColId();
|
||||||
if ('requiredQuantity' in event.data) {
|
if ('requiredQuantity' in event.data) {
|
||||||
|
const scenarioId = this.scenarioId(id);
|
||||||
|
if (scenarioId && this.canEdit) {
|
||||||
|
this.assignScenario.emit({ requirement: event.data, scenarioId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (id === 'actions') this.editRequirement.emit(event.data);
|
if (id === 'actions') this.editRequirement.emit(event.data);
|
||||||
if (id === 'addOption') this.addOption.emit(event.data);
|
if (id === 'addOption') this.addOption.emit(event.data);
|
||||||
return;
|
return;
|
||||||
@@ -543,7 +547,7 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
valueFormatter: (p) => money(p.value),
|
valueFormatter: (p) => money(p.value),
|
||||||
valueParser: (p) => parseGermanNumber(p.newValue),
|
valueParser: (p) => parseGermanNumber(p.newValue),
|
||||||
},
|
},
|
||||||
{ field: 'optionCount', headerName: 'Alternativen' },
|
{ field: 'optionCount', headerName: 'Möbelvorschläge' },
|
||||||
{
|
{
|
||||||
field: 'cheapestOption',
|
field: 'cheapestOption',
|
||||||
headerName: 'Günstigste',
|
headerName: 'Günstigste',
|
||||||
@@ -586,7 +590,7 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
colId: 'addOption',
|
colId: 'addOption',
|
||||||
headerName: 'Alternative',
|
headerName: 'Möbelvorschlag',
|
||||||
valueGetter: () => (this.canEdit ? '+ hinzufügen' : 'anzeigen'),
|
valueGetter: () => (this.canEdit ? '+ hinzufügen' : 'anzeigen'),
|
||||||
sortable: false,
|
sortable: false,
|
||||||
},
|
},
|
||||||
@@ -734,28 +738,18 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
const option = row.options.find((entry) => entry.id === params.value);
|
const option = row.options.find((entry) => entry.id === params.value);
|
||||||
return option ? `✓ ${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen';
|
return option ? `✓ ${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen';
|
||||||
},
|
},
|
||||||
editable: (params) =>
|
|
||||||
this.canEdit &&
|
|
||||||
!!params.data &&
|
|
||||||
'requiredQuantity' in params.data &&
|
|
||||||
this.selectableOptions(params.data).length > 0,
|
|
||||||
cellEditor: 'agSelectCellEditor',
|
|
||||||
cellEditorParams: (params: { data?: FurnitureRow }) => ({
|
|
||||||
values:
|
|
||||||
params.data && 'requiredQuantity' in params.data
|
|
||||||
? ['', ...this.selectableOptions(params.data).map((option) => option.id)]
|
|
||||||
: [''],
|
|
||||||
}),
|
|
||||||
cellStyle: (params) =>
|
cellStyle: (params) =>
|
||||||
params.value
|
params.value
|
||||||
? {
|
? {
|
||||||
color: 'var(--color-success)',
|
color: 'var(--color-success)',
|
||||||
backgroundColor: 'var(--color-success-subtle)',
|
backgroundColor: 'var(--color-success-subtle)',
|
||||||
fontWeight: '650',
|
fontWeight: '650',
|
||||||
|
cursor: this.canEdit ? 'pointer' : 'default',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
color: 'var(--color-warning)',
|
color: 'var(--color-warning)',
|
||||||
backgroundColor: 'var(--color-warning-subtle)',
|
backgroundColor: 'var(--color-warning-subtle)',
|
||||||
|
cursor: this.canEdit ? 'pointer' : 'default',
|
||||||
},
|
},
|
||||||
minWidth: 245,
|
minWidth: 245,
|
||||||
}),
|
}),
|
||||||
@@ -803,63 +797,6 @@ export class FurnitureGridComponent implements OnChanges {
|
|||||||
private scenarioId(columnId: string) {
|
private scenarioId(columnId: string) {
|
||||||
return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null;
|
return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null;
|
||||||
}
|
}
|
||||||
private selectableOptions(requirement: FurnitureRequirement) {
|
|
||||||
return requirement.options.filter(
|
|
||||||
(option) =>
|
|
||||||
!['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) &&
|
|
||||||
!['unavailable', 'discontinued'].includes(option.availability),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
private saveScenarioSelection(
|
|
||||||
event: CellValueChangedEvent<FurnitureRow>,
|
|
||||||
scenarioId: string,
|
|
||||||
requirement: FurnitureRequirement,
|
|
||||||
) {
|
|
||||||
const scenario = this.scenarios.find((entry) => entry.id === scenarioId);
|
|
||||||
if (!scenario) {
|
|
||||||
this.rollbackCell(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const optionId = typeof event.newValue === 'string' ? event.newValue : '';
|
|
||||||
const option = optionId
|
|
||||||
? this.selectableOptions(requirement).find((entry) => entry.id === optionId)
|
|
||||||
: undefined;
|
|
||||||
if (optionId && !option) {
|
|
||||||
this.rollbackCell(event);
|
|
||||||
this.error.set('Diese Alternative kann dem Szenario nicht zugewiesen werden.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const selections = scenario.selections
|
|
||||||
.filter((selection) => selection.requirementId !== requirement.id)
|
|
||||||
.map((selection) => ({ ...selection }));
|
|
||||||
if (option)
|
|
||||||
selections.push({
|
|
||||||
requirementId: requirement.id,
|
|
||||||
optionId: option.id,
|
|
||||||
quantity: option.quantity,
|
|
||||||
});
|
|
||||||
this.savingRow.set(requirement.id);
|
|
||||||
this.error.set(null);
|
|
||||||
this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({
|
|
||||||
next: (saved) => {
|
|
||||||
this.scenarios = this.scenarios.map((entry) => (entry.id === saved.id ? saved : entry));
|
|
||||||
this.savingRow.set(null);
|
|
||||||
this.refreshColumns();
|
|
||||||
this.dataChanged.emit();
|
|
||||||
},
|
|
||||||
error: (error: unknown) => {
|
|
||||||
this.rollbackCell(event);
|
|
||||||
this.savingRow.set(null);
|
|
||||||
this.fail(error);
|
|
||||||
if (this.status(error) === 409) this.load(this.page());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private rollbackCell(event: CellValueChangedEvent<FurnitureRow>) {
|
|
||||||
this.rollback = true;
|
|
||||||
event.node.setDataValue(event.column.getColId(), event.oldValue);
|
|
||||||
this.rollback = false;
|
|
||||||
}
|
|
||||||
private optionBody(row: FurnitureOption) {
|
private optionBody(row: FurnitureOption) {
|
||||||
return {
|
return {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|||||||
@@ -1,9 +1,67 @@
|
|||||||
|
import { registerLocaleData } from '@angular/common';
|
||||||
|
import localeDe from '@angular/common/locales/de';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
import type {
|
||||||
|
FurnitureOption,
|
||||||
|
FurnitureRequirement,
|
||||||
|
FurnitureScenario,
|
||||||
|
} from './hauspilot-api.service';
|
||||||
|
import { HauspilotApiService } from './hauspilot-api.service';
|
||||||
import { FurniturePlanningComponent } from './furniture-planning.component';
|
import { FurniturePlanningComponent } from './furniture-planning.component';
|
||||||
|
|
||||||
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
describe('FurniturePlanningComponent', () => {
|
describe('FurniturePlanningComponent', () => {
|
||||||
|
it('explains the planning concepts and recommends the next useful step', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FurniturePlanningComponent],
|
||||||
|
providers: [provideHttpClient()],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(FurniturePlanningComponent);
|
||||||
|
fixture.componentInstance.canEdit = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const host: unknown = fixture.nativeElement;
|
||||||
|
if (!(host instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.');
|
||||||
|
|
||||||
|
expect(host.querySelector('.concept-flow')?.textContent).toContain('Was wird wo benötigt?');
|
||||||
|
expect(host.querySelector('.concept-flow')?.textContent).toContain(
|
||||||
|
'Was kommt konkret infrage?',
|
||||||
|
);
|
||||||
|
expect(host.querySelector('.next-action button')?.textContent).toContain(
|
||||||
|
'Ersten Bedarf anlegen',
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.componentInstance.requirements.set([
|
||||||
|
{
|
||||||
|
id: 'requirement-1',
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
projectId: 'project-1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
name: 'Sofa',
|
||||||
|
description: null,
|
||||||
|
category: 'seating',
|
||||||
|
priority: 'normal',
|
||||||
|
requiredQuantity: 1,
|
||||||
|
status: 'identified',
|
||||||
|
responsibleUserId: null,
|
||||||
|
maximumBudget: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.nextStep()).toBe('option');
|
||||||
|
expect(host.querySelector('.next-action button')?.textContent).toContain(
|
||||||
|
'Möbelvorschlag hinzufügen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('calculates option totals including quantity, shipping, extras and discounts', async () => {
|
it('calculates option totals including quantity, shipping, extras and discounts', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [FurniturePlanningComponent],
|
imports: [FurniturePlanningComponent],
|
||||||
@@ -77,4 +135,134 @@ describe('FurniturePlanningComponent', () => {
|
|||||||
expect(showModal).toHaveBeenCalledOnce();
|
expect(showModal).toHaveBeenCalledOnce();
|
||||||
expect(fixture.componentInstance.showRequirementForm()).toBe(true);
|
expect(fixture.componentInstance.showRequirementForm()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('guides users through an explicit scenario assignment', async () => {
|
||||||
|
const option = {
|
||||||
|
id: 'option-1',
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
projectId: 'project-1',
|
||||||
|
requirementId: 'requirement-1',
|
||||||
|
name: 'Wunschsofa',
|
||||||
|
manufacturer: 'Nordmöbel',
|
||||||
|
model: null,
|
||||||
|
description: null,
|
||||||
|
retailer: 'Wohnwelt',
|
||||||
|
productUrl: null,
|
||||||
|
articleNumber: null,
|
||||||
|
unitPrice: '1000.00',
|
||||||
|
originalPrice: null,
|
||||||
|
shippingCost: '0.00',
|
||||||
|
additionalCost: '0.00',
|
||||||
|
discount: '0.00',
|
||||||
|
totalPrice: '1000.00',
|
||||||
|
currency: 'EUR',
|
||||||
|
quantity: 1,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
depth: null,
|
||||||
|
weight: null,
|
||||||
|
color: null,
|
||||||
|
material: null,
|
||||||
|
deliveryDays: null,
|
||||||
|
expectedDeliveryDate: null,
|
||||||
|
availability: 'available',
|
||||||
|
favorite: false,
|
||||||
|
currentlySelected: false,
|
||||||
|
status: 'idea',
|
||||||
|
notes: null,
|
||||||
|
budgetCategoryId: null,
|
||||||
|
existingItem: false,
|
||||||
|
movingCost: '0.00',
|
||||||
|
refurbishmentCost: '0.00',
|
||||||
|
deliveryStatus: 'not_ordered',
|
||||||
|
deliveredQuantity: 0,
|
||||||
|
orderNumber: null,
|
||||||
|
orderedAt: null,
|
||||||
|
} satisfies FurnitureOption;
|
||||||
|
const requirement = {
|
||||||
|
id: 'requirement-1',
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
projectId: 'project-1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
name: 'Sofa',
|
||||||
|
description: null,
|
||||||
|
category: 'seating',
|
||||||
|
priority: 'normal',
|
||||||
|
requiredQuantity: 1,
|
||||||
|
status: 'identified',
|
||||||
|
responsibleUserId: null,
|
||||||
|
maximumBudget: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
options: [option],
|
||||||
|
} satisfies FurnitureRequirement;
|
||||||
|
const scenario = {
|
||||||
|
id: 'scenario-1',
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
projectId: 'project-1',
|
||||||
|
name: 'Wunsch',
|
||||||
|
description: null,
|
||||||
|
type: 'preferred',
|
||||||
|
status: 'draft',
|
||||||
|
isDefault: true,
|
||||||
|
total: '0.00',
|
||||||
|
selectedRequirements: 0,
|
||||||
|
openRequirements: 1,
|
||||||
|
byRoom: {},
|
||||||
|
selections: [],
|
||||||
|
} satisfies FurnitureScenario;
|
||||||
|
const updateSelections = vi.fn(() =>
|
||||||
|
of({
|
||||||
|
...scenario,
|
||||||
|
version: 2,
|
||||||
|
selections: [{ requirementId: requirement.id, optionId: option.id, quantity: 1 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FurniturePlanningComponent],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
{
|
||||||
|
provide: HauspilotApiService,
|
||||||
|
useValue: {
|
||||||
|
updateFurnitureScenarioSelections: updateSelections,
|
||||||
|
furnitureRequirements: () =>
|
||||||
|
of({ items: [requirement], page: 1, pageSize: 50, totalItems: 1, totalPages: 1 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(FurniturePlanningComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.projectId = 'project-1';
|
||||||
|
component.requirements.set([requirement]);
|
||||||
|
component.scenarios.set([scenario]);
|
||||||
|
vi.spyOn(component, 'load').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
component.openAssignment(scenario.id, requirement.id, option.id);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const host: unknown = fixture.nativeElement;
|
||||||
|
if (!(host instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.');
|
||||||
|
const choice = host.querySelector('.option-choice');
|
||||||
|
expect(choice?.textContent).toContain('Wunschsofa');
|
||||||
|
expect(choice?.textContent).toContain('Nordmöbel');
|
||||||
|
expect(choice?.textContent).toContain('Wohnwelt');
|
||||||
|
const radio = choice?.querySelector('input[type="radio"]');
|
||||||
|
expect(radio instanceof HTMLInputElement && radio.checked).toBe(true);
|
||||||
|
const dialog = host.querySelector('#assignment-form-title')?.closest('dialog');
|
||||||
|
if (!(dialog instanceof HTMLDialogElement)) throw new Error('Zuordnungsdialog fehlt.');
|
||||||
|
Object.defineProperty(dialog, 'close', { value: vi.fn() });
|
||||||
|
component.saveAssignment();
|
||||||
|
|
||||||
|
expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [
|
||||||
|
{ requirementId: requirement.id, optionId: option.id, quantity: 1 },
|
||||||
|
]);
|
||||||
|
expect(component.assignedRequirements()).toBe(1);
|
||||||
|
expect(component.showAssignmentForm()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const categoryLabels: Record<string, string> = {
|
|||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
identified: 'Bedarf erkannt',
|
identified: 'Bedarf erkannt',
|
||||||
research: 'Recherche',
|
research: 'Recherche',
|
||||||
has_options: 'Alternativen vorhanden',
|
has_options: 'Möbelvorschläge vorhanden',
|
||||||
decision_open: 'Entscheidung offen',
|
decision_open: 'Entscheidung offen',
|
||||||
selected: 'Ausgewählt',
|
selected: 'Ausgewählt',
|
||||||
ordered: 'Bestellt',
|
ordered: 'Bestellt',
|
||||||
@@ -52,14 +52,126 @@ const statusLabels: Record<string, string> = {
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Möbel & Einrichtung</h2>
|
<h2>Möbel & Einrichtung</h2>
|
||||||
<p>Bedarfe, Produktalternativen und Einrichtungsszenarien gemeinsam planen.</p>
|
<p>Planen Sie vom benötigten Möbel bis zur fertigen Einrichtungsvariante.</p>
|
||||||
</div>
|
</div>
|
||||||
@if (canEdit) {
|
|
||||||
<button class="ui-button" type="button" (click)="newRequirement()">
|
|
||||||
Möbelbedarf hinzufügen
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
<section class="planning-guide ui-card" aria-labelledby="planning-guide-title">
|
||||||
|
<div class="guide-intro">
|
||||||
|
<span class="eyebrow">Geführte Planung</span>
|
||||||
|
<h3 id="planning-guide-title">Was gehört hier zusammen?</h3>
|
||||||
|
<p>
|
||||||
|
Sie notieren zuerst, <strong>was fehlt</strong>, sammeln dafür konkrete
|
||||||
|
<strong>Möbelvorschläge</strong> und stellen daraus ein
|
||||||
|
<strong>Szenario</strong> zusammen.
|
||||||
|
</p>
|
||||||
|
<div class="concept-flow" aria-label="Begriffe der Möbelplanung">
|
||||||
|
<article>
|
||||||
|
<span aria-hidden="true">1</span>
|
||||||
|
<div><strong>Bedarf</strong><small>Was wird wo benötigt?</small></div>
|
||||||
|
</article>
|
||||||
|
<span class="flow-arrow" aria-hidden="true">→</span>
|
||||||
|
<article>
|
||||||
|
<span aria-hidden="true">2</span>
|
||||||
|
<div><strong>Möbelvorschlag</strong><small>Was kommt konkret infrage?</small></div>
|
||||||
|
</article>
|
||||||
|
<span class="flow-arrow" aria-hidden="true">→</span>
|
||||||
|
<article>
|
||||||
|
<span aria-hidden="true">3</span>
|
||||||
|
<div>
|
||||||
|
<strong>Szenario</strong><small>Welche Vorschläge wählen wir zusammen?</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="guide-steps">
|
||||||
|
<li [class.complete]="requirements().length > 0" [class.current]="nextStep() === 'need'">
|
||||||
|
<span class="step-number" aria-hidden="true">1</span>
|
||||||
|
<div>
|
||||||
|
<strong>Bedarf notieren</strong
|
||||||
|
><small>{{ requirements().length }} Bedarfe erfasst</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
[class.complete]="
|
||||||
|
requirementsWithOptions() === requirements().length && requirements().length > 0
|
||||||
|
"
|
||||||
|
[class.current]="nextStep() === 'option'"
|
||||||
|
>
|
||||||
|
<span class="step-number" aria-hidden="true">2</span>
|
||||||
|
<div>
|
||||||
|
<strong>Möbelvorschläge sammeln</strong
|
||||||
|
><small
|
||||||
|
>{{ requirementsWithOptions() }} von {{ requirements().length }} Bedarfen haben
|
||||||
|
Vorschläge</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
[class.complete]="assignedRequirements() > 0"
|
||||||
|
[class.current]="nextStep() === 'scenario' || nextStep() === 'assign'"
|
||||||
|
>
|
||||||
|
<span class="step-number" aria-hidden="true">3</span>
|
||||||
|
<div>
|
||||||
|
<strong>Szenario zusammenstellen</strong
|
||||||
|
><small>{{ assignedRequirements() }} Bedarfe sind einem Szenario zugeordnet</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="next-action" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Als Nächstes</span>
|
||||||
|
@switch (nextStep()) {
|
||||||
|
@case ('need') {
|
||||||
|
<strong>Notieren Sie den ersten Möbelbedarf</strong>
|
||||||
|
<small
|
||||||
|
>Zum Beispiel „Sofa im Wohnzimmer“ – ein Produkt müssen Sie noch nicht
|
||||||
|
kennen.</small
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@case ('option') {
|
||||||
|
<strong>Ergänzen Sie einen konkreten Möbelvorschlag</strong>
|
||||||
|
<small
|
||||||
|
>Das kann ein neues Produkt oder ein bereits vorhandenes Möbelstück sein.</small
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@case ('scenario') {
|
||||||
|
<strong>Erstellen Sie Ihre erste Einrichtungsvariante</strong>
|
||||||
|
<small>Ein Szenario bündelt je Bedarf genau einen ausgewählten Vorschlag.</small>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<strong>Vervollständigen oder vergleichen Sie Ihre Szenarien</strong>
|
||||||
|
<small
|
||||||
|
>Ordnen Sie offene Bedarfe zu oder probieren Sie eine weitere Variante aus.</small
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (canEdit) {
|
||||||
|
@switch (nextStep()) {
|
||||||
|
@case ('need') {
|
||||||
|
<button class="ui-button" type="button" (click)="startGuidedPlanning()">
|
||||||
|
Ersten Bedarf anlegen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@case ('option') {
|
||||||
|
<button class="ui-button" type="button" (click)="openOptionPicker()">
|
||||||
|
Möbelvorschlag hinzufügen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@case ('scenario') {
|
||||||
|
<button class="ui-button" type="button" (click)="openScenarioForm()">
|
||||||
|
Szenario erstellen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<button class="ui-button" type="button" (click)="openAssignment()">
|
||||||
|
Möbel im Szenario auswählen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
<p class="error" role="alert">{{ error() }}</p>
|
<p class="error" role="alert">{{ error() }}</p>
|
||||||
}
|
}
|
||||||
@@ -67,65 +179,36 @@ const statusLabels: Record<string, string> = {
|
|||||||
<p role="status">Möbelplanung wird geladen …</p>
|
<p role="status">Möbelplanung wird geladen …</p>
|
||||||
}
|
}
|
||||||
@if (summary(); as data) {
|
@if (summary(); as data) {
|
||||||
<div class="metric-grid">
|
<details class="planning-details ui-card">
|
||||||
<article class="ui-card metric">
|
<summary>Kosten und Planungsstand anzeigen</summary>
|
||||||
<span>Ausgewählte Möbel</span
|
<div class="metric-grid">
|
||||||
><strong>{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
|
<article class="metric">
|
||||||
><small>{{ data.selected }} ausgewählt</small>
|
<span>Ausgewählte Möbel</span
|
||||||
</article>
|
><strong>{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
|
||||||
<article class="ui-card metric">
|
><small>{{ data.selected }} ausgewählt</small>
|
||||||
<span>Günstigste Variante</span
|
</article>
|
||||||
><strong>{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
|
<article class="metric">
|
||||||
><small>{{ data.openPrices }} Preise noch offen</small>
|
<span>Günstigste Variante</span
|
||||||
</article>
|
><strong>{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong
|
||||||
<article class="ui-card metric">
|
><small>{{ data.openPrices }} Preise noch offen</small>
|
||||||
<span>Tatsächliche Ausgaben</span
|
</article>
|
||||||
><strong>{{
|
<article class="metric">
|
||||||
data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
|
<span>Tatsächliche Ausgaben</span
|
||||||
}}</strong
|
><strong>{{
|
||||||
><small>Verknüpfte Ausgaben, nicht doppelt gezählt</small>
|
data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
|
||||||
</article>
|
}}</strong
|
||||||
<article class="ui-card metric">
|
><small>Verknüpfte Ausgaben, nicht doppelt gezählt</small>
|
||||||
<span>Entscheidungen</span><strong>{{ data.withoutDecision }}</strong
|
</article>
|
||||||
><small>{{ data.withoutOption }} ohne Alternative · {{ data.delayed }} verspätet</small>
|
<article class="metric">
|
||||||
</article>
|
<span>Offene Entscheidungen</span><strong>{{ data.withoutDecision }}</strong
|
||||||
</div>
|
><small
|
||||||
|
>{{ data.withoutOption }} ohne Möbelvorschlag · {{ data.delayed }} verspätet</small
|
||||||
|
>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form class="filters ui-card" [formGroup]="filterForm" (ngSubmit)="load(1)">
|
|
||||||
<label>Suche<input formControlName="search" placeholder="Möbel oder Händler" /></label>
|
|
||||||
<label
|
|
||||||
>Raum<select formControlName="roomId">
|
|
||||||
<option value="">Alle Räume</option>
|
|
||||||
@for (room of rooms; track room.id) {
|
|
||||||
<option [value]="room.id">{{ room.name }}</option>
|
|
||||||
}
|
|
||||||
</select></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Status<select formControlName="status">
|
|
||||||
<option value="">Alle Status</option>
|
|
||||||
@for (entry of requirementStatuses; track entry[0]) {
|
|
||||||
<option [value]="entry[0]">{{ entry[1] }}</option>
|
|
||||||
}
|
|
||||||
</select></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Sortierung<select formControlName="sortBy">
|
|
||||||
<option value="sortOrder">Reihenfolge</option>
|
|
||||||
<option value="name">Name</option>
|
|
||||||
<option value="room">Raum</option>
|
|
||||||
<option value="price">Preis</option>
|
|
||||||
<option value="priority">Priorität</option>
|
|
||||||
<option value="updatedAt">Zuletzt geändert</option>
|
|
||||||
</select></label
|
|
||||||
>
|
|
||||||
<button class="ui-button ui-button--secondary" type="submit">Anwenden</button>
|
|
||||||
<button class="ui-button ui-button--ghost" type="button" (click)="resetFilters()">
|
|
||||||
Zurücksetzen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<dialog
|
<dialog
|
||||||
#requirementDialog
|
#requirementDialog
|
||||||
class="editor-dialog"
|
class="editor-dialog"
|
||||||
@@ -201,22 +284,19 @@ const statusLabels: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<div #furnitureGridHost>
|
<section class="needs-overview" aria-labelledby="needs-overview-title">
|
||||||
<app-furniture-grid
|
<div class="section-head">
|
||||||
#furnitureGrid
|
<div>
|
||||||
[projectId]="projectId"
|
<span class="eyebrow">Ihre Planung</span>
|
||||||
[rooms]="rooms"
|
<h2 id="needs-overview-title">Bedarfe und Möbelvorschläge</h2>
|
||||||
[canEdit]="canEdit"
|
<p>Jeder Bedarf zeigt die konkreten Möbel, die dafür infrage kommen.</p>
|
||||||
[scenarios]="scenarios()"
|
</div>
|
||||||
(editRequirement)="editRequirement($event)"
|
@if (canEdit) {
|
||||||
(addOption)="newOption($event)"
|
<button class="ui-button ui-button--secondary" type="button" (click)="newRequirement()">
|
||||||
(editOption)="editOptionFromGrid($event)"
|
Weiteren Bedarf anlegen
|
||||||
(dataChanged)="load()"
|
</button>
|
||||||
/>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="legacy-cards">
|
|
||||||
<summary>Kompakte Kartenansicht</summary>
|
|
||||||
<div class="requirement-list">
|
<div class="requirement-list">
|
||||||
@for (requirement of requirements(); track requirement.id) {
|
@for (requirement of requirements(); track requirement.id) {
|
||||||
<article class="ui-card requirement">
|
<article class="ui-card requirement">
|
||||||
@@ -238,7 +318,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
requirement.maximumBudget || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
|
requirement.maximumBudget || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de'
|
||||||
}}</strong></span
|
}}</strong></span
|
||||||
><span
|
><span
|
||||||
>Alternativen: <strong>{{ requirement.options.length }}</strong></span
|
>Möbelvorschläge: <strong>{{ requirement.options.length }}</strong></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@if (requirement.description) {
|
@if (requirement.description) {
|
||||||
@@ -252,8 +332,8 @@ const statusLabels: Record<string, string> = {
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
expandedRequirementId() === requirement.id
|
expandedRequirementId() === requirement.id
|
||||||
? 'Alternativen schließen'
|
? 'Möbelvorschläge schließen'
|
||||||
: 'Alternativen vergleichen'
|
: 'Möbelvorschläge ansehen'
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
@if (canEdit) {
|
@if (canEdit) {
|
||||||
@@ -268,14 +348,14 @@ const statusLabels: Record<string, string> = {
|
|||||||
type="button"
|
type="button"
|
||||||
(click)="newOption(requirement)"
|
(click)="newOption(requirement)"
|
||||||
>
|
>
|
||||||
Alternative hinzufügen
|
Möbelvorschlag hinzufügen
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (expandedRequirementId() === requirement.id) {
|
@if (expandedRequirementId() === requirement.id) {
|
||||||
<div class="comparison" aria-label="Alternativenvergleich">
|
<div class="comparison" aria-label="Vergleich der Möbelvorschläge">
|
||||||
@if (!requirement.options.length) {
|
@if (!requirement.options.length) {
|
||||||
<p>Noch keine Alternative erfasst.</p>
|
<p>Noch kein Möbelvorschlag erfasst.</p>
|
||||||
}
|
}
|
||||||
@for (option of requirement.options; track option.id) {
|
@for (option of requirement.options; track option.id) {
|
||||||
<article class="option" [class.selected]="option.currentlySelected">
|
<article class="option" [class.selected]="option.currentlySelected">
|
||||||
@@ -392,6 +472,27 @@ const statusLabels: Record<string, string> = {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="planning-details ui-card">
|
||||||
|
<summary>Detailtabelle und Bestellungen öffnen</summary>
|
||||||
|
<p class="detail-description">
|
||||||
|
Für umfangreiche Planungen: Bedarfe filtern, Daten direkt bearbeiten, Bestellungen prüfen
|
||||||
|
und Szenarien tabellarisch vergleichen.
|
||||||
|
</p>
|
||||||
|
<div #furnitureGridHost>
|
||||||
|
<app-furniture-grid
|
||||||
|
[projectId]="projectId"
|
||||||
|
[rooms]="rooms"
|
||||||
|
[canEdit]="canEdit"
|
||||||
|
[scenarios]="scenarios()"
|
||||||
|
(editRequirement)="editRequirement($event)"
|
||||||
|
(addOption)="newOption($event)"
|
||||||
|
(editOption)="editOptionFromGrid($event)"
|
||||||
|
(assignScenario)="openScenarioAssignment($event.scenarioId, $event.requirement)"
|
||||||
|
(dataChanged)="load()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<dialog
|
<dialog
|
||||||
@@ -403,7 +504,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
@if (showOptionForm()) {
|
@if (showOptionForm()) {
|
||||||
<form class="ui-card form-grid" [formGroup]="optionForm" (ngSubmit)="saveOption()">
|
<form class="ui-card form-grid" [formGroup]="optionForm" (ngSubmit)="saveOption()">
|
||||||
<h3 id="option-form-title">
|
<h3 id="option-form-title">
|
||||||
{{ editingOption() ? 'Alternative bearbeiten' : 'Alternative hinzufügen' }}
|
{{ editingOption() ? 'Möbelvorschlag bearbeiten' : 'Möbelvorschlag hinzufügen' }}
|
||||||
</h3>
|
</h3>
|
||||||
<label>Name *<input formControlName="name" /></label
|
<label>Name *<input formControlName="name" /></label
|
||||||
><label>Hersteller<input formControlName="manufacturer" /></label
|
><label>Hersteller<input formControlName="manufacturer" /></label
|
||||||
@@ -500,42 +601,62 @@ const statusLabels: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
#optionPickerDialog
|
||||||
|
class="editor-dialog"
|
||||||
|
aria-labelledby="option-target-title"
|
||||||
|
(cancel)="closeOptionPicker()"
|
||||||
|
>
|
||||||
|
@if (showOptionPicker()) {
|
||||||
|
<form
|
||||||
|
class="ui-card dialog-form"
|
||||||
|
[formGroup]="optionTargetForm"
|
||||||
|
(ngSubmit)="chooseOptionRequirement()"
|
||||||
|
>
|
||||||
|
<div class="dialog-heading">
|
||||||
|
<span class="eyebrow">Schritt 2 von 3</span>
|
||||||
|
<h3 id="option-target-title">Für welchen Bedarf ist das Möbel?</h3>
|
||||||
|
<p>
|
||||||
|
So bleibt jede Produktalternative direkt dem richtigen Raum und Bedarf zugeordnet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>Bedarf *<select formControlName="requirementId">
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
@for (requirement of requirements(); track requirement.id) {
|
||||||
|
<option [value]="requirement.id">
|
||||||
|
{{ requirement.name }} · {{ roomName(requirement.roomId) }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ui-button" type="submit">Weiter zum Möbel</button>
|
||||||
|
<button class="ui-button ui-button--ghost" type="button" (click)="closeOptionPicker()">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<section class="ui-card scenarios">
|
<section class="ui-card scenarios">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Einrichtungsszenarien</h2>
|
<h2>Einrichtungsvarianten</h2>
|
||||||
<p>Budget-, Wunsch- und Premiumvarianten vergleichen.</p>
|
<p>
|
||||||
|
Ein Szenario ist eine komplette Variante: Für jeden Bedarf wählen Sie darin einen
|
||||||
|
Möbelvorschlag aus.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@if (canEdit) {
|
@if (canEdit) {
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<button class="ui-button" type="button" (click)="openScenarioForm()">
|
||||||
class="ui-button ui-button--secondary"
|
Neue Variante erstellen
|
||||||
type="button"
|
|
||||||
(click)="openScenarioGrid(furnitureGrid)"
|
|
||||||
>
|
|
||||||
Zuordnungen bearbeiten
|
|
||||||
</button>
|
|
||||||
<button class="ui-button" type="button" (click)="showScenarioForm.set(true)">
|
|
||||||
Szenario erstellen
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (showScenarioForm()) {
|
|
||||||
<form class="inline-form" [formGroup]="scenarioForm" (ngSubmit)="createScenario()">
|
|
||||||
<label>Name<input formControlName="name" /></label
|
|
||||||
><label
|
|
||||||
>Vorauswahl<select formControlName="automaticSelection">
|
|
||||||
<option value="budget">Günstig</option>
|
|
||||||
<option value="preferred">Bevorzugt</option>
|
|
||||||
<option value="premium">Premium</option>
|
|
||||||
<option value="existing">Bestand</option>
|
|
||||||
</select></label
|
|
||||||
><label class="check"
|
|
||||||
><input type="checkbox" formControlName="isDefault" /> Standardszenario</label
|
|
||||||
><button class="ui-button" type="submit" [disabled]="saving()">Erstellen</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
<div class="scenario-grid">
|
<div class="scenario-grid">
|
||||||
@for (scenario of scenarios(); track scenario.id) {
|
@for (scenario of scenarios(); track scenario.id) {
|
||||||
<article>
|
<article>
|
||||||
@@ -549,6 +670,15 @@ const statusLabels: Record<string, string> = {
|
|||||||
<p>
|
<p>
|
||||||
{{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen
|
{{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen
|
||||||
</p>
|
</p>
|
||||||
|
@if (canEdit) {
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button--ghost"
|
||||||
|
type="button"
|
||||||
|
(click)="openAssignment(scenario.id)"
|
||||||
|
>
|
||||||
|
Möbel zuordnen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</article>
|
</article>
|
||||||
} @empty {
|
} @empty {
|
||||||
<p>Noch kein Szenario vorhanden.</p>
|
<p>Noch kein Szenario vorhanden.</p>
|
||||||
@@ -574,6 +704,165 @@ const statusLabels: Record<string, string> = {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
#scenarioDialog
|
||||||
|
class="editor-dialog"
|
||||||
|
aria-labelledby="scenario-form-title"
|
||||||
|
(cancel)="closeScenarioForm()"
|
||||||
|
>
|
||||||
|
@if (showScenarioForm()) {
|
||||||
|
<form class="ui-card dialog-form" [formGroup]="scenarioForm" (ngSubmit)="createScenario()">
|
||||||
|
<div class="dialog-heading">
|
||||||
|
<span class="eyebrow">Einrichtungsvariante</span>
|
||||||
|
<h3 id="scenario-form-title">Neues Szenario erstellen</h3>
|
||||||
|
<p>
|
||||||
|
Die Vorauswahl befüllt das Szenario automatisch. Sie können jede Zuordnung danach
|
||||||
|
ändern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label>Name *<input formControlName="name" placeholder="z. B. Wunschlösung" /></label>
|
||||||
|
<label
|
||||||
|
>Automatische Vorauswahl<select formControlName="automaticSelection">
|
||||||
|
<option value="budget">Günstigste Möbelvorschläge</option>
|
||||||
|
<option value="preferred">Bevorzugte Möbelvorschläge</option>
|
||||||
|
<option value="premium">Hochwertigste Möbelvorschläge</option>
|
||||||
|
<option value="existing">Vorhandene Möbel</option></select
|
||||||
|
><small
|
||||||
|
>Damit erhalten Sie sofort eine erste, anpassbare Zusammenstellung.</small
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label class="check"
|
||||||
|
><input type="checkbox" formControlName="isDefault" /> Als Standardszenario
|
||||||
|
verwenden</label
|
||||||
|
>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ui-button" type="submit" [disabled]="saving()">
|
||||||
|
{{ saving() ? 'Erstellt …' : 'Szenario erstellen' }}
|
||||||
|
</button>
|
||||||
|
<button class="ui-button ui-button--ghost" type="button" (click)="closeScenarioForm()">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
#assignmentDialog
|
||||||
|
class="editor-dialog"
|
||||||
|
aria-labelledby="assignment-form-title"
|
||||||
|
(cancel)="closeAssignment()"
|
||||||
|
>
|
||||||
|
@if (showAssignmentForm()) {
|
||||||
|
<form
|
||||||
|
class="ui-card dialog-form"
|
||||||
|
[formGroup]="assignmentForm"
|
||||||
|
(ngSubmit)="saveAssignment()"
|
||||||
|
>
|
||||||
|
<div class="dialog-heading">
|
||||||
|
<span class="eyebrow">Schritt 3 von 3</span>
|
||||||
|
<h3 id="assignment-form-title">Möbel für ein Szenario auswählen</h3>
|
||||||
|
<p>
|
||||||
|
Wählen Sie zuerst die Variante und den Bedarf, danach den passenden Möbelvorschlag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@if (!scenarios().length) {
|
||||||
|
<div class="dialog-notice" role="status">
|
||||||
|
<strong>Es fehlt noch ein Szenario.</strong
|
||||||
|
><span>Erstellen Sie zuerst eine Einrichtungsvariante.</span>
|
||||||
|
</div>
|
||||||
|
} @else if (!assignableRequirements().length) {
|
||||||
|
<div class="dialog-notice" role="status">
|
||||||
|
<strong>Es fehlt noch ein Möbelvorschlag.</strong
|
||||||
|
><span>Erfassen Sie für mindestens einen Bedarf ein konkretes Möbel.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<label
|
||||||
|
>Szenario *<select formControlName="scenarioId">
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
@for (scenario of scenarios(); track scenario.id) {
|
||||||
|
<option [value]="scenario.id">{{ scenario.name }}</option>
|
||||||
|
}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Bedarf *<select
|
||||||
|
formControlName="requirementId"
|
||||||
|
(change)="assignmentRequirementChanged()"
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
@for (requirement of assignableRequirements(); track requirement.id) {
|
||||||
|
<option [value]="requirement.id">
|
||||||
|
{{ requirement.name }} · {{ roomName(requirement.roomId) }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<fieldset class="option-selection">
|
||||||
|
<legend>Möbelvorschlag auswählen *</legend>
|
||||||
|
<div class="option-choice-grid">
|
||||||
|
@for (option of assignmentOptions(); track option.id) {
|
||||||
|
<label
|
||||||
|
class="option-choice"
|
||||||
|
[class.option-choice--selected]="
|
||||||
|
assignmentForm.controls.optionId.value === option.id
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input type="radio" formControlName="optionId" [value]="option.id" />
|
||||||
|
<span class="option-choice__content">
|
||||||
|
<span class="option-choice__head">
|
||||||
|
<strong>{{ option.name }}</strong>
|
||||||
|
<strong class="price">{{
|
||||||
|
option.totalPrice | currency: option.currency : 'symbol' : '1.2-2' : 'de'
|
||||||
|
}}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="markers">
|
||||||
|
@if (option.favorite) {
|
||||||
|
<span>★ Favorit</span>
|
||||||
|
}
|
||||||
|
@if (option.existingItem) {
|
||||||
|
<span>↺ Bereits vorhanden</span>
|
||||||
|
}
|
||||||
|
<span>{{ optionAvailability(option.availability) }}</span>
|
||||||
|
</span>
|
||||||
|
<dl class="option-facts">
|
||||||
|
<dt>Hersteller / Modell</dt>
|
||||||
|
<dd>{{ optionManufacturer(option) }}</dd>
|
||||||
|
<dt>Händler</dt>
|
||||||
|
<dd>{{ option.retailer || 'Nicht angegeben' }}</dd>
|
||||||
|
<dt>Maße B × H × T</dt>
|
||||||
|
<dd>{{ optionDimensions(option) }}</dd>
|
||||||
|
<dt>Lieferzeit</dt>
|
||||||
|
<dd>{{ optionDelivery(option) }}</dd>
|
||||||
|
</dl>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
<div class="actions">
|
||||||
|
@if (!scenarios().length) {
|
||||||
|
<button class="ui-button" type="button" (click)="continueWithScenario()">
|
||||||
|
Szenario erstellen
|
||||||
|
</button>
|
||||||
|
} @else if (!assignableRequirements().length) {
|
||||||
|
<button class="ui-button" type="button" (click)="continueWithOption()">
|
||||||
|
Möbelvorschlag hinzufügen
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="ui-button" type="submit" [disabled]="saving()">
|
||||||
|
{{ saving() ? 'Wird zugeordnet …' : 'Jetzt zuordnen' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="ui-button ui-button--ghost" type="button" (click)="closeAssignment()">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</dialog>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
@@ -604,6 +893,14 @@ const statusLabels: Record<string, string> = {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.dialog-heading,
|
||||||
|
.dialog-notice {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.dialog-form label small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
.metric,
|
.metric,
|
||||||
.requirement,
|
.requirement,
|
||||||
.scenarios {
|
.scenarios {
|
||||||
@@ -675,6 +972,22 @@ const statusLabels: Record<string, string> = {
|
|||||||
border: 0;
|
border: 0;
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
}
|
}
|
||||||
|
.dialog-form {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
border: 0;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
.dialog-heading {
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.dialog-notice {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-left: 0.25rem solid var(--color-info);
|
||||||
|
background: var(--color-info-subtle);
|
||||||
|
}
|
||||||
.check {
|
.check {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -776,7 +1089,9 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
@Input() canEdit = false;
|
@Input() canEdit = false;
|
||||||
@ViewChild('requirementDialog') private requirementDialog?: ElementRef<HTMLDialogElement>;
|
@ViewChild('requirementDialog') private requirementDialog?: ElementRef<HTMLDialogElement>;
|
||||||
@ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>;
|
@ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>;
|
||||||
@ViewChild('furnitureGridHost') private furnitureGridHost?: ElementRef<HTMLElement>;
|
@ViewChild('optionPickerDialog') private optionPickerDialog?: ElementRef<HTMLDialogElement>;
|
||||||
|
@ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>;
|
||||||
|
@ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>;
|
||||||
private readonly api = inject(HauspilotApiService);
|
private readonly api = inject(HauspilotApiService);
|
||||||
readonly requirements = signal<FurnitureRequirement[]>([]);
|
readonly requirements = signal<FurnitureRequirement[]>([]);
|
||||||
readonly scenarios = signal<FurnitureScenario[]>([]);
|
readonly scenarios = signal<FurnitureScenario[]>([]);
|
||||||
@@ -789,12 +1104,35 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
readonly expandedRequirementId = signal<string | null>(null);
|
readonly expandedRequirementId = signal<string | null>(null);
|
||||||
readonly showRequirementForm = signal(false);
|
readonly showRequirementForm = signal(false);
|
||||||
readonly showOptionForm = signal(false);
|
readonly showOptionForm = signal(false);
|
||||||
|
readonly showOptionPicker = signal(false);
|
||||||
readonly showScenarioForm = signal(false);
|
readonly showScenarioForm = signal(false);
|
||||||
|
readonly showAssignmentForm = signal(false);
|
||||||
|
readonly guidedPlanning = signal(false);
|
||||||
readonly editingRequirement = signal<FurnitureRequirement | null>(null);
|
readonly editingRequirement = signal<FurnitureRequirement | null>(null);
|
||||||
readonly editingOption = signal<FurnitureOption | null>(null);
|
readonly editingOption = signal<FurnitureOption | null>(null);
|
||||||
readonly optionRequirement = signal<FurnitureRequirement | null>(null);
|
readonly optionRequirement = signal<FurnitureRequirement | null>(null);
|
||||||
readonly categories = Object.entries(categoryLabels);
|
readonly categories = Object.entries(categoryLabels);
|
||||||
readonly requirementStatuses = Object.entries(statusLabels);
|
readonly requirementStatuses = Object.entries(statusLabels);
|
||||||
|
readonly requirementsWithOptions = computed(
|
||||||
|
() => this.requirements().filter((requirement) => requirement.options.length > 0).length,
|
||||||
|
);
|
||||||
|
readonly assignedRequirements = computed(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
this.scenarios().flatMap((scenario) =>
|
||||||
|
scenario.selections.map((selection) => selection.requirementId),
|
||||||
|
),
|
||||||
|
).size,
|
||||||
|
);
|
||||||
|
readonly nextStep = computed<'need' | 'option' | 'scenario' | 'assign'>(() => {
|
||||||
|
if (!this.requirements().length) return 'need';
|
||||||
|
if (this.requirementsWithOptions() < this.requirements().length) return 'option';
|
||||||
|
if (!this.scenarios().length) return 'scenario';
|
||||||
|
return 'assign';
|
||||||
|
});
|
||||||
|
readonly assignableRequirements = computed(() =>
|
||||||
|
this.requirements().filter((requirement) => this.selectableOptions(requirement).length > 0),
|
||||||
|
);
|
||||||
readonly filterForm = new FormGroup({
|
readonly filterForm = new FormGroup({
|
||||||
search: new FormControl('', { nonNullable: true }),
|
search: new FormControl('', { nonNullable: true }),
|
||||||
roomId: new FormControl('', { nonNullable: true }),
|
roomId: new FormControl('', { nonNullable: true }),
|
||||||
@@ -878,6 +1216,26 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
automaticSelection: new FormControl('budget', { nonNullable: true }),
|
automaticSelection: new FormControl('budget', { nonNullable: true }),
|
||||||
isDefault: new FormControl(false, { nonNullable: true }),
|
isDefault: new FormControl(false, { nonNullable: true }),
|
||||||
});
|
});
|
||||||
|
readonly optionTargetForm = new FormGroup({
|
||||||
|
requirementId: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [(control) => Validators.required(control)],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
readonly assignmentForm = new FormGroup({
|
||||||
|
scenarioId: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [(control) => Validators.required(control)],
|
||||||
|
}),
|
||||||
|
requirementId: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [(control) => Validators.required(control)],
|
||||||
|
}),
|
||||||
|
optionId: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [(control) => Validators.required(control)],
|
||||||
|
}),
|
||||||
|
});
|
||||||
readonly calculatedTotal = computed(() => {
|
readonly calculatedTotal = computed(() => {
|
||||||
const v = this.optionForm.getRawValue();
|
const v = this.optionForm.getRawValue();
|
||||||
const acquisition = v.existingItem ? 0 : v.unitPrice * v.quantity;
|
const acquisition = v.existingItem ? 0 : v.unitPrice * v.quantity;
|
||||||
@@ -927,6 +1285,10 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
status(value: string) {
|
status(value: string) {
|
||||||
return statusLabels[value] ?? value;
|
return statusLabels[value] ?? value;
|
||||||
}
|
}
|
||||||
|
startGuidedPlanning() {
|
||||||
|
this.guidedPlanning.set(true);
|
||||||
|
this.newRequirement();
|
||||||
|
}
|
||||||
newRequirement() {
|
newRequirement() {
|
||||||
this.editingRequirement.set(null);
|
this.editingRequirement.set(null);
|
||||||
this.requirementForm.reset({
|
this.requirementForm.reset({
|
||||||
@@ -959,10 +1321,11 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
this.showRequirementForm.set(true);
|
this.showRequirementForm.set(true);
|
||||||
this.openDialog(this.requirementDialog);
|
this.openDialog(this.requirementDialog);
|
||||||
}
|
}
|
||||||
closeRequirementForm() {
|
closeRequirementForm(keepFlow = false) {
|
||||||
this.requirementDialog?.nativeElement.close();
|
this.requirementDialog?.nativeElement.close();
|
||||||
this.showRequirementForm.set(false);
|
this.showRequirementForm.set(false);
|
||||||
this.editingRequirement.set(null);
|
this.editingRequirement.set(null);
|
||||||
|
if (!keepFlow) this.guidedPlanning.set(false);
|
||||||
}
|
}
|
||||||
saveRequirement() {
|
saveRequirement() {
|
||||||
if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched();
|
if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched();
|
||||||
@@ -976,9 +1339,10 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
)
|
)
|
||||||
: this.api.createFurnitureRequirement(this.projectId, this.requirementForm.getRawValue());
|
: this.api.createFurnitureRequirement(this.projectId, this.requirementForm.getRawValue());
|
||||||
request.subscribe({
|
request.subscribe({
|
||||||
next: () => {
|
next: (saved) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.closeRequirementForm();
|
this.closeRequirementForm(true);
|
||||||
|
if (!existing && this.guidedPlanning()) this.newOption(saved);
|
||||||
this.load();
|
this.load();
|
||||||
},
|
},
|
||||||
error: (e: unknown) => this.fail(e),
|
error: (e: unknown) => this.fail(e),
|
||||||
@@ -987,6 +1351,32 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
toggleOptions(r: FurnitureRequirement) {
|
toggleOptions(r: FurnitureRequirement) {
|
||||||
this.expandedRequirementId.set(this.expandedRequirementId() === r.id ? null : r.id);
|
this.expandedRequirementId.set(this.expandedRequirementId() === r.id ? null : r.id);
|
||||||
}
|
}
|
||||||
|
openOptionPicker() {
|
||||||
|
if (!this.requirements().length) {
|
||||||
|
this.startGuidedPlanning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.guidedPlanning.set(true);
|
||||||
|
this.optionTargetForm.reset({
|
||||||
|
requirementId: this.requirements().length === 1 ? (this.requirements()[0]?.id ?? '') : '',
|
||||||
|
});
|
||||||
|
this.showOptionPicker.set(true);
|
||||||
|
this.openDialog(this.optionPickerDialog);
|
||||||
|
}
|
||||||
|
closeOptionPicker(keepFlow = false) {
|
||||||
|
this.optionPickerDialog?.nativeElement.close();
|
||||||
|
this.showOptionPicker.set(false);
|
||||||
|
if (!keepFlow) this.guidedPlanning.set(false);
|
||||||
|
}
|
||||||
|
chooseOptionRequirement() {
|
||||||
|
if (this.optionTargetForm.invalid) return this.optionTargetForm.markAllAsTouched();
|
||||||
|
const requirement = this.requirements().find(
|
||||||
|
(entry) => entry.id === this.optionTargetForm.controls.requirementId.value,
|
||||||
|
);
|
||||||
|
if (!requirement) return;
|
||||||
|
this.closeOptionPicker(true);
|
||||||
|
this.newOption(requirement);
|
||||||
|
}
|
||||||
newOption(r: FurnitureRequirement) {
|
newOption(r: FurnitureRequirement) {
|
||||||
this.optionRequirement.set(r);
|
this.optionRequirement.set(r);
|
||||||
this.editingOption.set(null);
|
this.editingOption.set(null);
|
||||||
@@ -1050,11 +1440,12 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
this.showOptionForm.set(true);
|
this.showOptionForm.set(true);
|
||||||
this.openDialog(this.optionDialog);
|
this.openDialog(this.optionDialog);
|
||||||
}
|
}
|
||||||
closeOptionForm() {
|
closeOptionForm(keepFlow = false) {
|
||||||
this.optionDialog?.nativeElement.close();
|
this.optionDialog?.nativeElement.close();
|
||||||
this.showOptionForm.set(false);
|
this.showOptionForm.set(false);
|
||||||
this.editingOption.set(null);
|
this.editingOption.set(null);
|
||||||
this.optionRequirement.set(null);
|
this.optionRequirement.set(null);
|
||||||
|
if (!keepFlow) this.guidedPlanning.set(false);
|
||||||
}
|
}
|
||||||
editOptionFromGrid(option: FurnitureOption) {
|
editOptionFromGrid(option: FurnitureOption) {
|
||||||
this.api.furnitureRequirement(this.projectId, option.requirementId).subscribe({
|
this.api.furnitureRequirement(this.projectId, option.requirementId).subscribe({
|
||||||
@@ -1072,9 +1463,18 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
? this.api.updateFurnitureOption(this.projectId, existing, body)
|
? this.api.updateFurnitureOption(this.projectId, existing, body)
|
||||||
: this.api.createFurnitureOption(this.projectId, requirement.id, body);
|
: this.api.createFurnitureOption(this.projectId, requirement.id, body);
|
||||||
request.subscribe({
|
request.subscribe({
|
||||||
next: () => {
|
next: (saved) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.closeOptionForm();
|
this.closeOptionForm(true);
|
||||||
|
if (!existing && this.guidedPlanning()) {
|
||||||
|
this.requirements.update((current) => {
|
||||||
|
const updated = { ...requirement, options: [...requirement.options, saved] };
|
||||||
|
return current.some((entry) => entry.id === requirement.id)
|
||||||
|
? current.map((entry) => (entry.id === requirement.id ? updated : entry))
|
||||||
|
: [...current, updated];
|
||||||
|
});
|
||||||
|
this.openAssignment(undefined, requirement.id, saved.id);
|
||||||
|
}
|
||||||
this.load();
|
this.load();
|
||||||
},
|
},
|
||||||
error: (e: unknown) => this.fail(e),
|
error: (e: unknown) => this.fail(e),
|
||||||
@@ -1103,14 +1503,18 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
.deliverFurnitureOption(this.projectId, o, o.quantity)
|
.deliverFurnitureOption(this.projectId, o, o.quantity)
|
||||||
.subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) });
|
.subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) });
|
||||||
}
|
}
|
||||||
openScenarioGrid(grid: FurnitureGridComponent) {
|
openScenarioForm() {
|
||||||
grid.setView('scenarios');
|
this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false });
|
||||||
queueMicrotask(() =>
|
this.showScenarioForm.set(true);
|
||||||
this.furnitureGridHost?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
this.openDialog(this.scenarioDialog);
|
||||||
);
|
}
|
||||||
|
closeScenarioForm(keepFlow = false) {
|
||||||
|
this.scenarioDialog?.nativeElement.close();
|
||||||
|
this.showScenarioForm.set(false);
|
||||||
|
if (!keepFlow) this.guidedPlanning.set(false);
|
||||||
}
|
}
|
||||||
createScenario() {
|
createScenario() {
|
||||||
if (this.scenarioForm.invalid) return;
|
if (this.scenarioForm.invalid) return this.scenarioForm.markAllAsTouched();
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
const value = this.scenarioForm.getRawValue();
|
const value = this.scenarioForm.getRawValue();
|
||||||
this.api
|
this.api
|
||||||
@@ -1122,15 +1526,128 @@ export class FurniturePlanningComponent implements OnChanges {
|
|||||||
isDefault: value.isDefault,
|
isDefault: value.isDefault,
|
||||||
})
|
})
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: (saved) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.showScenarioForm.set(false);
|
this.closeScenarioForm(true);
|
||||||
this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false });
|
this.scenarios.update((current) => [...current, saved]);
|
||||||
|
if (this.guidedPlanning()) this.openAssignment(saved.id);
|
||||||
this.load();
|
this.load();
|
||||||
},
|
},
|
||||||
error: (e: unknown) => this.fail(e),
|
error: (e: unknown) => this.fail(e),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
openAssignment(scenarioId = '', requirementId = '', optionId = '') {
|
||||||
|
const scenario =
|
||||||
|
this.scenarios().find((entry) => entry.id === scenarioId) ??
|
||||||
|
this.scenarios().find((entry) => entry.isDefault) ??
|
||||||
|
this.scenarios()[0];
|
||||||
|
const requirement =
|
||||||
|
this.assignableRequirements().find((entry) => entry.id === requirementId) ??
|
||||||
|
this.assignableRequirements()[0];
|
||||||
|
const selection = scenario?.selections.find((entry) => entry.requirementId === requirement?.id);
|
||||||
|
this.assignmentForm.reset({
|
||||||
|
scenarioId: scenario?.id ?? '',
|
||||||
|
requirementId: requirement?.id ?? '',
|
||||||
|
optionId: optionId || selection?.optionId || '',
|
||||||
|
});
|
||||||
|
this.showAssignmentForm.set(true);
|
||||||
|
this.openDialog(this.assignmentDialog);
|
||||||
|
}
|
||||||
|
openScenarioAssignment(scenarioId: string, requirement: FurnitureRequirement) {
|
||||||
|
this.requirements.update((current) =>
|
||||||
|
current.some((entry) => entry.id === requirement.id)
|
||||||
|
? current.map((entry) => (entry.id === requirement.id ? requirement : entry))
|
||||||
|
: [...current, requirement],
|
||||||
|
);
|
||||||
|
this.openAssignment(scenarioId, requirement.id);
|
||||||
|
}
|
||||||
|
closeAssignment(keepFlow = false) {
|
||||||
|
this.assignmentDialog?.nativeElement.close();
|
||||||
|
this.showAssignmentForm.set(false);
|
||||||
|
if (!keepFlow) this.guidedPlanning.set(false);
|
||||||
|
}
|
||||||
|
assignmentOptions() {
|
||||||
|
const requirement = this.requirements().find(
|
||||||
|
(entry) => entry.id === this.assignmentForm.controls.requirementId.value,
|
||||||
|
);
|
||||||
|
return requirement ? this.selectableOptions(requirement) : [];
|
||||||
|
}
|
||||||
|
optionManufacturer(option: FurnitureOption) {
|
||||||
|
return [option.manufacturer, option.model].filter(Boolean).join(' · ') || 'Nicht angegeben';
|
||||||
|
}
|
||||||
|
optionDimensions(option: FurnitureOption) {
|
||||||
|
if (option.width === null && option.height === null && option.depth === null)
|
||||||
|
return 'Nicht angegeben';
|
||||||
|
return `${option.width ?? '–'} × ${option.height ?? '–'} × ${option.depth ?? '–'} cm`;
|
||||||
|
}
|
||||||
|
optionDelivery(option: FurnitureOption) {
|
||||||
|
return option.deliveryDays === null ? 'Nicht angegeben' : `${option.deliveryDays} Tage`;
|
||||||
|
}
|
||||||
|
optionAvailability(value: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
unknown: 'Verfügbarkeit unbekannt',
|
||||||
|
available: 'Verfügbar',
|
||||||
|
limited: 'Begrenzt verfügbar',
|
||||||
|
unavailable: 'Nicht verfügbar',
|
||||||
|
discontinued: 'Nicht mehr erhältlich',
|
||||||
|
};
|
||||||
|
return labels[value] ?? value;
|
||||||
|
}
|
||||||
|
assignmentRequirementChanged() {
|
||||||
|
const scenario = this.scenarios().find(
|
||||||
|
(entry) => entry.id === this.assignmentForm.controls.scenarioId.value,
|
||||||
|
);
|
||||||
|
const selection = scenario?.selections.find(
|
||||||
|
(entry) => entry.requirementId === this.assignmentForm.controls.requirementId.value,
|
||||||
|
);
|
||||||
|
this.assignmentForm.controls.optionId.setValue(selection?.optionId ?? '');
|
||||||
|
}
|
||||||
|
continueWithScenario() {
|
||||||
|
this.guidedPlanning.set(true);
|
||||||
|
this.closeAssignment(true);
|
||||||
|
this.openScenarioForm();
|
||||||
|
}
|
||||||
|
continueWithOption() {
|
||||||
|
this.guidedPlanning.set(true);
|
||||||
|
this.closeAssignment(true);
|
||||||
|
this.openOptionPicker();
|
||||||
|
}
|
||||||
|
saveAssignment() {
|
||||||
|
if (this.assignmentForm.invalid) return this.assignmentForm.markAllAsTouched();
|
||||||
|
const value = this.assignmentForm.getRawValue();
|
||||||
|
const scenario = this.scenarios().find((entry) => entry.id === value.scenarioId);
|
||||||
|
const requirement = this.requirements().find((entry) => entry.id === value.requirementId);
|
||||||
|
const option = requirement?.options.find((entry) => entry.id === value.optionId);
|
||||||
|
if (!scenario || !requirement || !option) return;
|
||||||
|
const selections = scenario.selections
|
||||||
|
.filter((selection) => selection.requirementId !== requirement.id)
|
||||||
|
.map((selection) => ({ ...selection }));
|
||||||
|
selections.push({
|
||||||
|
requirementId: requirement.id,
|
||||||
|
optionId: option.id,
|
||||||
|
quantity: option.quantity,
|
||||||
|
});
|
||||||
|
this.saving.set(true);
|
||||||
|
this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({
|
||||||
|
next: (saved) => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.scenarios.update((current) =>
|
||||||
|
current.map((entry) => (entry.id === saved.id ? saved : entry)),
|
||||||
|
);
|
||||||
|
this.guidedPlanning.set(false);
|
||||||
|
this.closeAssignment();
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (error: unknown) => this.fail(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private selectableOptions(requirement: FurnitureRequirement) {
|
||||||
|
return requirement.options.filter(
|
||||||
|
(option) =>
|
||||||
|
!['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) &&
|
||||||
|
!['unavailable', 'discontinued'].includes(option.availability),
|
||||||
|
);
|
||||||
|
}
|
||||||
private fail(error: unknown) {
|
private fail(error: unknown) {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
import type { OnInit } from '@angular/core';
|
import type { OnInit } from '@angular/core';
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import type { Observable } from 'rxjs';
|
import type { Observable } from 'rxjs';
|
||||||
import type { ApiErrorBody, ProjectDto, ProjectMemberDto } from '@boilerplate/api-client';
|
import type { ApiErrorBody, ProjectDto, ProjectMemberDto } from '@boilerplate/api-client';
|
||||||
import { ApiClientService } from '@boilerplate/api-client';
|
import { ApiClientService } from '@boilerplate/api-client';
|
||||||
@@ -30,17 +30,6 @@ import type {
|
|||||||
Room,
|
Room,
|
||||||
} from './hauspilot-api.service';
|
} from './hauspilot-api.service';
|
||||||
|
|
||||||
const sections = [
|
|
||||||
['uebersicht', 'Übersicht'],
|
|
||||||
['raeume', 'Räume'],
|
|
||||||
['aufgaben', 'Aufgaben'],
|
|
||||||
['zeitplan', 'Zeitplan'],
|
|
||||||
['budget', 'Budget'],
|
|
||||||
['moebel', 'Möbel & Einrichtung'],
|
|
||||||
['dokumente', 'Dokumente'],
|
|
||||||
['aktivitaeten', 'Aktivitäten'],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
@@ -48,7 +37,6 @@ const sections = [
|
|||||||
DatePipe,
|
DatePipe,
|
||||||
DecimalPipe,
|
DecimalPipe,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterLink,
|
|
||||||
UiEmptyStateComponent,
|
UiEmptyStateComponent,
|
||||||
UiPageHeaderComponent,
|
UiPageHeaderComponent,
|
||||||
UiStatusBadgeComponent,
|
UiStatusBadgeComponent,
|
||||||
@@ -57,22 +45,11 @@ const sections = [
|
|||||||
FurniturePlanningComponent,
|
FurniturePlanningComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<a routerLink="/projekte">Zurück zu den Projekten</a>
|
|
||||||
@if (project(); as project) {
|
@if (project(); as project) {
|
||||||
<ui-page-header
|
<ui-page-header
|
||||||
[title]="project.name"
|
[title]="project.name"
|
||||||
[description]="project.description || 'Renovierung und Umzug gemeinsam planen'"
|
[description]="project.description || 'Renovierung und Umzug gemeinsam planen'"
|
||||||
/>
|
/>
|
||||||
<nav class="project-nav" aria-label="Projektbereiche">
|
|
||||||
@for (item of navigation; track item[0]) {
|
|
||||||
<a
|
|
||||||
[routerLink]="['/projekte', projectId, item[0]]"
|
|
||||||
[class.active]="section() === item[0]"
|
|
||||||
>{{ item[1] }}</a
|
|
||||||
>
|
|
||||||
}
|
|
||||||
<a [routerLink]="['/projekte', projectId]">Mitglieder</a>
|
|
||||||
</nav>
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<p role="status">Projektdaten werden geladen …</p>
|
<p role="status">Projektdaten werden geladen …</p>
|
||||||
}
|
}
|
||||||
@@ -680,25 +657,6 @@ const sections = [
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
.project-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.project-nav a {
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.project-nav a.active {
|
|
||||||
background: var(--color-primary-subtle);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.metric-grid,
|
.metric-grid,
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -775,7 +733,6 @@ export class ProjectWorkspacePageComponent implements OnInit {
|
|||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
projectId = '';
|
projectId = '';
|
||||||
readonly navigation = sections;
|
|
||||||
readonly section = signal('uebersicht');
|
readonly section = signal('uebersicht');
|
||||||
readonly project = signal<ProjectDto | null>(null);
|
readonly project = signal<ProjectDto | null>(null);
|
||||||
readonly dashboard = signal<ProjectDashboard | null>(null);
|
readonly dashboard = signal<ProjectDashboard | null>(null);
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { Router, provideRouter } from '@angular/router';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { ApiClientService } from '@boilerplate/api-client';
|
import { ApiClientService } from '@boilerplate/api-client';
|
||||||
import { AppShellComponent } from './app-shell';
|
import { AppShellComponent } from './app-shell';
|
||||||
|
|
||||||
|
@Component({ standalone: true, template: '' })
|
||||||
|
class EmptyPageComponent {}
|
||||||
|
|
||||||
describe('AppShellComponent', () => {
|
describe('AppShellComponent', () => {
|
||||||
|
beforeEach(() => sessionStorage.clear());
|
||||||
it('hides navigation entries without matching permissions', async () => {
|
it('hides navigation entries without matching permissions', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppShellComponent],
|
imports: [AppShellComponent],
|
||||||
@@ -99,4 +104,59 @@ describe('AppShellComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.sidebar.open')).toBeNull();
|
expect((fixture.nativeElement as HTMLElement).querySelector('.sidebar.open')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an opened project as a tab and its contents in the sidebar', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AppShellComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([{ path: 'projekte/:id/:section', component: EmptyPageComponent }]),
|
||||||
|
{
|
||||||
|
provide: ApiClientService,
|
||||||
|
useValue: {
|
||||||
|
me: () =>
|
||||||
|
of({
|
||||||
|
id: 'u1',
|
||||||
|
name: 'Ada',
|
||||||
|
email: null,
|
||||||
|
active: true,
|
||||||
|
lastLoginAt: null,
|
||||||
|
settings: { tablePageSize: 20, sidebarExpanded: true },
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
name: 'user',
|
||||||
|
protected: true,
|
||||||
|
permissions: [{ id: 'projects.use', description: 'projects.use' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
project: () =>
|
||||||
|
of({
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Wohnung Budapest',
|
||||||
|
description: null,
|
||||||
|
role: 'owner',
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
}),
|
||||||
|
unreadNotificationCount: () => of({ count: 0 }),
|
||||||
|
notifications: () => of({ items: [], total: 0, page: 1, pageSize: 20, unreadCount: 0 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
await router.navigateByUrl('/projekte/project-1/moebel');
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(host.querySelector('.project-tab')?.textContent).toContain('Wohnung Budapest');
|
||||||
|
expect(host.querySelector('.sidebar-context')?.textContent).toContain('Wohnung Budapest');
|
||||||
|
expect(host.querySelector('.project-navigation')?.textContent).toContain('Möbel & Einrichtung');
|
||||||
|
expect(host.querySelector('.project-navigation a.active')?.textContent).toContain(
|
||||||
|
'Möbel & Einrichtung',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, HostListener, computed, effect, inject, signal } from '@angular/core';
|
import { Component, HostListener, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import type { Permission } from '@boilerplate/api-client';
|
import { NavigationEnd, RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
|
||||||
|
import { ApiClientService, type Permission } from '@boilerplate/api-client';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
import { NotificationStore } from '../core/notification.store';
|
import { NotificationStore } from '../core/notification.store';
|
||||||
import { AuthService } from '../core/auth.service';
|
import { AuthService } from '../core/auth.service';
|
||||||
import { NotificationPanelComponent } from './notification-panel';
|
import { NotificationPanelComponent } from './notification-panel';
|
||||||
@@ -12,6 +14,23 @@ interface NavItem {
|
|||||||
permission?: Permission;
|
permission?: Permission;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectTab {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectSections = [
|
||||||
|
{ id: 'uebersicht', label: 'Übersicht' },
|
||||||
|
{ id: 'raeume', label: 'Räume' },
|
||||||
|
{ id: 'aufgaben', label: 'Aufgaben' },
|
||||||
|
{ id: 'zeitplan', label: 'Zeitplan' },
|
||||||
|
{ id: 'budget', label: 'Budget' },
|
||||||
|
{ id: 'moebel', label: 'Möbel & Einrichtung' },
|
||||||
|
{ id: 'dokumente', label: 'Dokumente' },
|
||||||
|
{ id: 'aktivitaeten', label: 'Aktivitäten' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shell',
|
selector: 'app-shell',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -30,7 +49,28 @@ interface NavItem {
|
|||||||
@if (auth.user()) {
|
@if (auth.user()) {
|
||||||
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
|
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
|
||||||
}
|
}
|
||||||
<strong class="brand">HausPilot</strong>
|
<a class="brand" routerLink="/">HausPilot</a>
|
||||||
|
@if (projectTabs().length) {
|
||||||
|
<nav class="project-tabs" aria-label="Geöffnete Projekte">
|
||||||
|
@for (tab of projectTabs(); track tab.id) {
|
||||||
|
<div class="project-tab" [class.active]="currentProjectId() === tab.id">
|
||||||
|
<a
|
||||||
|
[routerLink]="tab.path"
|
||||||
|
[attr.aria-current]="currentProjectId() === tab.id ? 'page' : null"
|
||||||
|
>
|
||||||
|
<span>{{ tab.name }}</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="tab.name + ' schließen'"
|
||||||
|
(click)="closeProjectTab($event, tab.id)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
@if (auth.user()) {
|
@if (auth.user()) {
|
||||||
<button
|
<button
|
||||||
class="ui-icon-button notification-button"
|
class="ui-icon-button notification-button"
|
||||||
@@ -68,24 +108,78 @@ interface NavItem {
|
|||||||
></button>
|
></button>
|
||||||
}
|
}
|
||||||
<aside class="sidebar" [class.open]="drawerOpen()">
|
<aside class="sidebar" [class.open]="drawerOpen()">
|
||||||
<nav aria-label="Hauptnavigation">
|
@if (currentProjectId(); as projectId) {
|
||||||
@for (item of nav; track item.path) {
|
<div class="sidebar-context">
|
||||||
@if (visible(item)) {
|
<span class="sidebar-eyebrow">Aktuelles Projekt</span>
|
||||||
|
<strong>{{ currentProjectName() }}</strong>
|
||||||
|
</div>
|
||||||
|
<nav class="project-navigation" aria-label="Projektinhalte">
|
||||||
|
@for (item of projectNavigation; track item.id) {
|
||||||
<a
|
<a
|
||||||
[routerLink]="item.path"
|
[routerLink]="['/projekte', projectId, item.id]"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
|
|
||||||
(click)="closeDrawerOnNavigation()"
|
(click)="closeDrawerOnNavigation()"
|
||||||
|
>{{ item.label }}</a
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
}
|
<a
|
||||||
</nav>
|
[routerLink]="['/projekte', projectId]"
|
||||||
|
routerLinkActive="active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
(click)="closeDrawerOnNavigation()"
|
||||||
|
>Mitglieder & Zugriff</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
<nav class="sidebar-secondary" aria-label="Allgemeine Navigation">
|
||||||
|
<a routerLink="/projekte" (click)="closeDrawerOnNavigation()">← Alle Projekte</a>
|
||||||
|
<a routerLink="/notifications" (click)="closeDrawerOnNavigation()"
|
||||||
|
>Benachrichtigungen</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
} @else {
|
||||||
|
<nav aria-label="Hauptnavigation">
|
||||||
|
<span class="nav-heading">Arbeitsbereich</span>
|
||||||
|
@for (item of workspaceNav; track item.path) {
|
||||||
|
@if (visible(item)) {
|
||||||
|
<a
|
||||||
|
[routerLink]="item.path"
|
||||||
|
routerLinkActive="active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
|
||||||
|
(click)="closeDrawerOnNavigation()"
|
||||||
|
>{{ item.label }}</a
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<span class="nav-heading">Mein Konto</span>
|
||||||
|
@for (item of accountNav; track item.path) {
|
||||||
|
@if (visible(item)) {
|
||||||
|
<a
|
||||||
|
[routerLink]="item.path"
|
||||||
|
routerLinkActive="active"
|
||||||
|
(click)="closeDrawerOnNavigation()"
|
||||||
|
>{{ item.label }}</a
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (hasVisibleAdminNavigation()) {
|
||||||
|
<span class="nav-heading">Administration</span>
|
||||||
|
@for (item of adminNav; track item.path) {
|
||||||
|
@if (visible(item)) {
|
||||||
|
<a
|
||||||
|
[routerLink]="item.path"
|
||||||
|
routerLinkActive="active"
|
||||||
|
(click)="closeDrawerOnNavigation()"
|
||||||
|
>{{ item.label }}</a
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<nav class="breadcrumbs">Start / {{ title() }}</nav>
|
<nav class="breadcrumbs">{{ breadcrumb() }}</nav>
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</main>
|
</main>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -131,6 +225,53 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.project-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.project-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 9rem;
|
||||||
|
max-width: 15rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
.project-tab.active {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
box-shadow: inset 0 0.2rem var(--color-primary);
|
||||||
|
}
|
||||||
|
.project-tab a {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-tab button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.logout {
|
.logout {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -190,20 +331,56 @@ interface NavItem {
|
|||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
transition: transform var(--transition-base) ease;
|
transition: transform var(--transition-base) ease;
|
||||||
z-index: var(--z-drawer);
|
z-index: var(--z-drawer);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.sidebar.open {
|
.sidebar.open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
nav a {
|
.sidebar nav a {
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
min-height: var(--touch-target);
|
min-height: var(--touch-target);
|
||||||
}
|
}
|
||||||
nav a.active {
|
.sidebar nav a.active {
|
||||||
background: var(--color-primary-subtle);
|
background: var(--color-primary-subtle);
|
||||||
border-left: 4px solid var(--color-primary);
|
border-left: 4px solid var(--color-primary);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
.sidebar-context {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-primary-subtle);
|
||||||
|
}
|
||||||
|
.sidebar-context strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sidebar-eyebrow,
|
||||||
|
.nav-heading {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.nav-heading {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-5) var(--space-5) var(--space-2);
|
||||||
|
}
|
||||||
|
.project-navigation {
|
||||||
|
padding-block: var(--space-2);
|
||||||
|
}
|
||||||
|
.sidebar-secondary {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.sidebar-secondary a {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -229,22 +406,55 @@ interface NavItem {
|
|||||||
padding: var(--space-7);
|
padding: var(--space-7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.topbar {
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.project-tab {
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
.logout {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppShellComponent {
|
export class AppShellComponent {
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly projectsApi = inject(ApiClientService);
|
||||||
|
private readonly loadingProjectIds = new Set<string>();
|
||||||
|
private readonly projectTabsStorageKey = 'hauspilot:open-projects';
|
||||||
readonly auth = inject(AuthService);
|
readonly auth = inject(AuthService);
|
||||||
readonly notifications = inject(NotificationStore);
|
readonly notifications = inject(NotificationStore);
|
||||||
readonly drawerOpen = signal(false);
|
readonly drawerOpen = signal(false);
|
||||||
readonly notificationPanelOpen = signal(false);
|
readonly notificationPanelOpen = signal(false);
|
||||||
readonly title = computed(
|
readonly projectTabs = signal<ProjectTab[]>(this.restoreProjectTabs());
|
||||||
() => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard',
|
readonly currentProjectId = signal<string | null>(null);
|
||||||
|
readonly currentProjectSection = signal<string | null>(null);
|
||||||
|
private readonly pageTitle = signal('Dashboard');
|
||||||
|
readonly projectNavigation = projectSections;
|
||||||
|
readonly currentProjectName = computed(
|
||||||
|
() => this.projectTabs().find((tab) => tab.id === this.currentProjectId())?.name ?? 'Projekt',
|
||||||
);
|
);
|
||||||
readonly nav: NavItem[] = [
|
readonly title = computed(() => this.pageTitle());
|
||||||
|
readonly breadcrumb = computed(() => {
|
||||||
|
const projectId = this.currentProjectId();
|
||||||
|
if (!projectId) return `Start / ${this.title()}`;
|
||||||
|
const section = projectSections.find((item) => item.id === this.currentProjectSection());
|
||||||
|
return `${this.currentProjectName()} / ${section?.label ?? 'Mitglieder & Zugriff'}`;
|
||||||
|
});
|
||||||
|
readonly workspaceNav: NavItem[] = [
|
||||||
{ label: 'Dashboard', path: '/' },
|
{ label: 'Dashboard', path: '/' },
|
||||||
{ label: 'Projekte', path: '/projekte', permission: 'projects.use' },
|
{ label: 'Projekte', path: '/projekte', permission: 'projects.use' },
|
||||||
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
|
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
|
||||||
|
{ label: 'Items', path: '/items', permission: 'items.read' },
|
||||||
|
];
|
||||||
|
readonly accountNav: NavItem[] = [
|
||||||
{ label: 'Profil', path: '/profil' },
|
{ label: 'Profil', path: '/profil' },
|
||||||
{ label: 'Sicherheit', path: '/account/security' },
|
{ label: 'Sicherheit', path: '/account/security' },
|
||||||
{
|
{
|
||||||
@@ -253,14 +463,23 @@ export class AppShellComponent {
|
|||||||
permission: 'notifications.readOwn',
|
permission: 'notifications.readOwn',
|
||||||
},
|
},
|
||||||
{ label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' },
|
{ label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' },
|
||||||
{ label: 'Items', path: '/items', permission: 'items.read' },
|
];
|
||||||
|
readonly adminNav: NavItem[] = [
|
||||||
{ label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' },
|
{ label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' },
|
||||||
{ label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' },
|
{ label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' },
|
||||||
{ label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' },
|
{ label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' },
|
||||||
];
|
];
|
||||||
|
readonly nav: NavItem[] = [...this.workspaceNav, ...this.accountNav, ...this.adminNav];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.auth.loadMe();
|
this.auth.loadMe();
|
||||||
|
this.syncProjectContext(this.router.url);
|
||||||
|
this.router.events
|
||||||
|
.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
takeUntilDestroyed(),
|
||||||
|
)
|
||||||
|
.subscribe((event) => this.syncProjectContext(event.urlAfterRedirects));
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.auth.user()) {
|
if (this.auth.user()) {
|
||||||
this.notifications.startPolling();
|
this.notifications.startPolling();
|
||||||
@@ -275,6 +494,20 @@ export class AppShellComponent {
|
|||||||
return !item.permission || this.auth.has(item.permission);
|
return !item.permission || this.auth.has(item.permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasVisibleAdminNavigation(): boolean {
|
||||||
|
return this.adminNav.some((item) => this.visible(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeProjectTab(event: MouseEvent, projectId: string): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const wasCurrent = this.currentProjectId() === projectId;
|
||||||
|
const remaining = this.projectTabs().filter((tab) => tab.id !== projectId);
|
||||||
|
this.projectTabs.set(remaining);
|
||||||
|
this.persistProjectTabs();
|
||||||
|
if (wasCurrent) void this.router.navigateByUrl(remaining.at(-1)?.path ?? '/projekte');
|
||||||
|
}
|
||||||
|
|
||||||
loginHref(): string {
|
loginHref(): string {
|
||||||
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
|
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
|
||||||
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
|
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
|
||||||
@@ -295,6 +528,80 @@ export class AppShellComponent {
|
|||||||
this.drawerOpen.set(false);
|
this.drawerOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncProjectContext(url: string): void {
|
||||||
|
this.pageTitle.set(
|
||||||
|
this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard',
|
||||||
|
);
|
||||||
|
const match = /^\/projekte\/([^/?]+)(?:\/([^/?]+))?/.exec(url);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
this.currentProjectId.set(null);
|
||||||
|
this.currentProjectSection.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projectId = decodeURIComponent(match[1]);
|
||||||
|
const section = match[2] ? decodeURIComponent(match[2]) : null;
|
||||||
|
const path = section
|
||||||
|
? `/projekte/${encodeURIComponent(projectId)}/${encodeURIComponent(section)}`
|
||||||
|
: `/projekte/${encodeURIComponent(projectId)}`;
|
||||||
|
this.currentProjectId.set(projectId);
|
||||||
|
this.currentProjectSection.set(section);
|
||||||
|
const existing = this.projectTabs().find((tab) => tab.id === projectId);
|
||||||
|
if (existing) {
|
||||||
|
this.upsertProjectTab({ ...existing, path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.upsertProjectTab({ id: projectId, name: 'Projekt wird geladen …', path });
|
||||||
|
if (this.loadingProjectIds.has(projectId)) return;
|
||||||
|
this.loadingProjectIds.add(projectId);
|
||||||
|
this.projectsApi.project(projectId).subscribe({
|
||||||
|
next: (project) => {
|
||||||
|
const current = this.projectTabs().find((tab) => tab.id === projectId);
|
||||||
|
if (current) this.upsertProjectTab({ ...current, name: project.name });
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
const current = this.projectTabs().find((tab) => tab.id === projectId);
|
||||||
|
if (current) this.upsertProjectTab({ ...current, name: 'Unbekanntes Projekt' });
|
||||||
|
this.loadingProjectIds.delete(projectId);
|
||||||
|
},
|
||||||
|
complete: () => this.loadingProjectIds.delete(projectId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private upsertProjectTab(tab: ProjectTab): void {
|
||||||
|
const tabs = this.projectTabs();
|
||||||
|
const next = tabs.some((entry) => entry.id === tab.id)
|
||||||
|
? tabs.map((entry) => (entry.id === tab.id ? tab : entry))
|
||||||
|
: [...tabs, tab].slice(-8);
|
||||||
|
this.projectTabs.set(next);
|
||||||
|
this.persistProjectTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreProjectTabs(): ProjectTab[] {
|
||||||
|
if (typeof sessionStorage === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(sessionStorage.getItem(this.projectTabsStorageKey) ?? '[]');
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((entry: unknown): entry is ProjectTab => this.isProjectTab(entry));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistProjectTabs(): void {
|
||||||
|
if (typeof sessionStorage !== 'undefined')
|
||||||
|
sessionStorage.setItem(this.projectTabsStorageKey, JSON.stringify(this.projectTabs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isProjectTab(value: unknown): value is ProjectTab {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof candidate['id'] === 'string' &&
|
||||||
|
typeof candidate['name'] === 'string' &&
|
||||||
|
typeof candidate['path'] === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
closeOverlays(): void {
|
closeOverlays(): void {
|
||||||
this.drawerOpen.set(false);
|
this.drawerOpen.set(false);
|
||||||
|
|||||||
@@ -108,6 +108,267 @@
|
|||||||
border-color: var(--color-danger);
|
border-color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planning-guide {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: linear-gradient(135deg, var(--color-primary-subtle), var(--color-surface) 55%);
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-intro {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-flow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-flow article {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-flow article > span {
|
||||||
|
display: grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
background: var(--color-primary-subtle);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-flow article div,
|
||||||
|
.next-action > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-flow small,
|
||||||
|
.next-action small,
|
||||||
|
.detail-description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-arrow {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps li.current {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps li.complete .step-number {
|
||||||
|
color: var(--color-success);
|
||||||
|
background: var(--color-success-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
display: grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
background: var(--color-primary-subtle);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-action {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-details {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-details summary {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-details[open] summary {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-details .metric-grid {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-description {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-selection {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-selection legend {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-choice-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-choice {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast) ease,
|
||||||
|
background var(--transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-choice:hover {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-choice--selected {
|
||||||
|
background: var(--color-primary-subtle);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-choice input {
|
||||||
|
width: 1.25rem;
|
||||||
|
min-height: auto;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-choice__content,
|
||||||
|
.option-choice__head {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-choice__head {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(7rem, auto) minmax(0, 1fr);
|
||||||
|
gap: var(--space-2) var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-facts dt {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-facts dd {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.planning-guide {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps li {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-flow,
|
||||||
|
.guide-steps {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-arrow {
|
||||||
|
justify-self: center;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-action {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-action .ui-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 48rem) {
|
@media (min-width: 48rem) {
|
||||||
.ui-page-header {
|
.ui-page-header {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user