187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react";
|
|
import {
|
|
Button,
|
|
Card,
|
|
MetricCard,
|
|
PageTitle,
|
|
dashboardWidgetsForModules,
|
|
hasAnyScope,
|
|
hasScope,
|
|
usePlatformModules,
|
|
type ApiSettings,
|
|
type AuthInfo,
|
|
type DashboardWidgetContribution,
|
|
type DashboardWidgetSize
|
|
} from "@govoplan/core-webui";
|
|
|
|
type DashboardLayout = {
|
|
visible: string[];
|
|
known: string[];
|
|
};
|
|
|
|
export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
|
const modules = usePlatformModules();
|
|
const widgets = useMemo(() => dashboardWidgetsForModules(modules).filter((widget) => canUseWidget(auth, widget)), [auth, 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 [configuring, setConfiguring] = useState(false);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
useEffect(() => {
|
|
setLayout(readLayout(storageKey) ?? defaultLayout(widgets));
|
|
}, [storageKey, widgetSignature]);
|
|
|
|
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);
|
|
}
|
|
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 setWidgetVisible(widgetId: string, visible: boolean) {
|
|
const allIds = widgets.map((widget) => widget.id);
|
|
const nextVisible = new Set(visibleWidgetIds);
|
|
if (visible) {
|
|
nextVisible.add(widgetId);
|
|
} else {
|
|
nextVisible.delete(widgetId);
|
|
}
|
|
saveLayout({
|
|
known: allIds,
|
|
visible: allIds.filter((id) => nextVisible.has(id))
|
|
});
|
|
}
|
|
|
|
function resetLayout() {
|
|
saveLayout(defaultLayout(widgets));
|
|
}
|
|
|
|
return (
|
|
<div className="content-pad workspace-data-page dashboard-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>
|
|
<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> :
|
|
<div className="dashboard-widget-picker">
|
|
{widgets.map((widget) =>
|
|
<label key={widget.id} className="dashboard-widget-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={visibleWidgetIds.includes(widget.id)}
|
|
onChange={(event) => setWidgetVisible(widget.id, event.currentTarget.checked)}
|
|
/>
|
|
<span>
|
|
<strong>{widget.title}</strong>
|
|
<small>{widget.description ?? widget.id}</small>
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
}
|
|
</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>
|
|
)}
|
|
</div>
|
|
}
|
|
</div>);
|
|
}
|
|
|
|
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(", ");
|
|
}
|
|
|