Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { _generateMetadata } from "app/_utils";

import InsightsCallHistoryPage from "~/insights/insights-call-history-view";

import { checkInsightsPagePermission } from "../checkInsightsPagePermission";

export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("call_history"),
(t) => t("call_history_subtitle"),
undefined,
undefined,
"/insights/call-history"
);

export default async function Page() {
await checkInsightsPagePermission();

return <InsightsCallHistoryPage />;
}
321 changes: 321 additions & 0 deletions apps/web/modules/insights/insights-call-history-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
"use client";

import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState, useReducer } from "react";

import {
DataTableProvider,
DataTableWrapper,
DataTableToolbar,
DataTableFilters,
ColumnFilterType,
convertFacetedValuesToMap,
useDataTable,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CallDetailsSheet } from "@calcom/features/ee/workflows/components/CallDetailsSheet";
import type { CallDetailsState, CallDetailsAction } from "@calcom/features/ee/workflows/components/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";

type CallHistoryRow = {
id: string;
time: string;
duration: number;
channelType: "web_call" | "phone_call";
sessionId: string;
endReason: string;
sessionStatus: "completed" | "ongoing" | "failed";
userSentiment: "positive" | "neutral" | "negative";
from: string;
to: string;
callCreated: boolean;
inVoicemail: boolean;
};

export type CallHistoryProps = {
org?: RouterOutputs["viewer"]["organizations"]["listCurrent"];
};

const initialState: CallDetailsState = {
callDetailsSheet: {
showModal: false,
},
};

function reducer(state: CallDetailsState, action: CallDetailsAction): CallDetailsState {
switch (action.type) {
case "OPEN_CALL_DETAILS":
return { ...state, callDetailsSheet: action.payload };
case "CLOSE_MODAL":
return {
...state,
callDetailsSheet: { showModal: false },
};
default:
return state;
}
}

function CallHistoryTable(props: CallHistoryProps) {
return (
<DataTableProvider useSegments={useSegments} defaultPageSize={25}>
<CallHistoryContent {...props} />
</DataTableProvider>
);
}

