Files
flat-pilot/apps/frontend/src/app/features/projects/furniture-grid.component.ts
Bastian Wagner 2328081aa3 xx
2026-07-20 21:47:51 +02:00

913 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string>;
};
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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: `
<section class="grid-shell" aria-labelledby="furniture-grid-title">
<div class="grid-tabs" role="tablist" aria-label="Möbelansichten">
@for (entry of views; track entry.id) {
<button
type="button"
role="tab"
[attr.aria-selected]="view() === entry.id"
[class.active]="view() === entry.id"
(click)="setView(entry.id)"
>
{{ entry.label }}
</button>
}
</div>
<div class="toolbar ui-card">
<label
>Suche
<input
[(ngModel)]="search"
(ngModelChange)="searchChanges.next($event)"
placeholder="Möbel, Hersteller oder Händler"
/></label>
<label
>Raum
<select [(ngModel)]="roomId" (ngModelChange)="load(1)">
<option value="">Alle Räume</option>
@for (room of rooms; track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
@if (view() === 'requirements') {
<label
><input type="checkbox" [(ngModel)]="openOnly" (ngModelChange)="load(1)" /> Nur offene
Entscheidungen</label
>
<label
><input type="checkbox" [(ngModel)]="overBudget" (ngModelChange)="load(1)" /> Nur über
Budget</label
>
}
@if (view() === 'options' || view() === 'orders') {
<label
><input type="checkbox" [(ngModel)]="favoriteOnly" (ngModelChange)="load(1)" /> Nur
Favoriten</label
>
<label
><input type="checkbox" [(ngModel)]="delayedOnly" (ngModelChange)="load(1)" /> Nur
verspätet</label
>
}
<button type="button" class="ui-button ui-button--ghost" (click)="exportCsv()">
CSV exportieren
</button>
</div>
@if (error()) {
<p class="row-error" role="alert">{{ error() }}</p>
}
@if (savingRow()) {
<p class="save-state" role="status">Änderung wird gespeichert …</p>
}
@if (view() === 'scenarios') {
<p class="scenario-help">
Klicken Sie auf eine Szenariozelle, um passende Möbel in Ruhe zu vergleichen und Ihre
Auswahl anschließend zu übernehmen.
</p>
}
<ag-grid-angular
class="furniture-grid"
[theme]="gridTheme"
[rowData]="rows()"
[columnDefs]="columns()"
[defaultColDef]="defaultColDef"
[getRowId]="getRowId"
[rowSelection]="rowSelection"
[loading]="loading()"
[animateRows]="true"
[singleClickEdit]="true"
[stopEditingWhenCellsLoseFocus]="true"
(gridReady)="gridReady($event)"
(sortChanged)="sortChanged()"
(cellValueChanged)="cellChanged($event)"
(cellClicked)="cellClicked($event)"
(columnMoved)="saveState()"
(columnResized)="saveState()"
(columnVisible)="saveState()"
/>
<nav class="pager" aria-label="Grid-Seitennavigation">
<button type="button" [disabled]="page() <= 1" (click)="load(page() - 1)">Zurück</button>
<span>Seite {{ page() }} von {{ totalPages() }} · {{ totalItems() }} Einträge</span>
<button type="button" [disabled]="page() >= totalPages()" (click)="load(page() + 1)">
Weiter
</button>
</nav>
@if (view() === 'scenarios') {
<div class="scenario-summary">
@for (scenario of scenarios; track scenario.id) {
<article class="ui-card">
<strong>{{ scenario.name }}</strong
><span>{{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</span
><small>{{ scenario.openRequirements }} offene Bedarfe</small>
</article>
}
</div>
}
</section>
`,
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<FurnitureRequirement>();
@Output() addOption = new EventEmitter<FurnitureRequirement>();
@Output() editOption = new EventEmitter<FurnitureOption>();
@Output() assignScenario = new EventEmitter<{
requirement: FurnitureRequirement;
scenarioId: string;
}>();
@Output() dataChanged = new EventEmitter<void>();
private readonly api = inject(HauspilotApiService);
private readonly auth = inject(AuthService);
private gridApi?: GridApi<FurnitureRow>;
private rollback = false;
readonly view = signal<View>('requirements');
readonly rows = signal<FurnitureRow[]>([]);
readonly loading = signal(false);
readonly savingRow = signal<string | null>(null);
readonly error = signal<string | null>(null);
readonly page = signal(1);
readonly totalPages = signal(1);
readonly totalItems = signal(0);
readonly searchChanges = new Subject<string>();
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<FurnitureRow> = { 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<ColDef<FurnitureRow>[]>([]);
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<FurnitureRow>) {
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<PageResult<FurnitureRow>> =
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<FurnitureRow>) {
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<FurnitureRow> =
'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<FurnitureRow>) {
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<FurnitureRow>[] {
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<FurnitureRow>[] {
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<FurnitureRow>[];
}
private scenarioColumns(): ColDef<FurnitureRow>[] {
const scenarioColumns: ColDef<FurnitureRow>[] = this.scenarios.map(
(scenario): ColDef<FurnitureRow> => ({
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<string, GridValuePresentation>) {
if (typeof value !== 'string' || !value) return '—';
const presentation = presentations[value];
return presentation ? `${presentation.icon} ${presentation.label}` : value;
}
private presentationStyle(
value: unknown,
presentations: Record<string, GridValuePresentation>,
): CellStyle {
const tone = typeof value === 'string' ? presentations[value]?.tone : undefined;
const colors: Record<GridValuePresentation['tone'], CellStyle> = {
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));
}
}