684 lines
32 KiB
TypeScript
684 lines
32 KiB
TypeScript
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>
|
||
);
|
||
}
|