Files
calendar/apps/web/modules/insights/views/insights-call-history-view.tsx
Eunjae LeeGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1368ffe55d refactor: move data-table hooks/contexts/provider from features to web modules (#27833)
* refactor: move data-table hooks/contexts/provider from features to web modules

Co-Authored-By: [email protected] <[email protected]>

* fix: update broken useColumnResizing import path in DataTable.tsx

Co-Authored-By: [email protected] <[email protected]>

* fix: update remaining broken import paths for moved hooks

Co-Authored-By: [email protected] <[email protected]>

* docs: update GUIDE.md import paths and file references for moved modules

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 00:00:40 +09:00

337 lines
11 KiB
TypeScript

"use client";
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { usePathname } from "next/navigation";
import { useMemo, useState, useReducer } from "react";
import { ColumnFilterType, convertFacetedValuesToMap } from "@calcom/features/data-table";
import { DataTableProvider } from "~/data-table/DataTableProvider";
import { useDataTable } from "~/data-table/hooks/useDataTable";
import { DataTableWrapper, DataTableToolbar, DataTableFilters } from "~/data-table/components";
import { useSegments } from "~/data-table/hooks/useSegments";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { CallDetailsState, CallDetailsAction } from "@calcom/features/ee/workflows/lib/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { CallDetailsSheet } from "@calcom/web/modules/ee/workflows/components/CallDetailsSheet";
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) {
const pathname = usePathname();
if (!pathname) return null;
return (
<DataTableProvider tableIdentifier={pathname} 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 } = 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, t]);
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 />
</>
}
EmptyView={
<EmptyScreen
Icon="phone"
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("no_call_history")}
description={t("no_call_history_description")}
className="mb-16"
buttonRaw={
!searchTerm && (
<Button href="/workflow/new?action=calAi&templateWorkflowId=wf-11">
{t("create_first_workflow")}
</Button>
)
}
/>
}
/>
{state.callDetailsSheet.showModal && <CallDetailsSheet state={state} dispatch={dispatch} />}
</>
);
}
export default function InsightsCallHistoryPage() {
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
return <CallHistoryTable org={org} />;
}