function CallHistoryContent({ org: _org }: CallHistoryProps) {
const orgBranding = useOrgBranding();
const _domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const { t } = useLocale();
const [rowSelection, setRowSelection] = useState({});
const [state, dispatch] = useReducer(reducer, initialState);

const { limit, offset, searchTerm: _searchTerm } = useDataTable();

const {
data: callsData,
isPending: isLoadingCalls,
error: _callsError,
} = trpc.viewer.aiVoiceAgent.listCalls.useQuery({
limit,
offset,
filters: {},
});

const callHistoryData: CallHistoryRow[] = useMemo(() => {
if (!callsData?.calls) return [];

return callsData.calls.map((call) => ({
id: call.call_id || Math.random().toString(),
time: call.start_timestamp ? new Date(call.start_timestamp).toISOString() : new Date().toISOString(),
duration: Math.round((call.duration_ms || 0) / 1000),
channelType: (call.call_type || "phone_call") as "web_call" | "phone_call",
sessionId: call.call_id || t("unknown"),
endReason: call.disconnection_reason || t("unknown"),
sessionStatus:
call.call_status === "ended" ? "completed" : call.call_status === "ongoing" ? "ongoing" : "failed",
userSentiment:
call.call_analysis?.user_sentiment?.toLowerCase() === "positive"
? "positive"
: call.call_analysis?.user_sentiment?.toLowerCase() === "negative"
? "negative"
: "neutral",
from: "from_number" in call ? call.from_number || t("unknown") : t("unknown"),
to: "to_number" in call ? call.to_number || t("unknown") : t("unknown"),
callCreated: call.call_analysis?.call_successful ?? true,
inVoicemail: call.call_analysis?.in_voicemail ?? false,
}));
}, [callsData?.calls]);

const columns = useMemo<ColumnDef<CallHistoryRow>[]>(
() => [
{
id: "time",
accessorKey: "time",
header: t("time_header"),
size: 150,
cell: ({ row }) => {
const date = new Date(row.original.time);
return (
<div className="text-sm">
<div>{date.toLocaleDateString()}</div>
<div className="text-subtle">{date.toLocaleTimeString()}</div>
</div>
);
},
},
{
id: "duration",
accessorKey: "duration",
header: t("duration"),
size: 140,
cell: ({ row }) => {
const seconds = row.original.duration;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return <span>{`${minutes}:${remainingSeconds.toString().padStart(2, "0")}`}</span>;
},
},
{
id: "channelType",
accessorKey: "channelType",
header: t("channel_type"),
size: 160,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => <span>{row.original.channelType}</span>,
},
{
id: "sessionId",
accessorKey: "sessionId",
header: t("session_id"),
size: 210,
cell: ({ row }) => <code className="text-xs">{row.original.sessionId}</code>,
},
{
id: "endReason",
accessorKey: "endReason",
header: t("end_reason"),
size: 180,
cell: ({ row }) => <span>{row.original.endReason}</span>,
},
{
id: "sessionStatus",
accessorKey: "sessionStatus",
header: t("session_status"),
size: 200,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => {
const status = row.original.sessionStatus;
const variant = status === "completed" ? "green" : status === "ongoing" ? "blue" : "red";
return <Badge variant={variant}>{status}</Badge>;
},
},
{
id: "userSentiment",
accessorKey: "userSentiment",
header: t("user_sentiment"),
size: 200,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => {
const sentiment = row.original.userSentiment;
const variant = sentiment === "positive" ? "green" : sentiment === "negative" ? "red" : "gray";
return <Badge variant={variant}>{sentiment}</Badge>;
},
},
{
id: "from",
accessorKey: "from",
header: t("from_header"),
size: 140,
},
{
id: "to",
accessorKey: "to",
header: t("to"),
size: 140,
},
{
id: "callCreated",
accessorKey: "callCreated",
header: t("call_created"),
size: 200,
cell: ({ row }) => {
const created = row.original.callCreated;
const variant = created ? "green" : "red";
return <Badge variant={variant}>{created ? t("successful") : t("unsuccessful")}</Badge>;
},
},
{
id: "inVoicemail",
accessorKey: "inVoicemail",
header: t("voicemail"),
size: 150,
cell: ({ row }) => {
const inVoicemail = row.original.inVoicemail;
const variant = inVoicemail ? "blue" : "gray";
return <Badge variant={variant}>{inVoicemail ? t("yes") : t("no")}</Badge>;
},
},
],
[t]
);

const table = useReactTable({
data: callHistoryData,
columns,
enableRowSelection: false,
manualPagination: true,
state: {
rowSelection,
},
initialState: {
columnPinning: {
left: ["time"],
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
getRowId: (row) => row.id,
getFacetedUniqueValues: (_, columnId) => () => {
switch (columnId) {
case "channelType":
return convertFacetedValuesToMap([
{ label: "Web Call", value: "web_call" },
{ label: "Phone Call", value: "phone_call" },
]);
case "sessionStatus":
return convertFacetedValuesToMap([
{ label: "Completed", value: "completed" },
{ label: "Ongoing", value: "ongoing" },
{ label: "Failed", value: "failed" },
]);
case "userSentiment":
return convertFacetedValuesToMap([
{ label: "Positive", value: "positive" },
{ label: "Neutral", value: "neutral" },
{ label: "Negative", value: "negative" },
]);
default:
return new Map();
}
},
});

return (
<>
<DataTableWrapper<CallHistoryRow>
testId="call-history-data-table"
table={table}
isPending={isLoadingCalls}
totalRowCount={callsData?.totalCount || 0}
paginationMode="standard"
rowClassName="cursor-pointer hover:bg-subtle"
onRowMouseclick={(row) => {
const callIndex = callHistoryData.findIndex((call) => call.id === row.original.id);
if (callIndex !== -1 && callsData?.calls?.[callIndex]) {
dispatch({
type: "OPEN_CALL_DETAILS",
payload: {
showModal: true,
selectedCall: callsData.calls[callIndex],
},
});
}
}}
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.ColumnVisibilityButton table={table} />
{/* <DataTableFilters.FilterBar table={table} /> */}
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
</>
}
/>

{state.callDetailsSheet.showModal && <CallDetailsSheet state={state} dispatch={dispatch} />}
</>
);
}

export default function InsightsCallHistoryPage() {
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();

return <CallHistoryTable org={org} />;
}
21 changes: 21 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2466,7 +2466,15 @@
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "User: {{userName}}",
"insights_subtitle": "View booking insights across your events",
"call_history": "Call History",
"call_history_subtitle": "View call history across your Cal.ai calls",
"location_options": "{{locationCount}} location options",
"channel_type": "Channel Type",
"end_reason": "End Reason",
"session_status": "Session Status",
"user_sentiment": "User Sentiment",
"time_header": "Time",
"from_header": "From",
"custom_plan": "Custom Plan",
"email_embed": "Email Embed",
"add_times_to_your_email": "Select a few available times and embed them in your Email",
Expand Down Expand Up @@ -3628,6 +3636,9 @@
"visit": "Visit",
"location_custom_label_input_label": "Custom label on booking page",
"meeting_link": "Meeting link",
"session_outcome": "Session Outcome",
"call_created": "Call Created",
"voicemail": "Voicemail",
"my_bookings": "My Bookings",
"phone": "Phone",
"free": "Free",
Expand Down Expand Up @@ -3655,6 +3666,16 @@
"before_scheduled_start_time": "Before scheduled start time",
"cancel_booking_acknowledge_no_show_fee": "I acknowledge that by cancelling the booking within {{timeValue}} {{timeUnit}} of the start time I will be charged the no show fee of {{amount, currency}}",
"contact_organizer": "If you have any questions, please contact the organizer.",
"call_details": "Call Details",
"call_id": "Call ID",
"call_information": "Call Information",
"sentiment": "Sentiment",
"disconnect_reason": "Disconnect Reason",
"call_summary": "Call Summary",
"transcription": "Transcription",
"event_details": "Event Details",
"agent": "Agent",
"no_transcript_available": "No transcript available",
"testing_sms_workflow_info_message": "When testing this workflow, be aware that SMS need to be scheduled at least 15 minutes in advance",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
2 changes: 2 additions & 0 deletions packages/features/calAIPhone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type {
AIConfigurationSetup,
AIConfigurationDeletion,
DeletionResult,
RetellCallListParams,
RetellCallListResponse,
} from "./providers/retellAI";

// Legacy exports for backward compatibility
Expand Down
Loading
Loading