1136 lines
32 KiB
TypeScript
1136 lines
32 KiB
TypeScript
// 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<string | false | null | undefined>) {
|
|
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<DashboardWidget[]>([])
|
|
const [isEditingDashboard, setIsEditingDashboard] = useState(false)
|
|
const [isWidgetPickerOpen, setIsWidgetPickerOpen] = useState(false)
|
|
const [pendingWidgetPlacement, setPendingWidgetPlacement] = useState<GridPlacement | null>(null)
|
|
const [drawPlacement, setDrawPlacement] = useState<GridPlacement | null>(null)
|
|
const [activeWidgetId, setActiveWidgetId] = useState<string | null>(null)
|
|
const [previewWidget, setPreviewWidget] = useState<DashboardWidget | null>(null)
|
|
const [settingsWidget, setSettingsWidget] = useState<DashboardWidget | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>({})
|
|
|
|
const gridRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const interactionRef = useRef<DashboardInteraction | null>(null)
|
|
const previewWidgetRef = useRef<DashboardWidget | null>(null)
|
|
|
|
const drawPlacementRef = useRef<GridPlacement | null>(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<string, unknown>
|
|
},
|
|
) {
|
|
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<HTMLDivElement>) {
|
|
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<DashboardInteraction, { type: 'resize' }>,
|
|
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<HTMLElement>,
|
|
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<HTMLButtonElement>,
|
|
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<HTMLDivElement>) {
|
|
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 (
|
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
|
<LoadingSpinner
|
|
size="lg"
|
|
label="Dashboard wird geladen..."
|
|
className="text-indigo-600 dark:text-indigo-400"
|
|
center
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
|
<div className="mb-8 sm:flex sm:items-start sm:justify-between sm:gap-x-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
|
Dashboard
|
|
</h1>
|
|
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
Willkommen zurück, {username}. Du kannst Widgets hinzufügen, verschieben und skalieren.
|
|
</p>
|
|
</div>
|
|
|
|
{widgets.length > 0 && (
|
|
<div className="mt-4 flex items-center gap-x-2 sm:mt-0">
|
|
<Button
|
|
type="button"
|
|
variant={isEditingDashboard ? 'primary' : 'secondary'}
|
|
color={isEditingDashboard ? 'emerald' : 'gray'}
|
|
leadingIcon={
|
|
isEditingDashboard ? (
|
|
<CheckIcon aria-hidden="true" className="size-4" />
|
|
) : (
|
|
<PencilSquareIcon aria-hidden="true" className="size-4" />
|
|
)
|
|
}
|
|
onClick={() => {
|
|
const nextValue = !isEditingDashboard
|
|
|
|
setIsEditingDashboard(nextValue)
|
|
setPreviewWidget(null)
|
|
setDrawPlacement(null)
|
|
setPendingWidgetPlacement(null)
|
|
|
|
void saveDashboardSettings({
|
|
...dashboardSettings,
|
|
isEditingDashboard: nextValue,
|
|
}).catch((error) => {
|
|
setError(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Dashboard-Einstellungen konnten nicht gespeichert werden',
|
|
)
|
|
})
|
|
}}
|
|
>
|
|
{isEditingDashboard ? 'Fertig' : 'Dashboard bearbeiten'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{widgets.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 dark:border-white/10 dark:bg-gray-900">
|
|
<EmptyState
|
|
icon={Squares2X2Icon}
|
|
iconClassName="text-indigo-400 dark:text-indigo-500"
|
|
title="Noch keine Widgets"
|
|
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
|
|
actionLabel="Widget hinzufügen"
|
|
onAction={openEmptyDashboardWidgetPicker}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={gridRef}
|
|
onPointerDown={handleGridPointerDown}
|
|
onPointerMove={handleGridPointerMove}
|
|
onPointerUp={handleGridPointerEnd}
|
|
onPointerCancel={handleGridPointerEnd}
|
|
className={classNames(
|
|
'group/dashboard relative grid rounded-lg border p-4 transition',
|
|
isEditingDashboard
|
|
? 'cursor-crosshair border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-gray-950/40'
|
|
: 'border-transparent bg-transparent',
|
|
)}
|
|
style={{
|
|
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
|
|
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
|
|
gap: `${GRID_GAP}px`,
|
|
minHeight: dashboardHeight + GRID_PADDING * 2,
|
|
}}
|
|
>
|
|
{isEditingDashboard && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-4 z-0 grid overflow-hidden rounded-lg"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
|
|
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
|
|
gap: `${GRID_GAP}px`,
|
|
}}
|
|
>
|
|
{Array.from({ length: dashboardRows * GRID_COLUMNS }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="rounded-md border border-indigo-500/20 bg-indigo-500/[0.015]"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{drawPlacement && (
|
|
<div
|
|
className="pointer-events-none z-10 rounded-lg border-2 border-dashed border-indigo-600 bg-indigo-500/10 ring-4 ring-indigo-500/10"
|
|
style={{
|
|
gridColumn: `${drawPlacement.x + 1} / span ${drawPlacement.w}`,
|
|
gridRow: `${drawPlacement.y + 1} / span ${drawPlacement.h}`,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{previewWidget && (
|
|
<div
|
|
className="pointer-events-none z-10 rounded-lg border-2 border-dashed border-indigo-500 bg-indigo-500/10 ring-4 ring-indigo-500/10"
|
|
style={{
|
|
gridColumn: `${previewWidget.x + 1} / span ${previewWidget.w}`,
|
|
gridRow: `${previewWidget.y + 1} / span ${previewWidget.h}`,
|
|
}}
|
|
/>
|
|
)}
|
|
{widgets.map((widget) => (
|
|
<section
|
|
key={widget.id}
|
|
data-dashboard-widget
|
|
className={classNames(
|
|
'group relative z-20 flex min-h-0 flex-col overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-200 transition dark:bg-gray-900 dark:ring-white/10',
|
|
activeWidgetId === widget.id && 'opacity-60 ring-2 ring-indigo-300 dark:ring-indigo-500',
|
|
)}
|
|
style={{
|
|
gridColumn: `${widget.x + 1} / span ${widget.w}`,
|
|
gridRow: `${widget.y + 1} / span ${widget.h}`,
|
|
}}
|
|
>
|
|
<div
|
|
onPointerDown={(event) => startMovingWidget(event, widget)}
|
|
className={classNames(
|
|
'flex shrink-0 touch-none items-center justify-between gap-x-3 border-b border-gray-200 px-4 py-3 select-none dark:border-white/10',
|
|
isEditingDashboard ? 'cursor-move' : 'cursor-default',
|
|
)}
|
|
>
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
|
{widget.title}
|
|
</h2>
|
|
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{getWidgetTypeDefinition(widget.type).label}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-x-1">
|
|
{isEditingDashboard && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
title="Widget konfigurieren"
|
|
aria-label="Widget konfigurieren"
|
|
onPointerDown={(event) => {
|
|
event.stopPropagation()
|
|
}}
|
|
onPointerUp={(event) => {
|
|
event.stopPropagation()
|
|
}}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
setSettingsWidget(widget)
|
|
}}
|
|
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
|
>
|
|
<Cog6ToothIcon aria-hidden="true" className="size-4" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
title="Widget löschen"
|
|
aria-label="Widget löschen"
|
|
onPointerDown={(event) => {
|
|
event.stopPropagation()
|
|
}}
|
|
onPointerUp={(event) => {
|
|
event.stopPropagation()
|
|
}}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
void deleteWidget(widget.id)
|
|
}}
|
|
className="inline-flex size-8 items-center justify-center rounded-md text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
|
|
>
|
|
<TrashIcon aria-hidden="true" className="size-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={classNames(
|
|
'min-h-0 flex-1',
|
|
widget.type === 'operationMap'
|
|
? 'overflow-hidden p-0'
|
|
: 'overflow-auto p-4',
|
|
)}
|
|
>
|
|
<DashboardWidgetRenderer
|
|
widget={widget}
|
|
username={username}
|
|
apiUrl={API_URL}
|
|
widgetCount={widgets.length}
|
|
/>
|
|
</div>
|
|
|
|
{isEditingDashboard &&
|
|
(['nw', 'ne', 'sw', 'se'] as ResizeDirection[]).map((direction) => (
|
|
<button
|
|
key={direction}
|
|
type="button"
|
|
aria-label="Widget skalieren"
|
|
onPointerDown={(event) => startResizingWidget(event, widget, direction)}
|
|
className={classNames(
|
|
'absolute z-20 size-4 rounded-full border-2 border-white bg-indigo-600 shadow-sm opacity-0 ring-1 ring-black/10 transition group-hover:opacity-100 focus:opacity-100 dark:border-gray-900',
|
|
'touch-none',
|
|
getResizeHandleClass(direction),
|
|
)}
|
|
/>
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<WidgetPickerModal
|
|
open={isWidgetPickerOpen}
|
|
setOpen={handleWidgetPickerOpenChange}
|
|
widgetTypes={widgetTypes}
|
|
isSaving={isSaving}
|
|
onCreateWidget={createWidget}
|
|
/>
|
|
|
|
<WidgetSettingsModal
|
|
open={settingsWidget !== null}
|
|
setOpen={(open) => {
|
|
if (!open) {
|
|
setSettingsWidget(null)
|
|
}
|
|
}}
|
|
widget={settingsWidget}
|
|
isSaving={isSaving}
|
|
onSave={updateWidgetSettings}
|
|
/>
|
|
</div>
|
|
)
|
|
} |