feat: add redial option in campaigns

This commit is contained in:
Abhishek Kumar 2026-04-13 23:25:43 +05:30
parent 79116e6af2
commit 7fab959e26
14 changed files with 998 additions and 58 deletions

View file

@ -1,7 +1,7 @@
"use client";
import { format } from 'date-fns';
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react';
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -11,6 +11,7 @@ import {
getCampaignApiV1CampaignCampaignIdGet,
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
pauseCampaignApiV1CampaignCampaignIdPausePost,
redialCampaignApiV1CampaignCampaignIdRedialPost,
resumeCampaignApiV1CampaignCampaignIdResumePost,
startCampaignApiV1CampaignCampaignIdStartPost,
} from '@/client/sdk.gen';
@ -19,6 +20,8 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@ -55,6 +58,14 @@ export default function CampaignDetailPage() {
const [reportEndTime, setReportEndTime] = useState('23:59');
const [isReportPopoverOpen, setIsReportPopoverOpen] = useState(false);
// Redial dialog state
const [isRedialDialogOpen, setIsRedialDialogOpen] = useState(false);
const [redialName, setRedialName] = useState('');
const [redialOnVoicemail, setRedialOnVoicemail] = useState(true);
const [redialOnNoAnswer, setRedialOnNoAnswer] = useState(true);
const [redialOnBusy, setRedialOnBusy] = useState(true);
const [isRedialing, setIsRedialing] = useState(false);
// Fetch campaign details
const fetchCampaign = useCallback(async () => {
if (!user) return;
@ -258,6 +269,62 @@ export default function CampaignDetailPage() {
}
};
// Open redial dialog with default name
const openRedialDialog = () => {
if (!campaign) return;
setRedialName(`${campaign.name} (Redial)`);
setRedialOnVoicemail(true);
setRedialOnNoAnswer(true);
setRedialOnBusy(true);
setIsRedialDialogOpen(true);
};
// Handle redial campaign
const handleRedial = async () => {
if (!user || !campaign) return;
if (!redialOnVoicemail && !redialOnNoAnswer && !redialOnBusy) {
toast.error('Select at least one reason to redial');
return;
}
setIsRedialing(true);
try {
const accessToken = await getAccessToken();
const response = await redialCampaignApiV1CampaignCampaignIdRedialPost({
path: {
campaign_id: campaignId,
},
body: {
name: redialName || null,
retry_on_voicemail: redialOnVoicemail,
retry_on_no_answer: redialOnNoAnswer,
retry_on_busy: redialOnBusy,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
toast.success('Redial campaign created');
setIsRedialDialogOpen(false);
router.push(`/campaigns/${response.data.id}`);
} else if (response.error) {
let errorMsg = 'Failed to create redial campaign';
if (typeof response.error === 'string') {
errorMsg = response.error;
} else if (response.error && typeof response.error === 'object') {
errorMsg = (response.error as unknown as { detail?: string }).detail || JSON.stringify(response.error);
}
toast.error(errorMsg);
}
} catch (error) {
console.error('Failed to redial campaign:', error);
toast.error('Failed to create redial campaign');
} finally {
setIsRedialing(false);
}
};
// Handle pause campaign
const handlePause = async () => {
if (!user) return;
@ -356,6 +423,16 @@ export default function CampaignDetailPage() {
</Button>
</div>
);
case 'completed':
if (campaign.redialed_campaign_id) {
return null;
}
return (
<Button onClick={openRedialDialog}>
<Phone className="h-4 w-4 mr-2" />
Redial Campaign
</Button>
);
default:
return null;
}
@ -541,6 +618,38 @@ export default function CampaignDetailPage() {
<dt className="text-sm font-medium">State</dt>
<dd className="mt-1 capitalize">{campaign.state}</dd>
</div>
<div>
<dt className="text-sm font-medium">Progress</dt>
<dd className="mt-1">
{campaign.executed_count} / {campaign.total_queued_count}
</dd>
</div>
{campaign.parent_campaign_id && (
<div>
<dt className="text-sm font-medium">Redial Of</dt>
<dd className="mt-1">
<button
onClick={() => router.push(`/campaigns/${campaign.parent_campaign_id}`)}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
Campaign #{campaign.parent_campaign_id}
</button>
</dd>
</div>
)}
{campaign.redialed_campaign_id && (
<div>
<dt className="text-sm font-medium">Redialed As</dt>
<dd className="mt-1">
<button
onClick={() => router.push(`/campaigns/${campaign.redialed_campaign_id}`)}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
Campaign #{campaign.redialed_campaign_id}
</button>
</dd>
</div>
)}
{campaign.started_at && (
<div>
<dt className="text-sm font-medium">Started At</dt>
@ -678,6 +787,75 @@ export default function CampaignDetailPage() {
workflowId={campaign.workflow_id}
searchParams={searchParams}
/>
<Dialog open={isRedialDialogOpen} onOpenChange={setIsRedialDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Redial Campaign</DialogTitle>
<DialogDescription>
Creates a new campaign that re-dials unique subscribers whose
last call ended with one of the selected outcomes. Subscribers
who were successfully reached on a retry are skipped.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="redial-name">Name</Label>
<Input
id="redial-name"
value={redialName}
onChange={(e) => setRedialName(e.target.value)}
placeholder="Campaign name"
/>
</div>
<div className="space-y-3">
<Label>Redial when last call was</Label>
<div className="flex items-center gap-2">
<Checkbox
id="redial-voicemail"
checked={redialOnVoicemail}
onCheckedChange={(v) => setRedialOnVoicemail(v === true)}
/>
<Label htmlFor="redial-voicemail" className="font-normal">
Voicemail
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="redial-no-answer"
checked={redialOnNoAnswer}
onCheckedChange={(v) => setRedialOnNoAnswer(v === true)}
/>
<Label htmlFor="redial-no-answer" className="font-normal">
No Answer
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="redial-busy"
checked={redialOnBusy}
onCheckedChange={(v) => setRedialOnBusy(v === true)}
/>
<Label htmlFor="redial-busy" className="font-normal">
Busy
</Label>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsRedialDialogOpen(false)}
disabled={isRedialing}
>
Cancel
</Button>
<Button onClick={handleRedial} disabled={isRedialing}>
{isRedialing ? 'Creating...' : 'Create Redial Campaign'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -129,6 +129,7 @@ export default function CampaignsPage() {
<TableHead>Name</TableHead>
<TableHead>Workflow</TableHead>
<TableHead>State</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
@ -148,6 +149,9 @@ export default function CampaignsPage() {
{campaign.state}
</Badge>
</TableCell>
<TableCell>
{campaign.executed_count} / {campaign.total_queued_count}
</TableCell>
<TableCell>{formatDate(campaign.created_at)}</TableCell>
<TableCell className="text-right">
<Button

View file

@ -1,20 +1,24 @@
"use client";
import { ArrowLeft, BookA, Brain, ExternalLink, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
import { format } from "date-fns";
import { ArrowLeft, BookA, Brain, CalendarIcon, Download, ExternalLink, FileDown, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import { downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import type { WorkflowResponse } from "@/client/types.gen";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import SpinLoader from "@/components/SpinLoader";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
@ -76,8 +80,166 @@ const NAV_ITEMS = [
{ id: "voicemail", label: "Voicemail Detection", icon: PhoneOff },
{ id: "recordings", label: "Recordings", icon: Mic },
{ id: "deployment", label: "Deployment", icon: Rocket },
{ id: "report", label: "Report", icon: FileDown },
];
// ---------------------------------------------------------------------------
// Section: Report
// ---------------------------------------------------------------------------
function ReportSection({ workflowId }: { workflowId: number }) {
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [startTime, setStartTime] = useState("00:00");
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [endTime, setEndTime] = useState("23:59");
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const buildDateTime = (date: Date | undefined, time: string): string | undefined => {
if (!date) return undefined;
const [hours, minutes] = time.split(":").map(Number);
const combined = new Date(date);
combined.setHours(hours, minutes, 0, 0);
return combined.toISOString();
};
const handleDownload = async () => {
setIsDownloading(true);
setIsPopoverOpen(false);
try {
const response = await downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet({
path: { workflow_id: workflowId },
query: {
start_date: buildDateTime(startDate, startTime),
end_date: buildDateTime(endDate, endTime),
},
parseAs: "blob",
});
if (response.data) {
const blob = response.data as Blob;
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `workflow_${workflowId}_report.csv`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} else {
toast.error("Failed to download report");
}
} catch (err) {
logger.error(`Failed to download workflow report: ${err}`);
toast.error("Failed to download report");
} finally {
setIsDownloading(false);
}
};
const handleClear = () => {
setStartDate(undefined);
setStartTime("00:00");
setEndDate(undefined);
setEndTime("23:59");
};
return (
<Card id="report">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileDown className="h-4 w-4" />
Report
</CardTitle>
<CardDescription>
Download a CSV report of completed runs for this agent, optionally filtered by date range.
</CardDescription>
</CardHeader>
<CardFooter className="border-t pt-6">
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" disabled={isDownloading}>
<Download className="h-4 w-4 mr-2" />
Download Report
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="text-sm font-medium">Filter by date range</div>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label className="text-xs">From</Label>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-[140px] justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
{startDate ? format(startDate, "MMM dd, yyyy") : "Start date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={setStartDate}
disabled={(date) => (endDate ? date > endDate : false)}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-[100px] h-8 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">To</Label>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-[140px] justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
{endDate ? format(endDate, "MMM dd, yyyy") : "End date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={endDate}
onSelect={setEndDate}
disabled={(date) => (startDate ? date < startDate : false)}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-[100px] h-8 text-xs"
/>
</div>
</div>
</div>
<Separator />
<div className="flex justify-between">
<Button variant="ghost" size="sm" onClick={handleClear}>
Clear
</Button>
<Button size="sm" onClick={handleDownload} disabled={isDownloading}>
<Download className="h-3.5 w-3.5 mr-1.5" />
{startDate || endDate ? "Download Filtered" : "Download All"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section: General
// ---------------------------------------------------------------------------
@ -1098,6 +1260,9 @@ function WorkflowSettingsInner({
</Button>
</CardFooter>
</Card>
{/* Report */}
<ReportSection workflowId={workflowId} />
</>
)}
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -467,6 +467,22 @@ export type CampaignResponse = {
max_concurrency?: number | null;
schedule_config?: ScheduleConfigResponse | null;
circuit_breaker?: CircuitBreakerConfigResponse | null;
/**
* Executed Count
*/
executed_count?: number;
/**
* Total Queued Count
*/
total_queued_count?: number;
/**
* Parent Campaign Id
*/
parent_campaign_id?: number | null;
/**
* Redialed Campaign Id
*/
redialed_campaign_id?: number | null;
};
/**
@ -2282,6 +2298,31 @@ export type RecordingUploadResponseSchema = {
storage_key: string;
};
/**
* RedialCampaignRequest
*/
export type RedialCampaignRequest = {
/**
* Name
*
* Name for the redial campaign
*/
name?: string | null;
/**
* Retry On Voicemail
*/
retry_on_voicemail?: boolean;
/**
* Retry On No Answer
*/
retry_on_no_answer?: boolean;
/**
* Retry On Busy
*/
retry_on_busy?: boolean;
retry_config?: RetryConfigRequest | null;
};
/**
* RetryConfigRequest
*/
@ -3267,6 +3308,10 @@ export type UserResponse = {
* Organization Id
*/
organization_id?: number | null;
/**
* Provider Id
*/
provider_id?: string | null;
};
/**
@ -5195,6 +5240,61 @@ export type GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponses = {
export type GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse = GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponses[keyof GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponses];
export type DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
};
query?: {
/**
* Start Date
*
* Filter runs created on or after this datetime (ISO 8601)
*/
start_date?: string | null;
/**
* End Date
*
* Filter runs created on or before this datetime (ISO 8601)
*/
end_date?: string | null;
};
url: '/api/v1/workflow/{workflow_id}/report';
};
export type DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetError = DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetErrors[keyof DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetErrors];
export type DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetWorkflowTemplatesApiV1WorkflowTemplatesGetData = {
body?: never;
path?: never;
@ -6041,6 +6141,50 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses = {
export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse = GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses[keyof GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses];
export type RedialCampaignApiV1CampaignCampaignIdRedialPostData = {
body: RedialCampaignRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Campaign Id
*/
campaign_id: number;
};
query?: never;
url: '/api/v1/campaign/{campaign_id}/redial';
};
export type RedialCampaignApiV1CampaignCampaignIdRedialPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RedialCampaignApiV1CampaignCampaignIdRedialPostError = RedialCampaignApiV1CampaignCampaignIdRedialPostErrors[keyof RedialCampaignApiV1CampaignCampaignIdRedialPostErrors];
export type RedialCampaignApiV1CampaignCampaignIdRedialPostResponses = {
/**
* Successful Response
*/
200: CampaignResponse;
};
export type RedialCampaignApiV1CampaignCampaignIdRedialPostResponse = RedialCampaignApiV1CampaignCampaignIdRedialPostResponses[keyof RedialCampaignApiV1CampaignCampaignIdRedialPostResponses];
export type ResumeCampaignApiV1CampaignCampaignIdResumePostData = {
body?: never;
headers?: {