chore: UI enhancements for workflow runs view (#142)

* add local state in filters

* feat: add sorting feature by duration

* chore: refactor workfow run view
This commit is contained in:
Abhishek 2026-01-30 17:08:15 +05:30 committed by GitHub
parent 6827744327
commit 5fe1c8ce2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1014 additions and 479 deletions

View file

@ -1,36 +1,29 @@
"use client";
import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
getCampaignApiV1CampaignCampaignIdGet,
getCampaignRunsApiV1CampaignCampaignIdRunsGet,
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
pauseCampaignApiV1CampaignCampaignIdPausePost,
resumeCampaignApiV1CampaignCampaignIdResumePost,
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
import type { CampaignResponse, WorkflowRunResponse } from '@/client/types.gen';
import type { CampaignResponse } from '@/client/types.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { CampaignRuns } from '@/components/workflow-runs';
import { useAuth } from '@/lib/auth';
export default function CampaignDetailPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const campaignId = parseInt(params.campaignId as string);
// Redirect if not authenticated
@ -44,10 +37,6 @@ export default function CampaignDetailPage() {
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
const [isLoadingCampaign, setIsLoadingCampaign] = useState(true);
// Runs state
const [runs, setRuns] = useState<WorkflowRunResponse[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(false);
// Action state
const [isExecutingAction, setIsExecutingAction] = useState(false);
@ -77,36 +66,10 @@ export default function CampaignDetailPage() {
}
}, [user, getAccessToken, campaignId]);
// Fetch campaign runs
const fetchCampaignRuns = useCallback(async () => {
if (!user) return;
setIsLoadingRuns(true);
try {
const accessToken = await getAccessToken();
const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({
path: {
campaign_id: campaignId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setRuns(response.data);
}
} catch (error) {
console.error('Failed to fetch campaign runs:', error);
} finally {
setIsLoadingRuns(false);
}
}, [user, getAccessToken, campaignId]);
// Initial load
useEffect(() => {
fetchCampaign();
fetchCampaignRuns();
}, [fetchCampaign, fetchCampaignRuns]);
}, [fetchCampaign]);
// Handle back navigation
const handleBack = () => {
@ -120,13 +83,6 @@ export default function CampaignDetailPage() {
}
};
// Handle run click
const handleRunClick = (runId: number) => {
if (campaign) {
router.push(`/workflow/${campaign.workflow_id}/run/${runId}`);
}
};
// Handle CSV download
const handleDownloadCsv = async () => {
if (!user || !campaign || campaign.source_type !== 'csv') return;
@ -497,71 +453,11 @@ export default function CampaignDetailPage() {
</Card>
{/* Workflow Runs */}
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Executions triggered by this campaign
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingRuns ? (
<div className="animate-pulse space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-muted rounded"></div>
))}
</div>
) : runs.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Run ID</TableHead>
<TableHead>State</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRunClick(run.id)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.state === 'completed' ? 'secondary' : 'default'}>
{run.state}
</Badge>
</TableCell>
<TableCell>{formatDateTime(run.created_at)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRunClick(run.id);
}}
>
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-center py-8 text-muted-foreground">
{campaign.state === 'created'
? 'No runs yet. Start the campaign to begin execution.'
: 'No workflow runs found for this campaign.'}
</p>
)}
</CardContent>
</Card>
<CampaignRuns
campaignId={campaignId}
workflowId={campaign.workflow_id}
searchParams={searchParams}
/>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from "react";
@ -31,7 +31,6 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import{ superadminFilterAttributes } from "@/lib/filterAttributes";
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { impersonateAsSuperadmin } from '@/lib/utils';
@ -81,7 +80,6 @@ export default function RunsPage() {
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
const [currentTime, setCurrentTime] = useState(Date.now());
const limit = 50;
// Initialize filters from URL
@ -89,6 +87,15 @@ export default function RunsPage() {
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
});
// Applied filters are the ones actually used for fetching (only updated on Apply click)
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
});
// Sort state
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Dialog state for comment editing
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
const [commentRunId, setCommentRunId] = useState<number | null>(null);
@ -100,7 +107,13 @@ export default function RunsPage() {
// Media preview dialog
const mediaPreview = MediaPreviewDialog({ accessToken });
const fetchRuns = useCallback(async (page: number, filters?: ActiveFilter[], isAutoRefresh = false) => {
const fetchRuns = useCallback(async (
page: number,
filters?: ActiveFilter[],
isAutoRefresh = false,
sortByParam?: string | null,
sortOrderParam?: 'asc' | 'desc'
) => {
if (!accessToken) return;
// Don't show loading state for auto-refresh to prevent UI flicker
@ -126,7 +139,9 @@ export default function RunsPage() {
query: {
page,
limit,
...(filterParam && { filters: filterParam })
...(filterParam && { filters: filterParam }),
...(sortByParam && { sort_by: sortByParam }),
...(sortOrderParam && { sort_order: sortOrderParam }),
},
headers: {
'Authorization': `Bearer ${accessToken}`,
@ -169,55 +184,42 @@ export default function RunsPage() {
}, [router]);
useEffect(() => {
// Fetch runs when token is available and when page changes
// Fetch runs when token is available and when page/sort changes
if (accessToken) {
fetchRuns(currentPage, activeFilters);
fetchRuns(currentPage, appliedFilters, false, sortBy, sortOrder);
}
}, [currentPage, accessToken, activeFilters, fetchRuns]);
}, [currentPage, accessToken, appliedFilters, fetchRuns, sortBy, sortOrder]);
// Auto-refresh every 5 seconds when enabled and filters are active
useEffect(() => {
// Only set up interval if auto-refresh is enabled and there are active filters
if (!autoRefresh || activeFilters.length === 0) {
// Only set up interval if auto-refresh is enabled and there are applied filters
if (!autoRefresh || appliedFilters.length === 0) {
return;
}
const intervalId = setInterval(() => {
// Pass true to indicate this is an auto-refresh
fetchRuns(currentPage, activeFilters, true);
fetchRuns(currentPage, appliedFilters, true, sortBy, sortOrder);
}, 5000);
// Cleanup interval on unmount or when dependencies change
return () => clearInterval(intervalId);
}, [currentPage, activeFilters, fetchRuns, autoRefresh]);
// Update current time every second to show live duration for running calls
useEffect(() => {
const hasRunningCalls = runs.some(run => !run.is_completed);
if (!hasRunningCalls) {
return;
}
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => clearInterval(intervalId);
}, [runs]);
}, [currentPage, appliedFilters, fetchRuns, autoRefresh, sortBy, sortOrder]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
updatePageInUrl(page, activeFilters);
fetchRuns(page, activeFilters);
updatePageInUrl(page, appliedFilters);
fetchRuns(page, appliedFilters, false, sortBy, sortOrder);
};
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
setAppliedFilters(activeFilters); // Update applied filters
updatePageInUrl(1, activeFilters);
await fetchRuns(1, activeFilters);
await fetchRuns(1, activeFilters, false, sortBy, sortOrder);
setIsExecutingFilters(false);
}, [activeFilters, fetchRuns, updatePageInUrl]);
}, [activeFilters, fetchRuns, updatePageInUrl, sortBy, sortOrder]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
@ -226,10 +228,25 @@ export default function RunsPage() {
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
setAppliedFilters([]); // Clear applied filters
updatePageInUrl(1, []); // Clear filters from URL
await fetchRuns(1, []); // Fetch all runs without filters
await fetchRuns(1, [], false, sortBy, sortOrder); // Fetch all runs without filters
setIsExecutingFilters(false);
}, [fetchRuns, updatePageInUrl]);
}, [fetchRuns, updatePageInUrl, sortBy, sortOrder]);
const handleSort = useCallback((field: string) => {
// Reset to first page when sort changes
setCurrentPage(1);
if (sortBy === field) {
// Toggle order if same field
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
// New field, default to desc
setSortBy(field);
setSortOrder('desc');
}
}, [sortBy]);
// Save comment function declared outside JSX (requirement #2)
const saveAdminComment = useCallback(async () => {
@ -265,29 +282,10 @@ export default function RunsPage() {
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
const calculateDuration = (createdAt: string, isCompleted: boolean, usageInfo?: Record<string, unknown>) => {
const calculateDuration = (isCompleted: boolean, usageInfo?: Record<string, unknown>) => {
if (isCompleted && typeof usageInfo?.call_duration_seconds === 'number') {
return `${Number(usageInfo.call_duration_seconds).toFixed(2)}s`;
}
if (!isCompleted) {
const startTime = new Date(createdAt).getTime();
const duration = Math.floor((currentTime - startTime) / 1000);
// If duration exceeds 5 minutes (300 seconds), show "-" as it's likely an error
if (duration > 300) {
return '-';
}
if (duration < 60) {
return `${duration}s`;
} else {
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes}m ${seconds}s`;
}
}
return '-';
};
@ -383,9 +381,33 @@ export default function RunsPage() {
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Tags</TableHead>
<TableHead className="font-semibold">Comment</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
onClick={() => handleSort('duration')}
>
<div className="flex items-center gap-1">
Duration
{sortBy === 'duration' ? (
sortOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
) : (
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
onClick={() => handleSort('created_at')}
>
<div className="flex items-center gap-1">
Created At
{sortBy === 'created_at' ? (
sortOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
) : (
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
@ -393,7 +415,7 @@ export default function RunsPage() {
{runs.map((run) => (
<TableRow
key={run.id}
className={selectedRowId === run.id ? "bg-blue-50" : ""}>
className={selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}>
<TableCell className="font-mono text-sm">
#{run.id}
</TableCell>
@ -422,7 +444,7 @@ export default function RunsPage() {
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
<Badge variant="default">
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
@ -451,7 +473,7 @@ export default function RunsPage() {
</TableCell>
<TableCell className="text-sm whitespace-pre-wrap break-words">
<span className={!run.is_completed ? "font-semibold text-blue-600" : ""}>
{calculateDuration(run.created_at, run.is_completed, run.usage_info)}
{calculateDuration(run.is_completed, run.usage_info)}
</span>
</TableCell>
<TableCell className="text-sm">

View file

@ -23,7 +23,6 @@ import {
TableRow,
} from '@/components/ui/table';
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import { usageFilterAttributes } from '@/lib/filterAttributes';
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { ActiveFilter, DateRangeValue } from '@/types/filters';
@ -534,7 +533,7 @@ export default function UsagePage() {
</TableCell>
<TableCell>
{run.disposition ? (
<Badge variant={getDispositionBadgeVariant(run.disposition)}>
<Badge variant="default">
{run.disposition}
</Badge>
) : (

View file

@ -1,27 +1,13 @@
"use client";
import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
import { WorkflowRunResponseSchema } from "@/client/types.gen";
import { FilterBuilder } from "@/components/filters/FilterBuilder";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { WorkflowRunsTable } from "@/components/workflow-runs";
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import { downloadFile } from "@/lib/files";
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
@ -44,6 +30,10 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
// Sort state
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const { accessToken } = useUserConfig();
// Initialize filters from URL
@ -51,8 +41,6 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
return decodeFiltersFromURL(searchParams, availableAttributes);
});
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
// Load disposition codes from workflow configuration
const loadDispositionCodes = useCallback(async () => {
if (!accessToken) return;
@ -89,7 +77,12 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
loadDispositionCodes();
}, [loadDispositionCodes]);
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
const fetchWorkflowRuns = useCallback(async (
page: number,
filters?: ActiveFilter[],
sortByParam?: string | null,
sortOrderParam?: 'asc' | 'desc'
) => {
if (!accessToken) return;
try {
setLoading(true);
@ -109,7 +102,9 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
query: {
page: page,
limit: 50,
...(filterParam && { filters: filterParam })
...(filterParam && { filters: filterParam }),
...(sortByParam && { sort_by: sortByParam }),
...(sortOrderParam && { sort_order: sortOrderParam }),
},
headers: {
'Authorization': `Bearer ${accessToken}`,
@ -152,16 +147,16 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
}, [router, workflowId]);
useEffect(() => {
fetchWorkflowRuns(currentPage, activeFilters);
}, [currentPage, activeFilters, fetchWorkflowRuns]);
fetchWorkflowRuns(currentPage, activeFilters, sortBy, sortOrder);
}, [currentPage, activeFilters, fetchWorkflowRuns, sortBy, sortOrder]);
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
updatePageInUrl(1, activeFilters);
await fetchWorkflowRuns(1, activeFilters);
await fetchWorkflowRuns(1, activeFilters, sortBy, sortOrder);
setIsExecutingFilters(false);
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl]);
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl, sortBy, sortOrder]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
@ -171,182 +166,51 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
setIsExecutingFilters(true);
setCurrentPage(1);
updatePageInUrl(1, []); // Clear filters from URL
await fetchWorkflowRuns(1, []); // Fetch all workflows without filters
await fetchWorkflowRuns(1, [], sortBy, sortOrder); // Fetch all workflows without filters
setIsExecutingFilters(false);
}, [fetchWorkflowRuns, updatePageInUrl]);
}, [fetchWorkflowRuns, updatePageInUrl, sortBy, sortOrder]);
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
updatePageInUrl(page, activeFilters);
}, [updatePageInUrl, activeFilters]);
const handleSort = useCallback((field: string) => {
// Reset to first page when sort changes
setCurrentPage(1);
if (sortBy === field) {
// Toggle order if same field
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
// New field, default to desc
setSortBy(field);
setSortOrder('desc');
}
}, [sortBy]);
return (
<div className="container mx-auto py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
<FilterBuilder
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
{error}
</div>
) : workflowRuns.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">No workflow runs found</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Showing {workflowRuns.length} of {totalCount} total runs
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Call Type</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflowRuns.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer"
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell>
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.dograh_token_usage === 'number'
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
: "-"}
</TableCell>
<TableCell>
<div className="flex space-x-2">
{run.transcript_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.transcript_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Transcript
</Button>
)}
{run.recording_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.recording_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Recording
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank');
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
View
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage - 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage + 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
<WorkflowRunsTable
runs={workflowRuns}
loading={loading}
error={error}
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPageChange={handlePageChange}
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecutingFilters={isExecutingFilters}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
workflowId={workflowId}
accessToken={accessToken}
/>
</div>
);
}

File diff suppressed because one or more lines are too long

View file

@ -81,6 +81,19 @@ export type CampaignResponse = {
max_concurrency?: number | null;
};
/**
* Paginated response for campaign workflow runs
*/
export type CampaignRunsResponse = {
runs: Array<{
[key: string]: unknown;
}>;
total_count: number;
page: number;
limit: number;
total_pages: number;
};
export type CampaignSourceDownloadResponse = {
download_url: string;
expires_in: number;
@ -1138,14 +1151,6 @@ export type WorkflowRunDetail = {
created_at: string;
};
export type WorkflowRunResponse = {
id: number;
workflow_id: number;
state: string;
created_at: string;
completed_at: string | null;
};
export type WorkflowRunResponseSchema = {
id: number;
workflow_id: number;
@ -1494,6 +1499,27 @@ export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses = {
200: unknown;
};
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/telephony/cloudonix/cdr';
};
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors = {
/**
* Not found
*/
404: unknown;
};
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type ImpersonateApiV1SuperuserImpersonatePostData = {
body: ImpersonateRequest;
headers?: {
@ -1547,6 +1573,14 @@ export type GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData = {
* JSON-encoded filter criteria
*/
filters?: string | null;
/**
* Field to sort by (e.g., 'duration', 'created_at')
*/
sort_by?: string | null;
/**
* Sort order ('asc' or 'desc')
*/
sort_order?: string | null;
};
url: '/api/v1/superuser/workflow-runs';
};
@ -1934,6 +1968,14 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = {
* JSON-encoded filter criteria
*/
filters?: string | null;
/**
* Field to sort by (e.g., 'duration', 'created_at')
*/
sort_by?: string | null;
/**
* Sort order ('asc' or 'desc')
*/
sort_order?: string | null;
};
url: '/api/v1/workflow/{workflow_id}/runs';
};
@ -2601,7 +2643,22 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = {
path: {
campaign_id: number;
};
query?: never;
query?: {
page?: number;
limit?: number;
/**
* JSON-encoded filter criteria
*/
filters?: string | null;
/**
* Field to sort by (e.g., 'duration', 'created_at')
*/
sort_by?: string | null;
/**
* Sort order ('asc' or 'desc')
*/
sort_order?: string | null;
};
url: '/api/v1/campaign/{campaign_id}/runs';
};
@ -2622,7 +2679,7 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses = {
/**
* Successful Response
*/
200: Array<WorkflowRunResponse>;
200: CampaignRunsResponse;
};
export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse = GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses[keyof GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses];

View file

@ -1,5 +1,5 @@
import { CalendarIcon } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@ -25,6 +25,19 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
const [isFromOpen, setIsFromOpen] = useState(false);
const [isToOpen, setIsToOpen] = useState(false);
// Local state for time inputs - only syncs to parent on blur
const [fromTime, setFromTime] = useState(value.from?.toTimeString().slice(0, 5) ?? "");
const [toTime, setToTime] = useState(value.to?.toTimeString().slice(0, 5) ?? "");
// Sync local time state when parent value changes
useEffect(() => {
setFromTime(value.from?.toTimeString().slice(0, 5) ?? "");
}, [value.from]);
useEffect(() => {
setToTime(value.to?.toTimeString().slice(0, 5) ?? "");
}, [value.to]);
const formatDate = (date: Date | null) => {
if (!date) return "Select date";
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@ -55,10 +68,11 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
setIsToOpen(false);
};
const handleTimeChange = (type: 'from' | 'to', timeString: string) => {
const handleTimeBlur = (type: 'from' | 'to') => {
const timeString = type === 'from' ? fromTime : toTime;
const [hours, minutes] = timeString.split(':').map(Number);
const date = type === 'from' ? value.from : value.to;
if (date) {
if (date && !isNaN(hours) && !isNaN(minutes)) {
const newDate = new Date(date);
newDate.setHours(hours, minutes);
onChange({ ...value, [type]: newDate });
@ -112,8 +126,9 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
id="from-time"
type="time"
className="w-full mt-1 px-3 py-2 border rounded-md"
value={value.from.toTimeString().slice(0, 5)}
onChange={(e) => handleTimeChange('from', e.target.value)}
value={fromTime}
onChange={(e) => setFromTime(e.target.value)}
onBlur={() => handleTimeBlur('from')}
/>
</div>
)}
@ -151,8 +166,9 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
id="to-time"
type="time"
className="w-full mt-1 px-3 py-2 border rounded-md"
value={value.to.toTimeString().slice(0, 5)}
onChange={(e) => handleTimeChange('to', e.target.value)}
value={toTime}
onChange={(e) => setToTime(e.target.value)}
onBlur={() => handleTimeBlur('to')}
/>
</div>
)}

View file

@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NumberValue } from "@/types/filters";
@ -21,12 +23,23 @@ export const NumberFilter: React.FC<NumberFilterProps> = ({
max,
step = 1,
}) => {
// Local state for fast typing - only syncs to parent on blur
const [localValue, setLocalValue] = useState<string>(value.value?.toString() ?? '');
// Sync local state when parent value changes (e.g., from URL or clear)
useEffect(() => {
setLocalValue(value.value?.toString() ?? '');
}, [value.value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (newValue === '') {
setLocalValue(e.target.value);
};
const handleBlur = () => {
if (localValue === '') {
onChange({ value: null });
} else {
const num = parseInt(newValue, 10);
const num = parseInt(localValue, 10);
if (!isNaN(num)) {
onChange({ value: num });
}
@ -40,8 +53,9 @@ export const NumberFilter: React.FC<NumberFilterProps> = ({
<Input
id="number-filter"
type="number"
value={value.value ?? ''}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
min={min}
max={max}

View file

@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -24,13 +26,23 @@ export const NumberRangeFilter: React.FC<NumberRangeFilterProps> = ({
step = 1,
presets = [],
}) => {
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === "" ? null : Number(e.target.value);
// Local state for fast typing - only syncs to parent on blur
const [localMin, setLocalMin] = useState<string>(value.min?.toString() ?? "");
const [localMax, setLocalMax] = useState<string>(value.max?.toString() ?? "");
// Sync local state when parent value changes (e.g., from URL, clear, or presets)
useEffect(() => {
setLocalMin(value.min?.toString() ?? "");
setLocalMax(value.max?.toString() ?? "");
}, [value.min, value.max]);
const handleMinBlur = () => {
const newValue = localMin === "" ? null : Number(localMin);
onChange({ ...value, min: newValue });
};
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === "" ? null : Number(e.target.value);
const handleMaxBlur = () => {
const newValue = localMax === "" ? null : Number(localMax);
onChange({ ...value, max: newValue });
};
@ -64,8 +76,9 @@ export const NumberRangeFilter: React.FC<NumberRangeFilterProps> = ({
id="min-value"
type="number"
placeholder={`Min ${unit || 'value'}`}
value={value.min ?? ""}
onChange={handleMinChange}
value={localMin}
onChange={(e) => setLocalMin(e.target.value)}
onBlur={handleMinBlur}
min={min}
max={max}
step={step}
@ -80,8 +93,9 @@ export const NumberRangeFilter: React.FC<NumberRangeFilterProps> = ({
id="max-value"
type="number"
placeholder={`Max ${unit || 'value'}`}
value={value.max ?? ""}
onChange={handleMaxChange}
value={localMax}
onChange={(e) => setLocalMax(e.target.value)}
onBlur={handleMaxBlur}
min={min}
max={max}
step={step}

View file

@ -1,4 +1,4 @@
import { ChangeEvent, useState } from "react";
import { ChangeEvent, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -14,6 +14,11 @@ interface TagInputFilterProps {
export const TagInputFilter: React.FC<TagInputFilterProps> = ({ value, onChange, error, placeholder="Enter tags (comma separated)" }) => {
const [text, setText] = useState(value.codes.join(", "));
// Sync local state when parent value changes (e.g., from URL or clear)
useEffect(() => {
setText(value.codes.join(", "));
}, [value.codes]);
const handleBlur = (e: ChangeEvent<HTMLInputElement>) => {
const tags = e.target.value
.split(/[,\n]/)

View file

@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { TextValue } from "@/types/filters";
@ -16,12 +18,25 @@ export const TextFilter: React.FC<TextFilterProps> = ({
placeholder = "Enter text",
maxLength,
}) => {
// Local state for fast typing - only syncs to parent on blur
const [localValue, setLocalValue] = useState(value.value || "");
// Sync local state when parent value changes (e.g., from URL or clear)
useEffect(() => {
setLocalValue(value.value || "");
}, [value.value]);
const handleBlur = () => {
onChange({ value: localValue });
};
return (
<div className="space-y-2">
<Input
type="text"
value={value.value || ""}
onChange={(e) => onChange({ value: e.target.value })}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleBlur}
placeholder={placeholder}
maxLength={maxLength}
className={error ? "border-red-500" : ""}

View file

@ -24,8 +24,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
// Check if we're in workflow editor mode - collapse sidebar by default
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
const isSuperadmin = pathname.startsWith("/superadmin");
// If no sidebar needed, just return children
if (!shouldShowSidebar) {
@ -33,7 +34,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
}
return (
<SidebarProvider defaultOpen={!isWorkflowEditor}>
<SidebarProvider defaultOpen={!isWorkflowEditor && !isSuperadmin}>
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">

View file

@ -0,0 +1,197 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { getCampaignRunsApiV1CampaignCampaignIdRunsGet } from "@/client/sdk.gen";
import { WorkflowRunResponseSchema } from "@/client/types.gen";
import { WorkflowRunsTable } from "@/components/workflow-runs";
import { useAuth } from "@/lib/auth";
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
interface CampaignRunsProps {
campaignId: number;
workflowId: number;
searchParams?: URLSearchParams;
}
export function CampaignRuns({ campaignId, workflowId, searchParams }: CampaignRunsProps) {
const router = useRouter();
const { getAccessToken } = useAuth();
const [runs, setRuns] = useState<WorkflowRunResponseSchema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(() => {
const pageParam = searchParams?.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
});
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [accessToken, setAccessToken] = useState<string | null>(null);
// Sort state
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
return searchParams ? decodeFiltersFromURL(searchParams, availableAttributes) : [];
});
// Get access token on mount
useEffect(() => {
const fetchToken = async () => {
const token = await getAccessToken();
setAccessToken(token);
};
fetchToken();
}, [getAccessToken]);
const fetchCampaignRuns = useCallback(async (
page: number,
filters?: ActiveFilter[],
sortByParam?: string | null,
sortOrderParam?: 'asc' | 'desc'
) => {
if (!accessToken) return;
try {
setLoading(true);
// Prepare filter data for API
let filterParam = undefined;
if (filters && filters.length > 0) {
const filterData = filters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value
}));
filterParam = JSON.stringify(filterData);
}
const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({
path: { campaign_id: campaignId },
query: {
page: page,
limit: 50,
...(filterParam && { filters: filterParam }),
...(sortByParam && { sort_by: sortByParam }),
...(sortOrderParam && { sort_order: sortOrderParam }),
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.error) {
throw new Error("Failed to fetch campaign runs");
}
if (response.data) {
// The API returns runs as array of dicts, convert to WorkflowRunResponseSchema
setRuns((response.data.runs || []) as unknown as WorkflowRunResponseSchema[]);
setTotalPages(response.data.total_pages || 1);
setTotalCount(response.data.total_count || 0);
setCurrentPage(response.data.page || 1);
}
setError(null);
} catch (err) {
console.error("Error fetching campaign runs:", err);
setError("Failed to load campaign runs");
} finally {
setLoading(false);
}
}, [campaignId, accessToken]);
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
const params = new URLSearchParams();
params.set('page', page.toString());
// Add filters to URL if present
if (filters && filters.length > 0) {
const filterString = encodeFiltersToURL(filters);
if (filterString) {
const filterParams = new URLSearchParams(filterString);
filterParams.forEach((value, key) => params.set(key, value));
}
}
router.push(`/campaigns/${campaignId}?${params.toString()}`, { scroll: false });
}, [router, campaignId]);
useEffect(() => {
if (accessToken) {
fetchCampaignRuns(currentPage, activeFilters, sortBy, sortOrder);
}
}, [currentPage, activeFilters, fetchCampaignRuns, accessToken, sortBy, sortOrder]);
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
updatePageInUrl(1, activeFilters);
await fetchCampaignRuns(1, activeFilters, sortBy, sortOrder);
setIsExecutingFilters(false);
}, [activeFilters, fetchCampaignRuns, updatePageInUrl, sortBy, sortOrder]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
}, []);
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
setActiveFilters([]);
updatePageInUrl(1, []);
await fetchCampaignRuns(1, [], sortBy, sortOrder);
setIsExecutingFilters(false);
}, [fetchCampaignRuns, updatePageInUrl, sortBy, sortOrder]);
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
updatePageInUrl(page, activeFilters);
}, [updatePageInUrl, activeFilters]);
const handleSort = useCallback((field: string) => {
// Reset to first page when sort changes
setCurrentPage(1);
if (sortBy === field) {
// Toggle order if same field
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
// New field, default to desc
setSortBy(field);
setSortOrder('desc');
}
}, [sortBy]);
// Use a subset of filter attributes relevant for campaigns
const campaignFilterAttributes: FilterAttribute[] = availableAttributes.filter(
attr => ['dateRange', 'dispositionCode', 'duration', 'status', 'tokenUsage'].includes(attr.id)
);
return (
<WorkflowRunsTable
runs={runs}
loading={loading}
error={error}
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPageChange={handlePageChange}
availableAttributes={campaignFilterAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecutingFilters={isExecutingFilters}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
workflowId={workflowId}
accessToken={accessToken}
title="Campaign Workflow Runs"
emptyMessage="No workflow runs found for this campaign"
/>
);
}

View file

@ -0,0 +1,251 @@
"use client";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
import { useState } from "react";
import { WorkflowRunResponseSchema } from "@/client/types.gen";
import { FilterBuilder } from "@/components/filters/FilterBuilder";
import { MediaPreviewButtons, MediaPreviewDialog } from "@/components/MediaPreviewDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ActiveFilter, FilterAttribute } from "@/types/filters";
export interface WorkflowRunsTableProps {
// Data
runs: WorkflowRunResponseSchema[];
loading: boolean;
error: string | null;
// Pagination
currentPage: number;
totalPages: number;
totalCount: number;
onPageChange: (page: number) => void;
// Filters
availableAttributes: FilterAttribute[];
activeFilters: ActiveFilter[];
onFiltersChange: (filters: ActiveFilter[]) => void;
onApplyFilters: () => void;
onClearFilters: () => void;
isExecutingFilters: boolean;
// Sorting
sortBy?: string | null;
sortOrder?: 'asc' | 'desc';
onSort?: (field: string) => void;
// Navigation & Actions
workflowId: number;
accessToken: string | null;
// Optional customization
title?: string;
subtitle?: string;
showFilters?: boolean;
emptyMessage?: string;
}
export function WorkflowRunsTable({
runs,
loading,
error,
currentPage,
totalPages,
totalCount,
onPageChange,
availableAttributes,
activeFilters,
onFiltersChange,
onApplyFilters,
onClearFilters,
isExecutingFilters,
sortBy,
sortOrder = 'desc',
onSort,
workflowId,
accessToken,
title = "Workflow Run History",
subtitle,
showFilters = true,
emptyMessage = "No workflow runs found",
}: WorkflowRunsTableProps) {
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
// Media preview dialog
const mediaPreview = MediaPreviewDialog({ accessToken });
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
const handleRowClick = (runId: number) => {
window.open(`/workflow/${workflowId}/run/${runId}`, '_blank');
};
return (
<div className="space-y-6">
{/* Title and Filters */}
{showFilters && (
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">{title}</h1>
<FilterBuilder
availableAttributes={availableAttributes}
activeFilters={activeFilters}
onFiltersChange={onFiltersChange}
onApplyFilters={onApplyFilters}
onClearFilters={onClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
)}
{/* Loading State */}
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
{error}
</div>
) : runs.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">{emptyMessage}</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
{subtitle || `Showing ${runs.length} of ${totalCount} total runs`}
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Call Type</TableHead>
<TableHead
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
onClick={() => onSort?.('duration')}
>
<div className="flex items-center gap-1">
Duration
{sortBy === 'duration' ? (
sortOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
) : (
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow
key={run.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}`}
onClick={() => handleRowClick(run.id)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell>
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant="default">
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex space-x-2" onClick={(e) => e.stopPropagation()}>
<MediaPreviewButtons
recordingUrl={run.recording_url}
transcriptUrl={run.transcript_url}
runId={run.id}
onOpenAudio={mediaPreview.openAudioModal}
onOpenTranscript={mediaPreview.openTranscriptModal}
onSelect={setSelectedRowId}
/>
<Button
variant="outline"
size="icon"
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Media Preview Dialog */}
{mediaPreview.dialog}
</div>
);
}

View file

@ -0,0 +1,3 @@
export { CampaignRuns } from "./CampaignRuns";
export type { WorkflowRunsTableProps } from "./WorkflowRunsTable";
export { WorkflowRunsTable } from "./WorkflowRunsTable";

View file

@ -3,23 +3,13 @@
* Update this array when adding new disposition codes
*/
export const DISPOSITION_CODES = [
'CALLBK',
'end_call_tool',
'user_hangup',
'call_duration_exceeded',
'DAIR',
'DNC',
'HU',
'LB',
'ND',
'NIBP',
'NQ',
'user_idle_max_duration_exceeded',
'system_connect_error',
'unknown',
'user_disqualified',
'user_idle_max_duration_exceeded',
'VM',
'voicemail_detected',
'WN',
'XFER',
'voicemail_detected'
] as const;
export type DispositionCode = typeof DISPOSITION_CODES[number];

View file

@ -1,17 +0,0 @@
// Color variants for disposition code
export const getDispositionBadgeVariant = (code: string | undefined): "default" | "secondary" | "destructive" | "outline" | "success" => {
if (!code) return "outline";
const upperCode = code.toUpperCase();
switch (upperCode) {
case "XFER":
return "success"; // Green color for transfers
case "HU":
case "NIBP":
return "destructive"; // Red color for hang up and NIBP
case "VM":
return "secondary";
default:
return "default"; // Default color for all other codes
}
};