Rocky_Mountain_Vending/app/admin/conversations/page.tsx

243 lines
8.9 KiB
TypeScript

import Link from "next/link"
import { fetchQuery } from "convex/nextjs"
import { MessageSquare, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
type PageProps = {
searchParams: Promise<{
search?: string
channel?: "call" | "sms" | "chat" | "unknown"
status?: "open" | "closed" | "archived"
page?: 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 getSyncMessage(sync: any) {
if (!sync.ghlConfigured) {
return "Connect GHL to load contacts and conversations."
}
if (sync.stages.conversations.status === "running") {
return "Conversations are syncing now."
}
if (sync.stages.conversations.error) {
return "Conversations could not be loaded from GHL yet."
}
if (!sync.latestSyncAt) {
return "No conversations yet."
}
return "Calls and messages appear here as they are synced."
}
export default async function AdminConversationsPage({
searchParams,
}: PageProps) {
const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
const search = params.search?.trim() || undefined
const data = await fetchQuery(api.crm.listAdminConversations, {
search,
page,
limit: 25,
channel: params.channel,
status: params.status,
})
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight text-balance">
Conversations
</h1>
<p className="mt-2 text-muted-foreground">
Customer conversations in one inbox.
</p>
</div>
<Link href="/admin">
<Button variant="outline">Back to Admin</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Sync Status</CardTitle>
<CardDescription>{getSyncMessage(data.sync)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<Badge variant="outline">{data.sync.overallStatus}</Badge>
<span>
Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}
</span>
{!data.sync.ghlConfigured ? (
<span>GHL is not connected.</span>
) : null}
{data.sync.stages.conversations.error ? (
<span>{data.sync.stages.conversations.error}</span>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Conversation Inbox
</CardTitle>
<CardDescription>
Search by contact, phone, email, or recent message.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_170px_170px_auto]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="search"
defaultValue={search || ""}
placeholder="Search conversations"
className="pl-9"
/>
</div>
<select
name="channel"
defaultValue={params.channel || ""}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">All channels</option>
<option value="call">Call</option>
<option value="sms">SMS</option>
<option value="chat">Chat</option>
<option value="unknown">Unknown</option>
</select>
<select
name="status"
defaultValue={params.status || ""}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="archived">Archived</option>
</select>
<Button type="submit">Filter</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[1100px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Conversation</th>
<th className="py-3 pr-4 font-medium">Contact</th>
<th className="py-3 pr-4 font-medium">Channel</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 pr-4 font-medium">Messages</th>
<th className="py-3 pr-4 font-medium">Recordings</th>
<th className="py-3 pr-4 font-medium">Last Activity</th>
<th className="py-3 font-medium">Open</th>
</tr>
</thead>
<tbody>
{data.items.length === 0 ? (
<tr>
<td
colSpan={8}
className="py-8 text-center text-muted-foreground"
>
{search || params.channel || params.status
? "No conversations matched this search."
: getSyncMessage(data.sync)}
</td>
</tr>
) : (
data.items.map((conversation: any) => (
<tr
key={conversation.id}
className="border-b align-top last:border-b-0"
>
<td className="py-3 pr-4">
<div className="font-medium">
{conversation.title || "Untitled conversation"}
</div>
<div className="text-xs text-muted-foreground">
{conversation.lastMessagePreview || "No preview yet"}
</div>
</td>
<td className="py-3 pr-4">
{conversation.contact ? (
<div>
<div className="font-medium">
{conversation.contact.name}
</div>
<div className="text-xs text-muted-foreground">
{conversation.contact.phone ||
conversation.contact.email ||
"—"}
</div>
</div>
) : (
"—"
)}
</td>
<td className="py-3 pr-4">
<Badge variant="outline">{conversation.channel}</Badge>
</td>
<td className="py-3 pr-4">
<Badge variant="secondary">{conversation.status}</Badge>
</td>
<td className="py-3 pr-4">{conversation.messageCount}</td>
<td className="py-3 pr-4">
{conversation.recordingCount}
</td>
<td className="py-3 pr-4">
{formatTimestamp(conversation.lastMessageAt)}
</td>
<td className="py-3">
<Link href={`/admin/conversations/${conversation.id}`}>
<Button size="sm" variant="outline">
View
</Button>
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
export const metadata = {
title: "Conversations | Admin",
description: "View Rocky customer conversations",
}