633 lines
26 KiB
TypeScript
633 lines
26 KiB
TypeScript
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,
|
|
DocumentationHelpLink,
|
|
DismissibleAlert,
|
|
IconButton,
|
|
LoadingIndicator,
|
|
PageScrollViewport,
|
|
SegmentedControl,
|
|
StatusBadge,
|
|
hasScope,
|
|
type DataGridColumn,
|
|
type PlatformRouteContext
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
createIntervalSchedule,
|
|
downloadExecution,
|
|
getDefinition,
|
|
listDefinitions,
|
|
listExecutions,
|
|
listProviderReports,
|
|
listSavedViews,
|
|
reportPayload,
|
|
runReport,
|
|
saveView,
|
|
semanticPayload,
|
|
type ReportExecution,
|
|
type ReportingDefinition,
|
|
type ReportingQuery,
|
|
type ReportingQueryMode,
|
|
type ReportingSavedView,
|
|
type ProviderReportDescriptor,
|
|
type SemanticModelPayload
|
|
} from "../../api/reporting";
|
|
import {
|
|
ProviderReportInspector,
|
|
ProviderReportWorkspace
|
|
} from "./ProviderReportWorkspace";
|
|
|
|
|
|
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 [providerReports, setProviderReports] = useState<ProviderReportDescriptor[]>([]);
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const [selectedProviderKey, setSelectedProviderKey] = 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 selectedProvider = useMemo(
|
|
() => providerReports.find((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey) ?? null,
|
|
[providerReports, selectedProviderKey]
|
|
);
|
|
const report = selected ? reportPayload(selected) : null;
|
|
|
|
function reload(signal?: AbortSignal) {
|
|
setLoading(true);
|
|
setError("");
|
|
return Promise.all([
|
|
listDefinitions(settings, {
|
|
kinds: ["report"],
|
|
status: ["active"],
|
|
query: submittedSearch,
|
|
limit: 200
|
|
}, signal),
|
|
listProviderReports(settings, signal)
|
|
]).
|
|
then(([result, providerResult]) => {
|
|
const providerRows = providerResult.reports.filter((item) => {
|
|
const query = submittedSearch.toLocaleLowerCase();
|
|
return !query || `${item.title} ${item.summary} ${item.provider_id}`.toLocaleLowerCase().includes(query);
|
|
});
|
|
setReports(result.definitions);
|
|
setProviderReports(providerRows);
|
|
const currentSemanticAvailable = result.definitions.some((item) => item.definition_id === selectedId);
|
|
const currentProviderAvailable = providerRows.some((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey);
|
|
if (!currentSemanticAvailable && !currentProviderAvailable) {
|
|
if (providerRows[0]) {
|
|
setSelectedProviderKey(`${providerRows[0].provider_id}:${providerRows[0].report_id}`);
|
|
setSelectedId("");
|
|
} else {
|
|
setSelectedId(result.definitions[0]?.definition_id ?? "");
|
|
setSelectedProviderKey("");
|
|
}
|
|
}
|
|
}).
|
|
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 + providerReports.length} reports</span>
|
|
<DocumentationHelpLink
|
|
reference={{ topicId: "reporting.governed-bi", documentationType: "user" }}
|
|
label="Open reporting documentation"
|
|
/>
|
|
<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 + providerReports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
|
|
<div className="reporting-report-list" role="list">
|
|
{providerReports.length > 0 && <div className="reporting-list-heading">Module reports</div>}
|
|
{providerReports.map((item) => {
|
|
const key = `${item.provider_id}:${item.report_id}`;
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="listitem"
|
|
key={key}
|
|
className={`reporting-report-row${selectedProviderKey === key ? " is-selected" : ""}`}
|
|
onClick={() => { setSelectedProviderKey(key); setSelectedId(""); }}>
|
|
<BarChart3 size={17} aria-hidden="true" />
|
|
<span><strong>{item.title}</strong><small>{item.provider_id} · {item.revision}</small></span>
|
|
<StatusBadge status={item.available ? "active" : "locked"} label={item.available ? "Available" : "Restricted"} />
|
|
</button>
|
|
);
|
|
})}
|
|
{reports.length > 0 && <div className="reporting-list-heading">Semantic reports</div>}
|
|
{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); setSelectedProviderKey(""); }}>
|
|
<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">
|
|
{selectedProvider ?
|
|
<ProviderReportWorkspace settings={settings} auth={auth} report={selectedProvider} /> :
|
|
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}
|
|
disabledReason={!canRun ? "Report run permission is required." : undefined}>
|
|
<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">
|
|
{selectedProvider ?
|
|
<ProviderReportInspector report={selectedProvider} /> :
|
|
<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.";
|
|
}
|