Render rv_tile / rv_chart elements above record views

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-05-23 11:08:10 +05:30
parent 942eae98db
commit 50f0591b41
5 changed files with 126 additions and 11 deletions

View File

@ -136,6 +136,24 @@ export interface RecordViewField {
activity_id?: string; 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 { export interface RecordViewResponse {
config: { fields: RecordViewField[] }; config: { fields: RecordViewField[] };
data: Record<string, unknown>[]; data: Record<string, unknown>[];
@ -145,6 +163,8 @@ export interface RecordViewResponse {
total_count: number; total_count: number;
total_pages: number; total_pages: number;
}; };
tile_values?: TileValue[];
chart_data?: ChartDataResponse[];
} }
export function getRecordView( export function getRecordView(

View 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>
);
}

View File

@ -4,7 +4,7 @@ import {
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
X, Inbox, ArrowRight, RefreshCw, CheckCircle2, X, Inbox, ArrowRight, RefreshCw, CheckCircle2,
} from "lucide-react"; } 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"; import FormModal from "./FormModal";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -141,6 +141,7 @@ interface Props {
viewId: number | string; viewId: number | string;
onRowClick?: (instanceId: string) => void; onRowClick?: (instanceId: string) => void;
toolbarAction?: React.ReactNode; toolbarAction?: React.ReactNode;
onAnalyticsLoaded?: (tiles: TileValue[], charts: ChartDataResponse[]) => void;
} }
// Walks the rv-screen layout to find the rv_table element and returns a // 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; return out;
} }
export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) { export default function RecordViewTable({ viewId, onRowClick, toolbarAction, onAnalyticsLoaded }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null); const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
@ -199,6 +200,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
setError(null); setError(null);
try { try {
const res = await getRecordView(rvTemplateUid, viewId, query); const res = await getRecordView(rvTemplateUid, viewId, query);
onAnalyticsLoaded?.(res.tile_values ?? [], res.chart_data ?? []);
setFields(res.config.fields.filter((f) => f.field_key !== "")); setFields(res.config.fields.filter((f) => f.field_key !== ""));
const dataRows = Array.isArray(res.data) ? res.data : []; const dataRows = Array.isArray(res.data) ? res.data : [];
setRows(dataRows); setRows(dataRows);

View File

@ -1,9 +1,11 @@
import { useState, ReactNode } from "react"; import { useState, ReactNode } from "react";
import { Plus } from "lucide-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 RecordViewTable from "./RecordViewTable";
import DetailViewPanel from "./DetailViewPanel"; import DetailViewPanel from "./DetailViewPanel";
import FormModal from "./FormModal"; import FormModal from "./FormModal";
import Tile from "./Tile";
import BarChart from "./BarChart";
interface Props { interface Props {
layout: LayoutElement[]; layout: LayoutElement[];
@ -19,8 +21,9 @@ interface AnyNode {
children?: AnyNode[]; children?: AnyNode[];
} }
// Flatten the screen layout depth-first. Container nodes (section/column/block) interface TileSpec { kind: "tile"; uid: string; title?: string; style?: Record<string, string> }
// carry no config we care about — only their leaf "element" descendants do. interface ChartSpec { kind: "chart"; uid: string; title?: string; style?: Record<string, string> }
function flatten(nodes: AnyNode[] | undefined): AnyNode[] { function flatten(nodes: AnyNode[] | undefined): AnyNode[] {
if (!nodes) return []; if (!nodes) return [];
const out: AnyNode[] = []; const out: AnyNode[] = [];
@ -33,16 +36,16 @@ function flatten(nodes: AnyNode[] | undefined): AnyNode[] {
export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick }: Props) { export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick }: Props) {
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title?: string } | null>(null); 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 buttons: ReactNode[] = [];
const elements: { type: "rv" | "dv"; viewId: number | string; style?: Record<string, string> }[] = []; const elements: { type: "rv" | "dv"; viewId: number | string; style?: Record<string, string> }[] = [];
const analytics: (TileSpec | ChartSpec)[] = [];
for (const node of flatten(layout as AnyNode[])) { for (const node of flatten(layout as AnyNode[])) {
const cfg = node.config!; const cfg = node.config!;
if (cfg.type === "button" || cfg.job_template) { if (cfg.type === "button" || cfg.job_template) {
// V1 uses activity_id_init / activity_id; V2 uses activity_uid_init / activity_uid.
const aid = const aid =
cfg.params?.activity_uid_init || cfg.params?.activity_uid_init ||
cfg.params?.activity_id_init || cfg.params?.activity_id_init ||
@ -61,19 +64,36 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
</button> </button>
); );
} else if (cfg.type === "recordsview") { } 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; const id = cfg.view_uuid || cfg.view_id;
elements.push({ type: "rv", viewId: id, style: node.style }); elements.push({ type: "rv", viewId: id, style: node.style });
} else if (cfg.type === "detailsview") { } else if (cfg.type === "detailsview") {
const id = cfg.view_uuid || cfg.view_id; const id = cfg.view_uuid || cfg.view_id;
elements.push({ type: "dv", viewId: id, style: node.style }); 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 ( return (
<> <>
<div className="space-y-4"> <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) => { {elements.map((el, i) => {
if (el.type === "rv") { if (el.type === "rv") {
return ( return (
@ -82,6 +102,7 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
viewId={el.viewId} viewId={el.viewId}
onRowClick={onRowClick} onRowClick={onRowClick}
toolbarAction={buttons.length > 0 ? <div className="flex items-center gap-2">{buttons}</div> : undefined} toolbarAction={buttons.length > 0 ? <div className="flex items-center gap-2">{buttons}</div> : undefined}
onAnalyticsLoaded={(tiles, charts) => { setTileValues(tiles); setChartData(charts); }}
/> />
</div> </div>
); );
@ -91,7 +112,6 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
} }
return null; return null;
})} })}
{/* If there's no record view to host the buttons, show them standalone. */}
{elements.length === 0 && buttons.length > 0 && ( {elements.length === 0 && buttons.length > 0 && (
<div className="flex items-center gap-2">{buttons}</div> <div className="flex items-center gap-2">{buttons}</div>
)} )}

21
src/components/Tile.tsx Normal file
View 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>
);
}