mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
feat: refactor telephony to support multiple telephony configurations (#251)
Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
parent
2f860e7f6d
commit
e16f6438bd
101 changed files with 10906 additions and 5420 deletions
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useId } from 'react';
|
||||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
|
||||
|
|
@ -137,12 +138,12 @@ export default function CampaignAdvancedSettings({
|
|||
</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>.
|
||||
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <Link href="/telephony-configurations" className="underline font-medium">Telephony Configuration</Link>.
|
||||
</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.
|
||||
No phone numbers configured. Add CLIs in <Link href="/telephony-configurations" className="underline font-medium">Telephony Configuration</Link> before running the campaign.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export default function EditCampaignPage() {
|
|||
}
|
||||
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.`);
|
||||
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})`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ITimezoneOption } from 'react-timezone-select';
|
||||
|
|
@ -9,9 +10,10 @@ import { toast } from 'sonner';
|
|||
import {
|
||||
createCampaignApiV1CampaignCreatePost,
|
||||
getCampaignDefaultsApiV1OrganizationsCampaignDefaultsGet,
|
||||
getWorkflowsSummaryApiV1WorkflowSummaryGet
|
||||
getWorkflowsSummaryApiV1WorkflowSummaryGet,
|
||||
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
|
||||
} from '@/client/sdk.gen';
|
||||
import type { WorkflowSummaryResponse } from '@/client/types.gen';
|
||||
import type { TelephonyConfigurationListItem, WorkflowSummaryResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
|
@ -48,6 +50,11 @@ export default function NewCampaignPage() {
|
|||
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
|
||||
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true);
|
||||
|
||||
// Telephony configurations state
|
||||
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
|
||||
const [selectedTelephonyConfigId, setSelectedTelephonyConfigId] = useState<string>('');
|
||||
const [isLoadingTelephonyConfigs, setIsLoadingTelephonyConfigs] = useState(true);
|
||||
|
||||
// Advanced settings state
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
|
||||
|
|
@ -94,7 +101,10 @@ export default function NewCampaignPage() {
|
|||
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
},
|
||||
query: {
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
|
|
@ -108,6 +118,33 @@ export default function NewCampaignPage() {
|
|||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Fetch telephony configurations
|
||||
const fetchTelephonyConfigs = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const configs = response.data.configurations ?? [];
|
||||
setTelephonyConfigs(configs);
|
||||
const defaultConfig = configs.find((c) => c.is_default_outbound) ?? configs[0];
|
||||
if (defaultConfig) {
|
||||
setSelectedTelephonyConfigId(String(defaultConfig.id));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch telephony configurations:', error);
|
||||
toast.error('Failed to load telephony configurations');
|
||||
} finally {
|
||||
setIsLoadingTelephonyConfigs(false);
|
||||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Fetch campaign limits
|
||||
const fetchCampaignDefaults = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
|
@ -183,12 +220,21 @@ export default function NewCampaignPage() {
|
|||
if (user) {
|
||||
fetchWorkflows();
|
||||
fetchCampaignDefaults();
|
||||
fetchTelephonyConfigs();
|
||||
}
|
||||
}, [fetchWorkflows, fetchCampaignDefaults, user]);
|
||||
}, [fetchWorkflows, fetchCampaignDefaults, fetchTelephonyConfigs, user]);
|
||||
|
||||
// Phone-number count for the selected telephony config drives concurrency
|
||||
// bounds. Falls back to the campaign-defaults endpoint's count (org default
|
||||
// config) until the configs list resolves.
|
||||
const selectedTelephonyConfig = telephonyConfigs.find(
|
||||
(c) => String(c.id) === selectedTelephonyConfigId,
|
||||
);
|
||||
const availableFromNumbersCount = selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount;
|
||||
|
||||
// Effective concurrency limit considering both org limit and available CLIs
|
||||
const effectiveLimit = fromNumbersCount > 0
|
||||
? Math.min(orgConcurrentLimit, fromNumbersCount)
|
||||
const effectiveLimit = availableFromNumbersCount > 0
|
||||
? Math.min(orgConcurrentLimit, availableFromNumbersCount)
|
||||
: orgConcurrentLimit;
|
||||
|
||||
// Handle form submission
|
||||
|
|
@ -196,7 +242,7 @@ export default function NewCampaignPage() {
|
|||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
|
||||
if (!campaignName || !selectedWorkflowId || !sourceId) {
|
||||
if (!campaignName || !selectedWorkflowId || !sourceId || !selectedTelephonyConfigId) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
|
@ -209,8 +255,8 @@ export default function NewCampaignPage() {
|
|||
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.`);
|
||||
if (availableFromNumbersCount > 0 && availableFromNumbersCount < orgConcurrentLimit) {
|
||||
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) — add more CLIs to increase concurrency.`);
|
||||
} else {
|
||||
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
|
||||
}
|
||||
|
|
@ -257,6 +303,7 @@ export default function NewCampaignPage() {
|
|||
workflow_id: parseInt(selectedWorkflowId),
|
||||
source_type: sourceType,
|
||||
source_id: sourceId,
|
||||
telephony_configuration_id: parseInt(selectedTelephonyConfigId),
|
||||
retry_config: retryConfig,
|
||||
max_concurrency: maxConcurrencyValue,
|
||||
schedule_config: scheduleConfig,
|
||||
|
|
@ -383,6 +430,52 @@ export default function NewCampaignPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telephony-config">Telephony Configuration</Label>
|
||||
{!isLoadingTelephonyConfigs && telephonyConfigs.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
No telephony configurations yet.{' '}
|
||||
<Link
|
||||
href="/telephony-configurations"
|
||||
className="underline text-foreground"
|
||||
>
|
||||
Add one
|
||||
</Link>{' '}
|
||||
to create a campaign.
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedTelephonyConfigId}
|
||||
onValueChange={setSelectedTelephonyConfigId}
|
||||
required
|
||||
>
|
||||
<SelectTrigger id="telephony-config">
|
||||
<SelectValue placeholder="Select a telephony configuration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingTelephonyConfigs ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading configurations...
|
||||
</SelectItem>
|
||||
) : (
|
||||
telephonyConfigs.map((config) => (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
value={config.id.toString()}
|
||||
>
|
||||
{config.name} ({config.provider})
|
||||
{config.is_default_outbound ? ' — default' : ''}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Outbound calls for this campaign will use this configuration's caller IDs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="source-type">Data Source Type</Label>
|
||||
<Select
|
||||
|
|
@ -480,7 +573,7 @@ export default function NewCampaignPage() {
|
|||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId}
|
||||
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId || !selectedTelephonyConfigId}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Campaign'}
|
||||
</Button>
|
||||
|
|
|
|||
419
ui/src/app/telephony-configurations/[configId]/page.tsx
Normal file
419
ui/src/app/telephony-configurations/[configId]/page.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deletePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdDelete,
|
||||
getTelephonyConfigurationByIdApiV1OrganizationsTelephonyConfigsConfigIdGet,
|
||||
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
|
||||
setDefaultCallerIdApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdSetDefaultCallerPost,
|
||||
setDefaultOutboundApiV1OrganizationsTelephonyConfigsConfigIdSetDefaultOutboundPost,
|
||||
} from "@/client/sdk.gen";
|
||||
import type {
|
||||
PhoneNumberResponse,
|
||||
TelephonyConfigurationDetail,
|
||||
} from "@/client/types.gen";
|
||||
import { ConfigFormDialog } from "@/components/telephony/ConfigFormDialog";
|
||||
import { PhoneNumberDialog } from "@/components/telephony/PhoneNumberDialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function TelephonyConfigurationDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ configId: string }>();
|
||||
const configId = Number(params.configId);
|
||||
|
||||
const { user, getAccessToken, loading: authLoading } = useAuth();
|
||||
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
|
||||
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editConfigOpen, setEditConfigOpen] = useState(false);
|
||||
|
||||
const [phoneDialogOpen, setPhoneDialogOpen] = useState(false);
|
||||
const [phoneEditTarget, setPhoneEditTarget] = useState<PhoneNumberResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [phoneDeleteTarget, setPhoneDeleteTarget] = useState<PhoneNumberResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
if (authLoading || !user || !configId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const [cfgRes, numbersRes] = await Promise.all([
|
||||
getTelephonyConfigurationByIdApiV1OrganizationsTelephonyConfigsConfigIdGet({
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: { config_id: configId },
|
||||
}),
|
||||
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet({
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: { config_id: configId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (cfgRes.error) throw new Error(detailFromError(cfgRes.error));
|
||||
if (numbersRes.error) throw new Error(detailFromError(numbersRes.error));
|
||||
|
||||
setConfig(cfgRes.data ?? null);
|
||||
setPhoneNumbers(numbersRes.data?.phone_numbers ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to load configuration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authLoading, user, configId, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
const onSetDefaultOutbound = async () => {
|
||||
if (!config) return;
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const res = await setDefaultOutboundApiV1OrganizationsTelephonyConfigsConfigIdSetDefaultOutboundPost(
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: { config_id: config.id },
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
toast.success("Set as default outbound");
|
||||
fetchAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to set default");
|
||||
}
|
||||
};
|
||||
|
||||
const onSetDefaultCaller = async (n: PhoneNumberResponse) => {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const res = await setDefaultCallerIdApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdSetDefaultCallerPost(
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: { config_id: configId, phone_number_id: n.id },
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
toast.success(`${n.address} is now the default caller ID`);
|
||||
fetchAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to set default caller");
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmDeletePhone = async () => {
|
||||
if (!phoneDeleteTarget) return;
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const res = await deletePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdDelete(
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: {
|
||||
config_id: configId,
|
||||
phone_number_id: phoneDeleteTarget.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
toast.success("Phone number deleted");
|
||||
setPhoneDeleteTarget(null);
|
||||
fetchAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete phone number");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 space-y-3">
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Button variant="ghost" onClick={() => router.push("/telephony-configurations")}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back
|
||||
</Button>
|
||||
<p className="mt-4 text-muted-foreground">Configuration not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/telephony-configurations"
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" /> All configurations
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="truncate">{config.name}</CardTitle>
|
||||
<Badge variant="secondary">{config.provider}</Badge>
|
||||
{config.is_default_outbound && (
|
||||
<Badge className="gap-1">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Updated {new Date(config.updated_at).toLocaleString()}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!config.is_default_outbound && (
|
||||
<Button variant="outline" size="sm" onClick={onSetDefaultOutbound}>
|
||||
<Star className="h-4 w-4 mr-2" /> Set as default
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setEditConfigOpen(true)}>
|
||||
<Pencil className="h-4 w-4 mr-2" /> Edit credentials
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
{Object.entries(config.credentials ?? {}).map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">{k}</dt>
|
||||
<dd className="font-mono text-right truncate max-w-[60%]">
|
||||
{String(v ?? "")}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Phone numbers</CardTitle>
|
||||
<CardDescription>
|
||||
Numbers used as caller ID for outbound and accepted for inbound matching.
|
||||
SIP URIs and extensions are supported alongside PSTN numbers.{" "}
|
||||
<a
|
||||
href="https://docs.dograh.com/integrations/telephony/inbound"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 underline"
|
||||
>
|
||||
Inbound docs <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPhoneEditTarget(null);
|
||||
setPhoneDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" /> Add phone number
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{phoneNumbers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No phone numbers yet. Add one to start placing or receiving calls on this
|
||||
configuration.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Inbound workflow</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{phoneNumbers.map((n) => (
|
||||
<TableRow key={n.id}>
|
||||
<TableCell className="font-mono">{n.address}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{n.address_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{n.label ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{n.is_active ? (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Inactive</Badge>
|
||||
)}
|
||||
{n.is_default_caller_id && (
|
||||
<Badge className="gap-1">
|
||||
<Star className="h-3 w-3 fill-current" /> Default caller
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{n.inbound_workflow_id ? (
|
||||
<Link
|
||||
href={`/workflow/${n.inbound_workflow_id}`}
|
||||
className="inline-flex items-center gap-1 hover:underline hover:text-foreground"
|
||||
>
|
||||
<span>#{n.inbound_workflow_id}</span>
|
||||
{n.inbound_workflow_name && (
|
||||
<span
|
||||
className="truncate max-w-[160px]"
|
||||
title={n.inbound_workflow_name}
|
||||
>
|
||||
{n.inbound_workflow_name.length > 24
|
||||
? `${n.inbound_workflow_name.slice(0, 24)}…`
|
||||
: n.inbound_workflow_name}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{!n.is_default_caller_id && n.is_active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSetDefaultCaller(n)}
|
||||
title="Set as default caller ID"
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPhoneEditTarget(n);
|
||||
setPhoneDialogOpen(true);
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhoneDeleteTarget(n)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfigFormDialog
|
||||
open={editConfigOpen}
|
||||
onOpenChange={setEditConfigOpen}
|
||||
existing={config}
|
||||
onSaved={fetchAll}
|
||||
/>
|
||||
|
||||
<PhoneNumberDialog
|
||||
open={phoneDialogOpen}
|
||||
onOpenChange={setPhoneDialogOpen}
|
||||
configId={configId}
|
||||
existing={phoneEditTarget}
|
||||
onSaved={fetchAll}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={!!phoneDeleteTarget}
|
||||
onOpenChange={(o) => !o && setPhoneDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete phone number?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{phoneDeleteTarget?.address} will no longer accept inbound calls or be
|
||||
available as a caller ID for this configuration.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirmDeletePhone}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function detailFromError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return "Request failed";
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
340
ui/src/components/telephony/ConfigFormDialog.tsx
Normal file
340
ui/src/components/telephony/ConfigFormDialog.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost,
|
||||
getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGet,
|
||||
updateTelephonyConfigurationApiV1OrganizationsTelephonyConfigsConfigIdPut,
|
||||
} from "@/client/sdk.gen";
|
||||
import type {
|
||||
TelephonyConfigurationCreateRequest,
|
||||
TelephonyConfigurationDetail,
|
||||
TelephonyProviderMetadata,
|
||||
} from "@/client/types.gen";
|
||||
|
||||
type TelephonyConfigPayload = TelephonyConfigurationCreateRequest["config"];
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface ConfigFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
// When provided, the dialog is in edit mode.
|
||||
existing?: TelephonyConfigurationDetail | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type FieldValues = Record<string, string | number | undefined>;
|
||||
|
||||
export function ConfigFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
existing,
|
||||
onSaved,
|
||||
}: ConfigFormDialogProps) {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const [providers, setProviders] = useState<TelephonyProviderMetadata[]>([]);
|
||||
const [providerName, setProviderName] = useState<string>("");
|
||||
const [name, setName] = useState<string>("");
|
||||
const [isDefault, setIsDefault] = useState<boolean>(false);
|
||||
const [values, setValues] = useState<FieldValues>({});
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
const isEdit = !!existing;
|
||||
const lockedProvider = isEdit;
|
||||
|
||||
const currentProvider = useMemo(
|
||||
() => providers.find((p) => p.provider === providerName),
|
||||
[providers, providerName],
|
||||
);
|
||||
|
||||
// Fetch provider metadata once when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open || !user) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const token = await getAccessToken();
|
||||
const res = await getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGet(
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (cancelled) return;
|
||||
const list = res.data?.providers ?? [];
|
||||
setProviders(list);
|
||||
if (existing) {
|
||||
setProviderName(existing.provider);
|
||||
setName(existing.name);
|
||||
setIsDefault(existing.is_default_outbound);
|
||||
setValues((existing.credentials ?? {}) as FieldValues);
|
||||
} else if (list.length > 0 && !providerName) {
|
||||
setProviderName(list[0].provider);
|
||||
setValues({});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, existing, user, getAccessToken]);
|
||||
|
||||
// When provider changes during create, clear field values.
|
||||
useEffect(() => {
|
||||
if (!isEdit) setValues({});
|
||||
}, [providerName, isEdit]);
|
||||
|
||||
const updateField = (fieldName: string, value: string | number) => {
|
||||
setValues((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentProvider) return;
|
||||
if (!isEdit && !name.trim()) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
|
||||
// Build the provider-discriminated config payload from collected values.
|
||||
const configPayload = {
|
||||
provider: providerName,
|
||||
...values,
|
||||
} as unknown as TelephonyConfigPayload;
|
||||
|
||||
if (isEdit && existing) {
|
||||
const res = await updateTelephonyConfigurationApiV1OrganizationsTelephonyConfigsConfigIdPut(
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: { config_id: existing.id },
|
||||
body: { name: name || undefined, config: configPayload },
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
toast.success("Configuration updated");
|
||||
} else {
|
||||
const res = await createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost(
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: {
|
||||
name: name.trim(),
|
||||
is_default_outbound: isDefault,
|
||||
config: configPayload,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
toast.success("Configuration created");
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Edit telephony configuration" : "Add telephony configuration"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update credentials for this configuration. Phone numbers are managed separately."
|
||||
: "Connect a telephony provider account. Phone numbers are added after the configuration is created."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cfg-name">Name</Label>
|
||||
<Input
|
||||
id="cfg-name"
|
||||
placeholder="e.g. Twilio US prod"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cfg-provider">Provider</Label>
|
||||
<Select
|
||||
value={providerName}
|
||||
onValueChange={setProviderName}
|
||||
disabled={lockedProvider || providers.length === 0}
|
||||
>
|
||||
<SelectTrigger id="cfg-provider">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.provider} value={p.provider}>
|
||||
{p.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{lockedProvider && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provider cannot be changed after creation.
|
||||
</p>
|
||||
)}
|
||||
{currentProvider?.docs_url && (
|
||||
<a
|
||||
href={currentProvider.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-600 underline"
|
||||
>
|
||||
{currentProvider.display_name} docs <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="flex items-center justify-between rounded border p-3">
|
||||
<div>
|
||||
<Label className="text-sm">Set as default for outbound calls</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used by test calls and campaigns when no specific config is selected.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProvider && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{currentProvider.fields.map((field) => (
|
||||
<div className="space-y-1" key={field.name}>
|
||||
<Label htmlFor={`cfg-field-${field.name}`}>
|
||||
{field.label}
|
||||
{!field.required && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<FieldInput
|
||||
field={field}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => updateField(field.name, v)}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !currentProvider}>
|
||||
{submitting ? "Saving..." : isEdit ? "Save changes" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldInputProps {
|
||||
field: TelephonyProviderMetadata["fields"][number];
|
||||
value: string | number | undefined;
|
||||
onChange: (v: string | number) => void;
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
// Skip from_numbers in the metadata-driven form — phone numbers are managed
|
||||
// via the dedicated phone-numbers endpoints and a different UI.
|
||||
function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
|
||||
if (field.name === "from_numbers") {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Phone numbers are managed separately on the configuration page.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholder =
|
||||
field.placeholder ??
|
||||
(field.sensitive && isEdit ? "Leave masked to keep existing" : "");
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<Textarea
|
||||
id={`cfg-field-${field.name}`}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={6}
|
||||
className="field-sizing-fixed resize-y break-all font-mono text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Input
|
||||
id={`cfg-field-${field.name}`}
|
||||
type="number"
|
||||
placeholder={placeholder}
|
||||
value={value as number | string | undefined ?? ""}
|
||||
onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={`cfg-field-${field.name}`}
|
||||
type={field.type === "password" || field.sensitive ? "password" : "text"}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete={field.sensitive ? "current-password" : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// FastAPI error responses come back as { detail: string } or
|
||||
// { detail: [{loc, msg, ...}] }. Surface a useful message either way.
|
||||
function detailFromError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return "Failed to save configuration";
|
||||
}
|
||||
278
ui/src/components/telephony/PhoneNumberDialog.tsx
Normal file
278
ui/src/components/telephony/PhoneNumberDialog.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
createPhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPost,
|
||||
getWorkflowsSummaryApiV1WorkflowSummaryGet,
|
||||
updatePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdPut,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { PhoneNumberResponse } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface PhoneNumberDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
configId: number;
|
||||
existing?: PhoneNumberResponse | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const NO_WORKFLOW = "__none__";
|
||||
|
||||
export function PhoneNumberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
configId,
|
||||
existing,
|
||||
onSaved,
|
||||
}: PhoneNumberDialogProps) {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const isEdit = !!existing;
|
||||
|
||||
const [address, setAddress] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [isDefaultCallerId, setIsDefaultCallerId] = useState(false);
|
||||
const [inboundWorkflowId, setInboundWorkflowId] = useState<string>(NO_WORKFLOW);
|
||||
const [workflows, setWorkflows] = useState<{ id: number; name: string }[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Reset form when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAddress(existing?.address ?? "");
|
||||
setCountryCode(existing?.country_code ?? "");
|
||||
setLabel(existing?.label ?? "");
|
||||
setIsActive(existing?.is_active ?? true);
|
||||
setIsDefaultCallerId(existing?.is_default_caller_id ?? false);
|
||||
setInboundWorkflowId(
|
||||
existing?.inbound_workflow_id ? String(existing.inbound_workflow_id) : NO_WORKFLOW,
|
||||
);
|
||||
}, [open, existing]);
|
||||
|
||||
// Load workflows for the inbound dropdown.
|
||||
useEffect(() => {
|
||||
if (!open || !user) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const token = await getAccessToken();
|
||||
const res = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
query: { status: "active" },
|
||||
});
|
||||
if (cancelled) return;
|
||||
const items = res.data ?? [];
|
||||
setWorkflows(items.map((w) => ({ id: w.id, name: w.name })));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, user, getAccessToken]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isEdit && !address.trim()) {
|
||||
toast.error("Address is required");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const inboundId =
|
||||
inboundWorkflowId === NO_WORKFLOW ? null : Number(inboundWorkflowId);
|
||||
|
||||
let providerSync: PhoneNumberResponse["provider_sync"] | undefined;
|
||||
if (isEdit && existing) {
|
||||
const res = await updatePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdPut(
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: { config_id: configId, phone_number_id: existing.id },
|
||||
body: {
|
||||
label: label || undefined,
|
||||
is_active: isActive,
|
||||
country_code: countryCode || undefined,
|
||||
inbound_workflow_id: inboundId ?? undefined,
|
||||
clear_inbound_workflow: inboundId === null,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
providerSync = res.data?.provider_sync;
|
||||
toast.success("Phone number updated");
|
||||
} else {
|
||||
const res = await createPhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPost(
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
path: { config_id: configId },
|
||||
body: {
|
||||
address: address.trim(),
|
||||
country_code: countryCode || undefined,
|
||||
label: label || undefined,
|
||||
is_active: isActive,
|
||||
is_default_caller_id: isDefaultCallerId,
|
||||
inbound_workflow_id: inboundId ?? undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
providerSync = res.data?.provider_sync;
|
||||
toast.success("Phone number added");
|
||||
}
|
||||
if (providerSync && !providerSync.ok) {
|
||||
toast.warning(
|
||||
providerSync.message ??
|
||||
"Saved, but failed to sync inbound webhook to the provider.",
|
||||
);
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save phone number");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Edit phone number" : "Add phone number"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
PSTN numbers (E.164), SIP URIs (sip:user@host), and SIP extensions are all supported.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="pn-address">Address</Label>
|
||||
<Input
|
||||
id="pn-address"
|
||||
placeholder="+19781899185, sip:101@asterisk.local, or 101"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Address cannot be changed. Delete this number and create a new one to
|
||||
change it.
|
||||
</p>
|
||||
)}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stored as <code>{existing?.address_normalized}</code> ({existing?.address_type})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="pn-country">Country (ISO-2)</Label>
|
||||
<Input
|
||||
id="pn-country"
|
||||
placeholder="US"
|
||||
maxLength={2}
|
||||
value={countryCode}
|
||||
onChange={(e) => setCountryCode(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="pn-label">Label</Label>
|
||||
<Input
|
||||
id="pn-label"
|
||||
placeholder="e.g. Boston caller ID"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="pn-workflow">Inbound workflow</Label>
|
||||
<Select value={inboundWorkflowId} onValueChange={setInboundWorkflowId}>
|
||||
<SelectTrigger id="pn-workflow">
|
||||
<SelectValue placeholder="(none)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_WORKFLOW}>(none)</SelectItem>
|
||||
{workflows.map((w) => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>
|
||||
#{w.id} - {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used when per-number inbound routing is enabled. Today, inbound calls still
|
||||
route by the workflow_id in the webhook URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded border p-3">
|
||||
<Label className="text-sm">Active</Label>
|
||||
<Switch checked={isActive} onCheckedChange={setIsActive} />
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="flex items-center justify-between rounded border p-3">
|
||||
<div>
|
||||
<Label className="text-sm">Default caller ID for this configuration</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used as the from-number for test calls when set.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isDefaultCallerId}
|
||||
onCheckedChange={setIsDefaultCallerId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Saving..." : isEdit ? "Save changes" : "Add"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function detailFromError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return "Failed to save phone number";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue