generated from bastian/boilerplate
913 lines
33 KiB
TypeScript
913 lines
33 KiB
TypeScript
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));
|
||
}
|
||
}
|