feat: implement governed reporting vertical
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, reportingModule } from "./module";
|
||||
export * from "./api/reporting";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user