// frontend/src/pages/DashboardPage.tsx import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react' import { CheckIcon, Cog6ToothIcon, PencilSquareIcon, TrashIcon, } from '@heroicons/react/20/solid' import { Squares2X2Icon } from '@heroicons/react/24/outline' import WidgetSettingsModal from '../components/dashboard/WidgetSettingsModal' import Button from '../components/Button' import LoadingSpinner from '../components/LoadingSpinner' import DashboardWidgetRenderer from '../components/dashboard/DashboardWidgetRenderer' import WidgetPickerModal from '../components/dashboard/WidgetPickerModal' import type { DashboardWidget, DashboardWidgetDefinition, DashboardWidgetType, } from '../components/dashboard/types' import EmptyState from '../components/EmptyState' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const GRID_COLUMNS = 12 const GRID_ROW_HEIGHT = 88 const GRID_GAP = 16 const GRID_PADDING = 16 type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se' type GridPlacement = { x: number y: number w: number h: number } type GridCell = { x: number y: number } type DashboardInteraction = | { type: 'move' pointerId: number widgetId: string offsetX: number offsetY: number } | { type: 'resize' pointerId: number widgetId: string direction: ResizeDirection startClientX: number startClientY: number startWidget: DashboardWidget } | { type: 'draw' pointerId: number startCell: GridCell } function getResizeHandleClass(direction: ResizeDirection) { switch (direction) { case 'nw': return '-top-1.5 -left-1.5 cursor-nwse-resize' case 'ne': return '-top-1.5 -right-1.5 cursor-nesw-resize' case 'sw': return '-bottom-1.5 -left-1.5 cursor-nesw-resize' case 'se': return '-right-1.5 -bottom-1.5 cursor-nwse-resize' } } function hasWidgetChanged(widget: DashboardWidget, nextWidget: DashboardWidget) { return ( widget.x !== nextWidget.x || widget.y !== nextWidget.y || widget.w !== nextWidget.w || widget.h !== nextWidget.h ) } type DashboardPageProps = { username: string } type DashboardSettings = { isEditingDashboard?: boolean } const widgetTypes: DashboardWidgetDefinition[] = [ { id: 'operationChanges', label: 'Letzte Einsatzänderungen', description: 'Zeigt die letzten Änderungen an Einsätzen ohne Journal-Einträge.', defaultTitle: 'Letzte Einsatzänderungen', defaultSize: { w: 6, h: 4, }, defaultConfig: { limit: 8, }, }, { id: 'operationMap', label: 'Einsatzkarte', description: 'Zeigt Einsatzorte auf einer Karte inklusive KW und Zielobjekt.', defaultTitle: 'Einsatzkarte', defaultSize: { w: 8, h: 5, }, defaultConfig: { defaultLayer: 'nrw-dop', showLabelsOverlay: true, showLocateControl: false, showCurrentLocationMarker: true, initialAddress: 'Tiefenbroicher Weg 32, 40472 Düsseldorf', initialZoom: 14, }, }, ] function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } function widgetsCollide(a: DashboardWidget, b: DashboardWidget) { return !( a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y ) } function findFreePosition( widgets: DashboardWidget[], candidate: DashboardWidget, ) { let nextWidget = { ...candidate, x: clamp(candidate.x, 0, GRID_COLUMNS - candidate.w), y: Math.max(0, candidate.y), } while ( widgets.some( (widget) => widget.id !== nextWidget.id && widgetsCollide(widget, nextWidget), ) ) { nextWidget = { ...nextWidget, y: nextWidget.y + 1, } } return nextWidget } function getNextY(widgets: DashboardWidget[]) { if (widgets.length === 0) { return 0 } return Math.max(...widgets.map((widget) => widget.y + widget.h)) } function getWidgetTypeDefinition(type: DashboardWidgetType) { return widgetTypes.find((widgetType) => widgetType.id === type) ?? widgetTypes[0] } export default function DashboardPage({ username }: DashboardPageProps) { const [widgets, setWidgets] = useState([]) const [isEditingDashboard, setIsEditingDashboard] = useState(false) const [isWidgetPickerOpen, setIsWidgetPickerOpen] = useState(false) const [pendingWidgetPlacement, setPendingWidgetPlacement] = useState(null) const [drawPlacement, setDrawPlacement] = useState(null) const [activeWidgetId, setActiveWidgetId] = useState(null) const [previewWidget, setPreviewWidget] = useState(null) const [settingsWidget, setSettingsWidget] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState(null) const [dashboardSettings, setDashboardSettings] = useState({}) const gridRef = useRef(null) const interactionRef = useRef(null) const previewWidgetRef = useRef(null) const drawPlacementRef = useRef(null) useEffect(() => { drawPlacementRef.current = drawPlacement }, [drawPlacement]) useEffect(() => { previewWidgetRef.current = previewWidget }, [previewWidget]) const dashboardRows = useMemo(() => { return Math.max( 4, ...widgets.map((widget) => widget.y + widget.h), drawPlacement ? drawPlacement.y + drawPlacement.h : 0, previewWidget ? previewWidget.y + previewWidget.h : 0, ) }, [drawPlacement, previewWidget, widgets]) const dashboardHeight = useMemo(() => { return dashboardRows * GRID_ROW_HEIGHT + Math.max(0, dashboardRows - 1) * GRID_GAP }, [dashboardRows]) useEffect(() => { void loadDashboard() }, []) async function loadWidgets() { setError(null) try { const response = await fetch(`${API_URL}/dashboard/widgets`, { credentials: 'include', }) if (!response.ok) { const errorData = await response.json().catch(() => null) throw new Error(errorData?.error ?? 'Dashboard konnte nicht geladen werden') } const data = await response.json() setWidgets(data.widgets ?? []) } catch (error) { setError( error instanceof Error ? error.message : 'Dashboard konnte nicht geladen werden', ) } } async function loadDashboard() { setIsLoading(true) setError(null) try { await Promise.all([ loadWidgets(), loadDashboardSettings(), ]) } finally { setIsLoading(false) } } async function loadDashboardSettings() { try { const response = await fetch(`${API_URL}/dashboard/settings`, { credentials: 'include', }) if (!response.ok) { const errorData = await response.json().catch(() => null) throw new Error( errorData?.error ?? 'Dashboard-Einstellungen konnten nicht geladen werden', ) } const data = await response.json() const settings = data.dashboardSettings?.settings ?? {} setDashboardSettings(settings) if (typeof settings.isEditingDashboard === 'boolean') { setIsEditingDashboard(settings.isEditingDashboard) } } catch (error) { setError( error instanceof Error ? error.message : 'Dashboard-Einstellungen konnten nicht geladen werden', ) } } async function saveDashboardSettings(nextSettings: DashboardSettings) { setDashboardSettings(nextSettings) const response = await fetch(`${API_URL}/dashboard/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ settings: nextSettings, }), }) if (!response.ok) { const errorData = await response.json().catch(() => null) throw new Error( errorData?.error ?? 'Dashboard-Einstellungen konnten nicht gespeichert werden', ) } const data = await response.json() setDashboardSettings(data.dashboardSettings?.settings ?? nextSettings) } async function saveWidget(widget: DashboardWidget) { const response = await fetch(`${API_URL}/dashboard/widgets/${widget.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ type: widget.type, title: widget.title, x: widget.x, y: widget.y, w: widget.w, h: widget.h, config: widget.config, }), }) if (!response.ok) { const errorData = await response.json().catch(() => null) throw new Error(errorData?.error ?? 'Widget konnte nicht gespeichert werden') } const data = await response.json() return data.widget as DashboardWidget } async function createWidget(widgetTypeId: DashboardWidgetType) { const widgetType = getWidgetTypeDefinition(widgetTypeId) const placement = pendingWidgetPlacement setIsSaving(true) setError(null) try { const response = await fetch(`${API_URL}/dashboard/widgets`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ type: widgetType.id, title: widgetType.defaultTitle, x: placement?.x ?? 0, y: placement?.y ?? getNextY(widgets), w: placement?.w ?? widgetType.defaultSize.w, h: placement?.h ?? widgetType.defaultSize.h, config: widgetType.defaultConfig, }), }) if (!response.ok) { const errorData = await response.json().catch(() => null) throw new Error(errorData?.error ?? 'Widget konnte nicht erstellt werden') } const data = await response.json() setWidgets((currentWidgets) => [...currentWidgets, data.widget]) setPendingWidgetPlacement(null) setIsWidgetPickerOpen(false) } catch (error) { setError( error instanceof Error ? error.message : 'Widget konnte nicht erstellt werden', ) } finally { setIsSaving(false) } } async function updateWidget(nextWidget: DashboardWidget) { const adjustedWidget = findFreePosition( widgets.filter((widget) => widget.id !== nextWidget.id), nextWidget, ) setWidgets((currentWidgets) => currentWidgets.map((widget) => widget.id === adjustedWidget.id ? adjustedWidget : widget, ), ) try { const savedWidget = await saveWidget(adjustedWidget) setWidgets((currentWidgets) => currentWidgets.map((widget) => widget.id === savedWidget.id ? savedWidget : widget, ), ) } catch (error) { setError( error instanceof Error ? error.message : 'Widget konnte nicht gespeichert werden', ) void loadWidgets() } } async function updateWidgetSettings( widget: DashboardWidget, nextValues: { title: string config: Record }, ) { const nextWidget: DashboardWidget = { ...widget, title: nextValues.title, config: nextValues.config, } setIsSaving(true) setError(null) setWidgets((currentWidgets) => currentWidgets.map((currentWidget) => currentWidget.id === nextWidget.id ? nextWidget : currentWidget, ), ) try { const savedWidget = await saveWidget(nextWidget) setWidgets((currentWidgets) => currentWidgets.map((currentWidget) => currentWidget.id === savedWidget.id ? savedWidget : currentWidget, ), ) setSettingsWidget(null) } catch (error) { setError( error instanceof Error ? error.message : 'Widget-Einstellungen konnten nicht gespeichert werden', ) void loadWidgets() } finally { setIsSaving(false) } } async function deleteWidget(widgetId: string) { setError(null) try { const response = await fetch(`${API_URL}/dashboard/widgets/${widgetId}`, { method: 'DELETE', credentials: 'include', }) if (!response.ok) { const errorData = await response.json().catch(() => null) throw new Error(errorData?.error ?? 'Widget konnte nicht gelöscht werden') } setWidgets((currentWidgets) => currentWidgets.filter((widget) => widget.id !== widgetId), ) } catch (error) { setError( error instanceof Error ? error.message : 'Widget konnte nicht gelöscht werden', ) } } function getGridMetrics() { if (!gridRef.current) { return null } const rect = gridRef.current.getBoundingClientRect() const styles = window.getComputedStyle(gridRef.current) const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0 const paddingTop = Number.parseFloat(styles.paddingTop) || 0 const paddingRight = Number.parseFloat(styles.paddingRight) || 0 const contentLeft = rect.left + paddingLeft const contentTop = rect.top + paddingTop const contentWidth = rect.width - paddingLeft - paddingRight const columnWidth = (contentWidth - (GRID_COLUMNS - 1) * GRID_GAP) / GRID_COLUMNS return { contentLeft, contentTop, columnWidth, } } function getGridCellFromPointer(clientX: number, clientY: number): GridCell | null { const metrics = getGridMetrics() if (!metrics) { return null } const relativeX = clientX - metrics.contentLeft const relativeY = clientY - metrics.contentTop return { x: clamp( Math.floor(relativeX / (metrics.columnWidth + GRID_GAP)), 0, GRID_COLUMNS - 1, ), y: Math.max( 0, Math.floor(relativeY / (GRID_ROW_HEIGHT + GRID_GAP)), ), } } function getDrawPlacement(startCell: GridCell, endCell: GridCell): GridPlacement { const x = Math.min(startCell.x, endCell.x) const y = Math.min(startCell.y, endCell.y) const w = clamp(Math.abs(endCell.x - startCell.x) + 1, 2, GRID_COLUMNS - x) const h = clamp(Math.abs(endCell.y - startCell.y) + 1, 1, 8) return { x, y, w, h, } } function handleGridPointerDown(event: PointerEvent) { if (!isEditingDashboard) { return } if (event.button !== 0) { return } if (event.target !== event.currentTarget) { return } const startCell = getGridCellFromPointer(event.clientX, event.clientY) if (!startCell || !gridRef.current) { return } interactionRef.current = { type: 'draw', pointerId: event.pointerId, startCell, } const initialPlacement: GridPlacement = { x: startCell.x, y: startCell.y, w: 2, h: 1, } setDrawPlacement(initialPlacement) gridRef.current.setPointerCapture(event.pointerId) } function openWidgetPickerForPlacement(placement: GridPlacement | null) { setPendingWidgetPlacement(placement) setIsWidgetPickerOpen(true) } function handleWidgetPickerOpenChange(open: boolean) { setIsWidgetPickerOpen(open) if (!open) { setPendingWidgetPlacement(null) } } function getMovedWidget( widget: DashboardWidget, clientX: number, clientY: number, offsetX: number, offsetY: number, ) { const metrics = getGridMetrics() if (!metrics) { return widget } const relativeX = clientX - metrics.contentLeft - offsetX const relativeY = clientY - metrics.contentTop - offsetY const nextX = clamp( Math.round(relativeX / (metrics.columnWidth + GRID_GAP)), 0, GRID_COLUMNS - widget.w, ) const nextY = Math.max( 0, Math.round(relativeY / (GRID_ROW_HEIGHT + GRID_GAP)), ) return { ...widget, x: nextX, y: nextY, } } function getResizedWidget( interaction: Extract, clientX: number, clientY: number, ) { const metrics = getGridMetrics() if (!metrics) { return interaction.startWidget } const columnDelta = Math.round( (clientX - interaction.startClientX) / (metrics.columnWidth + GRID_GAP), ) const rowDelta = Math.round( (clientY - interaction.startClientY) / (GRID_ROW_HEIGHT + GRID_GAP), ) const startWidget = interaction.startWidget let nextX = startWidget.x let nextY = startWidget.y let nextW = startWidget.w let nextH = startWidget.h if (interaction.direction.includes('e')) { nextW = clamp(startWidget.w + columnDelta, 2, GRID_COLUMNS - startWidget.x) } if (interaction.direction.includes('s')) { nextH = clamp(startWidget.h + rowDelta, 1, 8) } if (interaction.direction.includes('w')) { const maxX = startWidget.x + startWidget.w - 2 nextX = clamp(startWidget.x + columnDelta, 0, maxX) nextW = startWidget.x + startWidget.w - nextX } if (interaction.direction.includes('n')) { const maxY = startWidget.y + startWidget.h - 1 nextY = clamp(startWidget.y + rowDelta, 0, maxY) nextH = startWidget.y + startWidget.h - nextY } return { ...startWidget, x: nextX, y: nextY, w: nextW, h: nextH, } } function startMovingWidget( event: PointerEvent, widget: DashboardWidget, ) { if (event.button !== 0) { return } if (!isEditingDashboard) { return } const widgetElement = event.currentTarget.closest('[data-dashboard-widget]') if (!widgetElement || !gridRef.current) { return } const rect = widgetElement.getBoundingClientRect() interactionRef.current = { type: 'move', pointerId: event.pointerId, widgetId: widget.id, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, } setActiveWidgetId(widget.id) setPreviewWidget(widget) gridRef.current.setPointerCapture(event.pointerId) } function startResizingWidget( event: PointerEvent, widget: DashboardWidget, direction: ResizeDirection, ) { event.preventDefault() event.stopPropagation() if (!isEditingDashboard) { return } if (!gridRef.current) { return } interactionRef.current = { type: 'resize', pointerId: event.pointerId, widgetId: widget.id, direction, startClientX: event.clientX, startClientY: event.clientY, startWidget: widget, } setActiveWidgetId(widget.id) setPreviewWidget(widget) gridRef.current.setPointerCapture(event.pointerId) } function handleGridPointerMove(event: PointerEvent) { const interaction = interactionRef.current if (!interaction) { return } if (interaction.type === 'draw') { const currentCell = getGridCellFromPointer(event.clientX, event.clientY) if (!currentCell) { return } setDrawPlacement(getDrawPlacement(interaction.startCell, currentCell)) return } const widget = interaction.type === 'resize' ? interaction.startWidget : widgets.find((item) => item.id === interaction.widgetId) if (!widget) { return } const rawNextWidget = interaction.type === 'move' ? getMovedWidget( widget, event.clientX, event.clientY, interaction.offsetX, interaction.offsetY, ) : getResizedWidget(interaction, event.clientX, event.clientY) const adjustedWidget = findFreePosition( widgets.filter((item) => item.id !== rawNextWidget.id), rawNextWidget, ) setPreviewWidget(adjustedWidget) } function handleGridPointerEnd() { const interaction = interactionRef.current const nextWidget = previewWidgetRef.current const nextDrawPlacement = drawPlacementRef.current if (interaction && gridRef.current?.hasPointerCapture(interaction.pointerId)) { gridRef.current.releasePointerCapture(interaction.pointerId) } interactionRef.current = null setActiveWidgetId(null) setPreviewWidget(null) setDrawPlacement(null) if (interaction?.type === 'draw') { if (nextDrawPlacement) { openWidgetPickerForPlacement(nextDrawPlacement) } return } if (!nextWidget) { return } const currentWidget = widgets.find((widget) => widget.id === nextWidget.id) if (!currentWidget || !hasWidgetChanged(currentWidget, nextWidget)) { return } void updateWidget(nextWidget) } function openEmptyDashboardWidgetPicker() { setIsEditingDashboard(true) openWidgetPickerForPlacement(null) void saveDashboardSettings({ ...dashboardSettings, isEditingDashboard: true, }).catch((error) => { setError( error instanceof Error ? error.message : 'Dashboard-Einstellungen konnten nicht gespeichert werden', ) }) } if (isLoading) { return (
) } return (

Dashboard

Willkommen zurück, {username}. Du kannst Widgets hinzufügen, verschieben und skalieren.

{widgets.length > 0 && (
)}
{error && (
{error}
)} {widgets.length === 0 ? (
) : (
{isEditingDashboard && (