DV: activity timeline from instance._activities; calendar mode/expert cues
- Variant B DV right rail now shows Activity Timeline (LOG); At a Glance + Customer/Vehicle moved to wide lane. Recordings hoisted into left lane. - Timeline derived from instance.data._activities + synthesised INIT from created_at — no /view/audit dependency. - Per-activity context line drawn from instance top-level data (booking_id, feedback_rating, concern, etc.) via lib/activity.ts. - Calendar cells encode td_mode (home/showroom icon) and is_expert_td (gold inset ring); BookingPanel gains Expert badge + Mode/Address rows. - DetailViewPanel gains a TimelineSection mirror for any non-VariantB callers. - fmtAct lifted from AppShell into src/lib/activity.ts so timeline + legacy AppShell stay in sync. - getInstance now sends instance_id as int (Go decoder needs int64).
This commit is contained in:
parent
40dd53e0e3
commit
d8f99a2e70
@ -364,7 +364,7 @@ export interface InstanceResponse {
|
||||
export function getInstance(workflowUuid: string, instanceId: string): Promise<InstanceResponse> {
|
||||
return request("POST", `/app/${APP_ID}/instance`, {
|
||||
workflow_uuid: workflowUuid,
|
||||
instance_id: instanceId,
|
||||
instance_id: Number(instanceId),
|
||||
});
|
||||
}
|
||||
|
||||
@ -380,6 +380,9 @@ export interface AuditEntry {
|
||||
data: Record<string, unknown>;
|
||||
execution_state: string;
|
||||
created_at: string;
|
||||
// Optional human-readable summary line. Populated by useInstanceMeta when
|
||||
// the audit is synthesised from instance.data._activities.
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
type LayoutElement, type NavItem, type AuditEntry,
|
||||
} from "../api/viewService";
|
||||
import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, DETAIL_VIEW_IDS, SCREEN_TO_DV } from "../config";
|
||||
import { fmtAct } from "../lib/activity";
|
||||
|
||||
// State → contextual action buttons shown above the detail view.
|
||||
const ACTIVITY_META: Record<string, { label: string; icon: React.ReactNode; style: "primary" | "ghost" }> = {
|
||||
@ -365,19 +366,3 @@ function CurrentAppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
// Friendlier label for our UUID-style activity IDs.
|
||||
function fmtAct(id: string): string {
|
||||
switch (id) {
|
||||
case ACTIVITY_IDS.INIT_ACTIVITY: return "Lead Captured";
|
||||
case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE: return "Test Drive Completed";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: return "AI · Scheduling Confirmed";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED: return "AI · Scheduling Failed";
|
||||
case ACTIVITY_IDS.RETRY_SCHEDULING_CALL: return "Retry Scheduling Call";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: return "AI · Feedback Collected";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED: return "AI · Feedback Failed";
|
||||
case ACTIVITY_IDS.RETRY_FEEDBACK_CALL: return "Retry Feedback Call";
|
||||
case ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED: return "AI · Concern → Expert TD Booked";
|
||||
case ACTIVITY_IDS.MARK_EXPERT_TD_DONE: return "Expert TD Completed";
|
||||
default: return id.slice(0, 8) + "…";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { getDVScreen, getDetailView } from "../api/viewService";
|
||||
import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp, Activity } from "lucide-react";
|
||||
import { getDVScreen, getDetailView, getInstance, type InstanceResponse } from "../api/viewService";
|
||||
import { WORKFLOW_ID, STATE_IDS, ACTIVITY_IDS } from "../config";
|
||||
import { fmtAct } from "../lib/activity";
|
||||
|
||||
interface Field { field_key: string; output_label: string; data_type: string }
|
||||
interface Props { viewId: number | string; instanceId: string }
|
||||
@ -303,6 +305,138 @@ export default function DetailViewPanel({ viewId, instanceId }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Activity timeline ──────────────────────────────────────── */}
|
||||
{data.instance_id && <TimelineSection instanceId={String(data.instance_id)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimelineSection — derived from instance.data._activities (no audit endpoint
|
||||
// needed). INIT is synthesised from instance.created_at since the platform
|
||||
// doesn't write INIT into _activities.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActivityRecord {
|
||||
_system?: { created_at?: string; user_id?: string; user_roles?: string[] | null };
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
activityId: string;
|
||||
createdAt: string;
|
||||
userRoles: string[] | null;
|
||||
synthetic: boolean;
|
||||
}
|
||||
|
||||
const FAILURE_STATE_IDS = new Set<string>([
|
||||
STATE_IDS.SCHEDULING_CALL_FAILED,
|
||||
STATE_IDS.FEEDBACK_CALL_FAILED,
|
||||
]);
|
||||
|
||||
function TimelineSection({ instanceId }: { instanceId: string }) {
|
||||
const [inst, setInst] = useState<InstanceResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
let cancelled = false;
|
||||
setInst(null); setError(null);
|
||||
getInstance(WORKFLOW_ID, instanceId)
|
||||
.then((r) => !cancelled && setInst(r))
|
||||
.catch((e: any) => !cancelled && setError(e?.message ?? "failed to load"));
|
||||
return () => { cancelled = true; };
|
||||
}, [instanceId]);
|
||||
|
||||
if (error) return null;
|
||||
if (inst === null) {
|
||||
return (
|
||||
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800 text-[12px] text-stone-300 dark:text-zinc-700">
|
||||
Loading activity timeline…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activities = (inst.data as { _activities?: Record<string, ActivityRecord> } | null)?._activities;
|
||||
const entries: TimelineEntry[] = [];
|
||||
|
||||
// Synthesise INIT from instance.created_at — _activities never includes it.
|
||||
entries.push({
|
||||
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||
createdAt: inst.created_at,
|
||||
userRoles: null,
|
||||
synthetic: true,
|
||||
});
|
||||
|
||||
if (activities) {
|
||||
for (const [activityId, record] of Object.entries(activities)) {
|
||||
entries.push({
|
||||
activityId,
|
||||
createdAt: record._system?.created_at ?? inst.created_at,
|
||||
userRoles: record._system?.user_roles ?? null,
|
||||
synthetic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
|
||||
const lastIdx = entries.length - 1;
|
||||
const inFailureState = FAILURE_STATE_IDS.has(inst.current_state_id);
|
||||
|
||||
return (
|
||||
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
|
||||
<Activity size={10} className="text-[#C8102E]" /> Activity Timeline
|
||||
</p>
|
||||
<ol className="relative pl-6">
|
||||
<span aria-hidden className="absolute left-[7px] top-1 bottom-1 w-px bg-stone-200 dark:bg-zinc-700" />
|
||||
{entries.map((e, i) => {
|
||||
const isLast = i === lastIdx;
|
||||
const isFirst = i === 0;
|
||||
const failed = isLast && inFailureState;
|
||||
const dotBg = failed ? "#dc2626" : isLast ? "#C8102E" : isFirst ? "#10b981" : "#d1d5db";
|
||||
const actor = (e.userRoles && e.userRoles.length > 0) ? e.userRoles.join(", ") : "system";
|
||||
return (
|
||||
<li key={`${e.activityId}-${e.createdAt}`} className="relative pb-4 last:pb-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute -left-[22px] top-1 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-zinc-900 shadow-sm ${isLast ? "ring-2 ring-rose-200 dark:ring-rose-900" : ""}`}
|
||||
style={{ background: dotBg, boxShadow: isLast ? "0 0 8px rgba(200,16,46,0.5)" : undefined }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[13px] font-medium ${
|
||||
failed
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: isLast
|
||||
? "text-[#9D0D24] dark:text-rose-300"
|
||||
: "text-stone-700 dark:text-zinc-200"
|
||||
}`}>
|
||||
{fmtAct(e.activityId)}
|
||||
</span>
|
||||
{failed && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300">
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
{isLast && !failed && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
|
||||
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E" }}>
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400 dark:text-zinc-500 mt-0.5">
|
||||
{new Date(e.createdAt).toLocaleString("en-IN", {
|
||||
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit",
|
||||
})}
|
||||
<span className="mx-1.5 text-stone-200 dark:text-zinc-700">·</span>
|
||||
<span className="text-stone-500 dark:text-zinc-400">{actor}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight, Phone, X, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Phone, X, Calendar as CalendarIcon, Home, Building2 } from "lucide-react";
|
||||
import { fetchRdbmsLookupRecords } from "../../api/viewService";
|
||||
import { CALENDAR_LOOKUPS, WORKFLOW_NUMERIC_ID, ACTIVITY_IDS } from "../../config";
|
||||
|
||||
@ -27,6 +27,9 @@ interface TestDrive {
|
||||
feedback_would_recommend?: boolean | null;
|
||||
feedback_collected_at?: string | null;
|
||||
notes?: string | null;
|
||||
td_mode?: "home" | "showroom" | null;
|
||||
is_expert_td?: boolean | null;
|
||||
home_address?: string | null;
|
||||
}
|
||||
|
||||
interface Car {
|
||||
@ -254,7 +257,7 @@ export function CalendarView() {
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mb-4 flex items-center gap-5 text-[11.5px] text-stone-500">
|
||||
<div className="mb-4 flex items-center gap-5 text-[11.5px] text-stone-500 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3" style={{ background: ACCENT }} />
|
||||
Booked
|
||||
@ -263,6 +266,16 @@ export function CalendarView() {
|
||||
<span className="w-3 h-3 bg-white" style={{ border: `1px solid ${TM_BORDER}` }} />
|
||||
Available
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 size={12} className="text-stone-500" /> Showroom
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Home size={12} className="text-stone-500" /> Home
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3" style={{ background: ACCENT, boxShadow: "inset 0 0 0 2px #d4a017" }} />
|
||||
Expert TD
|
||||
</div>
|
||||
<div className="ml-auto text-stone-400">
|
||||
{totalBookings} bookings across {weekDays.length} days
|
||||
</div>
|
||||
@ -321,20 +334,34 @@ export function CalendarView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const booked = cell.status === "booked";
|
||||
const booked = cell.status === "booked";
|
||||
const isHome = cell.td_mode === "home";
|
||||
const isExpert = !!cell.is_expert_td;
|
||||
const ModeIcon = isHome ? Home : Building2;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => booked && setSelected(cell)}
|
||||
className={`h-7 px-2 flex items-center justify-between gap-2 text-[10.5px] font-medium transition-colors ${booked ? "cursor-pointer" : "cursor-default"}`}
|
||||
className={`h-7 px-2 flex items-center justify-between gap-1.5 text-[10.5px] font-medium transition-colors ${booked ? "cursor-pointer" : "cursor-default"}`}
|
||||
style={
|
||||
booked
|
||||
? { background: ACCENT, color: "white" }
|
||||
? {
|
||||
background: ACCENT,
|
||||
color: "white",
|
||||
boxShadow: isExpert ? "inset 0 0 0 2px #d4a017" : undefined,
|
||||
}
|
||||
: { background: "white", color: "#78716c", border: `1px solid ${TM_BORDER}` }
|
||||
}
|
||||
title={booked ? `${cell.customer_name ?? "Booked"} · ${c.model}` : `Available · ${c.model}`}
|
||||
title={
|
||||
booked
|
||||
? `${cell.customer_name ?? "Booked"} · ${c.model} · ${isHome ? "Home" : "Showroom"}${isExpert ? " · Expert TD" : ""}`
|
||||
: `Available · ${c.model}`
|
||||
}
|
||||
>
|
||||
<span className="truncate">{c.model}</span>
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
{booked && <ModeIcon size={10} className="shrink-0 opacity-90" />}
|
||||
<span className="truncate">{c.model}</span>
|
||||
</span>
|
||||
{booked && cell.customer_name && (
|
||||
<span className="truncate font-semibold text-white/90">
|
||||
{cell.customer_name.split(" ")[0]}
|
||||
@ -399,6 +426,14 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] inline-flex items-center gap-1.5" style={{ color: ACCENT_SOFT }}>
|
||||
<CalendarIcon size={11} /> Booking
|
||||
{drive.is_expert_td && (
|
||||
<span
|
||||
className="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-wide"
|
||||
style={{ background: "#d4a017", color: "#1a1a1a", borderRadius: 3 }}
|
||||
>
|
||||
★ Expert TD
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[22px] font-bold leading-tight mt-1 tabular-nums">
|
||||
{day.weekday}, {day.day} {day.month} · {time}
|
||||
@ -471,6 +506,15 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
|
||||
<Row label="Slot">
|
||||
<span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span>
|
||||
</Row>
|
||||
<Row label="Mode">
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold" style={{ color: INK }}>
|
||||
{drive.td_mode === "home" ? <Home size={12} /> : <Building2 size={12} />}
|
||||
{drive.td_mode === "home" ? "Home visit" : "Showroom"}
|
||||
</span>
|
||||
</Row>
|
||||
{drive.td_mode === "home" && drive.home_address && (
|
||||
<Row label="Address">{drive.home_address}</Row>
|
||||
)}
|
||||
{drive.booked_at && (
|
||||
<Row label="Reserved at">
|
||||
{new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
|
||||
|
||||
@ -949,34 +949,22 @@ function DetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-section grid — inside the unified card, separated from hero by a hairline.
|
||||
{/* Sub-section grid — all sections on the left, Activity Timeline on the right.
|
||||
Recordings surface as a "Play call" tile at the top of the left lane.
|
||||
Hide the AI analysis block on the Scheduled Test Drives view; the dealer is
|
||||
the audience there and the AI fields would just be noise. */}
|
||||
{(() => {
|
||||
const visible = Object.entries(groups).filter(
|
||||
([key, fs]) => fs.length > 0 && !(screenUuid === "98af93e0-0f66-444b-a007-129b322be86b" && key === "ai")
|
||||
);
|
||||
const wide = visible.filter(([key]) => SECTION_LANE[key] !== "narrow");
|
||||
const narrow = visible.filter(([key]) => SECTION_LANE[key] === "narrow");
|
||||
if (visible.length === 0 && !hasQuickInfo) return null;
|
||||
const hasBoth = wide.length > 0 && (narrow.length > 0 || hasQuickInfo);
|
||||
const quickInfo = hasQuickInfo ? (
|
||||
if (visible.length === 0 && playableRecordings.length === 0 && audit.length === 0) return null;
|
||||
|
||||
const recordingsBlock = playableRecordings.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.12em] mb-4" style={{ color: ACCENT }}>
|
||||
At a Glance
|
||||
Recordings
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{leadAgeLabel && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style={{ background: ACCENT_SOFT, color: ACCENT_DARK }}>
|
||||
<CalendarCheck2 size={16} strokeWidth={2.25} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11.5px] font-medium text-stone-400">Lead age</div>
|
||||
<div className="text-[14px] font-semibold leading-snug" style={{ color: INK }}>{leadAgeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{playableRecordings.map((r) => (
|
||||
<a
|
||||
key={r.field.field_key}
|
||||
@ -1000,75 +988,69 @@ function DetailPage({
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const timelineBlock = audit.length > 0 ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-[10.5px] font-bold tabular-nums px-1.5 py-0.5 text-white" style={{ background: ACCENT, letterSpacing: "0.04em" }}>LOG</span>
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.12em]" style={{ color: ACCENT }}>Activity Timeline</div>
|
||||
<div className="text-[11px] font-medium tabular-nums text-stone-400 ml-auto">{audit.length} {audit.length === 1 ? "event" : "events"}</div>
|
||||
</div>
|
||||
<ol className="relative">
|
||||
<span className="absolute left-[5px] top-2 bottom-2 w-px" style={{ background: TM_BORDER }} />
|
||||
{audit.map((e, i) => {
|
||||
const meta = ACTIVITY_LABELS[e.activity_id];
|
||||
const label = meta?.label ?? "Workflow step";
|
||||
const tone = meta?.tone ?? "system";
|
||||
const isLast = i === audit.length - 1;
|
||||
const dotColor = isLast ? ACCENT : tone === "ai" ? TM_NAVY : tone === "user" ? "#57534e" : "#a8a29e";
|
||||
const actor = tone === "ai" ? "AI · Maya" : (e.user_roles?.[0] ?? "System");
|
||||
return (
|
||||
<li key={e.id} className="relative pl-7 pb-4 last:pb-0 text-[13px]">
|
||||
<span
|
||||
className="absolute left-0 top-1 w-[11px] h-[11px] rounded-full"
|
||||
style={{ background: dotColor, boxShadow: `0 0 0 3px white` }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="font-semibold leading-tight" style={{ color: INK }}>{label}</div>
|
||||
<span className="text-stone-400 tabular-nums shrink-0 text-[11px]">
|
||||
{new Date(e.created_at).toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.04em] text-stone-400 mt-1">
|
||||
{actor}{e.execution_state && e.execution_state !== "completed" ? ` · ${e.execution_state}` : ""}
|
||||
</div>
|
||||
{e.context && (
|
||||
<div className="text-[12.5px] text-stone-500 mt-1 leading-snug break-words">
|
||||
{e.context}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="px-7 py-6 border-t border-stone-200">
|
||||
{hasBoth ? (
|
||||
<div className="grid grid-cols-3 gap-x-10">
|
||||
<div className="col-span-2 [&>*+*]:mt-8 [&>*+*]:pt-8 [&>*+*]:border-t [&>*+*]:border-stone-200">
|
||||
{wide.map(([key, fs]) => (
|
||||
<SubSection key={key} sectionKey={key} fields={fs} data={dv.data!} lane="wide" />
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-1 pl-10 border-l border-stone-200 [&>*+*]:mt-8 [&>*+*]:pt-8 [&>*+*]:border-t [&>*+*]:border-stone-200">
|
||||
{quickInfo}
|
||||
{narrow.map(([key, fs]) => (
|
||||
<SubSection key={key} sectionKey={key} fields={fs} data={dv.data!} lane="narrow" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="[&>*+*]:mt-8 [&>*+*]:pt-8 [&>*+*]:border-t [&>*+*]:border-stone-200">
|
||||
{quickInfo}
|
||||
<div className="grid grid-cols-3 gap-x-10">
|
||||
<div className="col-span-2 [&>*+*]:mt-8 [&>*+*]:pt-8 [&>*+*]:border-t [&>*+*]:border-stone-200">
|
||||
{recordingsBlock}
|
||||
{visible.map(([key, fs]) => (
|
||||
<SubSection key={key} sectionKey={key} fields={fs} data={dv.data!} lane={SECTION_LANE[key] === "narrow" ? "narrow" : "wide"} />
|
||||
<SubSection key={key} sectionKey={key} fields={fs} data={dv.data!} lane="wide" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-1 pl-10 border-l border-stone-200">
|
||||
{timelineBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{audit.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-6" style={{ border: CARD_BORDER }}>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<span className="text-[11px] font-bold tabular-nums px-1.5 py-0.5 text-white" style={{ background: ACCENT, letterSpacing: "0.04em" }}>LOG</span>
|
||||
<div className="text-[17px] font-bold tracking-tight leading-none" style={{ color: INK }}>Activity timeline</div>
|
||||
<div className="text-[11.5px] font-medium tabular-nums text-stone-400">{audit.length} {audit.length === 1 ? "event" : "events"}</div>
|
||||
</div>
|
||||
<ol className="relative">
|
||||
{/* Vertical rail down the timeline */}
|
||||
<span className="absolute left-[5px] top-2 bottom-2 w-px" style={{ background: TM_BORDER }} />
|
||||
{audit.map((e, i) => {
|
||||
const meta = ACTIVITY_LABELS[e.activity_id];
|
||||
const label = meta?.label ?? "Workflow step";
|
||||
const tone = meta?.tone ?? "system";
|
||||
const isLast = i === audit.length - 1;
|
||||
const dotColor = isLast ? ACCENT : tone === "ai" ? TM_NAVY : tone === "user" ? "#57534e" : "#a8a29e";
|
||||
const actor = tone === "ai" ? "AI · Maya" : (e.user_roles?.[0] ?? "System");
|
||||
return (
|
||||
<li key={e.id} className="relative pl-7 pb-4 last:pb-0 text-[13px]">
|
||||
<span
|
||||
className="absolute left-0 top-1 w-[11px] h-[11px] rounded-full"
|
||||
style={{ background: dotColor, boxShadow: `0 0 0 3px white` }}
|
||||
/>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold leading-tight" style={{ color: INK }}>{label}</div>
|
||||
<div className="text-[11.5px] uppercase tracking-[0.04em] text-stone-400 mt-1">
|
||||
{actor}{e.execution_state && e.execution_state !== "completed" ? ` · ${e.execution_state}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-stone-400 tabular-nums shrink-0 text-[12px]">
|
||||
{new Date(e.created_at).toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1102,8 +1084,8 @@ const SECTION_TITLE: Record<string, string> = {
|
||||
// many fields go in the wide (left, col-span-2) lane; short metadata sections
|
||||
// stack in the narrow (right) lane.
|
||||
const SECTION_LANE: Record<string, "wide" | "narrow"> = {
|
||||
customer: "narrow",
|
||||
vehicle: "narrow",
|
||||
customer: "wide",
|
||||
vehicle: "wide",
|
||||
booking: "wide",
|
||||
feedback: "wide",
|
||||
ai: "wide",
|
||||
|
||||
@ -4,12 +4,13 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getHeaderConfig, getScreen, getRVScreen, getRecordView,
|
||||
getDVScreen, getDetailView, getInstance, getAuditLog,
|
||||
getDVScreen, getDetailView, getInstance,
|
||||
type NavItem, type LayoutElement, type RecordViewField,
|
||||
type SearchQuery, type TileValue, type ChartDataResponse,
|
||||
type AuditEntry,
|
||||
} from "../api/viewService";
|
||||
import { WORKFLOW_ID, RV_HIDDEN_COLUMNS } from "../config";
|
||||
import { WORKFLOW_ID, RV_HIDDEN_COLUMNS, ACTIVITY_IDS } from "../config";
|
||||
import { activityContext } from "../lib/activity";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rv-screen layout walkers (same logic the production RecordViewTable uses;
|
||||
@ -320,13 +321,67 @@ export function useDetailView(viewId: number | string, instanceId: string) {
|
||||
// Instance + audit (for state badge / action menu / timeline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Synthesise an AuditEntry-compatible array from instance.data._activities so
|
||||
// the existing timeline UI works without the broken /view/audit endpoint.
|
||||
// INIT is synthesised from instance.created_at since the platform doesn't
|
||||
// write INIT into _activities.
|
||||
interface ActivityRecord {
|
||||
_system?: { created_at?: string; user_id?: string; user_roles?: string[] | null };
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function deriveAuditFromActivities(
|
||||
createdAt: string,
|
||||
instanceData: Record<string, unknown>,
|
||||
activities: Record<string, ActivityRecord> | undefined,
|
||||
): AuditEntry[] {
|
||||
const out: AuditEntry[] = [];
|
||||
let id = 1;
|
||||
out.push({
|
||||
id: id++,
|
||||
user_id: "",
|
||||
user_roles: [],
|
||||
activity_id: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||
data: {},
|
||||
execution_state: "completed",
|
||||
created_at: createdAt,
|
||||
});
|
||||
if (activities) {
|
||||
for (const [activityId, record] of Object.entries(activities)) {
|
||||
out.push({
|
||||
id: id++,
|
||||
user_id: record._system?.user_id ?? "",
|
||||
user_roles: record._system?.user_roles ?? [],
|
||||
activity_id: activityId,
|
||||
data: record.data ?? {},
|
||||
execution_state: "completed",
|
||||
created_at: record._system?.created_at ?? createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
// Renumber ids chronologically and attach a context line drawn from the
|
||||
// instance's top-level data (named keys, latest values).
|
||||
return out.map((e, i) => ({
|
||||
...e,
|
||||
id: i + 1,
|
||||
context: activityContext(e.activity_id, instanceData),
|
||||
}));
|
||||
}
|
||||
|
||||
export function useInstanceMeta(instanceId: string | null, refreshKey: number = 0) {
|
||||
const [instanceState, setInstanceState] = useState<string | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
useEffect(() => {
|
||||
if (!instanceId) { setInstanceState(null); setAudit([]); return; }
|
||||
getInstance(WORKFLOW_ID, instanceId).then((i) => setInstanceState(i.current_state_id)).catch(() => {});
|
||||
getAuditLog(instanceId).then(setAudit).catch(() => setAudit([]));
|
||||
getInstance(WORKFLOW_ID, instanceId)
|
||||
.then((i) => {
|
||||
setInstanceState(i.current_state_id);
|
||||
const instanceData = (i.data as Record<string, unknown>) ?? {};
|
||||
const acts = (instanceData as { _activities?: Record<string, ActivityRecord> })._activities;
|
||||
setAudit(deriveAuditFromActivities(i.created_at, instanceData, acts));
|
||||
})
|
||||
.catch(() => { setAudit([]); });
|
||||
}, [instanceId, refreshKey]);
|
||||
return { instanceState, audit };
|
||||
}
|
||||
|
||||
89
src/lib/activity.ts
Normal file
89
src/lib/activity.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { ACTIVITY_IDS, STATE_IDS } from "../config";
|
||||
|
||||
export function fmtAct(id: string): string {
|
||||
switch (id) {
|
||||
case ACTIVITY_IDS.INIT_ACTIVITY: return "Lead Captured";
|
||||
case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE: return "Test Drive Completed";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: return "AI · Scheduling Confirmed";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED: return "AI · Scheduling Failed";
|
||||
case ACTIVITY_IDS.RETRY_SCHEDULING_CALL: return "Retry Scheduling Call";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: return "AI · Feedback Collected";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED: return "AI · Feedback Failed";
|
||||
case ACTIVITY_IDS.RETRY_FEEDBACK_CALL: return "Retry Feedback Call";
|
||||
case ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED: return "AI · Concern → Expert TD Booked";
|
||||
case ACTIVITY_IDS.MARK_EXPERT_TD_DONE: return "Expert TD Completed";
|
||||
default: return id.slice(0, 8) + "…";
|
||||
}
|
||||
}
|
||||
|
||||
const FAILURE_STATE_IDS: ReadonlySet<string> = new Set([
|
||||
STATE_IDS.SCHEDULING_CALL_FAILED,
|
||||
STATE_IDS.FEEDBACK_CALL_FAILED,
|
||||
]);
|
||||
|
||||
export function isFailedExecution(execution_state: string): boolean {
|
||||
return execution_state === "TRIGGER_RESPONSE_TIMEOUT" || FAILURE_STATE_IDS.has(execution_state);
|
||||
}
|
||||
|
||||
export function isInflightExecution(execution_state: string): boolean {
|
||||
return execution_state === "TRIGGER_DISPATCHED";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// activityContext — short human-readable summary per activity, drawn from
|
||||
// the workflow instance's top-level data fields (clean named keys, latest
|
||||
// values). Returns "" when nothing useful is available.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||||
}
|
||||
|
||||
function fmtSlot(iso: unknown): string | null {
|
||||
if (typeof iso !== "string" || !iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function activityContext(activityId: string, data: Record<string, unknown>): string {
|
||||
const d = data as Record<string, any>;
|
||||
switch (activityId) {
|
||||
case ACTIVITY_IDS.INIT_ACTIVITY: {
|
||||
const name = d.customer_name;
|
||||
const model = d.model_interest;
|
||||
return [name, model].filter(Boolean).join(" · ");
|
||||
}
|
||||
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: {
|
||||
const parts: string[] = [];
|
||||
if (d.booking_id) parts.push(`Booked #${d.booking_id}`);
|
||||
if (d.dealer_name) parts.push(`at ${d.dealer_name}`);
|
||||
const slot = fmtSlot(d.booking_slot);
|
||||
if (slot) parts.push(slot);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED:
|
||||
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED:
|
||||
return d.call_summary ? truncate(String(d.call_summary), 120) : "Call did not complete";
|
||||
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: {
|
||||
const parts: string[] = [];
|
||||
if (d.feedback_rating != null) parts.push(`Rated ${d.feedback_rating}/10`);
|
||||
if (d.feedback_concern) parts.push(`Concern: ${truncate(String(d.feedback_concern), 80)}`);
|
||||
else if (d.feedback_liked_most) parts.push(`Liked: ${truncate(String(d.feedback_liked_most), 80)}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE:
|
||||
return "Dealer marked test drive complete";
|
||||
case ACTIVITY_IDS.MARK_EXPERT_TD_DONE:
|
||||
return "Dealer marked expert test drive complete";
|
||||
case ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED: {
|
||||
const slot = fmtSlot(d.booking_slot);
|
||||
return slot ? `Expert TD scheduled · ${slot}` : "Expert TD scheduled";
|
||||
}
|
||||
case ACTIVITY_IDS.RETRY_SCHEDULING_CALL:
|
||||
case ACTIVITY_IDS.RETRY_FEEDBACK_CALL:
|
||||
return "Re-dispatched call";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user