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;
|
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(
|
||||||
|
|||||||
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,
|
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);
|
||||||
|
|||||||
@ -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
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