generated from bastian/boilerplate
mvp
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user