Feat/campaign enhancements (#163)

* feat: add circuit breaker to safeguard

* feat: Add Circuit breaker in campaigns to safeguard against telephony failures

* feat: add schedules in campaigns
This commit is contained in:
Abhishek 2026-02-17 21:04:15 +05:30 committed by GitHub
parent 7552b6c819
commit fe4ea648e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2037 additions and 149 deletions

View file

@ -0,0 +1,300 @@
"use client";
import { Plus, X } from 'lucide-react';
import { useId } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
export type TimeSlot = { day_of_week: number; start_time: string; end_time: string };
export interface CampaignAdvancedSettingsProps {
// Concurrency
maxConcurrency: string;
onMaxConcurrencyChange: (value: string) => void;
effectiveLimit: number;
orgConcurrentLimit: number;
fromNumbersCount: number;
// Retry config
retryEnabled: boolean;
onRetryEnabledChange: (value: boolean) => void;
maxRetries: string;
onMaxRetriesChange: (value: string) => void;
retryDelaySeconds: string;
onRetryDelaySecondsChange: (value: string) => void;
retryOnBusy: boolean;
onRetryOnBusyChange: (value: boolean) => void;
retryOnNoAnswer: boolean;
onRetryOnNoAnswerChange: (value: boolean) => void;
retryOnVoicemail: boolean;
onRetryOnVoicemailChange: (value: boolean) => void;
// Schedule config
scheduleEnabled: boolean;
onScheduleEnabledChange: (value: boolean) => void;
scheduleTimezone: ITimezoneOption | string;
onScheduleTimezoneChange: (value: ITimezoneOption | string) => void;
timeSlots: TimeSlot[];
onTimeSlotsChange: (value: TimeSlot[]) => void;
}
/** Extract the string timezone value from ITimezoneOption | string */
export function getTimezoneValue(tz: ITimezoneOption | string): string {
return typeof tz === 'string' ? tz : tz.value;
}
const timezoneSelectStyles = {
control: (base: Record<string, unknown>, state: { isFocused: boolean }) => ({
...base,
minHeight: '36px',
fontSize: '14px',
backgroundColor: 'var(--background)',
borderColor: state.isFocused ? 'var(--ring)' : 'var(--border)',
boxShadow: state.isFocused ? '0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)' : 'none',
'&:hover': { borderColor: 'var(--border)' },
}),
menu: (base: Record<string, unknown>) => ({
...base,
zIndex: 9999,
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
}),
menuList: (base: Record<string, unknown>) => ({
...base,
backgroundColor: 'var(--popover)',
padding: 0,
}),
option: (base: Record<string, unknown>, state: { isSelected: boolean; isFocused: boolean }) => ({
...base,
backgroundColor: state.isSelected ? 'var(--accent)' : state.isFocused ? 'var(--accent)' : 'var(--popover)',
color: 'var(--foreground)',
cursor: 'pointer',
'&:active': { backgroundColor: 'var(--accent)' },
}),
singleValue: (base: Record<string, unknown>) => ({ ...base, color: 'var(--foreground)' }),
input: (base: Record<string, unknown>) => ({ ...base, color: 'var(--foreground)' }),
placeholder: (base: Record<string, unknown>) => ({ ...base, color: 'var(--muted-foreground)' }),
indicatorSeparator: (base: Record<string, unknown>) => ({ ...base, backgroundColor: 'var(--border)' }),
dropdownIndicator: (base: Record<string, unknown>) => ({
...base,
color: 'var(--muted-foreground)',
'&:hover': { color: 'var(--foreground)' },
}),
};
export default function CampaignAdvancedSettings({
maxConcurrency, onMaxConcurrencyChange, effectiveLimit, orgConcurrentLimit, fromNumbersCount,
retryEnabled, onRetryEnabledChange, maxRetries, onMaxRetriesChange,
retryDelaySeconds, onRetryDelaySecondsChange,
retryOnBusy, onRetryOnBusyChange, retryOnNoAnswer, onRetryOnNoAnswerChange,
retryOnVoicemail, onRetryOnVoicemailChange,
scheduleEnabled, onScheduleEnabledChange, scheduleTimezone, onScheduleTimezoneChange,
timeSlots, onTimeSlotsChange,
}: CampaignAdvancedSettingsProps) {
const timezoneSelectId = useId();
return (
<div className="space-y-6">
{/* Max Concurrent Calls */}
<div className="space-y-2">
<Label htmlFor="max-concurrency">Max Concurrent Calls</Label>
<Input
id="max-concurrency"
type="number"
placeholder={`Default: ${effectiveLimit}`}
value={maxConcurrency}
onChange={(e) => onMaxConcurrencyChange(e.target.value)}
min={1}
max={effectiveLimit}
/>
<p className="text-sm text-muted-foreground">
Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}.
{fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`}
</p>
{fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a>.
</p>
)}
{fromNumbersCount === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No phone numbers configured. Add CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a> before running the campaign.
</p>
)}
</div>
{/* Retry Configuration */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="retry-enabled">Enable Retries</Label>
<p className="text-sm text-muted-foreground">
Automatically retry failed calls
</p>
</div>
<Switch
id="retry-enabled"
checked={retryEnabled}
onCheckedChange={onRetryEnabledChange}
/>
</div>
{retryEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max-retries">Max Retries</Label>
<Input
id="max-retries"
type="number"
value={maxRetries}
onChange={(e) => onMaxRetriesChange(e.target.value)}
min={0}
max={10}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retry-delay">Retry Delay (seconds)</Label>
<Input
id="retry-delay"
type="number"
value={retryDelaySeconds}
onChange={(e) => onRetryDelaySecondsChange(e.target.value)}
min={30}
max={3600}
/>
</div>
</div>
<div className="space-y-3">
<Label>Retry On</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Busy Signal</span>
<Switch checked={retryOnBusy} onCheckedChange={onRetryOnBusyChange} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm">No Answer</span>
<Switch checked={retryOnNoAnswer} onCheckedChange={onRetryOnNoAnswerChange} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Voicemail</span>
<Switch checked={retryOnVoicemail} onCheckedChange={onRetryOnVoicemailChange} />
</div>
</div>
</div>
</div>
)}
</div>
<Separator />
{/* Call Schedule */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="schedule-enabled">Call Schedule</Label>
<p className="text-sm text-muted-foreground">
Restrict when calls are made
</p>
</div>
<Switch
id="schedule-enabled"
checked={scheduleEnabled}
onCheckedChange={onScheduleEnabledChange}
/>
</div>
{scheduleEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="space-y-2">
<Label>Timezone</Label>
<TimezoneSelect
instanceId={timezoneSelectId}
value={scheduleTimezone}
onChange={onScheduleTimezoneChange}
styles={timezoneSelectStyles}
/>
</div>
<div className="space-y-3">
<Label>Time Slots</Label>
{timeSlots.map((slot, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={String(slot.day_of_week)}
onValueChange={(val) => {
const updated = [...timeSlots];
updated[index] = { ...updated[index], day_of_week: parseInt(val) };
onTimeSlotsChange(updated);
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, i) => (
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="time"
value={slot.start_time}
onChange={(e) => {
const updated = [...timeSlots];
updated[index] = { ...updated[index], start_time: e.target.value };
onTimeSlotsChange(updated);
}}
className="w-[130px]"
/>
<span className="text-sm text-muted-foreground">to</span>
<Input
type="time"
value={slot.end_time}
onChange={(e) => {
const updated = [...timeSlots];
updated[index] = { ...updated[index], end_time: e.target.value };
onTimeSlotsChange(updated);
}}
className="w-[130px]"
/>
{timeSlots.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onTimeSlotsChange(timeSlots.filter((_, i) => i !== index))}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onTimeSlotsChange([...timeSlots, { day_of_week: 0, start_time: '09:00', end_time: '17:00' }])}
>
<Plus className="h-4 w-4 mr-1" />
Add Time Slot
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,364 @@
"use client";
import { ArrowLeft } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import type { ITimezoneOption } from 'react-timezone-select';
import { toast } from 'sonner';
import {
getCampaignApiV1CampaignCampaignIdGet,
getCampaignLimitsApiV1OrganizationsCampaignLimitsGet,
updateCampaignApiV1CampaignCampaignIdPatch,
} from '@/client/sdk.gen';
import type { CampaignResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { useAuth } from '@/lib/auth';
import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../../CampaignAdvancedSettings';
export default function EditCampaignPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const router = useRouter();
const params = useParams();
const campaignId = parseInt(params.campaignId as string);
// Loading state
const [isLoading, setIsLoading] = useState(true);
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
// Form state
const [campaignName, setCampaignName] = useState('');
const [maxConcurrency, setMaxConcurrency] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Limits state
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
const [fromNumbersCount, setFromNumbersCount] = useState<number>(0);
// Retry config state
const [retryEnabled, setRetryEnabled] = useState(true);
const [maxRetries, setMaxRetries] = useState<string>('2');
const [retryDelaySeconds, setRetryDelaySeconds] = useState<string>('120');
const [retryOnBusy, setRetryOnBusy] = useState(true);
const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true);
const [retryOnVoicemail, setRetryOnVoicemail] = useState(true);
// Schedule config state
const [scheduleEnabled, setScheduleEnabled] = useState(false);
const [scheduleTimezone, setScheduleTimezone] = useState<ITimezoneOption | string>('UTC');
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([
{ day_of_week: 0, start_time: '09:00', end_time: '17:00' },
]);
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
redirectToLogin();
}
}, [loading, user, redirectToLogin]);
// Fetch campaign and populate form
const fetchCampaign = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await getCampaignApiV1CampaignCampaignIdGet({
path: { campaign_id: campaignId },
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.data) {
const c = response.data;
// Redirect if campaign is completed or failed
if (['completed', 'failed'].includes(c.state)) {
router.replace(`/campaigns/${campaignId}`);
return;
}
setCampaign(c);
// Populate form state
setCampaignName(c.name);
setMaxConcurrency(c.max_concurrency ? String(c.max_concurrency) : '');
// Retry config
setRetryEnabled(c.retry_config.enabled);
setMaxRetries(String(c.retry_config.max_retries));
setRetryDelaySeconds(String(c.retry_config.retry_delay_seconds));
setRetryOnBusy(c.retry_config.retry_on_busy);
setRetryOnNoAnswer(c.retry_config.retry_on_no_answer);
setRetryOnVoicemail(c.retry_config.retry_on_voicemail);
// Schedule config
if (c.schedule_config) {
setScheduleEnabled(c.schedule_config.enabled);
setScheduleTimezone(c.schedule_config.timezone);
if (c.schedule_config.slots.length > 0) {
setTimeSlots(c.schedule_config.slots.map(s => ({ ...s })));
}
}
}
} catch (error) {
console.error('Failed to fetch campaign:', error);
toast.error('Failed to load campaign');
router.replace(`/campaigns/${campaignId}`);
} finally {
setIsLoading(false);
}
}, [user, getAccessToken, campaignId, router]);
// Fetch campaign limits
const fetchCampaignLimits = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await getCampaignLimitsApiV1OrganizationsCampaignLimitsGet({
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.data) {
setOrgConcurrentLimit(response.data.concurrent_call_limit);
setFromNumbersCount(response.data.from_numbers_count);
}
} catch (error) {
console.error('Failed to fetch campaign limits:', error);
}
}, [user, getAccessToken]);
// Initial load
useEffect(() => {
if (user) {
fetchCampaign();
fetchCampaignLimits();
}
}, [fetchCampaign, fetchCampaignLimits, user]);
// Effective concurrency limit
const effectiveLimit = fromNumbersCount > 0
? Math.min(orgConcurrentLimit, fromNumbersCount)
: orgConcurrentLimit;
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitError(null);
if (!campaignName.trim()) {
toast.error('Campaign name is required');
return;
}
// Validate max_concurrency if provided
const maxConcurrencyValue = maxConcurrency ? parseInt(maxConcurrency) : null;
if (maxConcurrencyValue !== null) {
if (isNaN(maxConcurrencyValue) || maxConcurrencyValue < 1 || maxConcurrencyValue > 100) {
toast.error('Max concurrent calls must be between 1 and 100');
return;
}
if (maxConcurrencyValue > effectiveLimit) {
if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured — add more CLIs to increase concurrency.`);
} else {
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
}
return;
}
}
// Validate schedule slots if enabled
if (scheduleEnabled) {
if (timeSlots.length === 0) {
toast.error('Add at least one time slot');
return;
}
for (const slot of timeSlots) {
if (slot.start_time >= slot.end_time) {
toast.error('Start time must be before end time for each slot');
return;
}
}
}
setIsSubmitting(true);
try {
const accessToken = await getAccessToken();
const retryConfig = {
enabled: retryEnabled,
max_retries: parseInt(maxRetries) || 2,
retry_delay_seconds: parseInt(retryDelaySeconds) || 120,
retry_on_busy: retryOnBusy,
retry_on_no_answer: retryOnNoAnswer,
retry_on_voicemail: retryOnVoicemail,
};
const timezoneValue = getTimezoneValue(scheduleTimezone);
const scheduleConfig = scheduleEnabled && timeSlots.length > 0
? {
enabled: true,
timezone: timezoneValue,
slots: timeSlots,
}
: {
enabled: false,
timezone: timezoneValue,
slots: [{ day_of_week: 0, start_time: '09:00', end_time: '17:00' }],
};
const response = await updateCampaignApiV1CampaignCampaignIdPatch({
path: { campaign_id: campaignId },
body: {
name: campaignName,
retry_config: retryConfig,
max_concurrency: maxConcurrencyValue,
schedule_config: scheduleConfig,
},
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
const errorDetail = (response.error as { detail?: string })?.detail;
const errorMessage = errorDetail || 'Failed to update campaign';
setSubmitError(errorMessage);
toast.error(errorMessage);
return;
}
if (response.data) {
toast.success('Campaign updated successfully');
router.push(`/campaigns/${campaignId}`);
}
} catch (error) {
console.error('Failed to update campaign:', error);
const errorMessage = 'Failed to update campaign';
setSubmitError(errorMessage);
toast.error(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleBack = () => {
router.push(`/campaigns/${campaignId}`);
};
if (isLoading) {
return (
<div className="container mx-auto p-6 space-y-6 max-w-2xl">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-1/4 mb-4"></div>
<div className="h-64 bg-muted rounded"></div>
</div>
</div>
);
}
if (!campaign) {
return (
<div className="container mx-auto p-6 space-y-6 max-w-2xl">
<p className="text-center text-muted-foreground">Campaign not found</p>
</div>
);
}
return (
<div className="container mx-auto p-6 pb-12 space-y-6 max-w-2xl">
<div>
<Button
variant="ghost"
onClick={handleBack}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Campaign
</Button>
<h1 className="text-3xl font-bold mb-2">Edit Campaign</h1>
<p className="text-muted-foreground">Modify campaign settings</p>
</div>
<Card>
<CardHeader>
<CardTitle>Campaign Settings</CardTitle>
<CardDescription>
Update name, concurrency, retry, and schedule configuration
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Campaign Name */}
<div className="space-y-2">
<Label htmlFor="campaign-name">Campaign Name</Label>
<Input
id="campaign-name"
placeholder="Enter campaign name"
value={campaignName}
onChange={(e) => setCampaignName(e.target.value)}
maxLength={255}
required
/>
</div>
<Separator />
<CampaignAdvancedSettings
maxConcurrency={maxConcurrency}
onMaxConcurrencyChange={setMaxConcurrency}
effectiveLimit={effectiveLimit}
orgConcurrentLimit={orgConcurrentLimit}
fromNumbersCount={fromNumbersCount}
retryEnabled={retryEnabled}
onRetryEnabledChange={setRetryEnabled}
maxRetries={maxRetries}
onMaxRetriesChange={setMaxRetries}
retryDelaySeconds={retryDelaySeconds}
onRetryDelaySecondsChange={setRetryDelaySeconds}
retryOnBusy={retryOnBusy}
onRetryOnBusyChange={setRetryOnBusy}
retryOnNoAnswer={retryOnNoAnswer}
onRetryOnNoAnswerChange={setRetryOnNoAnswer}
retryOnVoicemail={retryOnVoicemail}
onRetryOnVoicemailChange={setRetryOnVoicemail}
scheduleEnabled={scheduleEnabled}
onScheduleEnabledChange={setScheduleEnabled}
scheduleTimezone={scheduleTimezone}
onScheduleTimezoneChange={setScheduleTimezone}
timeSlots={timeSlots}
onTimeSlotsChange={setTimeSlots}
/>
{submitError && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{submitError}
</div>
)}
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={isSubmitting || !campaignName.trim()}
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleBack}
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react';
import { ArrowLeft, Check, Clock, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -10,7 +10,8 @@ import {
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
pauseCampaignApiV1CampaignCampaignIdPausePost,
resumeCampaignApiV1CampaignCampaignIdResumePost,
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
startCampaignApiV1CampaignCampaignIdStartPost,
} from '@/client/sdk.gen';
import type { CampaignResponse } from '@/client/types.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -236,31 +237,49 @@ export default function CampaignDetailPage() {
}
};
const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state);
// Render action button based on state
const renderActionButton = () => {
if (!campaign || isExecutingAction) return null;
const editButton = canEdit ? (
<Button variant="outline" onClick={() => router.push(`/campaigns/${campaignId}/edit`)}>
<Pencil className="h-4 w-4 mr-2" />
Edit Campaign
</Button>
) : null;
switch (campaign.state) {
case 'created':
return (
<Button onClick={handleStart} disabled={isExecutingAction}>
<Play className="h-4 w-4 mr-2" />
Start Campaign
</Button>
<div className="flex items-center gap-2">
{editButton}
<Button onClick={handleStart} disabled={isExecutingAction}>
<Play className="h-4 w-4 mr-2" />
Start Campaign
</Button>
</div>
);
case 'running':
return (
<Button onClick={handlePause} disabled={isExecutingAction}>
<Pause className="h-4 w-4 mr-2" />
Pause Campaign
</Button>
<div className="flex items-center gap-2">
{editButton}
<Button onClick={handlePause} disabled={isExecutingAction}>
<Pause className="h-4 w-4 mr-2" />
Pause Campaign
</Button>
</div>
);
case 'paused':
return (
<Button onClick={handleResume} disabled={isExecutingAction}>
<RefreshCw className="h-4 w-4 mr-2" />
Resume Campaign
</Button>
<div className="flex items-center gap-2">
{editButton}
<Button onClick={handleResume} disabled={isExecutingAction}>
<RefreshCw className="h-4 w-4 mr-2" />
Resume Campaign
</Button>
</div>
);
default:
return null;
@ -449,6 +468,51 @@ export default function CampaignDetailPage() {
</div>
)}
</div>
<Separator />
{/* Call Schedule (read-only) */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Call Schedule</span>
<div className="flex items-center gap-2">
{campaign.schedule_config?.enabled ? (
<Badge variant="default" className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Enabled
</Badge>
) : (
<Badge variant="secondary" className="flex items-center gap-1">
<X className="h-3 w-3" />
Not configured
</Badge>
)}
</div>
</div>
{campaign.schedule_config?.enabled && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Timezone</dt>
<dd className="mt-1 font-medium">{campaign.schedule_config.timezone.replace(/_/g, ' ')}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Time Slots</dt>
<dd className="mt-1 flex flex-wrap gap-2">
{campaign.schedule_config.slots.map((slot, index) => {
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return (
<div key={index} className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">{dayNames[slot.day_of_week]}</Badge>
<span className="text-sm">{slot.start_time} - {slot.end_time}</span>
</div>
);
})}
</dd>
</div>
</div>
)}
</div>
</CardContent>
</Card>

View file

@ -3,6 +3,7 @@
import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import type { ITimezoneOption } from 'react-timezone-select';
import { toast } from 'sonner';
import {
@ -23,9 +24,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useAuth } from '@/lib/auth';
import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../CampaignAdvancedSettings';
import CsvUploadSelector from '../CsvUploadSelector';
import GoogleSheetSelector from '../GoogleSheetSelector';
@ -59,6 +60,18 @@ export default function NewCampaignPage() {
const [retryOnBusy, setRetryOnBusy] = useState(true);
const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true);
const [retryOnVoicemail, setRetryOnVoicemail] = useState(true);
// Schedule config state
const [scheduleEnabled, setScheduleEnabled] = useState(false);
const [scheduleTimezone, setScheduleTimezone] = useState<ITimezoneOption | string>(() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'UTC';
}
});
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([
{ day_of_week: 0, start_time: '09:00', end_time: '17:00' },
]);
// Redirect if not authenticated
useEffect(() => {
@ -163,7 +176,6 @@ export default function NewCampaignPage() {
try {
const accessToken = await getAccessToken();
// Build retry_config only if user has modified settings from defaults
const retryConfig = {
enabled: retryEnabled,
max_retries: parseInt(maxRetries) || 2,
@ -173,6 +185,16 @@ export default function NewCampaignPage() {
retry_on_voicemail: retryOnVoicemail,
};
// Build schedule_config if enabled
const timezoneValue = getTimezoneValue(scheduleTimezone);
const scheduleConfig = scheduleEnabled && timeSlots.length > 0
? {
enabled: true,
timezone: timezoneValue,
slots: timeSlots,
}
: undefined;
const response = await createCampaignApiV1CampaignCreatePost({
body: {
name: campaignName,
@ -181,6 +203,7 @@ export default function NewCampaignPage() {
source_id: sourceId,
retry_config: retryConfig,
max_concurrency: maxConcurrencyValue,
schedule_config: scheduleConfig,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
@ -353,107 +376,32 @@ export default function NewCampaignPage() {
<ChevronRight className="h-4 w-4" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4 space-y-6">
{/* Max Concurrent Calls */}
<div className="space-y-2">
<Label htmlFor="max-concurrency">Max Concurrent Calls</Label>
<Input
id="max-concurrency"
type="number"
placeholder={`Default: ${effectiveLimit}`}
value={maxConcurrency}
onChange={(e) => setMaxConcurrency(e.target.value)}
min={1}
max={effectiveLimit}
/>
<p className="text-sm text-muted-foreground">
Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}.
{fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`}
</p>
{fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a>.
</p>
)}
{fromNumbersCount === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No phone numbers configured. Add CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a> before running the campaign.
</p>
)}
</div>
{/* Retry Configuration */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="retry-enabled">Enable Retries</Label>
<p className="text-sm text-muted-foreground">
Automatically retry failed calls
</p>
</div>
<Switch
id="retry-enabled"
checked={retryEnabled}
onCheckedChange={setRetryEnabled}
/>
</div>
{retryEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max-retries">Max Retries</Label>
<Input
id="max-retries"
type="number"
value={maxRetries}
onChange={(e) => setMaxRetries(e.target.value)}
min={0}
max={10}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retry-delay">Retry Delay (seconds)</Label>
<Input
id="retry-delay"
type="number"
value={retryDelaySeconds}
onChange={(e) => setRetryDelaySeconds(e.target.value)}
min={30}
max={3600}
/>
</div>
</div>
<div className="space-y-3">
<Label>Retry On</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Busy Signal</span>
<Switch
checked={retryOnBusy}
onCheckedChange={setRetryOnBusy}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">No Answer</span>
<Switch
checked={retryOnNoAnswer}
onCheckedChange={setRetryOnNoAnswer}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Voicemail</span>
<Switch
checked={retryOnVoicemail}
onCheckedChange={setRetryOnVoicemail}
/>
</div>
</div>
</div>
</div>
)}
</div>
<CollapsibleContent className="px-4 pb-4">
<CampaignAdvancedSettings
maxConcurrency={maxConcurrency}
onMaxConcurrencyChange={setMaxConcurrency}
effectiveLimit={effectiveLimit}
orgConcurrentLimit={orgConcurrentLimit}
fromNumbersCount={fromNumbersCount}
retryEnabled={retryEnabled}
onRetryEnabledChange={setRetryEnabled}
maxRetries={maxRetries}
onMaxRetriesChange={setMaxRetries}
retryDelaySeconds={retryDelaySeconds}
onRetryDelaySecondsChange={setRetryDelaySeconds}
retryOnBusy={retryOnBusy}
onRetryOnBusyChange={setRetryOnBusy}
retryOnNoAnswer={retryOnNoAnswer}
onRetryOnNoAnswerChange={setRetryOnNoAnswer}
retryOnVoicemail={retryOnVoicemail}
onRetryOnVoicemailChange={setRetryOnVoicemail}
scheduleEnabled={scheduleEnabled}
onScheduleEnabledChange={setScheduleEnabled}
scheduleTimezone={scheduleTimezone}
onScheduleTimezoneChange={setScheduleTimezone}
timeSlots={timeSlots}
onTimeSlotsChange={setTimeSlots}
/>
</CollapsibleContent>
</Collapsible>

File diff suppressed because one or more lines are too long

View file

@ -124,6 +124,7 @@ export type CampaignResponse = {
completed_at: string | null;
retry_config: RetryConfigResponse;
max_concurrency?: number | null;
schedule_config?: ScheduleConfigResponse | null;
};
/**
@ -244,6 +245,7 @@ export type CreateCampaignRequest = {
source_id: string;
retry_config?: RetryConfigRequest | null;
max_concurrency?: number | null;
schedule_config?: ScheduleConfigRequest | null;
};
/**
@ -777,6 +779,18 @@ export type S3SignedUrlResponse = {
expires_in: number;
};
export type ScheduleConfigRequest = {
enabled?: boolean;
timezone?: string;
slots: Array<TimeSlotRequest>;
};
export type ScheduleConfigResponse = {
enabled: boolean;
timezone: string;
slots: Array<TimeSlotResponse>;
};
export type ServiceKeyResponse = {
name: string;
id: number;
@ -862,6 +876,18 @@ export type TestSessionResponse = {
completed_at: string | null;
};
export type TimeSlotRequest = {
day_of_week: number;
start_time: string;
end_time: string;
};
export type TimeSlotResponse = {
day_of_week: number;
start_time: string;
end_time: string;
};
/**
* A parameter that the tool accepts.
*/
@ -1013,6 +1039,13 @@ export type TwilioConfigurationResponse = {
from_numbers: Array<string>;
};
export type UpdateCampaignRequest = {
name?: string | null;
retry_config?: RetryConfigRequest | null;
max_concurrency?: number | null;
schedule_config?: ScheduleConfigRequest | null;
};
/**
* Request schema for updating a webhook credential.
*/
@ -2727,6 +2760,41 @@ export type GetCampaignApiV1CampaignCampaignIdGetResponses = {
export type GetCampaignApiV1CampaignCampaignIdGetResponse = GetCampaignApiV1CampaignCampaignIdGetResponses[keyof GetCampaignApiV1CampaignCampaignIdGetResponses];
export type UpdateCampaignApiV1CampaignCampaignIdPatchData = {
body: UpdateCampaignRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
};
query?: never;
url: '/api/v1/campaign/{campaign_id}';
};
export type UpdateCampaignApiV1CampaignCampaignIdPatchErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateCampaignApiV1CampaignCampaignIdPatchError = UpdateCampaignApiV1CampaignCampaignIdPatchErrors[keyof UpdateCampaignApiV1CampaignCampaignIdPatchErrors];
export type UpdateCampaignApiV1CampaignCampaignIdPatchResponses = {
/**
* Successful Response
*/
200: CampaignResponse;
};
export type UpdateCampaignApiV1CampaignCampaignIdPatchResponse = UpdateCampaignApiV1CampaignCampaignIdPatchResponses[keyof UpdateCampaignApiV1CampaignCampaignIdPatchResponses];
export type StartCampaignApiV1CampaignCampaignIdStartPostData = {
body?: never;
headers?: {