Render rv_table custom action column inline (config-driven)
This commit is contained in:
parent
fe23207e5a
commit
5dfdab0d69
@ -2,39 +2,97 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
|
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
|
||||||
X, Inbox, MoreHorizontal, ArrowRight, RefreshCw,
|
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 } from "../api/viewService";
|
||||||
import FormModal from "./FormModal";
|
import FormModal from "./FormModal";
|
||||||
import { STATE_IDS, ACTIVITY_IDS } from "../config";
|
|
||||||
import { CheckCircle2, RefreshCw as RefreshIcon } from "lucide-react";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Row actions by state
|
// Action column (config-driven from rv-screen layout)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const ROW_ACTIONS: Record<string, Array<{ activityId: string; label: string; icon: React.ReactNode }>> = {
|
interface ActionCondition {
|
||||||
[STATE_IDS.SCHEDULED]: [
|
field_key: string;
|
||||||
{ activityId: ACTIVITY_IDS.MARK_TEST_DRIVE_DONE, label: "Mark Test Drive Done", icon: <CheckCircle2 size={13} /> },
|
operator: string;
|
||||||
],
|
value: unknown;
|
||||||
[STATE_IDS.SCHEDULING_CALL_FAILED]: [
|
data_type?: string;
|
||||||
{ activityId: ACTIVITY_IDS.RETRY_SCHEDULING_CALL, label: "Retry Scheduling Call", icon: <RefreshIcon size={13} /> },
|
}
|
||||||
],
|
interface ActionConditions {
|
||||||
[STATE_IDS.FEEDBACK_CALL_FAILED]: [
|
logic?: "AND" | "OR";
|
||||||
{ activityId: ACTIVITY_IDS.RETRY_FEEDBACK_CALL, label: "Retry Feedback Call", icon: <RefreshIcon size={13} /> },
|
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} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// State name (as returned by record view) → state UID. Used to resolve row actions.
|
function iconFor(name?: string): React.ReactNode {
|
||||||
const STATE_BY_NAME: Record<string, string> = {
|
return (name && ICONS[name]) || <RefreshCw size={13} />;
|
||||||
"Awaiting Scheduler Call": STATE_IDS.AWAITING_SCHEDULER_CALL,
|
}
|
||||||
"Scheduled": STATE_IDS.SCHEDULED,
|
|
||||||
"Awaiting Feedback Call": STATE_IDS.AWAITING_FEEDBACK_CALL,
|
// Walks the rv-screen layout to find columns with `type: "custom"` and their
|
||||||
"Feedback Collected": STATE_IDS.FEEDBACK_COLLECTED,
|
// inner button configs.
|
||||||
"Scheduling Call Failed": STATE_IDS.SCHEDULING_CALL_FAILED,
|
function extractActionColumns(layout: any): ActionColumn[] {
|
||||||
"Feedback Call Failed": STATE_IDS.FEEDBACK_CALL_FAILED,
|
const out: ActionColumn[] = [];
|
||||||
"End State": STATE_IDS.END_STATE,
|
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
|
// Skeleton
|
||||||
@ -109,6 +167,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
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>>({});
|
||||||
|
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
|
||||||
const [fields, setFields] = useState<RecordViewField[]>([]);
|
const [fields, setFields] = useState<RecordViewField[]>([]);
|
||||||
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 });
|
||||||
@ -124,9 +183,11 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setRvTemplateUid(null);
|
setRvTemplateUid(null);
|
||||||
setColumnLabels({});
|
setColumnLabels({});
|
||||||
|
setActionColumns([]);
|
||||||
getRVScreen(viewId)
|
getRVScreen(viewId)
|
||||||
.then((rv) => {
|
.then((rv) => {
|
||||||
setColumnLabels(extractColumnLabels(rv.layout));
|
setColumnLabels(extractColumnLabels(rv.layout));
|
||||||
|
setActionColumns(extractActionColumns(rv.layout));
|
||||||
setRvTemplateUid(rv.rv_template_uid);
|
setRvTemplateUid(rv.rv_template_uid);
|
||||||
})
|
})
|
||||||
.catch((err) => { setError(err.message); setLoading(false); });
|
.catch((err) => { setError(err.message); setLoading(false); });
|
||||||
@ -298,6 +359,11 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
</th>
|
</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" />
|
<th className="py-2.5 pr-4 w-16" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -305,7 +371,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
|
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={fields.length + 1} className="py-20 text-center">
|
<td colSpan={fields.length + 1 + actionColumns.length} className="py-20 text-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
||||||
style={{ background: "linear-gradient(135deg,#fff5f5,#ffe4e6)" }}>
|
style={{ background: "linear-gradient(135deg,#fff5f5,#ffe4e6)" }}>
|
||||||
@ -325,12 +391,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : rows.map((row, i) => {
|
) : rows.map((row, i) => (
|
||||||
const stateName = String(row.current_state_name ?? "");
|
|
||||||
const stateId = STATE_BY_NAME[stateName];
|
|
||||||
const actions = stateId ? ROW_ACTIONS[stateId] : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
<tr
|
||||||
key={String(row.instance_id ?? i)}
|
key={String(row.instance_id ?? i)}
|
||||||
onClick={() => handleRowClick(row)}
|
onClick={() => handleRowClick(row)}
|
||||||
@ -358,27 +419,48 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{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()}>
|
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover/row:opacity-100 transition-opacity">
|
<button
|
||||||
{actions && actions.length > 0 && (
|
onClick={() => handleRowClick(row)}
|
||||||
<RowMenu
|
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"
|
||||||
actions={actions}
|
>
|
||||||
onAction={(activityId, label) =>
|
<ArrowRight size={14} />
|
||||||
setFormModal({ activityId, instanceId: String(row.instance_id), title: label })
|
</button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<ArrowRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -458,55 +540,6 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row menu
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface RowMenuProps {
|
|
||||||
actions: Array<{ activityId: string; label: string; icon: React.ReactNode }>;
|
|
||||||
onAction: (activityId: string, label: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RowMenu({ actions, onAction }: RowMenuProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const close = (e: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", close);
|
|
||||||
return () => document.removeEventListener("mousedown", close);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="w-7 h-7 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"
|
|
||||||
>
|
|
||||||
<MoreHorizontal size={15} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 w-40 bg-white dark:bg-zinc-900 rounded-xl shadow-xl shadow-stone-900/10 dark:shadow-black/40 border border-stone-200 dark:border-zinc-700/70 py-1 overflow-hidden">
|
|
||||||
{actions.map((a) => (
|
|
||||||
<button
|
|
||||||
key={a.activityId}
|
|
||||||
onClick={() => { setOpen(false); onAction(a.activityId, a.label); }}
|
|
||||||
className="w-full text-left px-3.5 py-2 text-[13px] text-stone-600 dark:text-zinc-300 hover:bg-stone-50 dark:hover:bg-zinc-800 flex items-center gap-2.5 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-stone-400 dark:text-zinc-500">{a.icon}</span>
|
|
||||||
{a.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user