import { MetricGrid } from "@govoplan/core-webui"; import { useEffect, useMemo, useState, type DragEvent as ReactDragEvent } from "react"; import { Save, SlidersHorizontal, X } from "lucide-react"; import { Button, DocumentationHelpLink, LoadingFrame, MetricCard, PageActionBar, PageLayout, dashboardWidgetsForModules, hasAnyScope, hasScope, isApiError, useEffectiveView, usePlatformModules, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings, type AuthInfo, type DashboardWidgetContribution, type DashboardWidgetSize } from "@govoplan/core-webui"; import { fetchDashboardLayout, saveDashboardLayout } from "../../api/dashboard"; import DashboardGrid from "./DashboardGrid"; import WidgetConfigurationDialog from "./WidgetConfigurationDialog"; import WidgetLibrary from "./WidgetLibrary"; import { DASHBOARD_DOCUMENTATION, DASHBOARD_I18N } from "./interfacePatterns"; import { appendPlacement, DASHBOARD_COLUMN_COUNT, dashboardWidgetColumnSpan, defaultWidgetSize, defaultDashboardLayout, insertPlacement, layoutFromResponse, layoutUpdatePayload, layoutsEqual, localLayoutKey, MAX_DASHBOARD_WIDGETS, nextDashboardColumnStart, readLocalLayout, reconcileDashboardLayout, removePlacement, reorderPlacement, updatePlacement, writeLocalLayout, type DashboardLayoutState, type DashboardWidgetPlacement } from "./dashboardLayout"; import type { DashboardDragItem, DashboardDropTarget } from "./dashboardEditorTypes"; import { dashboardColumnStartFromPointer } from "./dashboardEditorTypes"; type LayoutSource = "server" | "browser" | "default"; export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo; }) { const modules = usePlatformModules(); const effectiveView = useEffectiveView(); const widgets = useMemo( () => dashboardWidgetsForModules(modules, effectiveView).filter((widget) => canUseWidget(auth, widget) ), [auth, effectiveView, modules] ); const widgetSignature = JSON.stringify( widgets.map((widget) => ({ id: widget.id, allowMultiple: widget.allowMultiple ?? false, defaultVisible: widget.defaultVisible ?? true, defaultSize: widget.defaultSize ?? "medium", supportedSizes: widget.supportedSizes ?? [], defaultConfiguration: widget.defaultConfiguration ?? {} })) ); const tenantId = auth.active_tenant?.id ?? auth.tenant.id; const accountId = auth.user.account_id || auth.user.id; const viewId = effectiveView?.activeViewId ?? null; const storageKey = localLayoutKey(tenantId, accountId, viewId); const legacyStorageKey = viewId === null ? `govoplan.dashboard.layout:${tenantId}:${auth.user.id}` : null; const initialLayout = useMemo( () => defaultDashboardLayout(widgets), [widgets] ); const [savedLayout, setSavedLayout] = useState(initialLayout); const [draftLayout, setDraftLayout] = useState(initialLayout); const [layoutSource, setLayoutSource] = useState("default"); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [configuring, setConfiguring] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [libraryQuery, setLibraryQuery] = useState(""); const [dragItem, setDragItem] = useState(null); const [dropTarget, setDropTarget] = useState(null); const [editingInstanceId, setEditingInstanceId] = useState(null); useEffect(() => { let active = true; setLoading(true); setError(""); setNotice(""); setConfiguring(false); setEditingInstanceId(null); void fetchDashboardLayout(settings, viewId) .then((response) => { if (!active) return; const local = readLocalLayout(storageKey, legacyStorageKey, widgets); const source: LayoutSource = response.exists ? "server" : local ? "browser" : "default"; const base = response.exists ? layoutFromResponse(response) : local ? { ...local, revision: 0 } : defaultDashboardLayout(widgets); const next = reconcileDashboardLayout(base, widgets); setSavedLayout(next); setDraftLayout(next); setLayoutSource(source); }) .catch((reason) => { if (!active) return; const local = readLocalLayout(storageKey, legacyStorageKey, widgets); const next = reconcileDashboardLayout( local ?? defaultDashboardLayout(widgets), widgets ); setSavedLayout(next); setDraftLayout(next); setLayoutSource(local ? "browser" : "default"); setError( `The saved Dashboard layout could not be loaded. ${ local ? "The browser fallback is shown." : "The module defaults are shown." } ${errorMessage(reason)}` ); }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [ legacyStorageKey, settings.accessToken, settings.apiBaseUrl, settings.apiKey, storageKey, viewId, widgetSignature ]); const dirty = configuring && !layoutsEqual(savedLayout, draftLayout); const { requestDiscard } = useUnsavedChanges(); useUnsavedDraftGuard({ dirty, onSave: persistLayout, onDiscard: discardChanges, title: "i18n:govoplan-dashboard.unsaved_layout_title", message: "i18n:govoplan-dashboard.unsaved_layout_message" }); const activeLayout = configuring ? draftLayout : savedLayout; const widgetById = useMemo( () => new Map(widgets.map((widget) => [widget.id, widget])), [widgets] ); const renderedPlacements = activeLayout.placements.filter((placement) => widgetById.has(placement.widgetId) ); const placedWidgetIds = new Set( draftLayout.placements.map((placement) => placement.widgetId) ); const libraryWidgets = widgets.filter((widget) => { if (!widget.allowMultiple && placedWidgetIds.has(widget.id)) return false; if (!libraryQuery.trim()) return true; const query = libraryQuery.trim().toLocaleLowerCase(); return [widget.title, widget.description, widget.category, widget.moduleId, widget.id] .filter(Boolean) .some((value) => String(value).toLocaleLowerCase().includes(query)); }); const hiddenCount = widgets.filter( (widget) => !placedWidgetIds.has(widget.id) ).length; const editingPlacement = editingInstanceId ? draftLayout.placements.find( (placement) => placement.instanceId === editingInstanceId ) ?? null : null; const editingWidget = editingPlacement ? widgetById.get(editingPlacement.widgetId) ?? null : null; const viewLabel = effectiveView?.activeViewName ?? "Full interface"; async function persistLayout(): Promise { setSaving(true); setError(""); setNotice(""); try { const response = await saveDashboardLayout( settings, viewId, layoutUpdatePayload(draftLayout) ); const next = reconcileDashboardLayout(layoutFromResponse(response), widgets); setSavedLayout(next); setDraftLayout(next); setLayoutSource("server"); setConfiguring(false); writeLocalLayout(storageKey, next); setNotice(`Dashboard layout saved for ${viewLabel}.`); return true; } catch (reason) { setError(errorMessage(reason)); return false; } finally { setSaving(false); } } function discardChanges() { setDraftLayout(savedLayout); exitConfiguration(); } function exitConfiguration() { setConfiguring(false); setEditingInstanceId(null); setDragItem(null); setDropTarget(null); } function cancelConfiguration() { if (dirty) requestDiscard(exitConfiguration); else exitConfiguration(); } function beginConfiguration() { setDraftLayout(savedLayout); setConfiguring(true); setError(""); setNotice(""); } function updateDraft(updater: (layout: DashboardLayoutState) => DashboardLayoutState) { setDraftLayout((current) => updater(current)); } function addWidget( widget: DashboardWidgetContribution, columnStart?: number ) { updateDraft((layout) => appendPlacement(layout, widget, columnStart)); } function startDrag(event: ReactDragEvent, item: DashboardDragItem) { event.dataTransfer.effectAllowed = item.kind === "catalogue" ? "copy" : "move"; event.dataTransfer.setData("application/x-govoplan-dashboard", JSON.stringify(item)); setDragItem(item); setDropTarget(null); } function finishDrag() { setDragItem(null); setDropTarget(null); } function markDropTarget( event: ReactDragEvent, instanceId: string ) { if (!dragItem) return; event.preventDefault(); event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; const bounds = event.currentTarget.getBoundingClientRect(); const verticalPosition = (event.clientY - bounds.top) / bounds.height; const edge = verticalPosition < 0.5 ? "before" : "after"; const columnStart = dropColumnStart(event); setDropTarget((current) => current?.kind === "placement" && current.instanceId === instanceId && current.edge === edge && current.columnStart === columnStart ? current : { kind: "placement", instanceId, edge, columnStart } ); } function markEndDropTarget(event: ReactDragEvent) { if (!dragItem) return; event.preventDefault(); event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; const columnStart = dropColumnStart(event); setDropTarget((current) => current?.kind === "end" && current.columnStart === columnStart ? current : { kind: "end", columnStart } ); } function draggedWidgetSize(): DashboardWidgetSize | null { if (!dragItem) return null; if (dragItem.kind === "placement") { return draftLayout.placements.find( (placement) => placement.instanceId === dragItem.instanceId )?.size ?? null; } const widget = widgetById.get(dragItem.widgetId); return widget ? defaultWidgetSize(widget) : null; } function dropColumnStart( event: ReactDragEvent ): number { const size = draggedWidgetSize(); if (!size) return 1; const grid = event.currentTarget.closest( "[data-dashboard-grid]" ); if (!grid) return 1; const styles = window.getComputedStyle(grid); const visibleColumns = styles.gridTemplateColumns .split(/\s+/) .filter(Boolean).length; if (visibleColumns !== 4) { if (dragItem?.kind === "placement") { return draftLayout.placements.find( (placement) => placement.instanceId === dragItem.instanceId )?.columnStart ?? 1; } return nextDashboardColumnStart(draftLayout.placements, size); } const bounds = grid.getBoundingClientRect(); return dashboardColumnStartFromPointer( event.clientX, bounds.left, bounds.width, Number.parseFloat(styles.columnGap), dashboardWidgetColumnSpan(size), DASHBOARD_COLUMN_COUNT ); } function dropOnPlacement( event: ReactDragEvent, target: DashboardWidgetPlacement ) { event.preventDefault(); event.stopPropagation(); if (!dragItem) return; const edge = dropTarget?.kind === "placement" && dropTarget.instanceId === target.instanceId ? dropTarget.edge : "after"; const columnStart = dropTarget?.columnStart ?? target.columnStart; if (dragItem.kind === "placement") { updateDraft((layout) => reorderPlacement( layout, dragItem.instanceId, target.instanceId, edge, columnStart ) ); } else { const widget = widgetById.get(dragItem.widgetId); if (widget) { updateDraft((layout) => insertPlacement( layout, widget, target.instanceId, edge, columnStart ) ); } } finishDrag(); } function dropOnPreview(event: ReactDragEvent) { if (dropTarget?.kind === "placement") { const target = renderedPlacements.find( (placement) => placement.instanceId === dropTarget.instanceId ); if (target) { dropOnPlacement(event, target); return; } } dropAtEnd(event); } function dropAtEnd(event: ReactDragEvent) { event.preventDefault(); event.stopPropagation(); if (!dragItem) return; const columnStart = dropTarget?.columnStart; if (dragItem.kind === "placement") { const last = draftLayout.placements.at(-1); if (last) { updateDraft((layout) => reorderPlacement( layout, dragItem.instanceId, last.instanceId, "after", columnStart ) ); } } else { const widget = widgetById.get(dragItem.widgetId); if (widget) addWidget(widget, columnStart); } finishDrag(); } return ( } description="Personal workspace assembled from installed module widgets." error={error} success={error ? "" : notice} actions={configuring ? ( Cancel, behavior: "exit", onClick: cancelConfiguration }} saveAction={{ label: <> Save layout, onClick: () => void persistLayout() }} /> ) : ( setRefreshKey((value) => value + 1), loading, disabledReason: loading ? DASHBOARD_I18N.loading : undefined }} primaryActions={( )} /> )} > {configuring ? (
= MAX_DASHBOARD_WIDGETS} query={libraryQuery} onQueryChange={setLibraryQuery} onReset={() => setDraftLayout({ ...defaultDashboardLayout(widgets), revision: savedLayout.revision }) } onAdd={addWidget} onDragStart={startDrag} onDragEnd={finishDrag} /> updateDraft((layout) => removePlacement(layout, instanceId)) } onConfigure={setEditingInstanceId} />
) : ( undefined} onConfigure={() => undefined} /> )}
setEditingInstanceId(null)} onSave={(placement) => { updateDraft((layout) => updatePlacement(layout, placement)); setEditingInstanceId(null); }} />
); } function canUseWidget( auth: AuthInfo, widget: DashboardWidgetContribution ): boolean { if ( widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope)) ) { return false; } if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false; return true; } function moduleLabels(modules: Array<{ id: string }>): string { if (!modules.length) return "Core shell only"; return modules.slice(0, 4).map((module) => module.id).join(", "); } function layoutSourceLabel(source: LayoutSource): string { if (source === "server") return "Saved for your account"; if (source === "browser") return "Browser layout; save to synchronize"; return "Module defaults"; } function errorMessage(error: unknown): string { if (isApiError(error)) { if (error.status === 409) { return "This layout changed in another session. Reload the page before saving again."; } return error.message; } return error instanceof Error ? error.message : "The Dashboard request failed."; }