Rocky_Mountain_Vending/app/admin/calls/page.tsx

187 lines
8 KiB
TypeScript

import Link from "next/link";
import { fetchQuery } from "convex/nextjs";
import { Phone, 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";
import {
formatPhoneCallDuration,
formatPhoneCallTimestamp,
normalizePhoneFromIdentity,
} from "@/lib/phone-calls";
type PageProps = {
searchParams: Promise<{
search?: string;
status?: "started" | "completed" | "failed";
page?: string;
}>;
};
function getStatusVariant(status: "started" | "completed" | "failed") {
if (status === "failed") {
return "destructive" as const;
}
if (status === "started") {
return "secondary" as const;
}
return "default" as const;
}
export default async function AdminCallsPage({ searchParams }: PageProps) {
const params = await searchParams;
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
const status = params.status;
const search = params.search?.trim() || undefined;
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
search,
status,
page,
limit: 25,
});
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">Phone Calls</h1>
<p className="mt-2 text-muted-foreground">
Every direct LiveKit phone call mirrored into RMV admin, including partial and non-lead calls.
</p>
</div>
<Link href="/admin">
<Button variant="outline">Back to Admin</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5" />
Call Inbox
</CardTitle>
<CardDescription>Search by caller number, room, summary, or linked lead ID.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_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 calls" className="pl-9" />
</div>
<select
name="status"
defaultValue={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="started">Started</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
<Button type="submit">Filter</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[1050px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Caller</th>
<th className="py-3 pr-4 font-medium">Started</th>
<th className="py-3 pr-4 font-medium">Duration</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 pr-4 font-medium">Answered</th>
<th className="py-3 pr-4 font-medium">Transcript</th>
<th className="py-3 pr-4 font-medium">Recording</th>
<th className="py-3 pr-4 font-medium">Lead</th>
<th className="py-3 pr-4 font-medium">Email</th>
<th className="py-3 pr-4 font-medium">Summary</th>
<th className="py-3 font-medium">Open</th>
</tr>
</thead>
<tbody>
{data.items.length === 0 ? (
<tr>
<td colSpan={11} className="py-8 text-center text-muted-foreground">
No phone calls matched this filter.
</td>
</tr>
) : (
data.items.map((call: any) => (
<tr key={call.id} className="border-b align-top last:border-b-0">
<td className="py-3 pr-4 font-medium">
<div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div>
<div className="text-xs text-muted-foreground">{call.roomName}</div>
</td>
<td className="py-3 pr-4">{formatPhoneCallTimestamp(call.startedAt)}</td>
<td className="py-3 pr-4">{formatPhoneCallDuration(call.durationMs)}</td>
<td className="py-3 pr-4">
<Badge variant={getStatusVariant(call.callStatus)}>{call.callStatus}</Badge>
</td>
<td className="py-3 pr-4">{call.answered ? "Yes" : "No"}</td>
<td className="py-3 pr-4">
{call.transcriptTurnCount > 0 ? `${call.transcriptTurnCount} turns` : "No transcript"}
</td>
<td className="py-3 pr-4">{call.recordingStatus || "Unavailable"}</td>
<td className="py-3 pr-4">{call.leadOutcome === "none" ? "—" : call.leadOutcome}</td>
<td className="py-3 pr-4">{call.notificationStatus}</td>
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
<span className="line-clamp-2">{call.summaryText || "No summary yet"}</span>
</td>
<td className="py-3">
<Link href={`/admin/calls/${call.id}`}>
<Button size="sm" variant="outline">View</Button>
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls)
</p>
<div className="flex gap-2">
{data.pagination.page > 1 ? (
<Link
href={`/admin/calls?${new URLSearchParams({
...(search ? { search } : {}),
...(status ? { status } : {}),
page: String(data.pagination.page - 1),
}).toString()}`}
>
<Button variant="outline" size="sm">Previous</Button>
</Link>
) : null}
{data.pagination.page < data.pagination.totalPages ? (
<Link
href={`/admin/calls?${new URLSearchParams({
...(search ? { search } : {}),
...(status ? { status } : {}),
page: String(data.pagination.page + 1),
}).toString()}`}
>
<Button variant="outline" size="sm">Next</Button>
</Link>
) : null}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export const metadata = {
title: "Phone Calls | Admin",
description: "View direct phone calls, transcript history, and lead outcomes",
};