feat: implement governed reporting vertical

This commit is contained in:
2026-08-01 17:48:39 +02:00
parent 720959536b
commit eba35edd3c
35 changed files with 8414 additions and 33 deletions
+260
View File
@@ -0,0 +1,260 @@
import {
apiFetch,
apiPath,
apiUrl,
authHeaders,
type ApiSettings
} from "@govoplan/core-webui";
export type ReportingDefinitionKind = "dataset" | "semantic_model" | "report" | "quality_plan";
export type ReportingQueryMode = "summary" | "detail" | "pivot";
export type ReportingQuery = {
mode: ReportingQueryMode;
dimensions: string[];
measures: string[];
filters: Array<Record<string, unknown>>;
sort: Array<{ key: string; direction: "asc" | "desc" }>;
pivot?: {
rows: string[];
columns: string[];
measures: string[];
include_totals: boolean;
} | null;
offset: number;
limit: number;
};
export type ReportingDefinition = {
tenant_id: string;
definition_kind: ReportingDefinitionKind;
definition_id: string;
definition_key: string;
revision: number;
name: string;
description?: string | null;
status: "draft" | "active" | "retired";
visibility: "tenant" | "restricted";
content_hash: string;
parent_kind?: ReportingDefinitionKind | null;
parent_id?: string | null;
parent_revision?: number | null;
recorded_at: string;
change_reason: string;
payload: Record<string, unknown>;
};
export type SemanticModelPayload = {
dataset_id: string;
dataset_revision: number;
dimensions: Array<{ key: string; field: string; label: string }>;
measures: Array<{ key: string; label: string; aggregation: string }>;
default_dimensions: string[];
default_measures: string[];
};
export type ReportPayload = {
semantic_model_id: string;
semantic_model_revision: number;
parameters: Array<{
key: string;
label: string;
type: string;
required: boolean;
default?: unknown;
allowed_values: unknown[];
}>;
default_query: ReportingQuery;
visualization: {
kind: string;
category_dimension?: string | null;
series_dimension?: string | null;
measures: string[];
tabular_fallback: boolean;
};
institutional_references: Array<Record<string, unknown>>;
};
export type ReportExecution = {
execution_id: string;
report_id: string;
report_revision: number;
semantic_model_id: string;
semantic_model_revision: number;
dataset_id: string;
dataset_revision: number;
status: "running" | "succeeded" | "failed";
parameters: Record<string, unknown>;
query: ReportingQuery;
definition_hashes: Record<string, string>;
source_fingerprints: Array<Record<string, unknown>>;
output_hash?: string | null;
executor_version?: string | null;
schema: Array<{ name: string; type: string }>;
rows: Array<Record<string, unknown>>;
total_rows: number;
truncated: boolean;
diagnostics: Array<{ severity?: string; code?: string; message?: string }>;
provenance: Record<string, unknown>;
started_at: string;
finished_at?: string | null;
visualization?: {
kind: string;
requested_kind?: string;
category?: string | null;
measures?: string[];
fallback_reason?: string | null;
};
};
export type ReportingSavedView = {
view_id: string;
report_id: string;
report_revision: number;
name: string;
revision: number;
state: { query?: ReportingQuery };
shared: boolean;
updated_at: string;
};
export function listDefinitions(
settings: ApiSettings,
options: { kinds?: ReportingDefinitionKind[]; status?: string[]; query?: string; limit?: number },
signal?: AbortSignal
): Promise<{ definitions: ReportingDefinition[]; total: number }> {
return apiFetch(settings, apiPath("/api/v1/reporting/definitions", {
definition_kind: options.kinds,
status: options.status,
query: options.query,
limit: options.limit ?? 200
}), { signal });
}
export function getDefinition(
settings: ApiSettings,
kind: ReportingDefinitionKind,
id: string,
revision?: number,
signal?: AbortSignal
): Promise<ReportingDefinition> {
return apiFetch(settings, apiPath(
`/api/v1/reporting/definitions/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,
{ revision }
), { signal });
}
export function runReport(
settings: ApiSettings,
report: ReportingDefinition,
query: ReportingQuery,
parameters: Record<string, unknown>
): Promise<ReportExecution> {
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(report.definition_id)}/executions`, {
method: "POST",
body: JSON.stringify({
report_revision: report.revision,
parameters,
query,
idempotency_key: crypto.randomUUID()
})
});
}
export function listExecutions(
settings: ApiSettings,
reportId: string,
signal?: AbortSignal
): Promise<{ executions: ReportExecution[] }> {
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/executions?limit=30`, { signal });
}
export function listSavedViews(
settings: ApiSettings,
reportId: string,
signal?: AbortSignal
): Promise<{ views: ReportingSavedView[] }> {
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/saved-views`, { signal });
}
export function saveView(
settings: ApiSettings,
report: ReportingDefinition,
name: string,
query: ReportingQuery
): Promise<ReportingSavedView> {
const viewId = crypto.randomUUID();
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(report.definition_id)}/saved-views/${viewId}`, {
method: "PUT",
body: JSON.stringify({
view_id: viewId,
report_revision: report.revision,
name,
state: { query },
shared: false,
access: {},
expected_revision: null
})
});
}
export function createIntervalSchedule(
settings: ApiSettings,
report: ReportingDefinition,
name: string,
seconds: number,
query: ReportingQuery,
parameters: Record<string, unknown>
): Promise<Record<string, unknown>> {
const scheduleId = crypto.randomUUID();
return apiFetch(settings, `/api/v1/reporting/schedules/${scheduleId}`, {
method: "PUT",
body: JSON.stringify({
schedule_id: scheduleId,
report_id: report.definition_id,
report_revision: report.revision,
name,
trigger_kind: "interval",
trigger_config: { seconds },
parameters,
query,
publication_target: {},
enabled: true,
next_run_at: null,
expected_revision: null
})
});
}
export async function downloadExecution(
settings: ApiSettings,
executionId: string,
format: "csv" | "json"
): Promise<void> {
const response = await fetch(apiUrl(settings, apiPath(
`/api/v1/reporting/executions/${encodeURIComponent(executionId)}/export`,
{ format }
)), {
headers: authHeaders(settings),
credentials: "include"
});
if (!response.ok) throw new Error(await response.text() || "The report export failed.");
const blob = await response.blob();
const disposition = response.headers.get("content-disposition") ?? "";
const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] ?? `report.${format}`;
const href = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = href;
link.download = filename;
link.click();
URL.revokeObjectURL(href);
}
export function reportPayload(definition: ReportingDefinition): ReportPayload {
return definition.payload as unknown as ReportPayload;
}
export function semanticPayload(definition: ReportingDefinition): SemanticModelPayload {
return definition.payload as unknown as SemanticModelPayload;
}
@@ -0,0 +1,573 @@
import {
BarChart3,
CalendarClock,
Download,
FileJson,
History,
Play,
RefreshCw,
Save,
Search,
SlidersHorizontal,
Table2
} from "lucide-react";
import {
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
SegmentedControl,
StatusBadge,
hasScope,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createIntervalSchedule,
downloadExecution,
getDefinition,
listDefinitions,
listExecutions,
listSavedViews,
reportPayload,
runReport,
saveView,
semanticPayload,
type ReportExecution,
type ReportingDefinition,
type ReportingQuery,
type ReportingQueryMode,
type ReportingSavedView,
type SemanticModelPayload
} from "../../api/reporting";
type OutputMode = "visual" | "table";
export default function ReportingPage({ settings, auth }: PlatformRouteContext) {
const [search, setSearch] = useState("");
const [submittedSearch, setSubmittedSearch] = useState("");
const [reports, setReports] = useState<ReportingDefinition[]>([]);
const [selectedId, setSelectedId] = useState("");
const [semantic, setSemantic] = useState<SemanticModelPayload | null>(null);
const [query, setQuery] = useState<ReportingQuery>(emptyQuery());
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const [execution, setExecution] = useState<ReportExecution | null>(null);
const [history, setHistory] = useState<ReportExecution[]>([]);
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
const [outputMode, setOutputMode] = useState<OutputMode>("visual");
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const canRun = hasScope(auth, "reporting:report:run");
const canSchedule = hasScope(auth, "reporting:schedule:write");
const selected = useMemo(
() => reports.find((item) => item.definition_id === selectedId) ?? null,
[reports, selectedId]
);
const report = selected ? reportPayload(selected) : null;
function reload(signal?: AbortSignal) {
setLoading(true);
setError("");
return listDefinitions(settings, {
kinds: ["report"],
status: ["active"],
query: submittedSearch,
limit: 200
}, signal).
then((result) => {
setReports(result.definitions);
setSelectedId((current) => result.definitions.some((item) => item.definition_id === current) ?
current : result.definitions[0]?.definition_id ?? "");
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
}).
finally(() => setLoading(false));
}
useEffect(() => {
const controller = new AbortController();
void reload(controller.signal);
return () => controller.abort();
}, [settings, submittedSearch]);
useEffect(() => {
if (!selected || !report) {
setSemantic(null);
setExecution(null);
setHistory([]);
setSavedViews([]);
return;
}
const controller = new AbortController();
setQuery(normalizeQuery(report.default_query));
setParameters(defaultParameters(report.parameters));
setExecution(null);
Promise.all([
getDefinition(settings, "semantic_model", report.semantic_model_id, report.semantic_model_revision, controller.signal),
canRun ? listExecutions(settings, selected.definition_id, controller.signal) : Promise.resolve({ executions: [] }),
listSavedViews(settings, selected.definition_id, controller.signal)
]).
then(([semanticDefinition, executions, views]) => {
setSemantic(semanticPayload(semanticDefinition));
setHistory(executions.executions);
setSavedViews(views.views);
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
});
return () => controller.abort();
}, [settings, selectedId]);
function submitSearch(event: FormEvent) {
event.preventDefault();
setSubmittedSearch(search.trim());
}
async function execute() {
if (!selected) return;
setRunning(true);
setError("");
try {
const result = await runReport(settings, selected, query, parameters);
setExecution(result);
setHistory((current) => [result, ...current.filter((item) => item.execution_id !== result.execution_id)]);
setOutputMode(result.visualization?.kind === "table" ? "table" : "visual");
} catch (reason) {
setError(message(reason));
} finally {
setRunning(false);
}
}
function applySavedView(view: ReportingSavedView) {
if (view.state.query) setQuery(normalizeQuery(view.state.query));
}
return (
<main className="reporting-page">
<div className="reporting-shell">
<div className="reporting-toolbar">
<form className="reporting-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
aria-label="Search reports"
placeholder="Search reports"
/>
</form>
<span className="reporting-count">{reports.length} reports</span>
<IconButton
label="Reload reports"
icon={<RefreshCw size={17} />}
variant="ghost"
onClick={() => void reload()}
/>
</div>
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
<div className="reporting-workspace">
<PageScrollViewport className="reporting-catalogue">
{loading && <LoadingIndicator label="Loading reports" />}
{!loading && reports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
<div className="reporting-report-list" role="list">
{reports.map((item) =>
<button
type="button"
role="listitem"
key={item.definition_id}
className={`reporting-report-row${selectedId === item.definition_id ? " is-selected" : ""}`}
onClick={() => setSelectedId(item.definition_id)}>
<BarChart3 size={17} aria-hidden="true" />
<span><strong>{item.name}</strong><small>{item.definition_key} · r{item.revision}</small></span>
<StatusBadge status="active" label="Active" />
</button>
)}
</div>
</PageScrollViewport>
<section className="reporting-result-region">
{selected && report ?
<>
<header className="reporting-result-header">
<div className="reporting-title">
<span>{selected.definition_key} · revision {selected.revision}</span>
<h1>{selected.name}</h1>
</div>
<div className="reporting-run-actions">
{canSchedule &&
<IconButton label="Schedule report" icon={<CalendarClock size={17} />} variant="ghost" onClick={() => setScheduleDialogOpen(true)} />
}
<IconButton label="Save current view" icon={<Save size={17} />} variant="ghost" onClick={() => setSaveDialogOpen(true)} />
<Button variant="primary" onClick={() => void execute()} disabled={!canRun || running}>
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
</Button>
</div>
</header>
<QueryControls
query={query}
semantic={semantic}
parameters={report.parameters}
parameterValues={parameters}
onQueryChange={setQuery}
onParametersChange={setParameters}
/>
<div className="reporting-output-toolbar">
<SegmentedControl
ariaLabel="Report output"
value={outputMode}
onChange={setOutputMode}
options={[
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
{ id: "table", label: <><Table2 size={15} /> Table</> }
]}
/>
{execution &&
<>
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
</>
}
</div>
<div className="reporting-output">
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
{execution && outputMode === "visual" && <ReportVisual execution={execution} />}
{execution && outputMode === "table" && <ReportTable execution={execution} />}
</div>
</> :
<div className="reporting-empty">Select a report.</div>
}
</section>
<PageScrollViewport className="reporting-inspector">
<Inspector
selected={selected}
execution={execution}
history={history}
savedViews={savedViews}
onSelectExecution={setExecution}
onApplySavedView={applySavedView}
/>
</PageScrollViewport>
</div>
</div>
<SaveViewDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
onSave={async (name) => {
if (!selected) return;
const saved = await saveView(settings, selected, name, query);
setSavedViews((current) => [...current, saved].sort((a, b) => a.name.localeCompare(b.name)));
setSaveDialogOpen(false);
}}
/>
<ScheduleDialog
open={scheduleDialogOpen}
onClose={() => setScheduleDialogOpen(false)}
onSave={async (name, seconds) => {
if (!selected) return;
await createIntervalSchedule(settings, selected, name, seconds, query, parameters);
setScheduleDialogOpen(false);
}}
/>
</main>
);
}
function QueryControls({ query, semantic, parameters, parameterValues, onQueryChange, onParametersChange }: {
query: ReportingQuery;
semantic: SemanticModelPayload | null;
parameters: Array<{ key: string; label: string; type: string; required: boolean; default?: unknown; allowed_values: unknown[] }>;
parameterValues: Record<string, unknown>;
onQueryChange: (query: ReportingQuery) => void;
onParametersChange: (parameters: Record<string, unknown>) => void;
}) {
const dimensions = semantic?.dimensions ?? [];
const measures = semantic?.measures ?? [];
function setMode(mode: ReportingQueryMode) {
const row = query.dimensions[0] ?? dimensions[0]?.key;
const column = query.dimensions[1] ?? dimensions[1]?.key;
onQueryChange({
...query,
mode,
pivot: mode === "pivot" ? {
rows: row ? [row] : [],
columns: column ? [column] : [],
measures: query.measures.length ? query.measures : measures.slice(0, 1).map((item) => item.key),
include_totals: true
} : null
});
}
return (
<div className="reporting-query-controls">
<div className="reporting-query-heading">
<SlidersHorizontal size={16} aria-hidden="true" />
<SegmentedControl
ariaLabel="Query shape"
value={query.mode}
onChange={setMode}
options={[
{ id: "summary", label: "Summary" },
{ id: "detail", label: "Detail" },
{ id: "pivot", label: "Pivot" }
]}
/>
</div>
<fieldset>
<legend>Dimensions</legend>
<div className="reporting-option-list">
{dimensions.map((item) =>
<label key={item.key}>
<input
type="checkbox"
checked={query.dimensions.includes(item.key)}
onChange={() => onQueryChange({ ...query, dimensions: toggle(query.dimensions, item.key) })}
/>
{item.label}
</label>
)}
</div>
</fieldset>
{query.mode !== "detail" &&
<fieldset>
<legend>Measures</legend>
<div className="reporting-option-list">
{measures.map((item) =>
<label key={item.key}>
<input
type="checkbox"
checked={query.measures.includes(item.key)}
onChange={() => onQueryChange({ ...query, measures: toggle(query.measures, item.key) })}
/>
{item.label}
</label>
)}
</div>
</fieldset>
}
{parameters.map((parameter) =>
<label className="reporting-parameter" key={parameter.key}>
<span>{parameter.label}</span>
{parameter.allowed_values.length > 0 ?
<select
value={String(parameterValues[parameter.key] ?? "")}
onChange={(event) => onParametersChange({ ...parameterValues, [parameter.key]: event.target.value })}>
{!parameter.required && <option value="">Any</option>}
{parameter.allowed_values.map((value) => <option key={String(value)} value={String(value)}>{String(value)}</option>)}
</select> :
<input
type={parameter.type === "number" || parameter.type === "integer" ? "number" : "text"}
value={String(parameterValues[parameter.key] ?? "")}
onChange={(event) => onParametersChange({ ...parameterValues, [parameter.key]: typedValue(event.target.value, parameter.type) })}
/>
}
</label>
)}
</div>
);
}
function ReportTable({ execution }: { execution: ReportExecution }) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [execution.execution_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
execution.schema.map((field) => ({
id: field.name,
header: humanize(field.name),
width: "1fr",
minWidth: 120,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
value: (row) => row[field.name],
render: (row) => formatValue(row[field.name])
})), [execution]);
return (
<DataGrid
id={`reporting-execution-${execution.execution_id}`}
rows={execution.rows}
columns={columns}
getRowKey={(_row, index) => `${execution.execution_id}:${index}`}
initialFit="container"
resizeBehavior="cover"
emptyText="The report returned no rows."
pagination={{ page, pageSize: 50, onPageChange: setPage }}
/>
);
}
function ReportVisual({ execution }: { execution: ReportExecution }) {
const visual = execution.visualization;
if (!visual || visual.kind === "table" || !visual.category || !visual.measures?.length) {
return (
<div className="reporting-visual-fallback">
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
<ReportTable execution={execution} />
</div>
);
}
const measure = visual.measures[0];
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
return (
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
{execution.rows.map((row, index) =>
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
<span>{formatValue(row[visual.category ?? ""])}</span>
<div><i style={{ width: `${Math.max(1, Math.abs(values[index]) / maximum * 100)}%` }} /></div>
<strong>{formatValue(row[measure])}</strong>
</div>
)}
<div className="reporting-chart-table"><ReportTable execution={execution} /></div>
</div>
);
}
function Inspector({ selected, execution, history, savedViews, onSelectExecution, onApplySavedView }: {
selected: ReportingDefinition | null;
execution: ReportExecution | null;
history: ReportExecution[];
savedViews: ReportingSavedView[];
onSelectExecution: (execution: ReportExecution) => void;
onApplySavedView: (view: ReportingSavedView) => void;
}) {
return (
<div className="reporting-inspector-content">
<section>
<h2><History size={16} /> Runs</h2>
{history.length === 0 && <p>No recorded runs.</p>}
{history.map((item) =>
<button type="button" key={item.execution_id} className={execution?.execution_id === item.execution_id ? "is-selected" : ""} onClick={() => onSelectExecution(item)}>
<span>{formatDateTime(item.started_at)}</span>
<StatusBadge status={item.status} label={humanize(item.status)} />
<small>{item.total_rows} rows</small>
</button>
)}
</section>
<section>
<h2><Save size={16} /> Saved views</h2>
{savedViews.length === 0 && <p>No saved views.</p>}
{savedViews.map((view) =>
<button type="button" key={view.view_id} onClick={() => onApplySavedView(view)}>
<span>{view.name}</span><small>revision {view.revision}</small>
</button>
)}
</section>
{selected &&
<section className="reporting-provenance">
<h2>Definition</h2>
<dl>
<dt>Report revision</dt><dd>{selected.revision}</dd>
<dt>Content hash</dt><dd title={selected.content_hash}>{shortHash(selected.content_hash)}</dd>
{execution && <><dt>Dataset</dt><dd>{execution.dataset_id} r{execution.dataset_revision}</dd></>}
{execution?.output_hash && <><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></>}
{execution?.executor_version && <><dt>Executor</dt><dd>{execution.executor_version}</dd></>}
</dl>
{execution?.diagnostics.map((item, index) =>
<DismissibleAlert key={`${item.code}:${index}`} tone={item.severity === "error" ? "danger" : item.severity === "warning" ? "warning" : "info"} dismissible={false} compact>
{item.message ?? item.code ?? "Execution diagnostic"}
</DismissibleAlert>
)}
</section>
}
</div>
);
}
function SaveViewDialog({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
return (
<Dialog open={open} title="Save report view" onClose={onClose} footer={<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim()).finally(() => setSaving(false)); }}>Save</Button>
</>}>
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
</Dialog>
);
}
function ScheduleDialog({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (name: string, seconds: number) => Promise<void> }) {
const [name, setName] = useState("");
const [interval, setIntervalValue] = useState("86400");
const [saving, setSaving] = useState(false);
return (
<Dialog open={open} title="Schedule report" onClose={onClose} footer={<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
</>}>
<div className="reporting-dialog-grid">
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
</div>
</Dialog>
);
}
function emptyQuery(): ReportingQuery {
return { mode: "summary", dimensions: [], measures: [], filters: [], sort: [], pivot: null, offset: 0, limit: 200 };
}
function normalizeQuery(value: ReportingQuery): ReportingQuery {
return { ...emptyQuery(), ...value, dimensions: [...(value.dimensions ?? [])], measures: [...(value.measures ?? [])], filters: [...(value.filters ?? [])], sort: [...(value.sort ?? [])] };
}
function defaultParameters(parameters: Array<{ key: string; default?: unknown }>): Record<string, unknown> {
return Object.fromEntries(parameters.filter((item) => item.default !== null && item.default !== undefined).map((item) => [item.key, item.default]));
}
function toggle(values: string[], value: string): string[] {
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
}
function typedValue(value: string, type: string): unknown {
if (value === "") return null;
if (type === "integer") return Number.parseInt(value, 10);
if (type === "number") return Number(value);
return value;
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "number") return new Intl.NumberFormat().format(value);
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function formatDateTime(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
function humanize(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function shortHash(value: string): string {
return `${value.slice(0, 10)}${value.slice(-6)}`;
}
function message(reason: unknown): string {
return reason instanceof Error ? reason.message : "Reporting could not complete the request.";
}
+2
View File
@@ -0,0 +1,2 @@
export { default, reportingModule } from "./module";
export * from "./api/reporting";
+52
View File
@@ -0,0 +1,52 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/reporting.css";
const ReportingPage = lazy(() => import("./features/reporting/ReportingPage"));
export const reportingModule: PlatformWebModule = {
id: "reporting",
label: "Reporting",
version: "0.1.14",
optionalDependencies: [
"dataflow",
"datasources",
"connectors",
"dashboard",
"files",
"mail",
"templates",
"workflow_engine",
"policy",
"search",
"notifications"
],
routes: [
{
path: "/reporting",
anyOf: ["reporting:definition:read"],
order: 74,
surfaceId: "reporting.workspace",
render: (context) => createElement(ReportingPage, context)
}
],
navItems: [
{
to: "/reporting",
label: "Reporting",
iconName: "clipboard-pen-line",
anyOf: ["reporting:definition:read"],
order: 74,
surfaceId: "reporting.navigation"
}
],
viewSurfaces: [
{ id: "reporting.navigation", moduleId: "reporting", kind: "navigation", label: "Reporting navigation", order: 10 },
{ id: "reporting.workspace", moduleId: "reporting", kind: "route", label: "Reporting workspace", order: 20 },
{ id: "reporting.parameters", moduleId: "reporting", kind: "section", label: "Report parameters and filters", parentId: "reporting.workspace", order: 30 },
{ id: "reporting.results", moduleId: "reporting", kind: "section", label: "Authorized report results", parentId: "reporting.workspace", order: 40 }
]
};
export default reportingModule;
+388
View File
@@ -0,0 +1,388 @@
.reporting-page,
.reporting-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.reporting-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.reporting-toolbar,
.reporting-result-header,
.reporting-output-toolbar {
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.reporting-toolbar {
min-height: 56px;
padding: 9px 14px;
}
.reporting-search {
display: flex;
align-items: center;
gap: 8px;
width: min(440px, 46vw);
}
.reporting-search input {
min-width: 140px;
flex: 1;
}
.reporting-count {
margin-left: auto;
color: var(--text-soft);
font-size: 0.8rem;
}
.reporting-workspace {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(250px, 20%) minmax(460px, 1fr) minmax(260px, 20%);
}
.reporting-catalogue,
.reporting-inspector {
min-height: 0;
padding: 10px;
background: var(--surface-subtle, var(--surface));
}
.reporting-catalogue {
border-right: 1px solid var(--border);
}
.reporting-inspector {
border-left: 1px solid var(--border);
}
.reporting-report-list,
.reporting-inspector-content section {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.reporting-report-row,
.reporting-inspector-content section > button {
display: grid;
align-items: center;
width: 100%;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.reporting-report-row {
grid-template-columns: 22px minmax(0, 1fr) auto;
gap: 8px;
min-height: 58px;
padding: 8px 10px;
}
.reporting-report-row:last-child,
.reporting-inspector-content section > button:last-child {
border-bottom: 0;
}
.reporting-report-row:hover,
.reporting-report-row:focus-visible,
.reporting-report-row.is-selected,
.reporting-inspector-content section > button:hover,
.reporting-inspector-content section > button.is-selected {
background: var(--hover-bg);
}
.reporting-report-row.is-selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.reporting-report-row > span:nth-child(2) {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.reporting-report-row strong,
.reporting-report-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-report-row small,
.reporting-inspector-content small,
.reporting-inspector-content p {
color: var(--text-soft);
font-size: 0.76rem;
}
.reporting-result-region {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.reporting-result-header {
min-height: 62px;
padding: 8px 14px;
}
.reporting-title {
min-width: 0;
}
.reporting-title span {
color: var(--text-soft);
font-size: 0.75rem;
}
.reporting-title h1 {
overflow: hidden;
margin: 2px 0 0;
font-size: 1.05rem;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-run-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.reporting-query-controls {
display: flex;
align-items: center;
gap: 14px;
min-height: 66px;
padding: 8px 14px;
overflow-x: auto;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
}
.reporting-query-heading {
display: flex;
align-items: center;
gap: 7px;
}
.reporting-query-controls fieldset {
min-width: 170px;
margin: 0;
padding: 4px 8px 7px;
border: 1px solid var(--border);
border-radius: 6px;
}
.reporting-query-controls legend,
.reporting-parameter > span,
.reporting-dialog-field > span {
color: var(--text-soft);
font-size: 0.72rem;
}
.reporting-option-list {
display: flex;
gap: 9px;
max-width: 340px;
overflow-x: auto;
}
.reporting-option-list label {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.reporting-parameter,
.reporting-dialog-field {
display: flex;
min-width: 150px;
flex-direction: column;
gap: 4px;
}
.reporting-output-toolbar {
min-height: 48px;
padding: 6px 12px;
}
.reporting-output-toolbar > span {
margin-left: auto;
color: var(--text-soft);
font-size: 0.78rem;
}
.reporting-output {
min-height: 0;
flex: 1;
overflow: auto;
padding: 10px;
}
.reporting-output .data-grid-shell {
min-width: 100%;
}
.reporting-empty {
padding: 38px 12px;
color: var(--text-soft);
text-align: center;
}
.reporting-inspector-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.reporting-inspector-content section h2 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 9px 10px;
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
letter-spacing: 0;
}
.reporting-inspector-content section > p {
margin: 0;
padding: 12px 10px;
}
.reporting-inspector-content section > button {
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 8px;
padding: 8px 10px;
}
.reporting-inspector-content section > button small {
grid-column: 1 / -1;
}
.reporting-provenance {
padding-bottom: 8px;
}
.reporting-provenance dl {
display: grid;
grid-template-columns: minmax(90px, auto) minmax(0, 1fr);
gap: 6px 8px;
margin: 0;
padding: 10px;
font-size: 0.76rem;
}
.reporting-provenance dt {
color: var(--text-soft);
}
.reporting-provenance dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.reporting-provenance .alert {
margin: 8px;
}
.reporting-bar-chart {
display: flex;
min-width: 420px;
flex-direction: column;
gap: 8px;
padding: 10px;
}
.reporting-bar-row {
display: grid;
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
align-items: center;
gap: 9px;
font-size: 0.8rem;
}
.reporting-bar-row > div {
height: 22px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-subtle, var(--surface));
}
.reporting-bar-row i {
display: block;
height: 100%;
background: var(--accent);
}
.reporting-chart-table {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.reporting-dialog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1100px) {
.reporting-workspace {
grid-template-columns: minmax(220px, 28%) minmax(0, 1fr);
}
.reporting-inspector {
display: none;
}
}
@media (max-width: 760px) {
.reporting-workspace {
grid-template-columns: 1fr;
}
.reporting-catalogue {
max-height: 30vh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.reporting-result-header,
.reporting-toolbar {
flex-wrap: wrap;
}
.reporting-search {
width: 100%;
}
.reporting-dialog-grid {
grid-template-columns: 1fr;
}
}