mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
fix: number pool initialization in multi telephony setup
If there are multiple telephony configurations, the form number should be initialized from the campaigns given telephonic configuration rather than the organization default telephonic configuration.
This commit is contained in:
parent
81a363b06e
commit
6d93be3ef6
31 changed files with 1105 additions and 238 deletions
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, Globe } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Download, Globe } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
|
||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
||||
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
||||
|
|
@ -49,15 +50,21 @@ export default function UsagePage() {
|
|||
return pageParam ? parseInt(pageParam, 10) : 1;
|
||||
});
|
||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||
const [isDownloadingReport, setIsDownloadingReport] = useState(false);
|
||||
|
||||
// Daily usage breakdown state (only for paid orgs)
|
||||
const [dailyUsage, setDailyUsage] = useState<DailyUsageBreakdownResponse | null>(null);
|
||||
const [isLoadingDaily, setIsLoadingDaily] = useState(false);
|
||||
|
||||
// Initialize filters from URL
|
||||
// Initialize filters from URL. `activeFilters` tracks the in-progress
|
||||
// edits in the FilterBuilder; `appliedFilters` is what's actually been
|
||||
// committed via Apply (and what drives fetching + the download button).
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
|
||||
});
|
||||
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
|
||||
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
|
||||
});
|
||||
|
||||
// Media preview dialog
|
||||
const mediaPreview = MediaPreviewDialog();
|
||||
|
|
@ -83,51 +90,50 @@ export default function UsagePage() {
|
|||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Translate the FilterBuilder state into the query-param shape the
|
||||
// backend expects. Shared between the listing fetch and the CSV export
|
||||
// so they stay in lockstep.
|
||||
const buildUsageQueryParams = (filters?: ActiveFilter[]) => {
|
||||
let filterParam: string | undefined;
|
||||
let startDate = '';
|
||||
let endDate = '';
|
||||
|
||||
if (filters && filters.length > 0) {
|
||||
const dateRangeFilter = filters.find(f => f.attribute.id === 'dateRange');
|
||||
if (dateRangeFilter && dateRangeFilter.value) {
|
||||
const dateValue = dateRangeFilter.value as DateRangeValue;
|
||||
if (dateValue.from) startDate = dateValue.from.toISOString();
|
||||
if (dateValue.to) endDate = dateValue.to.toISOString();
|
||||
}
|
||||
|
||||
const otherFilters = filters.filter(f => f.attribute.id !== 'dateRange');
|
||||
if (otherFilters.length > 0) {
|
||||
const filterData = otherFilters.map(filter => ({
|
||||
attribute: filter.attribute.id,
|
||||
type: filter.attribute.type,
|
||||
value: filter.value,
|
||||
}));
|
||||
filterParam = JSON.stringify(filterData);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(startDate && { start_date: startDate }),
|
||||
...(endDate && { end_date: endDate }),
|
||||
...(filterParam && { filters: filterParam }),
|
||||
};
|
||||
};
|
||||
|
||||
// Fetch usage history
|
||||
const fetchUsageHistory = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
let filterParam = undefined;
|
||||
let startDate = '';
|
||||
let endDate = '';
|
||||
|
||||
if (filters && filters.length > 0) {
|
||||
// Extract date range filter if present
|
||||
const dateRangeFilter = filters.find(f => f.attribute.id === 'dateRange');
|
||||
if (dateRangeFilter && dateRangeFilter.value) {
|
||||
const dateValue = dateRangeFilter.value as DateRangeValue;
|
||||
|
||||
if (dateValue.from) {
|
||||
// The dates are already in the user's local timezone
|
||||
// Convert to UTC ISO string for the backend
|
||||
startDate = dateValue.from.toISOString();
|
||||
}
|
||||
if (dateValue.to) {
|
||||
// Convert to UTC ISO string for the backend
|
||||
endDate = dateValue.to.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Process other filters (excluding dateRange)
|
||||
const otherFilters = filters.filter(f => f.attribute.id !== 'dateRange');
|
||||
if (otherFilters.length > 0) {
|
||||
const filterData = otherFilters.map(filter => ({
|
||||
attribute: filter.attribute.id,
|
||||
type: filter.attribute.type,
|
||||
value: filter.value,
|
||||
}));
|
||||
filterParam = JSON.stringify(filterData);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await getUsageHistoryApiV1OrganizationsUsageRunsGet({
|
||||
query: {
|
||||
page,
|
||||
limit: 50,
|
||||
...(startDate && { start_date: startDate }),
|
||||
...(endDate && { end_date: endDate }),
|
||||
...(filterParam && { filters: filterParam })
|
||||
...buildUsageQueryParams(filters),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -161,6 +167,37 @@ export default function UsagePage() {
|
|||
}
|
||||
}, [auth.isAuthenticated, organizationPricing]);
|
||||
|
||||
// Download a CSV of all runs matching the current filters.
|
||||
const handleDownloadReport = async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
setIsDownloadingReport(true);
|
||||
try {
|
||||
const response = await downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet({
|
||||
query: buildUsageQueryParams(appliedFilters),
|
||||
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 = 'usage_runs_report.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
toast.error('Failed to download report');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download usage report:', error);
|
||||
toast.error('Failed to download report');
|
||||
} finally {
|
||||
setIsDownloadingReport(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle timezone change
|
||||
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
|
||||
setSelectedTimezone(timezone);
|
||||
|
|
@ -195,9 +232,9 @@ export default function UsagePage() {
|
|||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
fetchMpsCredits();
|
||||
fetchUsageHistory(currentPage, activeFilters);
|
||||
fetchUsageHistory(currentPage, appliedFilters);
|
||||
}
|
||||
}, [auth.isAuthenticated, currentPage, activeFilters, fetchUsageHistory, fetchMpsCredits]);
|
||||
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory, fetchMpsCredits]);
|
||||
|
||||
// Fetch daily usage when organizationPricing becomes available
|
||||
useEffect(() => {
|
||||
|
|
@ -229,6 +266,7 @@ export default function UsagePage() {
|
|||
const handleApplyFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1); // Reset to first page when applying filters
|
||||
setAppliedFilters(activeFilters);
|
||||
updateUrlParams({ page: 1, filters: activeFilters });
|
||||
await fetchUsageHistory(1, activeFilters);
|
||||
setIsExecutingFilters(false);
|
||||
|
|
@ -241,6 +279,8 @@ export default function UsagePage() {
|
|||
const handleClearFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1);
|
||||
setActiveFilters([]);
|
||||
setAppliedFilters([]);
|
||||
updateUrlParams({ page: 1, filters: [] }); // Clear filters from URL
|
||||
await fetchUsageHistory(1, []); // Fetch all runs without filters
|
||||
setIsExecutingFilters(false);
|
||||
|
|
@ -249,8 +289,8 @@ export default function UsagePage() {
|
|||
// Handle page change
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage, filters: activeFilters });
|
||||
fetchUsageHistory(newPage, activeFilters);
|
||||
updateUrlParams({ page: newPage, filters: appliedFilters });
|
||||
fetchUsageHistory(newPage, appliedFilters);
|
||||
};
|
||||
|
||||
// Handle row click to navigate to workflow run
|
||||
|
|
@ -289,8 +329,8 @@ export default function UsagePage() {
|
|||
<div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Usage Dashboard</h1>
|
||||
<p className="text-muted-foreground">Monitor your Dograh Token usage and quota</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Agent Runs</h1>
|
||||
<p className="text-muted-foreground">See all your Agent Runs across all Voice Agents. You can use filters to filter out required Agent Runs.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
|
|
@ -419,7 +459,7 @@ export default function UsagePage() {
|
|||
)}
|
||||
|
||||
{/* Filter Builder */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-6 space-y-3">
|
||||
<FilterBuilder
|
||||
availableAttributes={usageFilterAttributes}
|
||||
activeFilters={activeFilters}
|
||||
|
|
@ -428,6 +468,19 @@ export default function UsagePage() {
|
|||
onClearFilters={handleClearFilters}
|
||||
isExecuting={isExecutingFilters}
|
||||
/>
|
||||
{appliedFilters.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadReport}
|
||||
disabled={isDownloadingReport}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{isDownloadingReport ? 'Preparing...' : 'Download Filtered Results'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage History */}
|
||||
|
|
@ -435,9 +488,9 @@ export default function UsagePage() {
|
|||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Usage History</CardTitle>
|
||||
<CardTitle>All Runs</CardTitle>
|
||||
<CardDescription>
|
||||
View detailed usage by workflow run
|
||||
Every agent run across your organization, with usage details
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -463,7 +516,7 @@ export default function UsagePage() {
|
|||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold text-right">Duration</TableHead>
|
||||
<TableHead className="font-semibold text-right">
|
||||
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Dograh Tokens'}
|
||||
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'}
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -490,7 +543,9 @@ export default function UsagePage() {
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{run.phone_number || '-'}
|
||||
{(run.call_type === 'inbound'
|
||||
? run.caller_number
|
||||
: run.called_number) || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.disposition ? (
|
||||
|
|
@ -526,7 +581,7 @@ export default function UsagePage() {
|
|||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{activeFilters.length > 0 && (
|
||||
{appliedFilters.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-muted rounded-md">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total for filtered period: <span className="font-semibold text-foreground">
|
||||
|
|
@ -570,7 +625,7 @@ export default function UsagePage() {
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center py-8 text-muted-foreground">No usage history found</p>
|
||||
<p className="text-center py-8 text-muted-foreground">No runs found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import { PhoneInput } from 'react-international-phone';
|
|||
|
||||
import {
|
||||
initiateCallApiV1TelephonyInitiateCallPost,
|
||||
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
|
||||
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
|
||||
} from '@/client/sdk.gen';
|
||||
import type { TelephonyConfigurationListItem } from '@/client/types.gen';
|
||||
import type { PhoneNumberResponse, TelephonyConfigurationListItem } from '@/client/types.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -58,6 +59,9 @@ export const PhoneCallDialog = ({
|
|||
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
|
||||
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
|
||||
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
|
||||
const [fromPhoneNumbers, setFromPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
|
||||
const [selectedFromPhoneNumberId, setSelectedFromPhoneNumberId] = useState<string>("");
|
||||
const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false);
|
||||
|
||||
// Check telephony configuration when dialog opens
|
||||
useEffect(() => {
|
||||
|
|
@ -102,9 +106,49 @@ export const PhoneCallDialog = ({
|
|||
setNeedsConfiguration(null);
|
||||
setTelephonyConfigs([]);
|
||||
setSelectedConfigId("");
|
||||
setFromPhoneNumbers([]);
|
||||
setSelectedFromPhoneNumberId("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Fetch phone numbers whenever the selected telephony configuration changes.
|
||||
useEffect(() => {
|
||||
if (!open || !selectedConfigId) {
|
||||
setFromPhoneNumbers([]);
|
||||
setSelectedFromPhoneNumberId("");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const fetchPhoneNumbers = async () => {
|
||||
setLoadingPhoneNumbers(true);
|
||||
try {
|
||||
const response = await listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet({
|
||||
path: { config_id: Number(selectedConfigId) },
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
const all = response.data?.phone_numbers ?? [];
|
||||
const active = all.filter((p) => p.is_active);
|
||||
setFromPhoneNumbers(active);
|
||||
const defaultPhone = active.find((p) => p.is_default_caller_id) ?? active[0];
|
||||
setSelectedFromPhoneNumberId(defaultPhone ? String(defaultPhone.id) : "");
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error("Failed to load phone numbers for config:", err);
|
||||
setFromPhoneNumbers([]);
|
||||
setSelectedFromPhoneNumberId("");
|
||||
} finally {
|
||||
if (!cancelled) setLoadingPhoneNumbers(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPhoneNumbers();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, selectedConfigId]);
|
||||
|
||||
// Keep phoneNumber in sync with userConfig when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -148,6 +192,7 @@ export const PhoneCallDialog = ({
|
|||
workflow_id: workflowId,
|
||||
phone_number: phoneNumber,
|
||||
telephony_configuration_id: selectedConfigId ? Number(selectedConfigId) : null,
|
||||
from_phone_number_id: selectedFromPhoneNumberId ? Number(selectedFromPhoneNumberId) : null,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -230,6 +275,38 @@ export const PhoneCallDialog = ({
|
|||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{selectedConfigId && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="from-phone-number">Caller ID (from)</Label>
|
||||
{loadingPhoneNumbers ? (
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading phone numbers...
|
||||
</div>
|
||||
) : fromPhoneNumbers.length > 0 ? (
|
||||
<Select
|
||||
value={selectedFromPhoneNumberId}
|
||||
onValueChange={setSelectedFromPhoneNumberId}
|
||||
>
|
||||
<SelectTrigger id="from-phone-number" className="w-full">
|
||||
<SelectValue placeholder="Select a phone number" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromPhoneNumbers.map((phone) => (
|
||||
<SelectItem key={phone.id} value={String(phone.id)}>
|
||||
{phone.label ? `${phone.label} — ${phone.address}` : phone.address}
|
||||
{phone.is_default_caller_id ? " — default" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No phone numbers in this configuration. The provider will pick one automatically.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sipMode ? (
|
||||
<Input
|
||||
value={phoneNumber}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue