This commit is contained in:
Bastian Wagner
2026-07-20 09:01:36 +02:00
parent 8cf57d7878
commit e62673ac11
98 changed files with 17372 additions and 80 deletions

View File

@@ -0,0 +1,975 @@
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: 'Alternativen 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">
Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus.
„Nicht zugewiesen“ lässt den Bedarf im Szenario offen.
</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() 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: 'Alternativen' },
{ 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 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);
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) {
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: 'Alternativen' },
{
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: 'Alternative',
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 ? '◉ Ausgewählt' : '○ 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';
},
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',
}
: {
color: 'var(--color-warning)',
backgroundColor: 'var(--color-warning-subtle)',
},
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 selectableOptions(requirement: FurnitureRequirement) {
return requirement.options.filter(
(option) =>
!['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) &&
!['unavailable', 'discontinued'].includes(option.availability),
);
}
private saveScenarioSelection(
event: CellValueChangedEvent<FurnitureRow>,
scenarioId: string,
requirement: FurnitureRequirement,
) {
const scenario = this.scenarios.find((entry) => entry.id === scenarioId);
if (!scenario) {
this.rollbackCell(event);
return;
}
const optionId = typeof event.newValue === 'string' ? event.newValue : '';
const option = optionId
? this.selectableOptions(requirement).find((entry) => entry.id === optionId)
: undefined;
if (optionId && !option) {
this.rollbackCell(event);
this.error.set('Diese Alternative kann dem Szenario nicht zugewiesen werden.');
return;
}
const selections = scenario.selections
.filter((selection) => selection.requirementId !== requirement.id)
.map((selection) => ({ ...selection }));
if (option)
selections.push({
requirementId: requirement.id,
optionId: option.id,
quantity: option.quantity,
});
this.savingRow.set(requirement.id);
this.error.set(null);
this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({
next: (saved) => {
this.scenarios = this.scenarios.map((entry) => (entry.id === saved.id ? saved : entry));
this.savingRow.set(null);
this.refreshColumns();
this.dataChanged.emit();
},
error: (error: unknown) => {
this.rollbackCell(event);
this.savingRow.set(null);
this.fail(error);
if (this.status(error) === 409) this.load(this.page());
},
});
}
private rollbackCell(event: CellValueChangedEvent<FurnitureRow>) {
this.rollback = true;
event.node.setDataValue(event.column.getColId(), event.oldValue);
this.rollback = false;
}
private optionBody(row: FurnitureOption) {
return {
name: row.name,
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));
}
}