commit 441f8f11acb46fb94c103dfd57f68a0e99ade6ea Author: Albrecht Degering Date: Thu Jul 9 17:08:00 2026 +0200 Add dashboard module diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f9028b --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# GovOPlaN Dashboard + +Configurable dashboard module for GovOPlaN. + +The module owns the `/dashboard` route when installed. Core keeps only a minimal +fallback home for installations where this module is absent. + +Modules contribute widgets through the `dashboard.widgets` WebUI capability. +The first implementation stores personal widget visibility in browser storage; +server-side layout persistence can be added later without changing the widget +contract. + diff --git a/package.json b/package.json new file mode 100644 index 0000000..a76dd1e --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@govoplan/dashboard-webui", + "version": "0.1.6", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + }, + "./styles/dashboard.css": "./webui/src/styles/dashboard.css" + }, + "files": [ + "webui/src", + "README.md" + ], + "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 + } + } +} + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28ddf81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-dashboard" +version = "0.1.6" +description = "GovOPlaN configurable dashboard module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.6", + "govoplan-access>=0.1.6", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_dashboard = ["py.typed"] + +[project.entry-points."govoplan.modules"] +dashboard = "govoplan_dashboard.backend.manifest:get_manifest" + diff --git a/src/govoplan_dashboard.egg-info/PKG-INFO b/src/govoplan_dashboard.egg-info/PKG-INFO new file mode 100644 index 0000000..85f019c --- /dev/null +++ b/src/govoplan_dashboard.egg-info/PKG-INFO @@ -0,0 +1,22 @@ +Metadata-Version: 2.4 +Name: govoplan-dashboard +Version: 0.1.6 +Summary: GovOPlaN configurable dashboard module. +Author: GovOPlaN +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: govoplan-core>=0.1.6 +Requires-Dist: govoplan-access>=0.1.6 + +# GovOPlaN Dashboard + +Configurable dashboard module for GovOPlaN. + +The module owns the `/dashboard` route when installed. Core keeps only a minimal +fallback home for installations where this module is absent. + +Modules contribute widgets through the `dashboard.widgets` WebUI capability. +The first implementation stores personal widget visibility in browser storage; +server-side layout persistence can be added later without changing the widget +contract. + diff --git a/src/govoplan_dashboard.egg-info/SOURCES.txt b/src/govoplan_dashboard.egg-info/SOURCES.txt new file mode 100644 index 0000000..59679ed --- /dev/null +++ b/src/govoplan_dashboard.egg-info/SOURCES.txt @@ -0,0 +1,12 @@ +README.md +pyproject.toml +src/govoplan_dashboard/__init__.py +src/govoplan_dashboard/py.typed +src/govoplan_dashboard.egg-info/PKG-INFO +src/govoplan_dashboard.egg-info/SOURCES.txt +src/govoplan_dashboard.egg-info/dependency_links.txt +src/govoplan_dashboard.egg-info/entry_points.txt +src/govoplan_dashboard.egg-info/requires.txt +src/govoplan_dashboard.egg-info/top_level.txt +src/govoplan_dashboard/backend/__init__.py +src/govoplan_dashboard/backend/manifest.py \ No newline at end of file diff --git a/src/govoplan_dashboard.egg-info/dependency_links.txt b/src/govoplan_dashboard.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_dashboard.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/govoplan_dashboard.egg-info/entry_points.txt b/src/govoplan_dashboard.egg-info/entry_points.txt new file mode 100644 index 0000000..b0a7d51 --- /dev/null +++ b/src/govoplan_dashboard.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[govoplan.modules] +dashboard = govoplan_dashboard.backend.manifest:get_manifest diff --git a/src/govoplan_dashboard.egg-info/requires.txt b/src/govoplan_dashboard.egg-info/requires.txt new file mode 100644 index 0000000..cd1e2a8 --- /dev/null +++ b/src/govoplan_dashboard.egg-info/requires.txt @@ -0,0 +1,2 @@ +govoplan-core>=0.1.6 +govoplan-access>=0.1.6 diff --git a/src/govoplan_dashboard.egg-info/top_level.txt b/src/govoplan_dashboard.egg-info/top_level.txt new file mode 100644 index 0000000..f30f210 --- /dev/null +++ b/src/govoplan_dashboard.egg-info/top_level.txt @@ -0,0 +1 @@ +govoplan_dashboard diff --git a/src/govoplan_dashboard/__init__.py b/src/govoplan_dashboard/__init__.py new file mode 100644 index 0000000..e7b9f27 --- /dev/null +++ b/src/govoplan_dashboard/__init__.py @@ -0,0 +1,6 @@ +"""GovOPlaN dashboard module.""" + +__all__ = ["__version__"] + +__version__ = "0.1.6" + diff --git a/src/govoplan_dashboard/__pycache__/__init__.cpython-313.pyc b/src/govoplan_dashboard/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..11a611e Binary files /dev/null and b/src/govoplan_dashboard/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_dashboard/backend/__init__.py b/src/govoplan_dashboard/backend/__init__.py new file mode 100644 index 0000000..4d95d38 --- /dev/null +++ b/src/govoplan_dashboard/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN dashboard module.""" + diff --git a/src/govoplan_dashboard/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_dashboard/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..aa7c010 Binary files /dev/null and b/src/govoplan_dashboard/backend/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_dashboard/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_dashboard/backend/__pycache__/manifest.cpython-313.pyc new file mode 100644 index 0000000..bceb1f3 Binary files /dev/null and b/src/govoplan_dashboard/backend/__pycache__/manifest.cpython-313.pyc differ diff --git a/src/govoplan_dashboard/backend/manifest.py b/src/govoplan_dashboard/backend/manifest.py new file mode 100644 index 0000000..fc3b8e5 --- /dev/null +++ b/src/govoplan_dashboard/backend/manifest.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, ModuleManifest, NavItem + + +manifest = ModuleManifest( + id="dashboard", + name="Dashboard", + version="0.1.6", + dependencies=("access",), + optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"), + nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), + frontend=FrontendModule( + module_id="dashboard", + package_name="@govoplan/dashboard-webui", + routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),), + nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), + ), + documentation=( + DocumentationTopic( + id="dashboard.configurable-home", + title="Configurable user dashboard", + summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.", + body=( + "Core only provides a minimal fallback home when the dashboard module is absent. " + "Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "tenant_admin", "operator"), + related_modules=("core", "ops"), + order=20, + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest + diff --git a/src/govoplan_dashboard/py.typed b/src/govoplan_dashboard/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_dashboard/py.typed @@ -0,0 +1 @@ + diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..9ad4430 --- /dev/null +++ b/webui/package.json @@ -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 + } + } +} + diff --git a/webui/src/features/dashboard/DashboardPage.tsx b/webui/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..30f1d70 --- /dev/null +++ b/webui/src/features/dashboard/DashboardPage.tsx @@ -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(() => 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 ( +
+
+
+ i18n:govoplan-dashboard.dashboard.3f8b4df2 +

