import { CurrencyPipe } from '@angular/common'; import { Component, EventEmitter, Input, Output, inject, signal } from '@angular/core'; import type { OnChanges } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AgGridAngular } from 'ag-grid-angular'; import { AllCommunityModule, ModuleRegistry, themeQuartz, type CellClickedEvent, type CellStyle, type CellValueChangedEvent, type ColDef, type GridApi, type GridReadyEvent, } from 'ag-grid-community'; import { debounceTime, distinctUntilChanged, Subject, type Observable } from 'rxjs'; import { AuthService } from '../../core/auth.service'; import type { FurnitureOption, FurnitureRequirement, FurnitureScenario, PageResult, Room, } from './hauspilot-api.service'; import { HauspilotApiService } from './hauspilot-api.service'; import { conflictMessage } from './project-workspace.helpers'; ModuleRegistry.registerModules([AllCommunityModule]); type View = 'requirements' | 'options' | 'orders' | 'scenarios'; type FurnitureRequirementRow = FurnitureRequirement & { scenarioSelections?: Record; }; type FurnitureRow = FurnitureRequirementRow | FurnitureOption; const money = (value: unknown) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value ?? 0)); const parseGermanNumber = (value: unknown) => { if (typeof value === 'number') return Number.isFinite(value) ? value : null; if (typeof value !== 'string') return null; const normalized = value.trim().replace(/\./g, '').replace(',', '.'); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; }; interface GridValuePresentation { icon: string; label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; } const requirementPriorities: Record = { optional: { icon: '○', label: 'Optional', tone: 'neutral' }, low: { icon: '↓', label: 'Niedrig', tone: 'neutral' }, normal: { icon: '●', label: 'Normal', tone: 'info' }, high: { icon: '↑', label: 'Hoch', tone: 'warning' }, essential: { icon: '◆', label: 'Unverzichtbar', tone: 'danger' }, }; const requirementStatuses: Record = { identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' }, research: { icon: '⌕', label: 'Recherche', 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' }, partially_delivered: { icon: '◒', label: 'Teilweise geliefert', tone: 'warning' }, delivered: { icon: '✓', label: 'Geliefert', tone: 'success' }, assembled: { icon: '⌂', label: 'Aufgebaut', tone: 'success' }, omitted: { icon: '—', label: 'Entfällt', tone: 'neutral' }, }; const optionStatuses: Record = { idea: { icon: '◌', label: 'Idee', tone: 'neutral' }, reviewing: { icon: '⌕', label: 'In Prüfung', tone: 'info' }, favorite: { icon: '★', label: 'Favorit', tone: 'warning' }, selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' }, rejected: { icon: '×', label: 'Abgelehnt', tone: 'neutral' }, unavailable: { icon: '!', label: 'Nicht verfügbar', tone: 'danger' }, ordered: { icon: '▣', label: 'Bestellt', tone: 'info' }, delivered: { icon: '✓', label: 'Geliefert', tone: 'success' }, returned: { icon: '↩', label: 'Zurückgegeben', tone: 'warning' }, archived: { icon: '—', label: 'Archiviert', tone: 'neutral' }, }; const availabilityValues: Record = { unknown: { icon: '?', label: 'Unbekannt', tone: 'neutral' }, available: { icon: '✓', label: 'Verfügbar', tone: 'success' }, limited: { icon: '!', label: 'Begrenzt verfügbar', tone: 'warning' }, unavailable: { icon: '×', label: 'Nicht verfügbar', tone: 'danger' }, discontinued: { icon: '—', label: 'Nicht mehr erhältlich', tone: 'danger' }, }; const deliveryStatuses: Record = { not_ordered: { icon: '○', label: 'Nicht bestellt', tone: 'neutral' }, planned: { icon: '◌', label: 'Bestellung geplant', tone: 'info' }, ordered: { icon: '▣', label: 'Bestellt', tone: 'info' }, shipped: { icon: '→', label: 'Versandt', tone: 'info' }, partially_delivered: { icon: '◒', label: 'Teilweise geliefert', tone: 'warning' }, delivered: { icon: '✓', label: 'Geliefert', tone: 'success' }, delayed: { icon: '!', label: 'Lieferverzögerung', tone: 'danger' }, cancelled: { icon: '×', label: 'Storniert', tone: 'neutral' }, returned: { icon: '↩', label: 'Zurückgegeben', tone: 'warning' }, }; @Component({ selector: 'app-furniture-grid', standalone: true, imports: [AgGridAngular, CurrencyPipe, FormsModule], template: `
@for (entry of views; track entry.id) { }
@if (view() === 'requirements') { } @if (view() === 'options' || view() === 'orders') { }
@if (error()) { } @if (savingRow()) {

Änderung wird gespeichert …

} @if (view() === 'scenarios') {

Klicken Sie auf eine Szenariozelle, um passende Möbel in Ruhe zu vergleichen und Ihre Auswahl anschließend zu übernehmen.

} @if (view() === 'scenarios') {
@for (scenario of scenarios; track scenario.id) {
{{ scenario.name }}{{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ scenario.openRequirements }} offene Bedarfe
}
}
`, styles: [ ` :host { display: block; } .grid-shell { display: grid; gap: var(--space-3); } .grid-tabs { display: flex; gap: var(--space-2); overflow: auto; } .grid-tabs button { min-height: 2.75rem; border: 0; border-bottom: 0.2rem solid transparent; background: transparent; color: var(--color-text-muted); padding: var(--space-2) var(--space-4); cursor: pointer; } .grid-tabs button.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 700; } .toolbar { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); } .toolbar label { display: grid; gap: var(--space-1); min-width: 11rem; } .toolbar label:has(input[type='checkbox']) { display: flex; min-width: auto; align-items: center; min-height: 2.75rem; } .toolbar input:not([type='checkbox']), .toolbar select { min-height: 2.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface); color: var(--color-text); padding: 0 var(--space-2); } .furniture-grid { width: 100%; height: min(62vh, 42rem); min-height: 25rem; } .pager { display: flex; justify-content: center; align-items: center; gap: var(--space-3); } .pager button { min-height: 2.75rem; } .row-error { color: var(--color-danger); border-left: 0.25rem solid var(--color-danger); padding: var(--space-2); } .save-state { color: var(--color-text-muted); } .scenario-help { margin: 0; color: var(--color-info); background: var(--color-info-subtle); border-left: 0.25rem solid var(--color-info); padding: var(--space-3) var(--space-4); } .scenario-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--space-3); } .scenario-summary article { display: grid; gap: var(--space-1); } @media (max-width: 40rem) { .toolbar { display: grid; } .toolbar label { min-width: 0; } .furniture-grid { height: 32rem; } } `, ], }) export class FurnitureGridComponent implements OnChanges { @Input({ required: true }) projectId = ''; @Input() rooms: Room[] = []; @Input() canEdit = false; @Input() scenarios: FurnitureScenario[] = []; @Output() editRequirement = new EventEmitter(); @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); private gridApi?: GridApi; private rollback = false; readonly view = signal('requirements'); readonly rows = signal([]); readonly loading = signal(false); readonly savingRow = signal(null); readonly error = signal(null); readonly page = signal(1); readonly totalPages = signal(1); readonly totalItems = signal(0); readonly searchChanges = new Subject(); search = ''; roomId = ''; openOnly = false; overBudget = false; favoriteOnly = false; delayedOnly = false; readonly views: Array<{ id: View; label: string }> = [ { id: 'requirements', label: 'Bedarfe' }, { id: 'options', label: 'Möbelvorschläge' }, { id: 'orders', label: 'Bestellungen' }, { id: 'scenarios', label: 'Szenarien' }, ]; readonly gridTheme = themeQuartz.withParams({ accentColor: 'var(--color-primary)', backgroundColor: 'var(--color-surface)', foregroundColor: 'var(--color-text)', borderColor: 'var(--color-border)', headerBackgroundColor: 'var(--color-surface-muted)', rowHoverColor: 'var(--color-surface-muted)', }); readonly defaultColDef: ColDef = { sortable: true, resizable: true, minWidth: 110 }; readonly rowSelection = { mode: 'multiRow' as const, checkboxes: true, headerCheckbox: true }; readonly getRowId = (params: { data: FurnitureRow }) => params.data.id; readonly columns = signal[]>([]); constructor() { this.searchChanges .pipe(debounceTime(300), distinctUntilChanged()) .subscribe(() => this.load(1)); } ngOnChanges() { this.refreshColumns(); if (this.projectId) this.load(1); } setView(view: View) { this.view.set(view); this.refreshColumns(); this.load(1); setTimeout(() => this.restoreState()); } gridReady(event: GridReadyEvent) { this.gridApi = event.api; this.restoreState(); } load(page = 1) { if (!this.projectId) return; this.loading.set(true); this.error.set(null); const sort = this.gridApi?.getColumnState().find((column) => column.sort); const fallbackSort = this.view() === 'requirements' || this.view() === 'scenarios' ? 'sortOrder' : 'updatedAt'; const sortBy = this.allowedSorts().has(sort?.colId ?? '') ? (sort?.colId ?? fallbackSort) : fallbackSort; const query = { page, pageSize: 50, search: this.search, roomId: this.roomId, sortBy, sortDirection: sort?.sort === 'asc' ? 'ASC' : 'DESC', ...(this.openOnly ? { openDecision: true } : {}), ...(this.overBudget ? { overBudget: true } : {}), ...(this.favoriteOnly ? { favorite: true } : {}), ...(this.delayedOnly ? { delayed: true } : {}), ...(this.view() === 'orders' ? { ordered: true } : {}), }; const request: Observable> = this.view() === 'requirements' || this.view() === 'scenarios' ? this.api.furnitureRequirements(this.projectId, query) : this.api.furnitureProjectOptions(this.projectId, query); request.subscribe({ next: (result) => { this.rows.set(this.withScenarioSelections(result.items)); this.page.set(result.page); this.totalPages.set(Math.max(1, result.totalPages)); this.totalItems.set(result.totalItems); this.loading.set(false); }, error: (e: unknown) => this.fail(e), }); } sortChanged() { if (!this.loading()) this.load(1); this.persistState(); } cellChanged(event: CellValueChangedEvent) { if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return; const row = event.data; this.savingRow.set(row.id); this.error.set(null); const request: Observable = 'requiredQuantity' in row ? this.api.updateFurnitureRequirement(this.projectId, row, this.requirementBody(row)) : this.api.updateFurnitureOption(this.projectId, row, this.optionBody(row)); request.subscribe({ next: (saved) => { event.node.setData(saved); this.savingRow.set(null); this.dataChanged.emit(); }, error: (error: unknown) => { this.rollback = true; event.node.setDataValue(event.column.getColId(), event.oldValue); this.rollback = false; this.savingRow.set(null); this.fail(error); if (this.status(error) === 409) this.load(this.page()); }, }); } cellClicked(event: CellClickedEvent) { 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; } if (id === 'actions') this.editOption.emit(event.data); if (!this.canEdit) return; if (id === 'favorite') this.api.favoriteFurnitureOption(this.projectId, event.data.id).subscribe({ next: () => { this.load(this.page()); this.dataChanged.emit(); }, error: (e: unknown) => this.fail(e), }); if (id === 'currentlySelected') this.api.selectFurnitureOption(this.projectId, event.data).subscribe({ next: () => { this.load(this.page()); this.dataChanged.emit(); }, error: (e: unknown) => { this.fail(e); this.load(this.page()); }, }); } exportCsv() { this.gridApi?.exportDataAsCsv({ fileName: `hauspilot-${this.view()}.csv` }); } saveState() { this.persistState(); } private requirementColumns(): ColDef[] { return [ { field: 'roomId', headerName: 'Raum', valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '–', editable: this.canEdit, cellEditor: 'agSelectCellEditor', cellEditorParams: { values: this.rooms.map((r) => r.id) }, sort: 'asc', colId: 'room', }, { field: 'category', headerName: 'Kategorie', editable: this.canEdit }, { field: 'name', headerName: 'Möbelbedarf', editable: this.canEdit, minWidth: 180 }, { field: 'priority', headerName: 'Priorität', editable: this.canEdit, cellEditor: 'agSelectCellEditor', cellEditorParams: { values: Object.keys(requirementPriorities) }, valueFormatter: (params) => this.present(params.value, requirementPriorities), cellStyle: (params) => this.presentationStyle(params.value, requirementPriorities), minWidth: 155, }, { field: 'requiredQuantity', headerName: 'Menge', editable: this.canEdit, type: 'numericColumn', }, { field: 'status', headerName: 'Status', editable: this.canEdit, cellEditor: 'agSelectCellEditor', cellEditorParams: { values: Object.keys(requirementStatuses) }, valueFormatter: (params) => this.present(params.value, requirementStatuses), cellStyle: (params) => this.presentationStyle(params.value, requirementStatuses), minWidth: 205, }, { field: 'maximumBudget', headerName: 'Maximalbudget', editable: this.canEdit, valueFormatter: (p) => money(p.value), valueParser: (p) => parseGermanNumber(p.newValue), }, { field: 'optionCount', headerName: 'Möbelvorschläge' }, { field: 'cheapestOption', headerName: 'Günstigste', valueFormatter: (p) => this.optionLabel(p.value, 'price'), minWidth: 190, }, { field: 'favoriteOption', headerName: 'Favorit', valueFormatter: (p) => this.optionLabel(p.value, 'favorite'), }, { field: 'selectedOption', headerName: 'Ausgewählt', valueFormatter: (p) => this.optionLabel(p.value, 'selected'), }, { field: 'selectedTotalPrice', headerName: 'Gesamtpreis', valueFormatter: (p) => (p.value ? money(p.value) : '–'), }, { field: 'budgetVariance', headerName: 'Abweichung', valueFormatter: (p) => p.value === null ? '–' : `${Number(p.value) > 0 ? '⚠ ' : '✓ '}${money(p.value)}`, }, { field: 'orderStatus', headerName: 'Bestellung', valueFormatter: (params) => this.present(params.value, deliveryStatuses), cellStyle: (params) => this.presentationStyle(params.value, deliveryStatuses), minWidth: 180, }, { field: 'expectedDelivery', headerName: 'Lieferung' }, { field: 'updatedAt', headerName: 'Geändert', valueFormatter: (p) => this.formatDate(p.value), }, { colId: 'addOption', headerName: 'Möbelvorschlag', valueGetter: () => (this.canEdit ? '+ hinzufügen' : 'anzeigen'), sortable: false, }, { colId: 'actions', headerName: 'Aktionen', valueGetter: () => (this.canEdit ? 'Details bearbeiten' : 'Details'), sortable: false, pinned: 'right', }, ]; } private refreshColumns() { this.columns.set( this.view() === 'requirements' ? this.requirementColumns() : this.view() === 'scenarios' ? this.scenarioColumns() : this.optionColumns(), ); } private allowedSorts() { return new Set( this.view() === 'requirements' || this.view() === 'scenarios' ? [ 'name', 'room', 'category', 'priority', 'status', 'updatedAt', 'sortOrder', 'price', 'deliveryDate', ] : [ 'name', 'retailer', 'unitPrice', 'totalPrice', 'status', 'availability', 'expectedDeliveryDate', 'updatedAt', 'room', 'requirement', ], ); } private optionColumns(): ColDef[] { return [ { field: 'roomName', headerName: 'Raum', colId: 'room' }, { field: 'requirementName', headerName: 'Möbelbedarf', colId: 'requirement', minWidth: 170 }, { field: 'name', headerName: 'Produkt', editable: this.canEdit, minWidth: 180 }, { field: 'manufacturer', headerName: 'Hersteller', editable: this.canEdit }, { field: 'retailer', headerName: 'Händler', editable: this.canEdit }, { field: 'articleNumber', headerName: 'Artikelnummer' }, ...(['unitPrice', 'quantity', 'shippingCost', 'additionalCost', 'discount'] as const).map( (field) => ({ field, headerName: { unitPrice: 'Einzelpreis', quantity: 'Menge', shippingCost: 'Versand', additionalCost: 'Zusatzkosten', discount: 'Rabatt', }[field], editable: this.canEdit, valueFormatter: field === 'quantity' ? undefined : (p: { value: unknown }) => money(p.value), valueParser: (p: { newValue: unknown }) => parseGermanNumber(p.newValue), }), ), { field: 'totalPrice', headerName: 'Gesamtpreis', valueFormatter: (p) => money(p.value) }, { field: 'color', headerName: 'Farbe', editable: this.canEdit }, { field: 'material', headerName: 'Material', editable: this.canEdit }, { field: 'width', headerName: 'Breite', editable: this.canEdit }, { field: 'height', headerName: 'Höhe', editable: this.canEdit }, { field: 'depth', headerName: 'Tiefe', editable: this.canEdit }, { field: 'availability', headerName: 'Verfügbarkeit', editable: this.canEdit, cellEditor: 'agSelectCellEditor', cellEditorParams: { values: Object.keys(availabilityValues) }, valueFormatter: (params) => this.present(params.value, availabilityValues), cellStyle: (params) => this.presentationStyle(params.value, availabilityValues), minWidth: 180, }, { field: 'expectedDeliveryDate', headerName: 'Erwartet', editable: this.canEdit }, { field: 'favorite', headerName: 'Favorit', valueFormatter: (p) => (p.value ? '★ Favorit' : '☆ setzen'), sortable: false, }, { field: 'currentlySelected', headerName: 'Auswahl', valueFormatter: (p) => (p.value ? '◉ Auswahl aufheben' : '○ auswählen'), sortable: false, }, { field: 'status', headerName: 'Status', editable: this.canEdit, cellEditor: 'agSelectCellEditor', cellEditorParams: { values: Object.keys(optionStatuses) }, valueFormatter: (params) => this.present(params.value, optionStatuses), cellStyle: (params) => this.presentationStyle(params.value, optionStatuses), minWidth: 165, }, { field: 'deliveryStatus', headerName: 'Lieferstatus', valueFormatter: (params) => this.present(params.value, deliveryStatuses), cellStyle: (params) => this.presentationStyle(params.value, deliveryStatuses), minWidth: 185, }, { field: 'orderNumber', headerName: 'Bestellnummer' }, { field: 'orderedAt', headerName: 'Bestellt am', valueFormatter: (p) => this.formatDate(p.value), }, { field: 'actualDeliveryDate', headerName: 'Geliefert am' }, { colId: 'actions', headerName: 'Aktionen', valueGetter: () => (this.canEdit ? 'Details bearbeiten' : 'Details'), sortable: false, pinned: 'right', }, ] as ColDef[]; } private scenarioColumns(): ColDef[] { const scenarioColumns: ColDef[] = this.scenarios.map( (scenario): ColDef => ({ colId: `scenario:${scenario.id}`, field: `scenarioSelections.${scenario.id}` as never, headerName: `${scenario.name} · ${money(scenario.total)}`, valueFormatter: (params) => { const row = params.data; if (!row || !('requiredQuantity' in row) || typeof params.value !== 'string') return '—'; const option = row.options.find((entry) => entry.id === params.value); return option ? `✓ ${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen'; }, 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, }), ); return [ { field: 'roomId', headerName: 'Raum', valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '–', }, { field: 'category', headerName: 'Kategorie' }, { field: 'name', headerName: 'Möbelbedarf', minWidth: 180 }, ...scenarioColumns, ]; } private requirementBody(row: FurnitureRequirement) { return { roomId: row.roomId, name: row.name, description: row.description ?? '', category: row.category, priority: row.priority, requiredQuantity: Number(row.requiredQuantity), status: row.status, ...(row.responsibleUserId ? { responsibleUserId: row.responsibleUserId } : {}), ...(row.maximumBudget !== null ? { maximumBudget: Number(row.maximumBudget) } : {}), sortOrder: row.sortOrder, }; } private withScenarioSelections(items: FurnitureRow[]): FurnitureRow[] { return items.map((item) => { if (!('requiredQuantity' in item)) return item; return { ...item, scenarioSelections: Object.fromEntries( this.scenarios.map((scenario) => [ scenario.id, scenario.selections.find((selection) => selection.requirementId === item.id) ?.optionId ?? '', ]), ), }; }); } private scenarioId(columnId: string) { return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null; } private optionBody(row: FurnitureOption) { return { name: row.name, manufacturer: row.manufacturer ?? '', model: row.model ?? '', description: row.description ?? '', retailer: row.retailer ?? '', ...(row.productUrl ? { productUrl: row.productUrl } : {}), articleNumber: row.articleNumber ?? '', unitPrice: Number(row.unitPrice), shippingCost: Number(row.shippingCost), additionalCost: Number(row.additionalCost), discount: Number(row.discount), currency: row.currency, quantity: Number(row.quantity), ...(row.width !== null ? { width: Number(row.width) } : {}), ...(row.height !== null ? { height: Number(row.height) } : {}), ...(row.depth !== null ? { depth: Number(row.depth) } : {}), color: row.color ?? '', material: row.material ?? '', ...(row.deliveryDays !== null ? { deliveryDays: row.deliveryDays } : {}), ...(row.expectedDeliveryDate ? { expectedDeliveryDate: row.expectedDeliveryDate } : {}), availability: row.availability, favorite: row.favorite, status: row.status, notes: row.notes ?? '', existingItem: row.existingItem, movingCost: Number(row.movingCost), refurbishmentCost: Number(row.refurbishmentCost), }; } private stateKey() { return `hauspilot:grid:furniture:${this.auth.user()?.id ?? 'anonymous'}:${this.projectId}:${this.view()}`; } private optionLabel(value: unknown, kind: 'price' | 'favorite' | 'selected') { if (!this.isOption(value)) return kind === 'price' ? '⚠ Preis offen' : kind === 'selected' ? '⚠ offen' : '–'; if (kind === 'price') return `${value.name} · ${money(value.totalPrice)}`; return `${kind === 'favorite' ? '★' : '✓'} ${value.name}`; } private present(value: unknown, presentations: Record) { if (typeof value !== 'string' || !value) return '—'; const presentation = presentations[value]; return presentation ? `${presentation.icon} ${presentation.label}` : value; } private presentationStyle( value: unknown, presentations: Record, ): CellStyle { const tone = typeof value === 'string' ? presentations[value]?.tone : undefined; const colors: Record = { neutral: { color: 'var(--color-text-muted)', backgroundColor: 'var(--color-neutral-subtle)', }, info: { color: 'var(--color-info)', backgroundColor: 'var(--color-info-subtle)' }, success: { color: 'var(--color-success)', backgroundColor: 'var(--color-success-subtle)', }, warning: { color: 'var(--color-warning)', backgroundColor: 'var(--color-warning-subtle)', }, danger: { color: 'var(--color-danger)', backgroundColor: 'var(--color-danger-subtle)' }, }; return tone ? { ...colors[tone], fontWeight: '650' } : { color: 'var(--color-text-muted)' }; } private isOption(value: unknown): value is FurnitureOption { return typeof value === 'object' && value !== null && 'name' in value && 'totalPrice' in value; } private formatDate(value: unknown) { return typeof value === 'string' || typeof value === 'number' || value instanceof Date ? new Date(value).toLocaleDateString('de-DE') : '–'; } private persistState() { if (this.gridApi) localStorage.setItem(this.stateKey(), JSON.stringify(this.gridApi.getColumnState())); } private restoreState() { const saved = localStorage.getItem(this.stateKey()); if (saved && this.gridApi) { try { this.gridApi.applyColumnState({ state: JSON.parse(saved) as never[], applyOrder: true }); } catch { localStorage.removeItem(this.stateKey()); } } } private status(error: unknown) { return typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number' ? error.status : 0; } private fail(error: unknown) { this.loading.set(false); const fallback = typeof error === 'object' && error !== null && 'error' in error && typeof error.error === 'object' && error.error !== null && 'message' in error.error && typeof error.error.message === 'string' ? error.error.message : 'Die Möbelansicht konnte nicht aktualisiert werden.'; this.error.set(conflictMessage(this.status(error), fallback)); } }