Add dashboard module

This commit is contained in:
2026-07-09 17:08:00 +02:00
commit 441f8f11ac
23 changed files with 548 additions and 0 deletions

29
webui/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/dashboard.css": "./src/styles/dashboard.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -0,0 +1,186 @@
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(", ");
}

View File

@@ -0,0 +1,16 @@
import { StatusBadge, type PlatformWebModule } from "@govoplan/core-webui";
export default function InstalledModulesWidget({ modules }: { modules: PlatformWebModule[] }) {
if (!modules.length) return <p className="muted">No WebUI modules are active.</p>;
return (
<dl className="detail-list dashboard-compact-list">
{modules.map((module) =>
<div key={module.id}>
<dt><StatusBadge status="success" label={module.id} /></dt>
<dd><strong>{module.label}</strong><span className="muted"> v{module.version}</span></dd>
</div>
)}
</dl>);
}

View File

@@ -0,0 +1,9 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard"
},
de: {
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard"
}
};

6
webui/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export { default } from "./module";
export * from "./module";
export { default as DashboardPage } from "./features/dashboard/DashboardPage";
export { default as InstalledModulesWidget } from "./features/dashboard/widgets/InstalledModulesWidget";
export type { DashboardWidgetContribution, DashboardWidgetsUiCapability } from "@govoplan/core-webui";

46
webui/src/module.ts Normal file
View File

@@ -0,0 +1,46 @@
import { createElement, lazy } from "react";
import type { DashboardWidgetsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import InstalledModulesWidget from "./features/dashboard/widgets/InstalledModulesWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/dashboard.css";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "dashboard.installed-modules",
title: "Installed modules",
description: "Enabled WebUI modules in this browser session.",
moduleId: "dashboard",
category: "Platform",
order: 10,
defaultSize: "medium",
render: ({ modules }) => createElement(InstalledModulesWidget, { modules })
}
]
};
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const dashboardModule: PlatformWebModule = {
id: "dashboard",
label: "i18n:govoplan-dashboard.dashboard.3f8b4df2",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"],
translations,
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }],
routes: [
{ path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) }
],
uiCapabilities: {
"dashboard.widgets": dashboardWidgets
}
};
export default dashboardModule;

View File

@@ -0,0 +1,97 @@
.dashboard-page .card + .dashboard-widget-grid {
margin-top: 18px;
}
.dashboard-summary-grid .metric-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-widget-grid {
display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr));
gap: 18px;
align-items: start;
}
.dashboard-widget {
min-width: 0;
}
.dashboard-widget-small {
grid-column: span 1;
}
.dashboard-widget-medium {
grid-column: span 2;
}
.dashboard-widget-wide,
.dashboard-widget-full {
grid-column: 1 / -1;
}
.dashboard-widget-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.dashboard-widget-toggle {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
padding: 12px;
}
.dashboard-widget-toggle input {
margin-top: 3px;
}
.dashboard-widget-toggle strong,
.dashboard-widget-toggle small {
display: block;
}
.dashboard-widget-toggle small {
color: var(--muted);
margin-top: 4px;
line-height: 1.35;
}
.dashboard-compact-list.detail-list div {
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
}
.dashboard-widget-metrics.metric-grid {
grid-template-columns: repeat(3, minmax(120px, 1fr));
margin-bottom: 0;
}
@media (max-width: 1120px) {
.dashboard-widget-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
}
@media (max-width: 900px) {
.dashboard-widget-grid,
.dashboard-widget-picker,
.dashboard-widget-metrics.metric-grid {
grid-template-columns: 1fr;
}
.dashboard-widget-small,
.dashboard-widget-medium,
.dashboard-widget-wide,
.dashboard-widget-full {
grid-column: 1;
}
}