Files
govoplan-dashboard/webui/src/features/dashboard/DashboardPage.tsx
T
zemion 944401d53b fix(ui): keep dashboard configuration exit available and align heading help
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:36 +02:00

635 lines
19 KiB
TypeScript

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<DashboardLayoutState>(initialLayout);
const [draftLayout, setDraftLayout] = useState<DashboardLayoutState>(initialLayout);
const [layoutSource, setLayoutSource] = useState<LayoutSource>("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<DashboardDragItem | null>(null);
const [dropTarget, setDropTarget] = useState<DashboardDropTarget | null>(null);
const [editingInstanceId, setEditingInstanceId] = useState<string | null>(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<boolean> {
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<HTMLElement>,
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<HTMLElement>) {
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<HTMLElement>
): number {
const size = draggedWidgetSize();
if (!size) return 1;
const grid = event.currentTarget.closest<HTMLElement>(
"[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<HTMLElement>,
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<HTMLElement>) {
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<HTMLElement>) {
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 (
<PageLayout
archetype={configuring ? "editor" : "overview"}
className="dashboard-page"
title="i18n:govoplan-dashboard.dashboard.3f8b4df2"
titleHelp={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}
description="Personal workspace assembled from installed module widgets."
error={error}
success={error ? "" : notice}
actions={configuring ? (
<PageActionBar
variant="editor"
state={saving ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: <><X size={16} /> Cancel</>, behavior: "exit", onClick: cancelConfiguration }}
saveAction={{ label: <><Save size={16} /> Save layout</>, onClick: () => void persistLayout() }}
/>
) : (
<PageActionBar
variant="overview"
refreshable
reloadAction={{
onReload: () => setRefreshKey((value) => value + 1),
loading,
disabledReason: loading ? DASHBOARD_I18N.loading : undefined
}}
primaryActions={(
<Button
onClick={beginConfiguration}
disabled={loading}
disabledReason={loading ? DASHBOARD_I18N.loading : undefined}
>
<SlidersHorizontal size={16} /> Configure
</Button>
)}
/>
)}
>
<MetricGrid className="dashboard-summary-metrics">
<MetricCard
label="Active interface modules"
value={modules.length}
tone="info"
detail={moduleLabels(modules)}
/>
<MetricCard
label="Available widgets"
value={widgets.length}
tone="neutral"
detail="Provided by active modules"
/>
<MetricCard
label="On dashboard"
value={renderedPlacements.length}
tone="good"
detail={hiddenCount ? `${hiddenCount} available to add` : "All shown"}
/>
<MetricCard
label="Layout"
value={viewLabel}
tone="neutral"
detail={layoutSourceLabel(layoutSource)}
/>
</MetricGrid>
<LoadingFrame loading={loading} label="Loading Dashboard layout">
{configuring ? (
<div className="dashboard-config-workspace">
<WidgetLibrary
widgets={libraryWidgets}
atCapacity={draftLayout.placements.length >= MAX_DASHBOARD_WIDGETS}
query={libraryQuery}
onQueryChange={setLibraryQuery}
onReset={() =>
setDraftLayout({
...defaultDashboardLayout(widgets),
revision: savedLayout.revision
})
}
onAdd={addWidget}
onDragStart={startDrag}
onDragEnd={finishDrag}
/>
<DashboardGrid
placements={renderedPlacements}
widgetById={widgetById}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
configuring
dragItem={dragItem}
dropTarget={dropTarget}
onDragStart={startDrag}
onDragEnd={finishDrag}
onDragOver={markDropTarget}
onDragOverEnd={markEndDropTarget}
onDrop={dropOnPlacement}
onDropPreview={dropOnPreview}
onDropAtEnd={dropAtEnd}
onRemove={(instanceId) =>
updateDraft((layout) => removePlacement(layout, instanceId))
}
onConfigure={setEditingInstanceId}
/>
</div>
) : (
<DashboardGrid
placements={renderedPlacements}
widgetById={widgetById}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
configuring={false}
dragItem={null}
dropTarget={null}
onDragStart={startDrag}
onDragEnd={finishDrag}
onDragOver={markDropTarget}
onDragOverEnd={markEndDropTarget}
onDrop={dropOnPlacement}
onDropPreview={dropOnPreview}
onDropAtEnd={dropAtEnd}
onRemove={() => undefined}
onConfigure={() => undefined}
/>
)}
</LoadingFrame>
<WidgetConfigurationDialog
open={Boolean(editingPlacement && editingWidget)}
widget={editingWidget}
placement={editingPlacement}
onClose={() => setEditingInstanceId(null)}
onSave={(placement) => {
updateDraft((layout) => updatePlacement(layout, placement));
setEditingInstanceId(null);
}}
/>
</PageLayout>
);
}
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.";
}