241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
import Link from "next/link"
|
|
import { notFound } from "next/navigation"
|
|
import { fetchQuery } from "convex/nextjs"
|
|
import { ArrowLeft, ExternalLink, MessageSquare } from "lucide-react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card"
|
|
|
|
type PageProps = {
|
|
params: Promise<{
|
|
id: string
|
|
}>
|
|
}
|
|
|
|
function formatTimestamp(value?: number) {
|
|
if (!value) {
|
|
return "—"
|
|
}
|
|
|
|
return new Date(value).toLocaleString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
}
|
|
|
|
function formatDuration(value?: number) {
|
|
if (!value) {
|
|
return "—"
|
|
}
|
|
const totalSeconds = Math.round(value / 1000)
|
|
const minutes = Math.floor(totalSeconds / 60)
|
|
const seconds = totalSeconds % 60
|
|
return `${minutes}:${String(seconds).padStart(2, "0")}`
|
|
}
|
|
|
|
export default async function AdminConversationDetailPage({
|
|
params,
|
|
}: PageProps) {
|
|
const { id } = await params
|
|
const detail = await fetchQuery(api.crm.getAdminConversationDetail, {
|
|
conversationId: id,
|
|
})
|
|
|
|
if (!detail) {
|
|
notFound()
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="space-y-8">
|
|
<div className="space-y-2">
|
|
<Link
|
|
href="/admin/conversations"
|
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to conversations
|
|
</Link>
|
|
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
|
{detail.conversation.title || "Conversation Detail"}
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
Unified thread for Rocky-owned conversation management.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<MessageSquare className="h-5 w-5" />
|
|
Conversation Status
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Channel, ownership, and sync metadata.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
Channel
|
|
</p>
|
|
<Badge className="mt-1" variant="outline">
|
|
{detail.conversation.channel}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
Status
|
|
</p>
|
|
<Badge className="mt-1" variant="secondary">
|
|
{detail.conversation.status}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
Contact
|
|
</p>
|
|
<p className="font-medium">
|
|
{detail.contact?.name || "Unlinked"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
Started
|
|
</p>
|
|
<p className="font-medium">
|
|
{formatTimestamp(detail.conversation.startedAt)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
Last Activity
|
|
</p>
|
|
<p className="font-medium">
|
|
{formatTimestamp(detail.conversation.lastMessageAt)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
GHL Conversation ID
|
|
</p>
|
|
<p className="font-medium break-all">
|
|
{detail.conversation.ghlConversationId || "—"}
|
|
</p>
|
|
</div>
|
|
{detail.conversation.summaryText ? (
|
|
<div className="md:col-span-2">
|
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
Summary
|
|
</p>
|
|
<p className="text-sm whitespace-pre-wrap">
|
|
{detail.conversation.summaryText}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Recordings & Leads</CardTitle>
|
|
<CardDescription>
|
|
Call artifacts and related lead outcomes for this thread.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{detail.recordings.map((recording: any) => (
|
|
<div key={recording.id} className="rounded-lg border p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<Badge variant="outline">
|
|
{recording.recordingStatus || "recording"}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDuration(recording.durationMs)}
|
|
</span>
|
|
</div>
|
|
{recording.recordingUrl ? (
|
|
<Link
|
|
href={recording.recordingUrl}
|
|
target="_blank"
|
|
className="mt-2 inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
|
>
|
|
Open recording
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Link>
|
|
) : null}
|
|
{recording.transcriptionText ? (
|
|
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
|
|
{recording.transcriptionText}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
|
|
{detail.leads.map((lead: any) => (
|
|
<div key={lead.id} className="rounded-lg border p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="font-medium">{lead.type}</p>
|
|
<Badge variant="secondary">{lead.status}</Badge>
|
|
</div>
|
|
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
|
|
{lead.message || lead.intent || "—"}
|
|
</p>
|
|
</div>
|
|
))}
|
|
|
|
{detail.recordings.length === 0 && detail.leads.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No recordings or linked leads for this conversation yet.
|
|
</p>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Messages</CardTitle>
|
|
<CardDescription>
|
|
Full backend-owned thread history for this conversation.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{detail.messages.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No messages have been mirrored into this conversation yet.
|
|
</p>
|
|
) : (
|
|
detail.messages.map((message: any) => (
|
|
<div key={message.id} className="rounded-lg border p-3">
|
|
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
|
<span className="uppercase tracking-wide">
|
|
{message.channel} • {message.direction}
|
|
</span>
|
|
<span>{formatTimestamp(message.sentAt)}</span>
|
|
</div>
|
|
<p className="whitespace-pre-wrap text-sm">{message.body}</p>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const metadata = {
|
|
title: "Conversation Detail | Admin",
|
|
description: "Review a Rocky conversation thread, recordings, and leads",
|
|
}
|