Personal workspace assembled from installed module widgets.

+
+
+ + +
+
+ +
+ + + + +
+ + {configuring && + Reset}> + {widgets.length === 0 ?

No installed module exposes dashboard widgets yet.

: +
+ {widgets.map((widget) => + + )} +
+ } +
+ } + + {visibleWidgets.length === 0 ? + +

Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.

+
: +
+ {visibleWidgets.map((widget) => +
+ + {widget.render({ + settings, + auth, + modules, + widgetId: widget.id, + refreshKey, + size: widgetSize(widget) + })} + +
+ )} +
+ } +
); +} + +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; + 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(", "); +} + diff --git a/webui/src/features/dashboard/widgets/InstalledModulesWidget.tsx b/webui/src/features/dashboard/widgets/InstalledModulesWidget.tsx new file mode 100644 index 0000000..8486e05 --- /dev/null +++ b/webui/src/features/dashboard/widgets/InstalledModulesWidget.tsx @@ -0,0 +1,16 @@ +import { StatusBadge, type PlatformWebModule } from "@govoplan/core-webui"; + +export default function InstalledModulesWidget({ modules }: { modules: PlatformWebModule[] }) { + if (!modules.length) return

No WebUI modules are active.

; + + return ( +
+ {modules.map((module) => +
+
+
{module.label} v{module.version}
+
+ )} +
); +} + diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts new file mode 100644 index 0000000..ce50435 --- /dev/null +++ b/webui/src/i18n/generatedTranslations.ts @@ -0,0 +1,9 @@ +export const generatedTranslations = { + en: { + "i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard" + }, + de: { + "i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard" + } +}; + diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..220b096 --- /dev/null +++ b/webui/src/index.ts @@ -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"; + diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..e236541 --- /dev/null +++ b/webui/src/module.ts @@ -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; + diff --git a/webui/src/styles/dashboard.css b/webui/src/styles/dashboard.css new file mode 100644 index 0000000..53b90a5 --- /dev/null +++ b/webui/src/styles/dashboard.css @@ -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; + } +} +