Render rv_tile / rv_chart elements above record views
This commit is contained in:
parent
942eae98db
commit
50f0591b41
@ -136,6 +136,24 @@ export interface RecordViewField {
|
||||
activity_id?: string;
|
||||
}
|
||||
|
||||
export interface TileValue {
|
||||
tile_uid: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface ChartDataRow {
|
||||
dimension: unknown;
|
||||
series?: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface ChartDataResponse {
|
||||
chart_uid: string;
|
||||
key: string;
|
||||
rows: ChartDataRow[];
|
||||
}
|
||||
|
||||
export interface RecordViewResponse {
|
||||
config: { fields: RecordViewField[] };
|
||||
data: Record<string, unknown>[];
|
||||
@ -145,6 +163,8 @@ export interface RecordViewResponse {
|
||||
total_count: number;
|
||||
total_pages: number;
|
||||
};
|
||||
tile_values?: TileValue[];
|
||||
chart_data?: ChartDataResponse[];
|
||||
}
|
||||
|
||||
export function getRecordView(
|
||||
|
||||
52
src/components/BarChart.tsx
Normal file
52
src/components/BarChart.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
interface Row {
|
||||
dimension: unknown;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
rows: Row[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function BarChart({ title, rows, loading }: Props) {
|
||||
const data = (rows ?? []).map(r => ({ label: String(r.dimension ?? "—"), value: Number(r.value ?? 0) }));
|
||||
const max = Math.max(1, ...data.map(d => d.value));
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-[280px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-5 py-4 shadow-sm">
|
||||
{title && (
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-3">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[0,1,2,3].map(i => <div key={i} className="h-5 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />)}
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="text-[12px] text-stone-400 dark:text-zinc-500 py-4 text-center">No data</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.map((d, i) => {
|
||||
const pct = (d.value / max) * 100;
|
||||
return (
|
||||
<div key={i} className="grid grid-cols-[140px_1fr_48px] items-center gap-2 text-[12px]">
|
||||
<span className="truncate text-stone-600 dark:text-zinc-400" title={d.label}>{d.label}</span>
|
||||
<div className="h-3 bg-stone-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${pct}%`, background: "linear-gradient(90deg, #C8102E, #E84258)" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-right font-semibold tabular-nums text-stone-700 dark:text-zinc-200">
|
||||
{d.value.toLocaleString("en-IN")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,7 @@ import {
|
||||
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
|
||||
X, Inbox, ArrowRight, RefreshCw, CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery } from "../api/viewService";
|
||||
import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery, type TileValue, type ChartDataResponse } from "../api/viewService";
|
||||
import FormModal from "./FormModal";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -141,6 +141,7 @@ interface Props {
|
||||
viewId: number | string;
|
||||
onRowClick?: (instanceId: string) => void;
|
||||
toolbarAction?: React.ReactNode;
|
||||
onAnalyticsLoaded?: (tiles: TileValue[], charts: ChartDataResponse[]) => void;
|
||||
}
|
||||
|
||||
// Walks the rv-screen layout to find the rv_table element and returns a
|
||||
@ -163,7 +164,7 @@ function extractColumnLabels(layout: any): Record<string, string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) {
|
||||
export default function RecordViewTable({ viewId, onRowClick, toolbarAction, onAnalyticsLoaded }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||
@ -199,6 +200,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getRecordView(rvTemplateUid, viewId, query);
|
||||
onAnalyticsLoaded?.(res.tile_values ?? [], res.chart_data ?? []);
|
||||
setFields(res.config.fields.filter((f) => f.field_key !== ""));
|
||||
const dataRows = Array.isArray(res.data) ? res.data : [];
|
||||
setRows(dataRows);
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState, ReactNode } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { LayoutElement } from "../api/viewService";
|
||||
import type { LayoutElement, TileValue, ChartDataResponse } from "../api/viewService";
|
||||
import RecordViewTable from "./RecordViewTable";
|
||||
import DetailViewPanel from "./DetailViewPanel";
|
||||
import FormModal from "./FormModal";
|
||||
import Tile from "./Tile";
|
||||
import BarChart from "./BarChart";
|
||||
|
||||
interface Props {
|
||||
layout: LayoutElement[];
|
||||
@ -19,8 +21,9 @@ interface AnyNode {
|
||||
children?: AnyNode[];
|
||||
}
|
||||
|
||||
// Flatten the screen layout depth-first. Container nodes (section/column/block)
|
||||
// carry no config we care about — only their leaf "element" descendants do.
|
||||
interface TileSpec { kind: "tile"; uid: string; title?: string; style?: Record<string, string> }
|
||||
interface ChartSpec { kind: "chart"; uid: string; title?: string; style?: Record<string, string> }
|
||||
|
||||
function flatten(nodes: AnyNode[] | undefined): AnyNode[] {
|
||||
if (!nodes) return [];
|
||||
const out: AnyNode[] = [];
|
||||
@ -33,16 +36,16 @@ function flatten(nodes: AnyNode[] | undefined): AnyNode[] {
|
||||
|
||||
export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick }: Props) {
|
||||
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title?: string } | null>(null);
|
||||
const [tileValues, setTileValues] = useState<TileValue[]>([]);
|
||||
const [chartData, setChartData] = useState<ChartDataResponse[]>([]);
|
||||
|
||||
// Collect button elements; they get injected into the toolbar of the
|
||||
// *next* record-view we find. Everything else renders inline in order.
|
||||
const buttons: ReactNode[] = [];
|
||||
const elements: { type: "rv" | "dv"; viewId: number | string; style?: Record<string, string> }[] = [];
|
||||
const analytics: (TileSpec | ChartSpec)[] = [];
|
||||
|
||||
for (const node of flatten(layout as AnyNode[])) {
|
||||
const cfg = node.config!;
|
||||
if (cfg.type === "button" || cfg.job_template) {
|
||||
// V1 uses activity_id_init / activity_id; V2 uses activity_uid_init / activity_uid.
|
||||
const aid =
|
||||
cfg.params?.activity_uid_init ||
|
||||
cfg.params?.activity_id_init ||
|
||||
@ -61,19 +64,36 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
|
||||
</button>
|
||||
);
|
||||
} else if (cfg.type === "recordsview") {
|
||||
// Prefer view_uuid — the v2 envelope renumbers numeric IDs across envs,
|
||||
// so the UUID is the stable cross-env handle.
|
||||
const id = cfg.view_uuid || cfg.view_id;
|
||||
elements.push({ type: "rv", viewId: id, style: node.style });
|
||||
} else if (cfg.type === "detailsview") {
|
||||
const id = cfg.view_uuid || cfg.view_id;
|
||||
elements.push({ type: "dv", viewId: id, style: node.style });
|
||||
} else if (cfg.type === "rv_tile" && cfg.tile_uid) {
|
||||
analytics.push({ kind: "tile", uid: cfg.tile_uid, title: cfg.title, style: node.style });
|
||||
} else if (cfg.type === "rv_chart" && cfg.chart_uid) {
|
||||
analytics.push({ kind: "chart", uid: cfg.chart_uid, title: cfg.title, style: node.style });
|
||||
}
|
||||
}
|
||||
|
||||
const tilesLoading = analytics.length > 0 && tileValues.length === 0 && chartData.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{analytics.length > 0 && (
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
{analytics.map((a, i) => {
|
||||
if (a.kind === "tile") {
|
||||
const tv = tileValues.find(t => t.tile_uid === a.uid);
|
||||
return <Tile key={i} title={a.title} value={tv?.value as number | undefined} loading={tilesLoading} />;
|
||||
}
|
||||
const cd = chartData.find(c => c.chart_uid === a.uid);
|
||||
return <BarChart key={i} title={a.title} rows={cd?.rows ?? []} loading={tilesLoading} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{elements.map((el, i) => {
|
||||
if (el.type === "rv") {
|
||||
return (
|
||||
@ -82,6 +102,7 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
|
||||
viewId={el.viewId}
|
||||
onRowClick={onRowClick}
|
||||
toolbarAction={buttons.length > 0 ? <div className="flex items-center gap-2">{buttons}</div> : undefined}
|
||||
onAnalyticsLoaded={(tiles, charts) => { setTileValues(tiles); setChartData(charts); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -91,7 +112,6 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{/* If there's no record view to host the buttons, show them standalone. */}
|
||||
{elements.length === 0 && buttons.length > 0 && (
|
||||
<div className="flex items-center gap-2">{buttons}</div>
|
||||
)}
|
||||
|
||||
21
src/components/Tile.tsx
Normal file
21
src/components/Tile.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
interface Props {
|
||||
title?: string;
|
||||
value: number | string | null | undefined;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function Tile({ title, value, loading }: Props) {
|
||||
const display = value == null ? "—" : typeof value === "number" ? value.toLocaleString("en-IN") : String(value);
|
||||
return (
|
||||
<div className="flex-1 min-w-[140px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-4 py-3 shadow-sm">
|
||||
{title && (
|
||||
<div className="text-[10px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-1">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[22px] font-bold text-stone-900 dark:text-zinc-100 tabular-nums leading-tight">
|
||||
{loading ? <span className="inline-block h-6 w-12 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" /> : display}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user