import { useEffect, useState, useCallback, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown, X, Inbox, ArrowRight, RefreshCw, CheckCircle2, } from "lucide-react"; import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery, type TileValue, type ChartDataResponse } from "../api/viewService"; import FormModal from "./FormModal"; import Tile from "./Tile"; import BarChart from "./BarChart"; import PieChart from "./PieChart"; // --------------------------------------------------------------------------- // Action column (config-driven from rv-screen layout) // --------------------------------------------------------------------------- interface ActionCondition { field_key: string; operator: string; value: unknown; data_type?: string; } interface ActionConditions { logic?: "AND" | "OR"; conditions: ActionCondition[]; } interface ActionButton { name: string; icon?: string; classes?: string[]; job_template?: string; params?: { activity_uid?: string; workflow_uuid?: string; [k: string]: unknown }; show_conditions?: ActionConditions | null; action_end?: Array<{ type: string }>; } interface ActionColumn { id: string; display: string; buttons: ActionButton[]; } const ICONS: Record = { refresh: , task_alt: , }; function iconFor(name?: string): React.ReactNode { return (name && ICONS[name]) || ; } // Walks the rv-screen layout to find columns with `type: "custom"` and their // inner button configs. function extractActionColumns(layout: any): ActionColumn[] { const out: ActionColumn[] = []; const walk = (nodes: any[]): void => { for (const n of nodes ?? []) { const cfg = n?.config; if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) { for (const col of cfg.columns) { if (col?.type === "custom" && Array.isArray(col.config)) { const buttons = col.config.filter((b: any) => b?.type === "button"); if (buttons.length > 0) { out.push({ id: col.id, display: col.display || "Actions", buttons }); } } } } if (Array.isArray(n?.children)) walk(n.children); } }; walk(Array.isArray(layout) ? layout : []); return out; } function evalShowConditions(c: ActionConditions | null | undefined, row: Record): boolean { if (!c || !Array.isArray(c.conditions) || c.conditions.length === 0) return true; const results = c.conditions.map((cond) => { const v = row[cond.field_key]; switch (cond.operator) { case "equals": return v === cond.value; case "not_equals": return v !== cond.value; case "is_empty": return v == null || v === ""; case "is_not_empty": return v != null && v !== ""; case "in": return Array.isArray(cond.value) && cond.value.includes(v as never); case "not_in": return Array.isArray(cond.value) && !cond.value.includes(v as never); default: return false; } }); return (c.logic === "OR") ? results.some(Boolean) : results.every(Boolean); } function buttonClass(classes?: string[]): string { const ghost = classes?.includes("z-btn-secondary"); const base = "h-7 px-2.5 text-[11px] font-semibold rounded-lg inline-flex items-center gap-1 transition-all"; return ghost ? `${base} text-stone-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-stone-200 dark:border-zinc-700 hover:bg-stone-50 dark:hover:bg-zinc-700` : `${base} text-white mh-glow hover:scale-[1.02]`; } // --------------------------------------------------------------------------- // Skeleton // --------------------------------------------------------------------------- function TableSkeleton({ rows, cols }: { rows: number; cols: number }) { return (
{Array.from({ length: cols }).map((_, i) => ( ))} {Array.from({ length: rows }).map((_, r) => ( {Array.from({ length: cols }).map((_, c) => ( ))} ))}
); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- interface Props { // Either the numeric source_screen_id or the source_uuid; the view-service // accepts both at /app/:appId/rv-screens/:idOrUuid. viewId: number | string; onRowClick?: (instanceId: string) => void; toolbarAction?: React.ReactNode; } type AnalyticsSpec = | { kind: "tile"; uid: string; title?: string } | { kind: "chart"; uid: string; title?: string; chartType?: string }; // Walk rv-screen layout to find rv_tile / rv_chart elements (the platform // places them as sibling blocks above the rv_table). function extractAnalyticsElements(layout: any): AnalyticsSpec[] { const out: AnalyticsSpec[] = []; const walk = (nodes: any[]): void => { for (const n of nodes ?? []) { const cfg = n?.config; if (cfg?.type === "rv_tile" && cfg.tile_uid) out.push({ kind: "tile", uid: cfg.tile_uid, title: cfg.title }); if (cfg?.type === "rv_chart" && cfg.chart_uid) out.push({ kind: "chart", uid: cfg.chart_uid, title: cfg.title, chartType: cfg.chart_type }); if (Array.isArray(n?.children)) walk(n.children); } }; walk(Array.isArray(layout) ? layout : []); return out; } // Walks the rv-screen layout to find the rv_table element and returns a // key_name → display map for column header labels (the recordview response's // output_label is empty for v2 envelope screens). function extractColumnLabels(layout: any): Record { const out: Record = {}; const walk = (nodes: any[]): void => { for (const n of nodes ?? []) { const cfg = n?.config; if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) { for (const c of cfg.columns) { if (c?.key_name && c?.display) out[c.key_name] = c.display; } } if (Array.isArray(n?.children)) walk(n.children); } }; walk(Array.isArray(layout) ? layout : []); return out; } export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) { const navigate = useNavigate(); const [rvTemplateUid, setRvTemplateUid] = useState(null); const [columnLabels, setColumnLabels] = useState>({}); const [actionColumns, setActionColumns] = useState([]); const [analyticsSpecs, setAnalyticsSpecs] = useState([]); const [tileValues, setTileValues] = useState([]); const [chartData, setChartData] = useState([]); const [fields, setFields] = useState([]); const [rows, setRows] = useState[]>([]); const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 }); const [query, setQuery] = useState({ page: 1, limit: 10, sort_dir: "desc" }); const [searchInput, setSearchInput] = useState(""); const [loading, setLoading] = useState(true); const [dataLoading, setDataLoading] = useState(false); const [error, setError] = useState(null); const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null); const searchRef = useRef(null); useEffect(() => { setLoading(true); setRvTemplateUid(null); setColumnLabels({}); setActionColumns([]); setAnalyticsSpecs([]); setTileValues([]); setChartData([]); getRVScreen(viewId) .then((rv) => { setColumnLabels(extractColumnLabels(rv.layout)); setActionColumns(extractActionColumns(rv.layout)); setAnalyticsSpecs(extractAnalyticsElements(rv.layout)); setRvTemplateUid(rv.rv_template_uid); }) .catch((err) => { setError(err.message); setLoading(false); }); }, [viewId]); const fetchData = useCallback(async () => { if (!rvTemplateUid) return; if (!loading) setDataLoading(true); setError(null); try { const res = await getRecordView(rvTemplateUid, viewId, query); setTileValues(res.tile_values ?? []); setChartData(res.chart_data ?? []); setFields(res.config.fields.filter((f) => f.field_key !== "")); const dataRows = Array.isArray(res.data) ? res.data : []; setRows(dataRows); if (res.pagination) { setPagination(res.pagination); } else { setPagination((p) => ({ ...p, total_count: dataRows.length, total_pages: Math.ceil(dataRows.length / p.limit) || 1 })); } } catch (err: any) { setError(err.message); } finally { setLoading(false); setDataLoading(false); } }, [rvTemplateUid, viewId, query]); useEffect(() => { fetchData(); }, [fetchData]); const handleSort = (key: string) => setQuery((q) => ({ ...q, page: 1, sort_by: key, sort_dir: q.sort_by === key && q.sort_dir === "asc" ? "desc" : "asc", })); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); setQuery((q) => ({ ...q, page: 1, search: searchInput })); }; const clearSearch = () => { setSearchInput(""); setQuery((q) => ({ ...q, page: 1, search: "" })); }; const handlePage = (p: number) => setQuery((q) => ({ ...q, page: p })); const handleRowClick = (row: Record) => { const id = String(row.instance_id ?? row._pk_value ?? ""); if (!id) return; onRowClick ? onRowClick(id) : navigate(`/detail/${id}`); }; const tileSpecs = analyticsSpecs.filter(a => a.kind === "tile"); const chartSpecs = analyticsSpecs.filter(a => a.kind === "chart"); const analyticsRow = analyticsSpecs.length > 0 ? (
{tileSpecs.length > 0 && (
{tileSpecs.map((a, i) => { const tv = tileValues.find(t => t.tile_uid === a.uid); return ; })}
)} {chartSpecs.length > 0 && (
{chartSpecs.map((a, i) => { const cd = chartData.find(c => c.chart_uid === a.uid); const Chart = (a as { chartType?: string }).chartType === "pie" ? PieChart : BarChart; return ; })}
)}
) : null; if (loading) return <>{analyticsRow}; if (error) { return ( <> {analyticsRow}

{error}

); } const primaryFieldKey = fields.find( (f) => f.field_key !== "current_state_name" && f.field_key !== "instance_id" && f.data_type === "text" )?.field_key; const { page, total_count, total_pages, limit } = pagination; const from = total_count > 0 ? (page - 1) * limit + 1 : 0; const to = Math.min(page * limit, total_count); const pages = buildPageRange(page, total_pages); return ( <> {analyticsRow}
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
{total_count} records
{query.search && (
Filtered: {query.search}
)} {toolbarAction && (
{toolbarAction}
)}
setSearchInput(e.target.value)} placeholder="Search…" className="w-44 h-8 pl-8 pr-7 text-[13px] border border-stone-200 dark:border-zinc-700 rounded-lg bg-stone-50 dark:bg-zinc-800 text-stone-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rose-500/20 focus:border-rose-400 dark:focus:border-rose-500 focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600" /> {searchInput && ( )}
{/* ── Table ─────────────────────────────────────────────────────────── */}
{dataLoading && (
)} {fields.map((f, i) => { const isSorted = query.sort_by === f.field_key; return ( ); })} {actionColumns.map((ac) => ( ))} {rows.length === 0 ? ( ) : rows.map((row, i) => ( handleRowClick(row)} className="group/row cursor-pointer hover:bg-stone-50/80 dark:hover:bg-zinc-800/40 transition-colors" > {fields.map((f, fi) => { const isPrimary = f.field_key === primaryFieldKey; const isFirst = fi === 0; return ( ); })} {actionColumns.map((ac) => { const visible = ac.buttons.filter((b) => evalShowConditions(b.show_conditions, row)); return ( ); })} ))}
handleSort(f.field_key)} className={`py-2.5 text-left text-[12px] font-semibold cursor-pointer select-none whitespace-nowrap transition-colors group/th ${i === 0 ? "pl-5 pr-4" : "px-4"} ${isSorted ? "text-rose-600 dark:text-rose-400" : "text-stone-500 dark:text-zinc-400 hover:text-stone-700 dark:hover:text-zinc-200" } `} > {columnLabels[f.field_key] || f.output_label || f.field_key} {isSorted ? (query.sort_dir === "asc" ? : ) : } {ac.display}
No records found
{query.search ? "Try a different search term" : "No data available"}
{query.search && ( )}
{renderCell(row[f.field_key], f.data_type, f.field_key)} e.stopPropagation()}> {visible.length === 0 ? ( ) : (
{visible.map((b, bi) => { const aid = b.params?.activity_uid; if (!aid) return null; return ( ); })}
)}
e.stopPropagation()}>
{/* ── Pagination ────────────────────────────────────────────────────── */} {total_count > 0 && (
Show per page
{from}–{to} of {total_count}
{total_pages > 1 && (
{pages.map((p, idx) => p === "…" ? ( ) : ( ) )}
)}
)} {formModal && ( setFormModal(null)} onSuccess={() => { setFormModal(null); fetchData(); }} /> )}
); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function buildPageRange(current: number, total: number): Array { if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); if (current <= 4) return [1, 2, 3, 4, 5, "…", total]; if (current >= total - 3) return [1, "…", total - 4, total - 3, total - 2, total - 1, total]; return [1, "…", current - 1, current, current + 1, "…", total]; } function renderCell(value: unknown, dataType: string, fieldKey: string): React.ReactNode { if (value == null || value === "") return ; if (fieldKey === "current_state_name") return ; if (dataType === "phone" && typeof value === "object") { const p = value as { phone_with_dial_code?: string; phone?: string; dial_code?: string }; const text = p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || ""); return text ? {text} : ; } if (dataType === "number") { const n = Number(value); if (fieldKey.includes("amount") || fieldKey.includes("Amount") || fieldKey.includes("tax") || fieldKey.includes("price")) return {n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 })}; return {n.toLocaleString()}; } if (dataType === "datetime" || dataType === "date") { try { return {new Date(String(value)).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" })}; } catch { return String(value); } } if (fieldKey === "instance_id") { return {String(value).slice(0, 8)}; } return String(value); } // --------------------------------------------------------------------------- // Status badge — Mahindra workflow states // --------------------------------------------------------------------------- const STATUS: Record = { "Awaiting Scheduler Call": { bg: "bg-rose-50 border-rose-200", text: "text-rose-700", dot: "bg-rose-500 animate-pulse", darkBg: "dark:bg-rose-950/30 dark:border-rose-900", darkText: "dark:text-rose-300", darkDot: "dark:bg-rose-400", }, "Scheduled": { bg: "bg-amber-50 border-amber-200", text: "text-amber-700", dot: "bg-amber-500", darkBg: "dark:bg-amber-950/30 dark:border-amber-900", darkText: "dark:text-amber-300", darkDot: "dark:bg-amber-400", }, "Awaiting Feedback Call": { bg: "bg-rose-50 border-rose-200", text: "text-rose-700", dot: "bg-rose-500 animate-pulse", darkBg: "dark:bg-rose-950/30 dark:border-rose-900", darkText: "dark:text-rose-300", darkDot: "dark:bg-rose-400", }, "Feedback Collected": { bg: "bg-emerald-50 border-emerald-200", text: "text-emerald-700", dot: "bg-emerald-500", darkBg: "dark:bg-emerald-950/30 dark:border-emerald-900", darkText: "dark:text-emerald-300", darkDot: "dark:bg-emerald-400", }, "Scheduling Call Failed": { bg: "bg-red-50 border-red-200", text: "text-red-700", dot: "bg-red-500", darkBg: "dark:bg-red-950/30 dark:border-red-900", darkText: "dark:text-red-300", darkDot: "dark:bg-red-400", }, "Feedback Call Failed": { bg: "bg-red-50 border-red-200", text: "text-red-700", dot: "bg-red-500", darkBg: "dark:bg-red-950/30 dark:border-red-900", darkText: "dark:text-red-300", darkDot: "dark:bg-red-400", }, "End State": { bg: "bg-stone-100 border-stone-200", text: "text-stone-600", dot: "bg-stone-400", darkBg: "dark:bg-zinc-800 dark:border-zinc-700", darkText: "dark:text-zinc-400", darkDot: "dark:bg-zinc-500", }, }; function StatusBadge({ value }: { value: string }) { const s = STATUS[value] ?? { bg: "bg-stone-100 border-stone-200", text: "text-stone-600", dot: "bg-stone-400", darkBg: "dark:bg-zinc-800 dark:border-zinc-700", darkText: "dark:text-zinc-400", darkDot: "dark:bg-zinc-500" }; return ( {value} ); }