tech_mahindra/src/components/RecordViewTable.tsx
2026-05-23 11:21:56 +05:30

684 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, React.ReactNode> = {
refresh: <RefreshCw size={13} />,
task_alt: <CheckCircle2 size={13} />,
};
function iconFor(name?: string): React.ReactNode {
return (name && ICONS[name]) || <RefreshCw size={13} />;
}
// 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<string, unknown>): 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 (
<div className="rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm">
<div className="px-5 py-3 border-b border-stone-100 dark:border-zinc-800 flex items-center gap-3">
<div className="h-5 w-16 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
<div className="ml-auto h-8 w-44 bg-stone-100 dark:bg-zinc-800 rounded-lg animate-pulse" />
</div>
<table className="w-full">
<thead>
<tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80">
{Array.from({ length: cols }).map((_, i) => (
<th key={i} className={`py-2.5 ${i === 0 ? "pl-5" : "px-4"}`}>
<div className="h-3 bg-stone-200 dark:bg-zinc-700 rounded animate-pulse w-20" />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: cols }).map((_, c) => (
<td key={c} className={`py-3.5 ${c === 0 ? "pl-5 pr-4" : "px-4"}`}>
<div className="h-3 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" style={{ width: `${60 + Math.random() * 40}%` }} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<string, string> {
const out: Record<string, string> = {};
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<string | null>(null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
const [analyticsSpecs, setAnalyticsSpecs] = useState<AnalyticsSpec[]>([]);
const [tileValues, setTileValues] = useState<TileValue[]>([]);
const [chartData, setChartData] = useState<ChartDataResponse[]>([]);
const [fields, setFields] = useState<RecordViewField[]>([]);
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 });
const [query, setQuery] = useState<SearchQuery>({ 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<string | null>(null);
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
const searchRef = useRef<HTMLInputElement>(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<string, unknown>) => {
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 ? (
<div className="space-y-3 mb-4">
{tileSpecs.length > 0 && (
<div className="flex flex-wrap items-stretch gap-3">
{tileSpecs.map((a, i) => {
const tv = tileValues.find(t => t.tile_uid === a.uid);
return <Tile key={`tile-${i}`} title={a.title} value={tv?.value as number | undefined} loading={loading || dataLoading} />;
})}
</div>
)}
{chartSpecs.length > 0 && (
<div className="flex flex-wrap items-stretch gap-3">
{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 <Chart key={`chart-${i}`} title={a.title} rows={cd?.rows ?? []} loading={loading || dataLoading} />;
})}
</div>
)}
</div>
) : null;
if (loading) return <>{analyticsRow}<TableSkeleton rows={6} cols={fields.length || 6} /></>;
if (error) {
return (
<>
{analyticsRow}
<div className="rounded-2xl border border-red-100 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 p-10 text-center">
<p className="text-sm text-red-600 dark:text-red-400 mb-2">{error}</p>
<button onClick={fetchData} className="text-xs text-rose-600 dark:text-rose-400 hover:text-rose-800 font-semibold">Try again</button>
</div>
</>
);
}
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}
<div className="rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm dark:shadow-black/20">
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
<div className="px-5 py-3 flex items-center justify-between gap-4 border-b border-stone-100 dark:border-zinc-800">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-[15px] font-bold text-stone-800 dark:text-zinc-100 tabular-nums leading-none">{total_count}</span>
<span className="text-[13px] text-stone-400 dark:text-zinc-500 leading-none">records</span>
</div>
{query.search && (
<div className="flex items-center gap-1.5 pl-3 border-l border-stone-200 dark:border-zinc-700">
<span className="text-[11px] text-stone-400 dark:text-zinc-500">Filtered:</span>
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-semibold border"
style={{ background: "rgba(200,16,46,0.08)", borderColor: "rgba(200,16,46,0.22)", color: "#C8102E" }}
>
{query.search}
<button onClick={clearSearch} className="ml-0.5 hover:opacity-60"><X size={9} /></button>
</span>
</div>
)}
{toolbarAction && (
<div className="pl-3 border-l border-stone-200 dark:border-zinc-700">
{toolbarAction}
</div>
)}
</div>
<div className="flex items-center gap-1.5">
<button
onClick={fetchData}
disabled={dataLoading}
title="Refresh"
className="w-8 h-8 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-600 dark:hover:text-zinc-300 hover:bg-stone-100 dark:hover:bg-zinc-800 transition-colors disabled:opacity-40"
>
<RefreshCw size={14} className={dataLoading ? "animate-spin" : ""} />
</button>
<form onSubmit={handleSearch}>
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -transtone-y-1/2 text-stone-400 dark:text-zinc-500 pointer-events-none" />
<input
ref={searchRef}
type="text"
value={searchInput}
onChange={(e) => 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 && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -transtone-y-1/2 text-stone-300 dark:text-zinc-600 hover:text-stone-500"
>
<X size={12} />
</button>
)}
</div>
</form>
</div>
</div>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<div className="overflow-x-auto relative">
{dataLoading && (
<div className="absolute inset-0 bg-white/60 dark:bg-zinc-900/60 z-10 flex items-center justify-center backdrop-blur-[1px]">
<div className="w-5 h-5 border-2 border-rose-200 dark:border-rose-900 border-t-rose-600 rounded-full animate-spin" />
</div>
)}
<table className="w-full">
<thead>
<tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80">
{fields.map((f, i) => {
const isSorted = query.sort_by === f.field_key;
return (
<th
key={f.field_key}
onClick={() => 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"
}
`}
>
<span className="inline-flex items-center gap-1">
{columnLabels[f.field_key] || f.output_label || f.field_key}
{isSorted
? (query.sort_dir === "asc"
? <ArrowUp size={11} className="text-rose-500" />
: <ArrowDown size={11} className="text-rose-500" />)
: <ArrowDown size={10} className="text-stone-300 dark:text-zinc-700 opacity-0 group-hover/th:opacity-100 transition-opacity" />
}
</span>
</th>
);
})}
{actionColumns.map((ac) => (
<th key={ac.id} className="py-2.5 px-4 text-left text-[12px] font-semibold text-stone-500 dark:text-zinc-400 whitespace-nowrap">
{ac.display}
</th>
))}
<th className="py-2.5 pr-4 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
{rows.length === 0 ? (
<tr>
<td colSpan={fields.length + 1 + actionColumns.length} className="py-20 text-center">
<div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: "linear-gradient(135deg,#fff5f5,#ffe4e6)" }}>
<Inbox size={22} className="text-rose-400" />
</div>
<div>
<div className="text-[14px] font-semibold text-stone-600 dark:text-zinc-300">No records found</div>
<div className="text-[12px] text-stone-400 dark:text-zinc-600 mt-1">
{query.search ? "Try a different search term" : "No data available"}
</div>
</div>
{query.search && (
<button onClick={clearSearch} className="text-[12px] font-semibold text-rose-600 dark:text-rose-400 hover:text-rose-800 transition-colors">
Clear search
</button>
)}
</div>
</td>
</tr>
) : rows.map((row, i) => (
<tr
key={String(row.instance_id ?? i)}
onClick={() => 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 (
<td
key={f.field_key}
className={`py-3.5 whitespace-nowrap text-[13px]
${isFirst
? "pl-5 pr-4 border-l-2 border-l-transparent group-hover/row:border-l-rose-500 transition-colors"
: "px-4"
}
${isPrimary
? "font-semibold text-stone-800 dark:text-zinc-100"
: "text-stone-500 dark:text-zinc-400"
}
`}
>
{renderCell(row[f.field_key], f.data_type, f.field_key)}
</td>
);
})}
{actionColumns.map((ac) => {
const visible = ac.buttons.filter((b) => evalShowConditions(b.show_conditions, row));
return (
<td key={ac.id} className="px-4 py-3 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
{visible.length === 0 ? (
<span className="text-stone-300 dark:text-zinc-700"></span>
) : (
<div className="flex items-center gap-1.5">
{visible.map((b, bi) => {
const aid = b.params?.activity_uid;
if (!aid) return null;
return (
<button
key={bi}
onClick={() => setFormModal({
activityId: aid,
instanceId: String(row.instance_id),
title: b.name,
})}
className={buttonClass(b.classes)}
style={!b.classes?.includes("z-btn-secondary") ? { background: "linear-gradient(135deg, #C8102E, #E84258)" } : {}}
>
{iconFor(b.icon)}{b.name}
</button>
);
})}
</div>
)}
</td>
);
})}
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleRowClick(row)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-100 dark:hover:bg-rose-950/50 transition-colors opacity-0 group-hover/row:opacity-100 transition-opacity"
>
<ArrowRight size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Pagination ────────────────────────────────────────────────────── */}
{total_count > 0 && (
<div className="px-5 py-3 border-t border-stone-100 dark:border-zinc-800 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 text-[12px] text-stone-400 dark:text-zinc-500">
<div className="flex items-center gap-1.5">
Show
<select
value={query.limit}
onChange={(e) => setQuery((q) => ({ ...q, page: 1, limit: Number(e.target.value) }))}
className="h-7 px-2 text-[12px] border border-stone-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-stone-600 dark:text-zinc-300 font-medium focus:outline-none cursor-pointer"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
per page
</div>
<span className="pl-3 border-l border-stone-200 dark:border-zinc-700 tabular-nums">
{from}{to} of {total_count}
</span>
</div>
{total_pages > 1 && (
<div className="flex items-center gap-0.5">
<button
disabled={page <= 1}
onClick={() => handlePage(page - 1)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 disabled:opacity-30 disabled:pointer-events-none transition-colors"
>
<ChevronLeft size={15} />
</button>
{pages.map((p, idx) =>
p === "…" ? (
<span key={`e${idx}`} className="w-7 h-7 flex items-center justify-center text-[12px] text-stone-300 dark:text-zinc-700 select-none"></span>
) : (
<button
key={p}
onClick={() => handlePage(p as number)}
className={`w-7 h-7 rounded-lg text-[12px] font-medium transition-colors ${
p === page
? "bg-rose-600 text-white"
: "text-stone-500 dark:text-zinc-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 hover:text-rose-600 dark:hover:text-rose-400"
}`}
>
{p}
</button>
)
)}
<button
disabled={page >= total_pages}
onClick={() => handlePage(page + 1)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 disabled:opacity-30 disabled:pointer-events-none transition-colors"
>
<ChevronRight size={15} />
</button>
</div>
)}
</div>
)}
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={() => { setFormModal(null); fetchData(); }}
/>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildPageRange(current: number, total: number): Array<number | ""> {
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 <span className="text-stone-300 dark:text-zinc-700"></span>;
if (fieldKey === "current_state_name") return <StatusBadge value={String(value)} />;
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 ? <span className="tabular-nums">{text}</span> : <span className="text-stone-300 dark:text-zinc-700"></span>;
}
if (dataType === "number") {
const n = Number(value);
if (fieldKey.includes("amount") || fieldKey.includes("Amount") || fieldKey.includes("tax") || fieldKey.includes("price"))
return <span className="tabular-nums font-medium text-stone-700 dark:text-zinc-200">{n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 })}</span>;
return <span className="tabular-nums">{n.toLocaleString()}</span>;
}
if (dataType === "datetime" || dataType === "date") {
try { return <span>{new Date(String(value)).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" })}</span>; }
catch { return String(value); }
}
if (fieldKey === "instance_id") {
return <span className="font-mono text-[11px] text-stone-400 dark:text-zinc-500 bg-stone-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md">{String(value).slice(0, 8)}</span>;
}
return String(value);
}
// ---------------------------------------------------------------------------
// Status badge — Mahindra workflow states
// ---------------------------------------------------------------------------
const STATUS: Record<string, { bg: string; text: string; dot: string; darkBg: string; darkText: string; darkDot: string }> = {
"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 (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold border ${s.bg} ${s.text} ${s.darkBg} ${s.darkText}`}>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${s.dot} ${s.darkDot}`} />
{value}
</span>
);
}