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.
635 lines
19 KiB
TypeScript
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.";
|
|
}
|