diff --git a/apps/backend/storage/documents/9a9850e2-1386-49b0-b169-a0786f12bd90.pdf b/apps/backend/storage/documents/9a9850e2-1386-49b0-b169-a0786f12bd90.pdf new file mode 100644 index 0000000..217e823 Binary files /dev/null and b/apps/backend/storage/documents/9a9850e2-1386-49b0-b169-a0786f12bd90.pdf differ diff --git a/apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts b/apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts index bb77082..e47b248 100644 --- a/apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts +++ b/apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts @@ -4,12 +4,7 @@ import { of } from 'rxjs'; import { vi } from 'vitest'; import { AuthService } from '../../core/auth.service'; import { FurnitureGridComponent } from './furniture-grid.component'; -import type { - FurnitureOption, - FurnitureRequirement, - FurnitureScenario, - PageResult, -} from './hauspilot-api.service'; +import type { FurnitureRequirement, PageResult } from './hauspilot-api.service'; import { HauspilotApiService } from './hauspilot-api.service'; const requirement: FurnitureRequirement = { @@ -199,78 +194,7 @@ describe('FurnitureGridComponent', () => { expect(calls[0]?.['sortBy']).toBe('sortOrder'); }); - it('assigns an option to a scenario through the existing selection endpoint', async () => { - const option: FurnitureOption = { - id: 'option-1', - version: 1, - createdAt: '2026-01-01', - updatedAt: '2026-01-01', - projectId: 'project-1', - requirementId: requirement.id, - name: 'Sofa Wunsch', - manufacturer: null, - model: null, - description: null, - retailer: null, - productUrl: null, - articleNumber: null, - unitPrice: '1000.00', - originalPrice: null, - shippingCost: '0.00', - additionalCost: '0.00', - discount: '0.00', - totalPrice: '1000.00', - currency: 'EUR', - quantity: 1, - width: null, - height: null, - depth: null, - weight: null, - color: null, - material: null, - deliveryDays: null, - expectedDeliveryDate: null, - availability: 'available', - favorite: true, - currentlySelected: false, - status: 'favorite', - notes: null, - budgetCategoryId: null, - existingItem: false, - movingCost: '0.00', - refurbishmentCost: '0.00', - deliveryStatus: 'not_ordered', - deliveredQuantity: 0, - orderNumber: null, - orderedAt: null, - }; - const scenario: FurnitureScenario = { - id: 'scenario-1', - version: 3, - createdAt: '2026-01-01', - updatedAt: '2026-01-01', - projectId: 'project-1', - name: 'Wunsch', - description: null, - type: 'preferred', - status: 'active', - isDefault: true, - total: '0.00', - selectedRequirements: 0, - openRequirements: 1, - byRoom: {}, - selections: [], - }; - const updateSelections = vi.fn(() => - of({ - ...scenario, - version: 4, - total: option.totalPrice, - selections: [ - { requirementId: requirement.id, optionId: option.id, quantity: option.quantity }, - ], - }), - ); + it('opens the assignment dialog when a scenario cell is clicked', async () => { await TestBed.configureTestingModule({ imports: [FurnitureGridComponent], providers: [ @@ -280,7 +204,6 @@ describe('FurnitureGridComponent', () => { useValue: { furnitureRequirements: () => of(page), furnitureProjectOptions: () => of({ ...page, items: [] }), - updateFurnitureScenarioSelections: updateSelections, }, }, { provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } }, @@ -289,16 +212,12 @@ describe('FurnitureGridComponent', () => { const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; component.projectId = 'project-1'; component.canEdit = true; - component.scenarios = [scenario]; - component.cellChanged({ - oldValue: '', - newValue: option.id, - data: { ...requirement, options: [option] }, - column: { getColId: () => `scenario:${scenario.id}` }, - node: { setDataValue: vi.fn() }, + const assignment = vi.fn(); + component.assignScenario.subscribe(assignment); + component.cellClicked({ + data: requirement, + column: { getColId: () => 'scenario:scenario-1' }, } as never); - expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [ - { requirementId: requirement.id, optionId: option.id, quantity: 1 }, - ]); + expect(assignment).toHaveBeenCalledWith({ requirement, scenarioId: 'scenario-1' }); }); }); diff --git a/apps/frontend/src/app/features/projects/furniture-grid.component.ts b/apps/frontend/src/app/features/projects/furniture-grid.component.ts index 03316a8..3ca0ec4 100644 --- a/apps/frontend/src/app/features/projects/furniture-grid.component.ts +++ b/apps/frontend/src/app/features/projects/furniture-grid.component.ts @@ -61,7 +61,7 @@ const requirementPriorities: Record = { const requirementStatuses: Record = { identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' }, research: { icon: '⌕', label: 'Recherche', tone: 'info' }, - has_options: { icon: '≡', label: 'Alternativen vorhanden', tone: 'info' }, + has_options: { icon: '≡', label: 'Möbelvorschläge vorhanden', tone: 'info' }, decision_open: { icon: '?', label: 'Entscheidung offen', tone: 'warning' }, selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' }, ordered: { icon: '▣', label: 'Bestellt', tone: 'info' }, @@ -172,8 +172,8 @@ const deliveryStatuses: Record = { } @if (view() === 'scenarios') {

- Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus. - „Nicht zugewiesen“ lässt den Bedarf im Szenario offen. + Klicken Sie auf eine Szenariozelle, um passende Möbel in Ruhe zu vergleichen und Ihre + Auswahl anschließend zu übernehmen.

} (); @Output() addOption = new EventEmitter(); @Output() editOption = new EventEmitter(); + @Output() assignScenario = new EventEmitter<{ + requirement: FurnitureRequirement; + scenarioId: string; + }>(); @Output() dataChanged = new EventEmitter(); private readonly api = inject(HauspilotApiService); private readonly auth = inject(AuthService); @@ -352,7 +356,7 @@ export class FurnitureGridComponent implements OnChanges { delayedOnly = false; readonly views: Array<{ id: View; label: string }> = [ { id: 'requirements', label: 'Bedarfe' }, - { id: 'options', label: 'Alternativen' }, + { id: 'options', label: 'Möbelvorschläge' }, { id: 'orders', label: 'Bestellungen' }, { id: 'scenarios', label: 'Szenarien' }, ]; @@ -432,11 +436,6 @@ export class FurnitureGridComponent implements OnChanges { } cellChanged(event: CellValueChangedEvent) { if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return; - const scenarioId = this.scenarioId(event.column.getColId()); - if (scenarioId && 'requiredQuantity' in event.data) { - this.saveScenarioSelection(event, scenarioId, event.data); - return; - } const row = event.data; this.savingRow.set(row.id); this.error.set(null); @@ -464,6 +463,11 @@ export class FurnitureGridComponent implements OnChanges { if (!event.data) return; const id = event.column.getColId(); if ('requiredQuantity' in event.data) { + const scenarioId = this.scenarioId(id); + if (scenarioId && this.canEdit) { + this.assignScenario.emit({ requirement: event.data, scenarioId }); + return; + } if (id === 'actions') this.editRequirement.emit(event.data); if (id === 'addOption') this.addOption.emit(event.data); return; @@ -543,7 +547,7 @@ export class FurnitureGridComponent implements OnChanges { valueFormatter: (p) => money(p.value), valueParser: (p) => parseGermanNumber(p.newValue), }, - { field: 'optionCount', headerName: 'Alternativen' }, + { field: 'optionCount', headerName: 'Möbelvorschläge' }, { field: 'cheapestOption', headerName: 'Günstigste', @@ -586,7 +590,7 @@ export class FurnitureGridComponent implements OnChanges { }, { colId: 'addOption', - headerName: 'Alternative', + headerName: 'Möbelvorschlag', valueGetter: () => (this.canEdit ? '+ hinzufügen' : 'anzeigen'), sortable: false, }, @@ -734,28 +738,18 @@ export class FurnitureGridComponent implements OnChanges { const option = row.options.find((entry) => entry.id === params.value); return option ? `✓ ${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen'; }, - editable: (params) => - this.canEdit && - !!params.data && - 'requiredQuantity' in params.data && - this.selectableOptions(params.data).length > 0, - cellEditor: 'agSelectCellEditor', - cellEditorParams: (params: { data?: FurnitureRow }) => ({ - values: - params.data && 'requiredQuantity' in params.data - ? ['', ...this.selectableOptions(params.data).map((option) => option.id)] - : [''], - }), cellStyle: (params) => params.value ? { color: 'var(--color-success)', backgroundColor: 'var(--color-success-subtle)', fontWeight: '650', + cursor: this.canEdit ? 'pointer' : 'default', } : { color: 'var(--color-warning)', backgroundColor: 'var(--color-warning-subtle)', + cursor: this.canEdit ? 'pointer' : 'default', }, minWidth: 245, }), @@ -803,63 +797,6 @@ export class FurnitureGridComponent implements OnChanges { private scenarioId(columnId: string) { return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null; } - private selectableOptions(requirement: FurnitureRequirement) { - return requirement.options.filter( - (option) => - !['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) && - !['unavailable', 'discontinued'].includes(option.availability), - ); - } - private saveScenarioSelection( - event: CellValueChangedEvent, - 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) { - this.rollback = true; - event.node.setDataValue(event.column.getColId(), event.oldValue); - this.rollback = false; - } private optionBody(row: FurnitureOption) { return { name: row.name, diff --git a/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts b/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts index 1b0b823..376c02d 100644 --- a/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts +++ b/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts @@ -1,9 +1,67 @@ +import { registerLocaleData } from '@angular/common'; +import localeDe from '@angular/common/locales/de'; import { provideHttpClient } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; import { vi } from 'vitest'; +import type { + FurnitureOption, + FurnitureRequirement, + FurnitureScenario, +} from './hauspilot-api.service'; +import { HauspilotApiService } from './hauspilot-api.service'; import { FurniturePlanningComponent } from './furniture-planning.component'; +registerLocaleData(localeDe); + describe('FurniturePlanningComponent', () => { + it('explains the planning concepts and recommends the next useful step', async () => { + await TestBed.configureTestingModule({ + imports: [FurniturePlanningComponent], + providers: [provideHttpClient()], + }).compileComponents(); + const fixture = TestBed.createComponent(FurniturePlanningComponent); + fixture.componentInstance.canEdit = true; + fixture.detectChanges(); + const host: unknown = fixture.nativeElement; + if (!(host instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.'); + + expect(host.querySelector('.concept-flow')?.textContent).toContain('Was wird wo benötigt?'); + expect(host.querySelector('.concept-flow')?.textContent).toContain( + 'Was kommt konkret infrage?', + ); + expect(host.querySelector('.next-action button')?.textContent).toContain( + 'Ersten Bedarf anlegen', + ); + + fixture.componentInstance.requirements.set([ + { + id: 'requirement-1', + version: 1, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + projectId: 'project-1', + roomId: 'room-1', + name: 'Sofa', + description: null, + category: 'seating', + priority: 'normal', + requiredQuantity: 1, + status: 'identified', + responsibleUserId: null, + maximumBudget: null, + sortOrder: 0, + options: [], + }, + ]); + fixture.detectChanges(); + + expect(fixture.componentInstance.nextStep()).toBe('option'); + expect(host.querySelector('.next-action button')?.textContent).toContain( + 'Möbelvorschlag hinzufügen', + ); + }); + it('calculates option totals including quantity, shipping, extras and discounts', async () => { await TestBed.configureTestingModule({ imports: [FurniturePlanningComponent], @@ -77,4 +135,134 @@ describe('FurniturePlanningComponent', () => { expect(showModal).toHaveBeenCalledOnce(); expect(fixture.componentInstance.showRequirementForm()).toBe(true); }); + + it('guides users through an explicit scenario assignment', async () => { + const option = { + id: 'option-1', + version: 1, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + projectId: 'project-1', + requirementId: 'requirement-1', + name: 'Wunschsofa', + manufacturer: 'Nordmöbel', + model: null, + description: null, + retailer: 'Wohnwelt', + productUrl: null, + articleNumber: null, + unitPrice: '1000.00', + originalPrice: null, + shippingCost: '0.00', + additionalCost: '0.00', + discount: '0.00', + totalPrice: '1000.00', + currency: 'EUR', + quantity: 1, + width: null, + height: null, + depth: null, + weight: null, + color: null, + material: null, + deliveryDays: null, + expectedDeliveryDate: null, + availability: 'available', + favorite: false, + currentlySelected: false, + status: 'idea', + notes: null, + budgetCategoryId: null, + existingItem: false, + movingCost: '0.00', + refurbishmentCost: '0.00', + deliveryStatus: 'not_ordered', + deliveredQuantity: 0, + orderNumber: null, + orderedAt: null, + } satisfies FurnitureOption; + const requirement = { + id: 'requirement-1', + version: 1, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + projectId: 'project-1', + roomId: 'room-1', + name: 'Sofa', + description: null, + category: 'seating', + priority: 'normal', + requiredQuantity: 1, + status: 'identified', + responsibleUserId: null, + maximumBudget: null, + sortOrder: 0, + options: [option], + } satisfies FurnitureRequirement; + const scenario = { + id: 'scenario-1', + version: 1, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + projectId: 'project-1', + name: 'Wunsch', + description: null, + type: 'preferred', + status: 'draft', + isDefault: true, + total: '0.00', + selectedRequirements: 0, + openRequirements: 1, + byRoom: {}, + selections: [], + } satisfies FurnitureScenario; + const updateSelections = vi.fn(() => + of({ + ...scenario, + version: 2, + selections: [{ requirementId: requirement.id, optionId: option.id, quantity: 1 }], + }), + ); + await TestBed.configureTestingModule({ + imports: [FurniturePlanningComponent], + providers: [ + provideHttpClient(), + { + provide: HauspilotApiService, + useValue: { + updateFurnitureScenarioSelections: updateSelections, + furnitureRequirements: () => + of({ items: [requirement], page: 1, pageSize: 50, totalItems: 1, totalPages: 1 }), + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(FurniturePlanningComponent); + const component = fixture.componentInstance; + component.projectId = 'project-1'; + component.requirements.set([requirement]); + component.scenarios.set([scenario]); + vi.spyOn(component, 'load').mockImplementation(() => undefined); + + component.openAssignment(scenario.id, requirement.id, option.id); + fixture.detectChanges(); + const host: unknown = fixture.nativeElement; + if (!(host instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.'); + const choice = host.querySelector('.option-choice'); + expect(choice?.textContent).toContain('Wunschsofa'); + expect(choice?.textContent).toContain('Nordmöbel'); + expect(choice?.textContent).toContain('Wohnwelt'); + const radio = choice?.querySelector('input[type="radio"]'); + expect(radio instanceof HTMLInputElement && radio.checked).toBe(true); + const dialog = host.querySelector('#assignment-form-title')?.closest('dialog'); + if (!(dialog instanceof HTMLDialogElement)) throw new Error('Zuordnungsdialog fehlt.'); + Object.defineProperty(dialog, 'close', { value: vi.fn() }); + component.saveAssignment(); + + expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [ + { requirementId: requirement.id, optionId: option.id, quantity: 1 }, + ]); + expect(component.assignedRequirements()).toBe(1); + expect(component.showAssignmentForm()).toBe(false); + }); }); diff --git a/apps/frontend/src/app/features/projects/furniture-planning.component.ts b/apps/frontend/src/app/features/projects/furniture-planning.component.ts index fbf8984..c2b1437 100644 --- a/apps/frontend/src/app/features/projects/furniture-planning.component.ts +++ b/apps/frontend/src/app/features/projects/furniture-planning.component.ts @@ -34,7 +34,7 @@ const categoryLabels: Record = { const statusLabels: Record = { identified: 'Bedarf erkannt', research: 'Recherche', - has_options: 'Alternativen vorhanden', + has_options: 'Möbelvorschläge vorhanden', decision_open: 'Entscheidung offen', selected: 'Ausgewählt', ordered: 'Bestellt', @@ -52,14 +52,126 @@ const statusLabels: Record = {

Möbel & Einrichtung

-

Bedarfe, Produktalternativen und Einrichtungsszenarien gemeinsam planen.

+

Planen Sie vom benötigten Möbel bis zur fertigen Einrichtungsvariante.

- @if (canEdit) { - - }
+
+
+ Geführte Planung +

Was gehört hier zusammen?

+

+ Sie notieren zuerst, was fehlt, sammeln dafür konkrete + Möbelvorschläge und stellen daraus ein + Szenario zusammen. +

+
+
+ +
BedarfWas wird wo benötigt?
+
+ +
+ +
MöbelvorschlagWas kommt konkret infrage?
+
+ +
+ +
+ SzenarioWelche Vorschläge wählen wir zusammen? +
+
+
+
+
    +
  1. + +
    + Bedarf notieren{{ requirements().length }} Bedarfe erfasst +
    +
  2. +
  3. + +
    + Möbelvorschläge sammeln{{ requirementsWithOptions() }} von {{ requirements().length }} Bedarfen haben + Vorschläge +
    +
  4. +
  5. + +
    + Szenario zusammenstellen{{ assignedRequirements() }} Bedarfe sind einem Szenario zugeordnet +
    +
  6. +
+
+
+ Als Nächstes + @switch (nextStep()) { + @case ('need') { + Notieren Sie den ersten Möbelbedarf + Zum Beispiel „Sofa im Wohnzimmer“ – ein Produkt müssen Sie noch nicht + kennen. + } + @case ('option') { + Ergänzen Sie einen konkreten Möbelvorschlag + Das kann ein neues Produkt oder ein bereits vorhandenes Möbelstück sein. + } + @case ('scenario') { + Erstellen Sie Ihre erste Einrichtungsvariante + Ein Szenario bündelt je Bedarf genau einen ausgewählten Vorschlag. + } + @default { + Vervollständigen oder vergleichen Sie Ihre Szenarien + Ordnen Sie offene Bedarfe zu oder probieren Sie eine weitere Variante aus. + } + } +
+ @if (canEdit) { + @switch (nextStep()) { + @case ('need') { + + } + @case ('option') { + + } + @case ('scenario') { + + } + @default { + + } + } + } +
+
@if (error()) { } @@ -67,65 +179,36 @@ const statusLabels: Record = {

Möbelplanung wird geladen …

} @if (summary(); as data) { -
-
- Ausgewählte Möbel{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ data.selected }} ausgewählt -
-
- Günstigste Variante{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ data.openPrices }} Preise noch offen -
-
- Tatsächliche Ausgaben{{ - data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' - }}Verknüpfte Ausgaben, nicht doppelt gezählt -
-
- Entscheidungen{{ data.withoutDecision }}{{ data.withoutOption }} ohne Alternative · {{ data.delayed }} verspätet -
-
+
+ Kosten und Planungsstand anzeigen +
+
+ Ausgewählte Möbel{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ data.selected }} ausgewählt +
+
+ Günstigste Variante{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ data.openPrices }} Preise noch offen +
+
+ Tatsächliche Ausgaben{{ + data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' + }}Verknüpfte Ausgaben, nicht doppelt gezählt +
+
+ Offene Entscheidungen{{ data.withoutDecision }}{{ data.withoutOption }} ohne Möbelvorschlag · {{ data.delayed }} verspätet +
+
+
} -
- - - - - - -
- = { } -
- -
- -
- Kompakte Kartenansicht +
+
+
+ Ihre Planung +

Bedarfe und Möbelvorschläge

+

Jeder Bedarf zeigt die konkreten Möbel, die dafür infrage kommen.

+
+ @if (canEdit) { + + } +
@for (requirement of requirements(); track requirement.id) {
@@ -238,7 +318,7 @@ const statusLabels: Record = { requirement.maximumBudget || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}Alternativen: {{ requirement.options.length }}Möbelvorschläge: {{ requirement.options.length }}
@if (requirement.description) { @@ -252,8 +332,8 @@ const statusLabels: Record = { > {{ expandedRequirementId() === requirement.id - ? 'Alternativen schließen' - : 'Alternativen vergleichen' + ? 'Möbelvorschläge schließen' + : 'Möbelvorschläge ansehen' }} @if (canEdit) { @@ -268,14 +348,14 @@ const statusLabels: Record = { type="button" (click)="newOption(requirement)" > - Alternative hinzufügen + Möbelvorschlag hinzufügen } @if (expandedRequirementId() === requirement.id) { -
+
@if (!requirement.options.length) { -

Noch keine Alternative erfasst.

+

Noch kein Möbelvorschlag erfasst.

} @for (option of requirement.options; track option.id) {
@@ -392,6 +472,27 @@ const statusLabels: Record = { } +
+ +
+ Detailtabelle und Bestellungen öffnen +

+ Für umfangreiche Planungen: Bedarfe filtern, Daten direkt bearbeiten, Bestellungen prüfen + und Szenarien tabellarisch vergleichen. +

+
+ +
= { @if (showOptionForm()) {

- {{ editingOption() ? 'Alternative bearbeiten' : 'Alternative hinzufügen' }} + {{ editingOption() ? 'Möbelvorschlag bearbeiten' : 'Möbelvorschlag hinzufügen' }}

= { }
+ + @if (showOptionPicker()) { + +
+ Schritt 2 von 3 +

Für welchen Bedarf ist das Möbel?

+

+ So bleibt jede Produktalternative direkt dem richtigen Raum und Bedarf zugeordnet. +

+
+ +
+ + +
+ + } +
+
-

Einrichtungsszenarien

-

Budget-, Wunsch- und Premiumvarianten vergleichen.

+

Einrichtungsvarianten

+

+ Ein Szenario ist eine komplette Variante: Für jeden Bedarf wählen Sie darin einen + Möbelvorschlag aus. +

@if (canEdit) {
- -
}
- @if (showScenarioForm()) { -
- -
- }
@for (scenario of scenarios(); track scenario.id) {
@@ -549,6 +670,15 @@ const statusLabels: Record = {

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

+ @if (canEdit) { + + }
} @empty {

Noch kein Szenario vorhanden.

@@ -574,6 +704,165 @@ const statusLabels: Record = {
}
+ + + @if (showScenarioForm()) { +
+
+ Einrichtungsvariante +

Neues Szenario erstellen

+

+ Die Vorauswahl befüllt das Szenario automatisch. Sie können jede Zuordnung danach + ändern. +

+
+ + + +
+ + +
+
+ } +
+ + + @if (showAssignmentForm()) { +
+
+ Schritt 3 von 3 +

Möbel für ein Szenario auswählen

+

+ Wählen Sie zuerst die Variante und den Bedarf, danach den passenden Möbelvorschlag. +

+
+ @if (!scenarios().length) { +
+ Es fehlt noch ein Szenario.Erstellen Sie zuerst eine Einrichtungsvariante. +
+ } @else if (!assignableRequirements().length) { +
+ Es fehlt noch ein Möbelvorschlag.Erfassen Sie für mindestens einen Bedarf ein konkretes Möbel. +
+ } @else { + + +
+ Möbelvorschlag auswählen * +
+ @for (option of assignmentOptions(); track option.id) { + + } +
+
+ } +
+ @if (!scenarios().length) { + + } @else if (!assignableRequirements().length) { + + } @else { + + } + +
+
+ } +
`, styles: [ ` @@ -604,6 +893,14 @@ const statusLabels: Record = { grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); gap: var(--space-4); } + .dialog-heading, + .dialog-notice { + display: grid; + gap: var(--space-1); + } + .dialog-form label small { + color: var(--color-text-muted); + } .metric, .requirement, .scenarios { @@ -675,6 +972,22 @@ const statusLabels: Record = { border: 0; padding: var(--space-6); } + .dialog-form { + display: grid; + gap: var(--space-5); + border: 0; + padding: var(--space-6); + } + .dialog-heading { + gap: var(--space-2); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border); + } + .dialog-notice { + padding: var(--space-4); + border-left: 0.25rem solid var(--color-info); + background: var(--color-info-subtle); + } .check { display: flex; align-items: center; @@ -776,7 +1089,9 @@ export class FurniturePlanningComponent implements OnChanges { @Input() canEdit = false; @ViewChild('requirementDialog') private requirementDialog?: ElementRef; @ViewChild('optionDialog') private optionDialog?: ElementRef; - @ViewChild('furnitureGridHost') private furnitureGridHost?: ElementRef; + @ViewChild('optionPickerDialog') private optionPickerDialog?: ElementRef; + @ViewChild('scenarioDialog') private scenarioDialog?: ElementRef; + @ViewChild('assignmentDialog') private assignmentDialog?: ElementRef; private readonly api = inject(HauspilotApiService); readonly requirements = signal([]); readonly scenarios = signal([]); @@ -789,12 +1104,35 @@ export class FurniturePlanningComponent implements OnChanges { readonly expandedRequirementId = signal(null); readonly showRequirementForm = signal(false); readonly showOptionForm = signal(false); + readonly showOptionPicker = signal(false); readonly showScenarioForm = signal(false); + readonly showAssignmentForm = signal(false); + readonly guidedPlanning = signal(false); readonly editingRequirement = signal(null); readonly editingOption = signal(null); readonly optionRequirement = signal(null); readonly categories = Object.entries(categoryLabels); readonly requirementStatuses = Object.entries(statusLabels); + readonly requirementsWithOptions = computed( + () => this.requirements().filter((requirement) => requirement.options.length > 0).length, + ); + readonly assignedRequirements = computed( + () => + new Set( + this.scenarios().flatMap((scenario) => + scenario.selections.map((selection) => selection.requirementId), + ), + ).size, + ); + readonly nextStep = computed<'need' | 'option' | 'scenario' | 'assign'>(() => { + if (!this.requirements().length) return 'need'; + if (this.requirementsWithOptions() < this.requirements().length) return 'option'; + if (!this.scenarios().length) return 'scenario'; + return 'assign'; + }); + readonly assignableRequirements = computed(() => + this.requirements().filter((requirement) => this.selectableOptions(requirement).length > 0), + ); readonly filterForm = new FormGroup({ search: new FormControl('', { nonNullable: true }), roomId: new FormControl('', { nonNullable: true }), @@ -878,6 +1216,26 @@ export class FurniturePlanningComponent implements OnChanges { automaticSelection: new FormControl('budget', { nonNullable: true }), isDefault: new FormControl(false, { nonNullable: true }), }); + readonly optionTargetForm = new FormGroup({ + requirementId: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + }); + readonly assignmentForm = new FormGroup({ + scenarioId: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + requirementId: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + optionId: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + }); readonly calculatedTotal = computed(() => { const v = this.optionForm.getRawValue(); const acquisition = v.existingItem ? 0 : v.unitPrice * v.quantity; @@ -927,6 +1285,10 @@ export class FurniturePlanningComponent implements OnChanges { status(value: string) { return statusLabels[value] ?? value; } + startGuidedPlanning() { + this.guidedPlanning.set(true); + this.newRequirement(); + } newRequirement() { this.editingRequirement.set(null); this.requirementForm.reset({ @@ -959,10 +1321,11 @@ export class FurniturePlanningComponent implements OnChanges { this.showRequirementForm.set(true); this.openDialog(this.requirementDialog); } - closeRequirementForm() { + closeRequirementForm(keepFlow = false) { this.requirementDialog?.nativeElement.close(); this.showRequirementForm.set(false); this.editingRequirement.set(null); + if (!keepFlow) this.guidedPlanning.set(false); } saveRequirement() { if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched(); @@ -976,9 +1339,10 @@ export class FurniturePlanningComponent implements OnChanges { ) : this.api.createFurnitureRequirement(this.projectId, this.requirementForm.getRawValue()); request.subscribe({ - next: () => { + next: (saved) => { this.saving.set(false); - this.closeRequirementForm(); + this.closeRequirementForm(true); + if (!existing && this.guidedPlanning()) this.newOption(saved); this.load(); }, error: (e: unknown) => this.fail(e), @@ -987,6 +1351,32 @@ export class FurniturePlanningComponent implements OnChanges { toggleOptions(r: FurnitureRequirement) { this.expandedRequirementId.set(this.expandedRequirementId() === r.id ? null : r.id); } + openOptionPicker() { + if (!this.requirements().length) { + this.startGuidedPlanning(); + return; + } + this.guidedPlanning.set(true); + this.optionTargetForm.reset({ + requirementId: this.requirements().length === 1 ? (this.requirements()[0]?.id ?? '') : '', + }); + this.showOptionPicker.set(true); + this.openDialog(this.optionPickerDialog); + } + closeOptionPicker(keepFlow = false) { + this.optionPickerDialog?.nativeElement.close(); + this.showOptionPicker.set(false); + if (!keepFlow) this.guidedPlanning.set(false); + } + chooseOptionRequirement() { + if (this.optionTargetForm.invalid) return this.optionTargetForm.markAllAsTouched(); + const requirement = this.requirements().find( + (entry) => entry.id === this.optionTargetForm.controls.requirementId.value, + ); + if (!requirement) return; + this.closeOptionPicker(true); + this.newOption(requirement); + } newOption(r: FurnitureRequirement) { this.optionRequirement.set(r); this.editingOption.set(null); @@ -1050,11 +1440,12 @@ export class FurniturePlanningComponent implements OnChanges { this.showOptionForm.set(true); this.openDialog(this.optionDialog); } - closeOptionForm() { + closeOptionForm(keepFlow = false) { this.optionDialog?.nativeElement.close(); this.showOptionForm.set(false); this.editingOption.set(null); this.optionRequirement.set(null); + if (!keepFlow) this.guidedPlanning.set(false); } editOptionFromGrid(option: FurnitureOption) { this.api.furnitureRequirement(this.projectId, option.requirementId).subscribe({ @@ -1072,9 +1463,18 @@ export class FurniturePlanningComponent implements OnChanges { ? this.api.updateFurnitureOption(this.projectId, existing, body) : this.api.createFurnitureOption(this.projectId, requirement.id, body); request.subscribe({ - next: () => { + next: (saved) => { this.saving.set(false); - this.closeOptionForm(); + this.closeOptionForm(true); + if (!existing && this.guidedPlanning()) { + this.requirements.update((current) => { + const updated = { ...requirement, options: [...requirement.options, saved] }; + return current.some((entry) => entry.id === requirement.id) + ? current.map((entry) => (entry.id === requirement.id ? updated : entry)) + : [...current, updated]; + }); + this.openAssignment(undefined, requirement.id, saved.id); + } this.load(); }, error: (e: unknown) => this.fail(e), @@ -1103,14 +1503,18 @@ export class FurniturePlanningComponent implements OnChanges { .deliverFurnitureOption(this.projectId, o, o.quantity) .subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) }); } - openScenarioGrid(grid: FurnitureGridComponent) { - grid.setView('scenarios'); - queueMicrotask(() => - this.furnitureGridHost?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }), - ); + openScenarioForm() { + this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false }); + this.showScenarioForm.set(true); + this.openDialog(this.scenarioDialog); + } + closeScenarioForm(keepFlow = false) { + this.scenarioDialog?.nativeElement.close(); + this.showScenarioForm.set(false); + if (!keepFlow) this.guidedPlanning.set(false); } createScenario() { - if (this.scenarioForm.invalid) return; + if (this.scenarioForm.invalid) return this.scenarioForm.markAllAsTouched(); this.saving.set(true); const value = this.scenarioForm.getRawValue(); this.api @@ -1122,15 +1526,128 @@ export class FurniturePlanningComponent implements OnChanges { isDefault: value.isDefault, }) .subscribe({ - next: () => { + next: (saved) => { this.saving.set(false); - this.showScenarioForm.set(false); - this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false }); + this.closeScenarioForm(true); + this.scenarios.update((current) => [...current, saved]); + if (this.guidedPlanning()) this.openAssignment(saved.id); this.load(); }, error: (e: unknown) => this.fail(e), }); } + openAssignment(scenarioId = '', requirementId = '', optionId = '') { + const scenario = + this.scenarios().find((entry) => entry.id === scenarioId) ?? + this.scenarios().find((entry) => entry.isDefault) ?? + this.scenarios()[0]; + const requirement = + this.assignableRequirements().find((entry) => entry.id === requirementId) ?? + this.assignableRequirements()[0]; + const selection = scenario?.selections.find((entry) => entry.requirementId === requirement?.id); + this.assignmentForm.reset({ + scenarioId: scenario?.id ?? '', + requirementId: requirement?.id ?? '', + optionId: optionId || selection?.optionId || '', + }); + this.showAssignmentForm.set(true); + this.openDialog(this.assignmentDialog); + } + openScenarioAssignment(scenarioId: string, requirement: FurnitureRequirement) { + this.requirements.update((current) => + current.some((entry) => entry.id === requirement.id) + ? current.map((entry) => (entry.id === requirement.id ? requirement : entry)) + : [...current, requirement], + ); + this.openAssignment(scenarioId, requirement.id); + } + closeAssignment(keepFlow = false) { + this.assignmentDialog?.nativeElement.close(); + this.showAssignmentForm.set(false); + if (!keepFlow) this.guidedPlanning.set(false); + } + assignmentOptions() { + const requirement = this.requirements().find( + (entry) => entry.id === this.assignmentForm.controls.requirementId.value, + ); + return requirement ? this.selectableOptions(requirement) : []; + } + optionManufacturer(option: FurnitureOption) { + return [option.manufacturer, option.model].filter(Boolean).join(' · ') || 'Nicht angegeben'; + } + optionDimensions(option: FurnitureOption) { + if (option.width === null && option.height === null && option.depth === null) + return 'Nicht angegeben'; + return `${option.width ?? '–'} × ${option.height ?? '–'} × ${option.depth ?? '–'} cm`; + } + optionDelivery(option: FurnitureOption) { + return option.deliveryDays === null ? 'Nicht angegeben' : `${option.deliveryDays} Tage`; + } + optionAvailability(value: string) { + const labels: Record = { + unknown: 'Verfügbarkeit unbekannt', + available: 'Verfügbar', + limited: 'Begrenzt verfügbar', + unavailable: 'Nicht verfügbar', + discontinued: 'Nicht mehr erhältlich', + }; + return labels[value] ?? value; + } + assignmentRequirementChanged() { + const scenario = this.scenarios().find( + (entry) => entry.id === this.assignmentForm.controls.scenarioId.value, + ); + const selection = scenario?.selections.find( + (entry) => entry.requirementId === this.assignmentForm.controls.requirementId.value, + ); + this.assignmentForm.controls.optionId.setValue(selection?.optionId ?? ''); + } + continueWithScenario() { + this.guidedPlanning.set(true); + this.closeAssignment(true); + this.openScenarioForm(); + } + continueWithOption() { + this.guidedPlanning.set(true); + this.closeAssignment(true); + this.openOptionPicker(); + } + saveAssignment() { + if (this.assignmentForm.invalid) return this.assignmentForm.markAllAsTouched(); + const value = this.assignmentForm.getRawValue(); + const scenario = this.scenarios().find((entry) => entry.id === value.scenarioId); + const requirement = this.requirements().find((entry) => entry.id === value.requirementId); + const option = requirement?.options.find((entry) => entry.id === value.optionId); + if (!scenario || !requirement || !option) return; + const selections = scenario.selections + .filter((selection) => selection.requirementId !== requirement.id) + .map((selection) => ({ ...selection })); + selections.push({ + requirementId: requirement.id, + optionId: option.id, + quantity: option.quantity, + }); + this.saving.set(true); + this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({ + next: (saved) => { + this.saving.set(false); + this.scenarios.update((current) => + current.map((entry) => (entry.id === saved.id ? saved : entry)), + ); + this.guidedPlanning.set(false); + this.closeAssignment(); + this.load(); + }, + error: (error: unknown) => this.fail(error), + }); + } + private selectableOptions(requirement: FurnitureRequirement) { + return requirement.options.filter( + (option) => + !['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) && + !['unavailable', 'discontinued'].includes(option.availability), + ); + } private fail(error: unknown) { this.saving.set(false); this.loading.set(false); diff --git a/apps/frontend/src/app/features/projects/project-workspace.page.ts b/apps/frontend/src/app/features/projects/project-workspace.page.ts index dac6ed8..a4a678b 100644 --- a/apps/frontend/src/app/features/projects/project-workspace.page.ts +++ b/apps/frontend/src/app/features/projects/project-workspace.page.ts @@ -2,7 +2,7 @@ import { CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common'; import { Component, computed, inject, signal } from '@angular/core'; import type { OnInit } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import type { Observable } from 'rxjs'; import type { ApiErrorBody, ProjectDto, ProjectMemberDto } from '@boilerplate/api-client'; import { ApiClientService } from '@boilerplate/api-client'; @@ -30,17 +30,6 @@ import type { Room, } from './hauspilot-api.service'; -const sections = [ - ['uebersicht', 'Übersicht'], - ['raeume', 'Räume'], - ['aufgaben', 'Aufgaben'], - ['zeitplan', 'Zeitplan'], - ['budget', 'Budget'], - ['moebel', 'Möbel & Einrichtung'], - ['dokumente', 'Dokumente'], - ['aktivitaeten', 'Aktivitäten'], -] as const; - @Component({ standalone: true, imports: [ @@ -48,7 +37,6 @@ const sections = [ DatePipe, DecimalPipe, ReactiveFormsModule, - RouterLink, UiEmptyStateComponent, UiPageHeaderComponent, UiStatusBadgeComponent, @@ -57,22 +45,11 @@ const sections = [ FurniturePlanningComponent, ], template: ` - Zurück zu den Projekten @if (project(); as project) { - @if (loading()) {

Projektdaten werden geladen …

} @@ -680,25 +657,6 @@ const sections = [ display: grid; gap: var(--space-5); } - .project-nav { - display: flex; - gap: var(--space-2); - overflow-x: auto; - padding-block: var(--space-2); - border-bottom: 1px solid var(--color-border); - } - .project-nav a { - white-space: nowrap; - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - color: var(--color-text-secondary); - text-decoration: none; - } - .project-nav a.active { - background: var(--color-primary-subtle); - color: var(--color-primary); - font-weight: 600; - } .metric-grid, .card-grid { display: grid; @@ -775,7 +733,6 @@ export class ProjectWorkspacePageComponent implements OnInit { private readonly auth = inject(AuthService); private readonly route = inject(ActivatedRoute); projectId = ''; - readonly navigation = sections; readonly section = signal('uebersicht'); readonly project = signal(null); readonly dashboard = signal(null); diff --git a/apps/frontend/src/app/layout/app-shell.spec.ts b/apps/frontend/src/app/layout/app-shell.spec.ts index 4007fb2..90dffe9 100644 --- a/apps/frontend/src/app/layout/app-shell.spec.ts +++ b/apps/frontend/src/app/layout/app-shell.spec.ts @@ -1,10 +1,15 @@ +import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { Router, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { ApiClientService } from '@boilerplate/api-client'; import { AppShellComponent } from './app-shell'; +@Component({ standalone: true, template: '' }) +class EmptyPageComponent {} + describe('AppShellComponent', () => { + beforeEach(() => sessionStorage.clear()); it('hides navigation entries without matching permissions', async () => { await TestBed.configureTestingModule({ imports: [AppShellComponent], @@ -99,4 +104,59 @@ describe('AppShellComponent', () => { fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('.sidebar.open')).toBeNull(); }); + + it('shows an opened project as a tab and its contents in the sidebar', async () => { + await TestBed.configureTestingModule({ + imports: [AppShellComponent], + providers: [ + provideRouter([{ path: 'projekte/:id/:section', component: EmptyPageComponent }]), + { + provide: ApiClientService, + useValue: { + me: () => + of({ + id: 'u1', + name: 'Ada', + email: null, + active: true, + lastLoginAt: null, + settings: { tablePageSize: 20, sidebarExpanded: true }, + roles: [ + { + id: 'r1', + name: 'user', + protected: true, + permissions: [{ id: 'projects.use', description: 'projects.use' }], + }, + ], + }), + project: () => + of({ + id: 'project-1', + name: 'Wohnung Budapest', + description: null, + role: 'owner', + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }), + unreadNotificationCount: () => of({ count: 0 }), + notifications: () => of({ items: [], total: 0, page: 1, pageSize: 20, unreadCount: 0 }), + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(AppShellComponent); + const router = TestBed.inject(Router); + await router.navigateByUrl('/projekte/project-1/moebel'); + await fixture.whenStable(); + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + expect(host.querySelector('.project-tab')?.textContent).toContain('Wohnung Budapest'); + expect(host.querySelector('.sidebar-context')?.textContent).toContain('Wohnung Budapest'); + expect(host.querySelector('.project-navigation')?.textContent).toContain('Möbel & Einrichtung'); + expect(host.querySelector('.project-navigation a.active')?.textContent).toContain( + 'Möbel & Einrichtung', + ); + }); }); diff --git a/apps/frontend/src/app/layout/app-shell.ts b/apps/frontend/src/app/layout/app-shell.ts index 9c0e926..a23eb05 100644 --- a/apps/frontend/src/app/layout/app-shell.ts +++ b/apps/frontend/src/app/layout/app-shell.ts @@ -1,6 +1,8 @@ import { Component, HostListener, computed, effect, inject, signal } from '@angular/core'; -import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router'; -import type { Permission } from '@boilerplate/api-client'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router'; +import { ApiClientService, type Permission } from '@boilerplate/api-client'; +import { filter } from 'rxjs'; import { NotificationStore } from '../core/notification.store'; import { AuthService } from '../core/auth.service'; import { NotificationPanelComponent } from './notification-panel'; @@ -12,6 +14,23 @@ interface NavItem { permission?: Permission; } +interface ProjectTab { + id: string; + name: string; + path: string; +} + +const projectSections = [ + { id: 'uebersicht', label: 'Übersicht' }, + { id: 'raeume', label: 'Räume' }, + { id: 'aufgaben', label: 'Aufgaben' }, + { id: 'zeitplan', label: 'Zeitplan' }, + { id: 'budget', label: 'Budget' }, + { id: 'moebel', label: 'Möbel & Einrichtung' }, + { id: 'dokumente', label: 'Dokumente' }, + { id: 'aktivitaeten', label: 'Aktivitäten' }, +] as const; + @Component({ selector: 'app-shell', standalone: true, @@ -30,7 +49,28 @@ interface NavItem { @if (auth.user()) { } - HausPilot + HausPilot + @if (projectTabs().length) { + + } @if (auth.user()) { }
- +
} @else { @@ -131,6 +225,53 @@ interface NavItem { } .brand { white-space: nowrap; + color: var(--color-text-primary); + font-weight: var(--font-weight-bold); + text-decoration: none; + } + .project-tabs { + display: flex; + flex: 1 1 auto; + align-self: stretch; + gap: var(--space-2); + min-width: 0; + overflow-x: auto; + padding-top: var(--space-3); + } + .project-tab { + display: flex; + align-items: center; + min-width: 9rem; + max-width: 15rem; + border: 1px solid var(--color-border); + border-bottom: 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-background); + } + .project-tab.active { + background: var(--color-surface); + border-color: var(--color-border-strong); + box-shadow: inset 0 0.2rem var(--color-primary); + } + .project-tab a { + min-width: 0; + flex: 1; + overflow: hidden; + padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4); + color: var(--color-text-primary); + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + } + .project-tab button { + width: 2rem; + height: 2rem; + flex: 0 0 auto; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; } .logout { margin-left: auto; @@ -190,20 +331,56 @@ interface NavItem { transform: translateX(-100%); transition: transform var(--transition-base) ease; z-index: var(--z-drawer); + overflow-y: auto; } .sidebar.open { transform: translateX(0); } - nav a { + .sidebar nav a { display: block; padding: var(--space-4) var(--space-5); color: var(--color-text-primary); text-decoration: none; min-height: var(--touch-target); } - nav a.active { + .sidebar nav a.active { background: var(--color-primary-subtle); border-left: 4px solid var(--color-primary); + font-weight: var(--font-weight-semibold); + } + .sidebar-context { + display: grid; + gap: var(--space-2); + padding: var(--space-5); + border-bottom: 1px solid var(--color-border); + background: var(--color-primary-subtle); + } + .sidebar-context strong { + overflow: hidden; + text-overflow: ellipsis; + } + .sidebar-eyebrow, + .nav-heading { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.06em; + text-transform: uppercase; + } + .nav-heading { + display: block; + padding: var(--space-5) var(--space-5) var(--space-2); + } + .project-navigation { + padding-block: var(--space-2); + } + .sidebar-secondary { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); + } + .sidebar-secondary a { + color: var(--color-text-secondary); } .content { min-width: 0; @@ -229,22 +406,55 @@ interface NavItem { padding: var(--space-7); } } + @media (max-width: 40rem) { + .topbar { + gap: var(--space-2); + padding-inline: var(--space-3); + } + .brand { + display: none; + } + .project-tab { + min-width: 8rem; + } + .logout { + display: none; + } + } `, ], }) export class AppShellComponent { private readonly router = inject(Router); + private readonly projectsApi = inject(ApiClientService); + private readonly loadingProjectIds = new Set(); + private readonly projectTabsStorageKey = 'hauspilot:open-projects'; readonly auth = inject(AuthService); readonly notifications = inject(NotificationStore); readonly drawerOpen = signal(false); readonly notificationPanelOpen = signal(false); - readonly title = computed( - () => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard', + readonly projectTabs = signal(this.restoreProjectTabs()); + readonly currentProjectId = signal(null); + readonly currentProjectSection = signal(null); + private readonly pageTitle = signal('Dashboard'); + readonly projectNavigation = projectSections; + readonly currentProjectName = computed( + () => this.projectTabs().find((tab) => tab.id === this.currentProjectId())?.name ?? 'Projekt', ); - readonly nav: NavItem[] = [ + readonly title = computed(() => this.pageTitle()); + readonly breadcrumb = computed(() => { + const projectId = this.currentProjectId(); + if (!projectId) return `Start / ${this.title()}`; + const section = projectSections.find((item) => item.id === this.currentProjectSection()); + return `${this.currentProjectName()} / ${section?.label ?? 'Mitglieder & Zugriff'}`; + }); + readonly workspaceNav: NavItem[] = [ { label: 'Dashboard', path: '/' }, { label: 'Projekte', path: '/projekte', permission: 'projects.use' }, { label: 'Einladungen', path: '/einladungen', permission: 'projects.use' }, + { label: 'Items', path: '/items', permission: 'items.read' }, + ]; + readonly accountNav: NavItem[] = [ { label: 'Profil', path: '/profil' }, { label: 'Sicherheit', path: '/account/security' }, { @@ -253,14 +463,23 @@ export class AppShellComponent { permission: 'notifications.readOwn', }, { label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' }, - { label: 'Items', path: '/items', permission: 'items.read' }, + ]; + readonly adminNav: NavItem[] = [ { label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' }, { label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' }, { label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' }, ]; + readonly nav: NavItem[] = [...this.workspaceNav, ...this.accountNav, ...this.adminNav]; constructor() { this.auth.loadMe(); + this.syncProjectContext(this.router.url); + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(), + ) + .subscribe((event) => this.syncProjectContext(event.urlAfterRedirects)); effect(() => { if (this.auth.user()) { this.notifications.startPolling(); @@ -275,6 +494,20 @@ export class AppShellComponent { return !item.permission || this.auth.has(item.permission); } + hasVisibleAdminNavigation(): boolean { + return this.adminNav.some((item) => this.visible(item)); + } + + closeProjectTab(event: MouseEvent, projectId: string): void { + event.preventDefault(); + event.stopPropagation(); + const wasCurrent = this.currentProjectId() === projectId; + const remaining = this.projectTabs().filter((tab) => tab.id !== projectId); + this.projectTabs.set(remaining); + this.persistProjectTabs(); + if (wasCurrent) void this.router.navigateByUrl(remaining.at(-1)?.path ?? '/projekte'); + } + loginHref(): string { const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/'; return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`; @@ -295,6 +528,80 @@ export class AppShellComponent { this.drawerOpen.set(false); } + private syncProjectContext(url: string): void { + this.pageTitle.set( + this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard', + ); + const match = /^\/projekte\/([^/?]+)(?:\/([^/?]+))?/.exec(url); + if (!match?.[1]) { + this.currentProjectId.set(null); + this.currentProjectSection.set(null); + return; + } + const projectId = decodeURIComponent(match[1]); + const section = match[2] ? decodeURIComponent(match[2]) : null; + const path = section + ? `/projekte/${encodeURIComponent(projectId)}/${encodeURIComponent(section)}` + : `/projekte/${encodeURIComponent(projectId)}`; + this.currentProjectId.set(projectId); + this.currentProjectSection.set(section); + const existing = this.projectTabs().find((tab) => tab.id === projectId); + if (existing) { + this.upsertProjectTab({ ...existing, path }); + return; + } + this.upsertProjectTab({ id: projectId, name: 'Projekt wird geladen …', path }); + if (this.loadingProjectIds.has(projectId)) return; + this.loadingProjectIds.add(projectId); + this.projectsApi.project(projectId).subscribe({ + next: (project) => { + const current = this.projectTabs().find((tab) => tab.id === projectId); + if (current) this.upsertProjectTab({ ...current, name: project.name }); + }, + error: () => { + const current = this.projectTabs().find((tab) => tab.id === projectId); + if (current) this.upsertProjectTab({ ...current, name: 'Unbekanntes Projekt' }); + this.loadingProjectIds.delete(projectId); + }, + complete: () => this.loadingProjectIds.delete(projectId), + }); + } + + private upsertProjectTab(tab: ProjectTab): void { + const tabs = this.projectTabs(); + const next = tabs.some((entry) => entry.id === tab.id) + ? tabs.map((entry) => (entry.id === tab.id ? tab : entry)) + : [...tabs, tab].slice(-8); + this.projectTabs.set(next); + this.persistProjectTabs(); + } + + private restoreProjectTabs(): ProjectTab[] { + if (typeof sessionStorage === 'undefined') return []; + try { + const value: unknown = JSON.parse(sessionStorage.getItem(this.projectTabsStorageKey) ?? '[]'); + if (!Array.isArray(value)) return []; + return value.filter((entry: unknown): entry is ProjectTab => this.isProjectTab(entry)); + } catch { + return []; + } + } + + private persistProjectTabs(): void { + if (typeof sessionStorage !== 'undefined') + sessionStorage.setItem(this.projectTabsStorageKey, JSON.stringify(this.projectTabs())); + } + + private isProjectTab(value: unknown): value is ProjectTab { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate['id'] === 'string' && + typeof candidate['name'] === 'string' && + typeof candidate['path'] === 'string' + ); + } + @HostListener('document:keydown.escape') closeOverlays(): void { this.drawerOpen.set(false); diff --git a/apps/frontend/src/styles/_layout.scss b/apps/frontend/src/styles/_layout.scss index d363a27..3e9afa7 100644 --- a/apps/frontend/src/styles/_layout.scss +++ b/apps/frontend/src/styles/_layout.scss @@ -108,6 +108,267 @@ border-color: var(--color-danger); } +.planning-guide { + display: grid; + gap: var(--space-6); + padding: var(--space-6); + background: linear-gradient(135deg, var(--color-primary-subtle), var(--color-surface) 55%); + border-color: var(--color-border-strong); +} + +.guide-intro { + display: grid; + gap: var(--space-3); +} + +.concept-flow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); + gap: var(--space-3); + align-items: center; + margin-top: var(--space-2); +} + +.concept-flow article { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-3); + align-items: center; + min-height: 5rem; + padding: var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.concept-flow article > span { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + color: var(--color-primary); + font-weight: var(--font-weight-bold); + background: var(--color-primary-subtle); + border-radius: var(--radius-pill); +} + +.concept-flow article div, +.next-action > div { + display: grid; + gap: var(--space-1); +} + +.concept-flow small, +.next-action small, +.detail-description { + color: var(--color-text-muted); +} + +.flow-arrow { + color: var(--color-text-muted); + font-size: var(--font-size-xl); +} + +.eyebrow { + color: var(--color-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.guide-steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.guide-steps li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-3); + align-items: center; + padding: var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.guide-steps li.current { + border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary); +} + +.guide-steps li.complete .step-number { + color: var(--color-success); + background: var(--color-success-subtle); +} + +.step-number { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + color: var(--color-primary); + font-weight: var(--font-weight-bold); + background: var(--color-primary-subtle); + border-radius: var(--radius-pill); +} + +.guide-steps div { + display: grid; + gap: var(--space-1); +} + +.guide-steps small { + color: var(--color-text-muted); +} + +.next-action { + display: flex; + gap: var(--space-5); + align-items: center; + justify-content: space-between; + padding: var(--space-5); + background: var(--color-surface); + border: 1px solid var(--color-primary); + border-radius: var(--radius-lg); +} + +.planning-details { + display: block; + padding: var(--space-4) var(--space-5); +} + +.planning-details summary { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + cursor: pointer; +} + +.planning-details[open] summary { + margin-bottom: var(--space-4); +} + +.planning-details .metric-grid { + margin-top: var(--space-4); +} + +.detail-description { + margin-bottom: var(--space-4); +} + +.option-selection { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.option-selection legend { + margin-bottom: var(--space-3); + color: var(--color-text-secondary); +} + +.option-choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: var(--space-3); +} + +.option-choice { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-3); + align-items: start; + padding: var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + cursor: pointer; + transition: + border-color var(--transition-fast) ease, + background var(--transition-fast) ease; +} + +.option-choice:hover { + border-color: var(--color-border-strong); +} + +.option-choice--selected { + background: var(--color-primary-subtle); + border-color: var(--color-primary); +} + +.option-choice input { + width: 1.25rem; + min-height: auto; + margin-top: var(--space-1); + accent-color: var(--color-primary); +} + +.option-choice__content, +.option-choice__head { + display: grid; + gap: var(--space-3); +} + +.option-choice__head { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; +} + +.option-facts { + display: grid; + grid-template-columns: minmax(7rem, auto) minmax(0, 1fr); + gap: var(--space-2) var(--space-3); + margin: 0; + font-size: var(--font-size-sm); +} + +.option-facts dt { + color: var(--color-text-muted); +} + +.option-facts dd { + margin: 0; + text-align: right; +} + +@media (max-width: 40rem) { + .planning-guide { + grid-template-columns: 1fr; + padding: var(--space-4); + } + + .guide-steps li { + grid-template-columns: auto minmax(0, 1fr); + } + + .concept-flow, + .guide-steps { + grid-template-columns: 1fr; + } + + .flow-arrow { + justify-self: center; + transform: rotate(90deg); + } + + .next-action { + align-items: stretch; + flex-direction: column; + } + + .next-action .ui-button { + width: 100%; + } +} + @media (min-width: 48rem) { .ui-page-header { grid-template-columns: minmax(0, 1fr) auto;