feat: add configurable view-aware dashboards

This commit is contained in:
2026-07-29 14:32:54 +02:00
parent b28229c003
commit f0732bead2
24 changed files with 3169 additions and 146 deletions
+506 -128
View File
@@ -1,29 +1,72 @@
import { useEffect, useMemo, useState } from "react";
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react";
import {
AdminSelectionList,
useEffect,
useMemo,
useState,
type DragEvent as ReactDragEvent
} from "react";
import {
RefreshCw,
Save,
SlidersHorizontal,
X
} from "lucide-react";
import {
Button,
Card,
DismissibleAlert,
LoadingFrame,
MetricCard,
PageScrollViewport,
PageTitle,
dashboardWidgetsForModules,
hasAnyScope,
hasScope,
isApiError,
useEffectiveView,
usePlatformModules,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type DashboardWidgetContribution,
type DashboardWidgetSize
type DashboardWidgetContribution
} from "@govoplan/core-webui";
import {
fetchDashboardLayout,
saveDashboardLayout
} from "../../api/dashboard";
import DashboardGrid from "./DashboardGrid";
import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
import WidgetLibrary from "./WidgetLibrary";
import {
appendPlacement,
defaultDashboardLayout,
insertPlacement,
layoutFromResponse,
layoutUpdatePayload,
layoutsEqual,
localLayoutKey,
MAX_DASHBOARD_WIDGETS,
readLocalLayout,
reconcileDashboardLayout,
removePlacement,
reorderPlacement,
updatePlacement,
writeLocalLayout,
type DashboardLayoutState,
type DashboardWidgetPlacement
} from "./dashboardLayout";
import type {
DashboardDragItem,
DashboardDropTarget
} from "./dashboardEditorTypes";
type DashboardLayout = {
visible: string[];
known: string[];
};
type LayoutSource = "server" | "browser" | "default";
export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
export default function DashboardPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const modules = usePlatformModules();
const effectiveView = useEffectiveView();
const widgets = useMemo(
@@ -33,150 +76,485 @@ export default function DashboardPage({ settings, auth }: { settings: ApiSetting
),
[auth, effectiveView, modules]
);
const widgetSignature = widgets.map((widget) => widget.id).join("|");
const storageKey = `govoplan.dashboard.layout:${auth.active_tenant?.id ?? auth.tenant.id}:${auth.user.id}`;
const [layout, setLayout] = useState<DashboardLayout>(() => defaultLayout(widgets));
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(() => {
setLayout(readLayout(storageKey) ?? defaultLayout(widgets));
}, [storageKey, widgetSignature]);
let active = true;
setLoading(true);
setError("");
setNotice("");
setConfiguring(false);
setEditingInstanceId(null);
const visibleWidgetIds = useMemo(() => {
const known = new Set(layout.known);
const visible = new Set(layout.visible);
for (const widget of widgets) {
if (!known.has(widget.id) && widget.defaultVisible !== false) visible.add(widget.id);
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);
useUnsavedDraftGuard({
dirty,
onSave: persistLayout,
onDiscard: discardChanges,
title: "Unsaved Dashboard layout",
message: "Save or discard the Dashboard arrangement before leaving this page."
});
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);
}
const availableIds = new Set(widgets.map((widget) => widget.id));
return widgets.map((widget) => widget.id).filter((id) => availableIds.has(id) && visible.has(id));
}, [layout, widgets]);
const visibleWidgets = widgets.filter((widget) => visibleWidgetIds.includes(widget.id));
const hiddenCount = Math.max(widgets.length - visibleWidgets.length, 0);
function saveLayout(next: DashboardLayout) {
setLayout(next);
writeLayout(storageKey, next);
}
function setVisibleWidgets(selected: string[]) {
const allIds = widgets.map((widget) => widget.id);
const nextVisible = new Set(selected);
saveLayout({
known: allIds,
visible: allIds.filter((id) => nextVisible.has(id))
});
function discardChanges() {
setDraftLayout(savedLayout);
setConfiguring(false);
setEditingInstanceId(null);
setDragItem(null);
setDropTarget(null);
}
function resetLayout() {
saveLayout(defaultLayout(widgets));
function beginConfiguration() {
setDraftLayout(savedLayout);
setConfiguring(true);
setError("");
setNotice("");
}
function updateDraft(updater: (layout: DashboardLayoutState) => DashboardLayoutState) {
setDraftLayout((current) => updater(current));
}
function addWidget(widget: DashboardWidgetContribution) {
updateDraft((layout) => appendPlacement(layout, widget));
}
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.3
|| (
verticalPosition <= 0.7
&& event.clientX < bounds.left + bounds.width / 2
)
? "before"
: "after";
setDropTarget((current) =>
current?.kind === "placement"
&& current.instanceId === instanceId
&& current.edge === edge
? current
: { kind: "placement", instanceId, edge }
);
}
function markEndDropTarget(event: ReactDragEvent<HTMLElement>) {
if (!dragItem) return;
event.preventDefault();
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
setDropTarget((current) =>
current?.kind === "end" ? current : { kind: "end" }
);
}
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";
if (dragItem.kind === "placement") {
updateDraft((layout) =>
reorderPlacement(layout, dragItem.instanceId, target.instanceId, edge)
);
} else {
const widget = widgetById.get(dragItem.widgetId);
if (widget) {
updateDraft((layout) =>
insertPlacement(layout, widget, target.instanceId, edge)
);
}
}
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;
if (dragItem.kind === "placement") {
const last = draftLayout.placements.at(-1);
if (last) {
updateDraft((layout) =>
reorderPlacement(layout, dragItem.instanceId, last.instanceId, "after")
);
}
} else {
const widget = widgetById.get(dragItem.widgetId);
if (widget) addWidget(widget);
}
finishDrag();
}
return (
<PageScrollViewport className="dashboard-page">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
<p>Personal workspace assembled from installed module widgets.</p>
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
<p>Personal workspace assembled from installed module widgets.</p>
</div>
<div className="button-row compact-actions">
{!configuring && (
<>
<Button onClick={() => setRefreshKey((value) => value + 1)}>
<RefreshCw size={16} /> Refresh
</Button>
<Button onClick={beginConfiguration}>
<SlidersHorizontal size={16} /> Configure
</Button>
</>
)}
{configuring && (
<>
<Button onClick={discardChanges} disabled={saving}>
<X size={16} /> Cancel
</Button>
<Button
variant="primary"
onClick={() => void persistLayout()}
disabled={saving || !dirty}
>
<Save size={16} /> {saving ? "Saving..." : "Save layout"}
</Button>
</>
)}
</div>
</div>
<div className="button-row compact-actions">
<Button onClick={() => setRefreshKey((value) => value + 1)}><RefreshCw size={16} /> Refresh</Button>
<Button onClick={() => setConfiguring((value) => !value)}><SlidersHorizontal size={16} /> Configure</Button>
</div>
</div>
<div className="metric-grid dashboard-summary-grid">
<MetricCard label="Installed 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="Visible widgets" value={visibleWidgets.length} tone="good" detail={hiddenCount ? `${hiddenCount} hidden` : "All shown"} />
<MetricCard label="Layout" value="personal" tone="neutral" detail="Saved in this browser" />
</div>
{configuring &&
<Card
title="Dashboard widgets"
actions={<Button onClick={resetLayout}><RotateCcw size={16} /> Reset</Button>}>
{widgets.length === 0 ? <p className="muted">No installed module exposes dashboard widgets yet.</p> :
<AdminSelectionList
options={widgets.map((widget) => ({ id: widget.id, label: widget.title, description: widget.description ?? widget.id }))}
selected={visibleWidgetIds}
onChange={setVisibleWidgets}
/>
}
</Card>
}
{visibleWidgets.length === 0 ?
<Card title="No widgets selected">
<p className="muted">Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.</p>
</Card> :
<div className="dashboard-widget-grid">
{visibleWidgets.map((widget) =>
<section key={widget.id} className={`dashboard-widget dashboard-widget-${widgetSize(widget)}`}>
<Card title={widget.title} collapsible collapseKey={`dashboard-widget:${widget.id}`}>
{widget.render({
settings,
auth,
modules,
widgetId: widget.id,
refreshKey,
size: widgetSize(widget)
})}
</Card>
</section>
{error && (
<DismissibleAlert tone="danger" resetKey={error} floating>
{error}
</DismissibleAlert>
)}
{notice && !error && (
<DismissibleAlert tone="success" resetKey={notice} floating>
{notice}
</DismissibleAlert>
)}
<div className="metric-grid dashboard-summary-grid">
<MetricCard
label="Installed 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)}
/>
</div>
}
<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>
</div>
</PageScrollViewport>);
<WidgetConfigurationDialog
open={Boolean(editingPlacement && editingWidget)}
widget={editingWidget}
placement={editingPlacement}
onClose={() => setEditingInstanceId(null)}
onSave={(placement) => {
updateDraft((layout) => updatePlacement(layout, placement));
setEditingInstanceId(null);
}}
/>
</PageScrollViewport>
);
}
function canUseWidget(auth: AuthInfo, widget: DashboardWidgetContribution): boolean {
if (widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope))) return false;
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 widgetSize(widget: DashboardWidgetContribution): DashboardWidgetSize {
return widget.defaultSize ?? "medium";
}
function defaultLayout(widgets: DashboardWidgetContribution[]): DashboardLayout {
const ids = widgets.map((widget) => widget.id);
return {
known: ids,
visible: widgets.filter((widget) => widget.defaultVisible !== false).map((widget) => widget.id)
};
}
function readLayout(storageKey: string): DashboardLayout | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<DashboardLayout>;
if (!Array.isArray(parsed.visible) || !Array.isArray(parsed.known)) return null;
return {
visible: parsed.visible.filter((id): id is string => typeof id === "string"),
known: parsed.known.filter((id): id is string => typeof id === "string")
};
} catch {
return null;
}
}
function writeLayout(storageKey: string, layout: DashboardLayout): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(layout));
} catch {
// localStorage may be unavailable in restricted browsing contexts.
}
}
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.";
}