feat(voip): make Daftaresoma setup inbound-first and filter calls by line

Drop required from_number for webhook logging, persist CalleeExtension as lineExtension, and add direction/status/line filters on the calls list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-07 13:58:42 +03:30
co-authored by Cursor
parent abb1a3a18e
commit f36711174e
8 changed files with 176 additions and 42 deletions
+107 -20
View File
@@ -29,6 +29,11 @@ const ok = ref('');
const items = ref<VoipConnectionDto[]>([]);
const calls = ref<VoipCallLogDto[]>([]);
const tab = ref<TabKey>('daftareshoma');
const callFilters = reactive({
direction: '',
status: '',
lineExtension: '',
});
const form = reactive({
label: 'سانترال اصلی',
@@ -54,6 +59,16 @@ const daftaItems = computed(() =>
const activeDafta = computed(() => daftaItems.value[0] ?? null);
const knownLines = ref<string[]>([]);
const lineOptions = computed(() => {
const set = new Set(knownLines.value);
for (const c of calls.value) {
if (c.lineExtension) set.add(c.lineExtension);
}
if (callFilters.lineExtension) set.add(callFilters.lineExtension);
return [...set].sort();
});
const webhookUrlFor = (id: string | undefined) => {
if (!id) return '';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
@@ -88,16 +103,31 @@ function noteLink(c: VoipCallLogDto): string | null {
return '/notes';
}
async function loadCalls() {
const params: Record<string, string> = {};
if (callFilters.direction) params.direction = callFilters.direction;
if (callFilters.status) params.status = callFilters.status;
if (callFilters.lineExtension) params.lineExtension = callFilters.lineExtension;
const callRes = await api.get<VoipCallLogDto[]>('/integrations/voip/calls', {
params,
});
calls.value = callRes.data;
const merged = new Set(knownLines.value);
for (const c of callRes.data) {
if (c.lineExtension) merged.add(c.lineExtension);
}
knownLines.value = [...merged].sort();
}
async function load() {
loading.value = true;
error.value = '';
try {
const [connRes, callRes] = await Promise.all([
const [connRes] = await Promise.all([
api.get<VoipConnectionDto[]>('/integrations/voip/connections'),
api.get<VoipCallLogDto[]>('/integrations/voip/calls'),
loadCalls(),
]);
items.value = connRes.data;
calls.value = callRes.data;
fillDaftaFromActive();
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
@@ -110,6 +140,18 @@ async function load() {
}
}
async function applyCallFilters() {
loading.value = true;
error.value = '';
try {
await loadCalls();
} catch {
error.value = 'فیلتر تماس‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createGeneric() {
if (!form.label.trim()) {
error.value = 'نام اتصال را وارد کنید.';
@@ -148,10 +190,6 @@ async function saveDafta() {
error.value = 'توکن API دفتر شما را وارد کنید.';
return;
}
if (!daftaForm.fromNumber.trim() || !daftaForm.callerExtension.trim()) {
error.value = 'شماره مبدأ و خط / داخلی را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
@@ -159,8 +197,8 @@ async function saveDafta() {
if (activeDafta.value) {
await api.patch(`/integrations/voip/connections/${activeDafta.value.id}`, {
label: daftaForm.label.trim(),
fromNumber: daftaForm.fromNumber.trim(),
callerExtension: daftaForm.callerExtension.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
...(daftaForm.apiToken.trim()
? { apiToken: daftaForm.apiToken.trim() }
@@ -173,8 +211,8 @@ async function saveDafta() {
label: daftaForm.label.trim(),
provider: 'daftareshoma',
apiToken: daftaForm.apiToken.trim(),
fromNumber: daftaForm.fromNumber.trim(),
callerExtension: daftaForm.callerExtension.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
};
await api.post('/integrations/voip/connections', payload);
@@ -265,11 +303,11 @@ onMounted(async () => {
<template v-if="tab === 'daftareshoma'">
<UiCard title="اتصال دفتر شما">
<p class="hint">
توکن API را از پرتال دفتر شما (توسعه‌دهندگان → API) بگیرید.
پس از ذخیره، آدرس وب‌هوک زیر را در
برای ثبت همه تماس‌های ورودی و خروجی فقط توکن و وب‌هوک لازم است.
توکن را از پرتال (توسعه‌دهندگان → API) بگیرید؛ بعد آدرس وب‌هوک را در
<strong>تنظیمات → هوک</strong>
پرتال ثبت کنید و رویدادهای شروع/پایان تماس ورودی و پایان تماس خروجی را فعال کنید.
برای تماس خروجی از CRM، توکن باید دسترسی «تماس خروجی» داشته باشد؛ برای خلاصه خودکار، «گزارش تماس» و «دریافت فایل تماس» هم لازم است.
با رویدادهای شروع/پایان ورودی و پایان خروجی ثبت کنید.
فیلدهای شماره مبدأ و خط فقط برای «تماس از داخل CRM» اختیاری‌اند و بعداً قابل تکمیل‌اند.
</p>
<div class="form-grid">
<UiField label="نام">
@@ -285,17 +323,17 @@ onMounted(async () => {
autocomplete="off"
/>
</UiField>
<UiField label="شماره مبدأ (from_number)">
<UiField label="شماره مبدأ — اختیاری (فقط تماس خروجی از CRM)">
<UiInput
v-model="daftaForm.fromNumber"
placeholder="مثلاً 0912…"
placeholder="موبایل همکار برای click-to-call"
dir="ltr"
/>
</UiField>
<UiField label="خط / داخلی (caller_extension)">
<UiField label="خط پیش‌فرض — اختیاری (فقط تماس خروجی از CRM)">
<UiInput
v-model="daftaForm.callerExtension"
placeholder="مثلاً 02128…"
placeholder="مثلاً 02128425990"
dir="ltr"
/>
</UiField>
@@ -427,6 +465,37 @@ onMounted(async () => {
</template>
<UiCard title="آخرین تماس‌ها">
<div class="call-filters">
<UiField label="جهت">
<select v-model="callFilters.direction" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="inbound">ورودی</option>
<option value="outbound">خروجی</option>
</select>
</UiField>
<UiField label="وضعیت">
<select v-model="callFilters.status" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="ringing">زنگ</option>
<option value="answered">پاسخ</option>
<option value="missed">از دست‌رفته</option>
<option value="completed">تمام‌شده</option>
<option value="failed">ناموفق</option>
</select>
</UiField>
<UiField label="خط">
<select
v-model="callFilters.lineExtension"
class="filter-select"
@change="applyCallFilters"
>
<option value="">همه خطوط</option>
<option v-for="line in lineOptions" :key="line" :value="line">
{{ line }}
</option>
</select>
</UiField>
</div>
<UiEmptyState
v-if="!calls.length"
title="تماسی نیست"
@@ -441,6 +510,7 @@ onMounted(async () => {
<tr>
<th>جهت</th>
<th>وضعیت</th>
<th>خط</th>
<th>از / به</th>
<th>مشتری</th>
<th>مدت</th>
@@ -452,6 +522,7 @@ onMounted(async () => {
<tr v-for="c in calls" :key="c.id">
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
<td>{{ statusLabel[c.status] ?? c.status }}</td>
<td dir="ltr">{{ c.lineExtension || '—' }}</td>
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
<td>{{ c.personName || '—' }}</td>
<td>
@@ -536,6 +607,21 @@ onMounted(async () => {
gap: 0.75rem;
margin: 0.75rem 0 1rem;
}
.call-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-select {
width: 100%;
min-height: 36px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.6rem;
background: #fff;
font: inherit;
}
.list {
display: grid;
gap: 0.75rem;
@@ -628,7 +714,8 @@ th {
color: #0f766e;
}
@media (max-width: 800px) {
.form-grid {
.form-grid,
.call-filters {
grid-template-columns: 1fr;
}
}