518 lines
23 KiB
TypeScript
518 lines
23 KiB
TypeScript
import { useEffect, useState, useRef } from "react";
|
|
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 }
|
|
|
|
type GroupKey = "hero" | "customer" | "vehicle" | "booking" | "feedback" | "ai" | "general";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Classification — Mahindra test-drive workflow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function classify(key: string): GroupKey {
|
|
const k = key.toLowerCase();
|
|
if (k === "customer_name") return "hero";
|
|
if (k.startsWith("customer_") || k === "lead_source" || k === "phone" || k === "email") return "customer";
|
|
if (k === "model_interest" || k.startsWith("vehicle_") || k === "variant" || k === "color" || k === "fuel_type") return "vehicle";
|
|
if (k.startsWith("booking_") || k === "dealer_name" || k === "dealer_id" || k === "slot_start" || k === "slot_end") return "booking";
|
|
if (k.startsWith("feedback_") || k === "test_drive_outcome" || k === "would_recommend" || k === "rating") return "feedback";
|
|
if (k.startsWith("ai_") || k.startsWith("call_") || k.includes("summary") || k.includes("failure_reason") ||
|
|
k.includes("confidence") || k.includes("reasoning") || k.includes("escalation")) return "ai";
|
|
return "general";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STATUS: Record<string, { pill: string; dot: string; bar: string; pulse?: boolean }> = {
|
|
"Awaiting Scheduler Call": {
|
|
pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900",
|
|
dot: "bg-rose-500", bar: "bg-rose-500", pulse: true,
|
|
},
|
|
"Scheduled": {
|
|
pill: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/30 dark:ring-amber-900",
|
|
dot: "bg-amber-500", bar: "bg-amber-500",
|
|
},
|
|
"Awaiting Feedback Call": {
|
|
pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900",
|
|
dot: "bg-rose-500", bar: "bg-rose-500", pulse: true,
|
|
},
|
|
"Feedback Collected": {
|
|
pill: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/30 dark:ring-emerald-900",
|
|
dot: "bg-emerald-500", bar: "bg-emerald-500",
|
|
},
|
|
"Scheduling Call Failed": {
|
|
pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900",
|
|
dot: "bg-red-500", bar: "bg-red-500",
|
|
},
|
|
"Feedback Call Failed": {
|
|
pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900",
|
|
dot: "bg-red-500", bar: "bg-red-500",
|
|
},
|
|
"End State": {
|
|
pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700",
|
|
dot: "bg-stone-400", bar: "bg-stone-400",
|
|
},
|
|
};
|
|
const DS = { pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", dot: "bg-stone-400", bar: "bg-stone-300" };
|
|
|
|
const SKIP_KEYS = new Set(["analysis_so_far"]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skeleton
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DetailSkeleton() {
|
|
return (
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
|
|
<div className="h-[3px] bg-stone-100 dark:bg-zinc-800" />
|
|
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800 space-y-3">
|
|
<div className="h-6 w-48 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
|
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
|
</div>
|
|
<div className="px-7 py-5 grid grid-cols-2 gap-6">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="h-3 w-20 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
|
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function DetailViewPanel({ viewId, instanceId }: Props) {
|
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
|
const [fields, setFields] = useState<Field[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!instanceId) return;
|
|
setLoading(true); setError(null);
|
|
getDVScreen(viewId)
|
|
.then(dv => getDetailView(dv.dv_template_uid, instanceId))
|
|
.catch(() => getDetailView(viewId, instanceId))
|
|
.then(res => {
|
|
setFields(res.config?.fields?.filter((f: any) => f.field_key !== "") ?? []);
|
|
setData(res.data ?? {});
|
|
})
|
|
.catch((e: any) => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
}, [viewId, instanceId]);
|
|
|
|
if (loading) return <DetailSkeleton />;
|
|
if (error) return <p className="text-sm text-red-500 dark:text-red-400">{error}</p>;
|
|
if (!data || !fields.length) return null;
|
|
|
|
const sys = new Set(["instance_id", "current_state_name", "created_at", "updated_at"]);
|
|
const groups: Record<GroupKey, Field[]> = { hero: [], customer: [], vehicle: [], booking: [], feedback: [], ai: [], general: [] };
|
|
|
|
for (const f of fields) {
|
|
if (sys.has(f.field_key) || SKIP_KEYS.has(f.field_key)) continue;
|
|
const g = classify(f.field_key);
|
|
groups[g].push(f);
|
|
}
|
|
|
|
const heroField = groups.hero[0];
|
|
const heroValue = heroField ? String(data[heroField.field_key] ?? "") : "";
|
|
const stateName = String(data.current_state_name ?? "");
|
|
const sc = STATUS[stateName] ?? DS;
|
|
|
|
const phone = fmtPhone(data.customer_phone);
|
|
const model = String(data.model_interest ?? "");
|
|
|
|
const leftGroups = [
|
|
{ label: "Customer", fields: groups.customer },
|
|
{ label: "Vehicle", fields: groups.vehicle },
|
|
{ label: "Other", fields: groups.general },
|
|
].filter(g => g.fields.length > 0);
|
|
|
|
const rightGroups = [
|
|
{ label: "Booking", fields: groups.booking },
|
|
{ label: "Feedback", fields: groups.feedback },
|
|
].filter(g => g.fields.length > 0);
|
|
|
|
const isScore = (f: Field) => f.field_key.toLowerCase().includes("confidence") || f.field_key.toLowerCase().includes("rating") || f.field_key.toLowerCase().includes("score");
|
|
const isLongText = (f: Field) => { const v = data[f.field_key]; return typeof v === "string" && v.length > 55; };
|
|
|
|
const aiShort = groups.ai.filter(f => isScore(f) || !isLongText(f));
|
|
const aiLong = groups.ai.filter(f => !isScore(f) && isLongText(f));
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
|
|
|
|
{/* Status bar */}
|
|
<div className={`h-[3px] ${sc.bar}`} />
|
|
|
|
{/* ── Header ──────────────────────────────────────────────────── */}
|
|
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800">
|
|
<div className="flex items-start justify-between gap-6 mb-3">
|
|
<div className="min-w-0 flex items-center gap-3 flex-wrap">
|
|
{heroValue && (
|
|
<h1 className="text-[20px] font-bold tracking-tight text-stone-900 dark:text-white leading-none">
|
|
{heroValue}
|
|
</h1>
|
|
)}
|
|
{stateName && (
|
|
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full ring-1 shrink-0 ${sc.pill}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot} ${sc.pulse ? "animate-pulse" : ""}`} />
|
|
{stateName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{model && (
|
|
<div className="text-right shrink-0">
|
|
<p className="text-[10px] font-medium text-stone-400 dark:text-zinc-600 mb-0.5 uppercase tracking-wider">Model Interest</p>
|
|
<p className="text-[18px] font-bold text-stone-900 dark:text-white leading-none">{model}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 flex-wrap text-[12px]">
|
|
{phone && (
|
|
<span className="font-medium text-stone-600 dark:text-zinc-300">{phone}</span>
|
|
)}
|
|
{phone && (data.created_at || data.instance_id) && (
|
|
<span className="text-stone-200 dark:text-zinc-700">·</span>
|
|
)}
|
|
{data.created_at && (
|
|
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500">
|
|
<Calendar size={11} />{fmtDate(String(data.created_at))}
|
|
</span>
|
|
)}
|
|
{data.instance_id && (
|
|
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500 font-mono text-[11px]">
|
|
<Hash size={10} />Lead #{String(data.instance_id)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Two-column metadata ──────────────────────────────────────── */}
|
|
{(leftGroups.length > 0 || rightGroups.length > 0) && (
|
|
<div className="grid lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-stone-100 dark:divide-zinc-800 border-b border-stone-100 dark:border-zinc-800">
|
|
<div className="px-7 py-5 space-y-6">
|
|
{leftGroups.map(({ label, fields: fs }) => (
|
|
<section key={label}>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3">{label}</p>
|
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
|
|
{fs.map(f => (
|
|
<div key={f.field_key}>
|
|
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
|
|
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
|
|
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</section>
|
|
))}
|
|
</div>
|
|
|
|
<div className="px-7 py-5 space-y-6">
|
|
{rightGroups.length > 0 ? rightGroups.map(({ label, fields: fs }) => (
|
|
<section key={label}>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3 flex items-center gap-1.5">
|
|
{label === "Booking" && <TrendingUp size={11} className="text-amber-500" />}
|
|
{label === "Feedback" && <MessageSquare size={11} className="text-emerald-500" />}
|
|
{label}
|
|
</p>
|
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
|
|
{fs.map(f => (
|
|
<div key={f.field_key}>
|
|
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
|
|
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
|
|
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</section>
|
|
)) : (
|
|
<div className="text-[13px] text-stone-300 dark:text-zinc-700 italic">
|
|
Booking and feedback details will appear here once the AI completes its call.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── AI Analysis ─────────────────────────────────────────────── */}
|
|
{groups.ai.length > 0 && (
|
|
<div className="px-7 py-5">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
|
|
<Sparkles size={10} className="text-[#C8102E]" />AI · Call Outcome
|
|
</p>
|
|
|
|
{aiShort.length > 0 && (
|
|
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-5 mb-5">
|
|
{aiShort.map(f => {
|
|
const val = data[f.field_key];
|
|
const score = isScore(f);
|
|
const num = score ? Number(val) : null;
|
|
const pct = num !== null ? Math.min(num > 1 ? num : num * 100, 100) : 0;
|
|
const barCls = pct >= 70 ? "bg-emerald-500" : pct >= 40 ? "bg-amber-500" : "bg-red-500";
|
|
const pctCls = pct >= 70 ? "text-emerald-600 dark:text-emerald-400" : pct >= 40 ? "text-amber-600 dark:text-amber-400" : "text-red-500";
|
|
return (
|
|
<div key={f.field_key}>
|
|
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-1">{fmtLabel(f.output_label)}</dt>
|
|
{score && num !== null ? (
|
|
<dd className="flex items-center gap-2.5">
|
|
<div className="flex-1 h-1.5 bg-stone-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full ${barCls}`} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className={`text-[12px] font-bold tabular-nums w-8 text-right ${pctCls}`}>{pct.toFixed(0)}%</span>
|
|
</dd>
|
|
) : (
|
|
<dd className="text-[14px] font-semibold text-stone-900 dark:text-zinc-100">
|
|
{fmtVal(val, f.data_type, f.field_key)}
|
|
</dd>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</dl>
|
|
)}
|
|
|
|
{aiLong.length > 0 && (
|
|
<div className="space-y-4 border-t border-stone-100 dark:border-zinc-800 pt-4">
|
|
{aiLong.map(f => {
|
|
const val = data[f.field_key];
|
|
const isClarif = f.field_key.includes("clarification") || f.field_key.includes("response");
|
|
return (
|
|
<div key={f.field_key} className="border-b border-stone-50 dark:border-zinc-800/50 pb-4 last:border-0 last:pb-0">
|
|
<div className="flex items-center gap-1.5 mb-1.5">
|
|
{isClarif && <MessageSquare size={11} className="text-amber-400 shrink-0" />}
|
|
<span className="text-[11px] font-medium text-stone-400 dark:text-zinc-500">{fmtLabel(f.output_label)}</span>
|
|
</div>
|
|
<ClampedText text={String(val ?? "—")} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ClampedText
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ClampedText({ text }: { text: string }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [clamped, setClamped] = useState(false);
|
|
const ref = useRef<HTMLParagraphElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (el) setClamped(el.scrollHeight > el.clientHeight + 2);
|
|
}, [text]);
|
|
|
|
return (
|
|
<div>
|
|
<p
|
|
ref={ref}
|
|
className={`text-[13px] leading-relaxed text-stone-700 dark:text-zinc-200 transition-all ${expanded ? "" : "line-clamp-3"}`}
|
|
>
|
|
{text}
|
|
</p>
|
|
{(clamped || expanded) && (
|
|
<button
|
|
onClick={() => setExpanded(e => !e)}
|
|
className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors"
|
|
>
|
|
{expanded ? <><ChevronUp size={12} /> Show less</> : <><ChevronDown size={12} /> Show more</>}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function fmtLabel(label: string): string {
|
|
return label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
|
}
|
|
|
|
function fmtVal(val: unknown, type: string, key: string): string {
|
|
if (val == null || val === "") return "—";
|
|
if (type === "phone" && typeof val === "object") {
|
|
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
|
|
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "—");
|
|
}
|
|
if (type === "number") {
|
|
const n = Number(val);
|
|
if (["amount","tax","price","cost"].some(x => key.toLowerCase().includes(x))) return fmtCurrency(n);
|
|
return n.toLocaleString("en-IN");
|
|
}
|
|
if (type === "datetime" || type === "date") return fmtDate(String(val));
|
|
return String(val);
|
|
}
|
|
|
|
function fmtCurrency(n: number): string {
|
|
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
|
}
|
|
|
|
function fmtDate(val: string): string {
|
|
try { return new Date(val).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); }
|
|
catch { return val; }
|
|
}
|
|
|
|
function fmtPhone(val: unknown): string {
|
|
if (val == null || val === "") return "";
|
|
if (typeof val === "object") {
|
|
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
|
|
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "");
|
|
}
|
|
return String(val);
|
|
}
|