2026-09-27 08:47:11 +03:30
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
2026-10-05 11:56:49 +03:30
|
|
|
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
2026-09-27 15:06:41 +03:30
|
|
|
|
import { RouterLink } from 'vue-router';
|
2026-09-27 08:47:11 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
CreateVoipConnectionRequest,
|
|
|
|
|
|
VoipCallLogDto,
|
|
|
|
|
|
VoipConnectionDto,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-10-13 18:04:53 +03:30
|
|
|
|
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
|
2026-11-04 10:01:11 +03:30
|
|
|
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Alert as AAlert,
|
|
|
|
|
|
Button as AButton,
|
|
|
|
|
|
Card as ACard,
|
|
|
|
|
|
Col as ACol,
|
|
|
|
|
|
Empty as AEmpty,
|
|
|
|
|
|
Flex as AFlex,
|
|
|
|
|
|
Form as AForm,
|
|
|
|
|
|
FormItem as AFormItem,
|
|
|
|
|
|
Input as AInput,
|
|
|
|
|
|
InputPassword as AInputPassword,
|
|
|
|
|
|
Row as ARow,
|
|
|
|
|
|
Spin as ASpin,
|
|
|
|
|
|
Table as ATable,
|
|
|
|
|
|
Tag as ATag,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
} from 'ant-design-vue';
|
|
|
|
|
|
import type { TableColumnsType } from 'ant-design-vue';
|
|
|
|
|
|
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
|
|
|
|
|
|
|
|
|
|
|
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
|
|
|
|
|
Typography;
|
|
|
|
|
|
|
|
|
|
|
|
const CALL_COL_LABELS = [
|
|
|
|
|
|
'جهت',
|
|
|
|
|
|
'وضعیت',
|
|
|
|
|
|
'از / به',
|
|
|
|
|
|
'مشتری',
|
|
|
|
|
|
'مدت',
|
|
|
|
|
|
'خلاصه',
|
|
|
|
|
|
'زمان',
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
registerTableColumns('voip.calls', [...CALL_COL_LABELS]);
|
|
|
|
|
|
const callTableCols = useTableColumns(() => 'voip.calls');
|
2026-09-27 08:47:11 +03:30
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const ok = ref('');
|
|
|
|
|
|
const items = ref<VoipConnectionDto[]>([]);
|
|
|
|
|
|
const calls = ref<VoipCallLogDto[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
const form = reactive({
|
|
|
|
|
|
label: 'سانترال اصلی',
|
|
|
|
|
|
webhookSecret: '',
|
|
|
|
|
|
clickToCallUrl: '',
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const webhookUrl = computed(() => {
|
|
|
|
|
|
if (!items.value[0]) return '';
|
|
|
|
|
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
|
|
|
|
return `${origin}/api/public/integrations/voip/${items.value[0].id}/webhook`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const statusLabel: Record<string, string> = {
|
|
|
|
|
|
ringing: 'زنگ',
|
|
|
|
|
|
answered: 'پاسخ',
|
|
|
|
|
|
missed: 'از دسترفته',
|
|
|
|
|
|
completed: 'تمامشده',
|
|
|
|
|
|
failed: 'ناموفق',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-27 15:06:41 +03:30
|
|
|
|
const summaryLabel: Record<string, string> = {
|
|
|
|
|
|
none: '—',
|
|
|
|
|
|
pending: 'در صف',
|
|
|
|
|
|
processing: 'در حال ساخت',
|
|
|
|
|
|
done: 'آماده',
|
|
|
|
|
|
failed: 'ناموفق',
|
|
|
|
|
|
skipped: 'رد شده',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
const callColumns = computed<TableColumnsType<VoipCallLogDto>>(() => {
|
|
|
|
|
|
const all: TableColumnsType<VoipCallLogDto> = [
|
|
|
|
|
|
{ title: 'جهت', key: 'جهت' },
|
|
|
|
|
|
{ title: 'وضعیت', key: 'وضعیت' },
|
|
|
|
|
|
{ title: 'از / به', key: 'از / به' },
|
|
|
|
|
|
{ title: 'مشتری', key: 'مشتری' },
|
|
|
|
|
|
{ title: 'مدت', key: 'مدت' },
|
|
|
|
|
|
{ title: 'خلاصه', key: 'خلاصه' },
|
|
|
|
|
|
{ title: 'زمان', key: 'زمان' },
|
|
|
|
|
|
];
|
|
|
|
|
|
return all.filter((col) => callTableCols.isVisible(String(col.key ?? '')));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 15:06:41 +03:30
|
|
|
|
function noteLink(c: VoipCallLogDto): string | null {
|
|
|
|
|
|
if (!c.summaryNoteId) return null;
|
|
|
|
|
|
if (c.personId) return `/contacts/people/${c.personId}`;
|
|
|
|
|
|
return '/notes';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
function summaryColor(status: string | null | undefined): string {
|
|
|
|
|
|
switch (status) {
|
|
|
|
|
|
case 'done':
|
|
|
|
|
|
return 'success';
|
|
|
|
|
|
case 'pending':
|
|
|
|
|
|
case 'processing':
|
|
|
|
|
|
return 'processing';
|
|
|
|
|
|
case 'failed':
|
|
|
|
|
|
return 'error';
|
|
|
|
|
|
default:
|
|
|
|
|
|
return 'default';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 08:47:11 +03:30
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [connRes, callRes] = await Promise.all([
|
|
|
|
|
|
api.get<VoipConnectionDto[]>('/integrations/voip/connections'),
|
|
|
|
|
|
api.get<VoipCallLogDto[]>('/integrations/voip/calls'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
items.value = connRes.data;
|
|
|
|
|
|
calls.value = callRes.data;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
|
|
|
|
|
error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'بارگذاری ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function create() {
|
|
|
|
|
|
if (!form.label.trim()) {
|
|
|
|
|
|
error.value = 'نام اتصال را وارد کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload: CreateVoipConnectionRequest = {
|
|
|
|
|
|
label: form.label.trim(),
|
|
|
|
|
|
webhookSecret: form.webhookSecret.trim() || undefined,
|
|
|
|
|
|
clickToCallUrl: form.clickToCallUrl.trim() || null,
|
|
|
|
|
|
enabled: form.enabled,
|
|
|
|
|
|
};
|
|
|
|
|
|
await api.post('/integrations/voip/connections', payload);
|
|
|
|
|
|
form.webhookSecret = '';
|
|
|
|
|
|
ok.value = 'اتصال ذخیره شد.';
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
|
|
|
|
|
|
: 'ذخیره ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function remove(id: string) {
|
|
|
|
|
|
if (!confirm('این اتصال حذف شود؟')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/integrations/voip/connections/${id}`);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'حذف ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyWebhook() {
|
|
|
|
|
|
if (!webhookUrl.value) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(webhookUrl.value);
|
|
|
|
|
|
ok.value = 'آدرس وبهوک کپی شد.';
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'کپی ممکن نشد.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-flex vertical :gap="16">
|
|
|
|
|
|
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
|
|
|
|
|
<a-typography-title :level="3" :style="{ margin: 0 }">تلفن سازمانی</a-typography-title>
|
|
|
|
|
|
<a-button :disabled="loading" @click="load">
|
|
|
|
|
|
<template #icon><ReloadOutlined /></template>
|
|
|
|
|
|
بروزرسانی
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-flex>
|
2026-09-27 08:47:11 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
|
|
|
|
|
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
2026-09-27 08:47:11 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-spin :spinning="loading">
|
|
|
|
|
|
<a-flex vertical :gap="16">
|
|
|
|
|
|
<a-card title="اتصال جدید" :bordered="false">
|
|
|
|
|
|
<a-typography-paragraph type="secondary">
|
|
|
|
|
|
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وبهوک بفرستد. فیلدهای اصلی:
|
|
|
|
|
|
<code>callId</code>، <code>direction</code> (inbound/outbound)، <code>status</code>،
|
|
|
|
|
|
<code>from</code>، <code>to</code>. برای خلاصه خودکار یکی از <code>recordingUrl</code>
|
|
|
|
|
|
(لینک فایل ضبط) یا <code>transcript</code> (متن آماده) را بفرستید؛ پس از
|
|
|
|
|
|
<code>completed</code> / <code>answered</code> صف خلاصه ساخته میشود. امضا اختیاری با هدر
|
|
|
|
|
|
<code>X-Voip-Signature</code> (HMAC-SHA256 هگز).
|
|
|
|
|
|
</a-typography-paragraph>
|
|
|
|
|
|
<a-form layout="vertical" @finish="create">
|
|
|
|
|
|
<a-row :gutter="[16, 0]">
|
|
|
|
|
|
<a-col :xs="24" :md="12">
|
|
|
|
|
|
<a-form-item label="نام" required>
|
|
|
|
|
|
<a-input
|
|
|
|
|
|
v-model:value="form.label"
|
|
|
|
|
|
placeholder="سانترال اصلی"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
<a-col :xs="24" :md="12">
|
|
|
|
|
|
<a-form-item label="رمز وبهوک (اختیاری)">
|
|
|
|
|
|
<a-input-password
|
|
|
|
|
|
v-model:value="form.webhookSecret"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
placeholder="برای تأیید امضا"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
<a-col :xs="24" :md="12">
|
|
|
|
|
|
<a-form-item label="آدرس Click-to-Call (اختیاری)">
|
|
|
|
|
|
<a-input
|
|
|
|
|
|
v-model:value="form.clickToCallUrl"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
placeholder="https://pbx.example/originate?num={number}"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
</a-row>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<a-button type="primary" html-type="submit" :loading="saving">ذخیره اتصال</a-button>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
|
|
<a-card title="اتصالها" :bordered="false">
|
|
|
|
|
|
<a-flex vertical :gap="12">
|
|
|
|
|
|
<a-empty
|
|
|
|
|
|
v-if="!loading && !items.length"
|
|
|
|
|
|
description="اتصالی نیست. یک اتصال برای دریافت رویداد تماس بسازید."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<a-card v-for="c in items" :key="c.id" type="inner">
|
|
|
|
|
|
<a-row :gutter="[16, 12]" align="middle">
|
|
|
|
|
|
<a-col :xs="24" :lg="18">
|
|
|
|
|
|
<a-flex vertical :gap="4">
|
|
|
|
|
|
<a-typography-text strong>{{ c.label }}</a-typography-text>
|
|
|
|
|
|
<a-typography-text type="secondary">
|
|
|
|
|
|
تماسهای ثبتشده {{ toFaDigits(c.callCount) }} ·
|
|
|
|
|
|
{{
|
|
|
|
|
|
c.lastEventAt
|
|
|
|
|
|
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
|
|
|
|
|
|
: 'هنوز رویدادی نیامده'
|
|
|
|
|
|
}}
|
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
<a-tag v-if="c.clickToCallUrl" color="processing" :bordered="false">
|
|
|
|
|
|
Click-to-Call فعال
|
|
|
|
|
|
</a-tag>
|
|
|
|
|
|
<a-alert
|
|
|
|
|
|
v-if="c.lastError"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
:message="c.lastError"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
<a-col :xs="24" :lg="6">
|
|
|
|
|
|
<a-button danger @click="remove(c.id)">حذف</a-button>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
</a-row>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
|
|
<a-card v-if="webhookUrl" type="inner" title="آدرس وبهوک">
|
|
|
|
|
|
<a-flex vertical :gap="8">
|
|
|
|
|
|
<a-typography-text dir="ltr" code>{{ webhookUrl }}</a-typography-text>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<a-button @click="copyWebhook">
|
|
|
|
|
|
<template #icon><CopyOutlined /></template>
|
|
|
|
|
|
کپی
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
|
|
<a-card title="آخرین تماسها" :bordered="false">
|
|
|
|
|
|
<a-empty
|
|
|
|
|
|
v-if="!calls.length"
|
|
|
|
|
|
description="تماسی نیست. پس از ارسال رویداد از سانترال اینجا دیده میشوند."
|
2026-09-27 08:47:11 +03:30
|
|
|
|
/>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-flex v-else vertical :gap="12">
|
|
|
|
|
|
<a-flex justify="flex-end">
|
|
|
|
|
|
<TableColumnsMenu table-key="voip.calls" align="end" />
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
<a-table
|
|
|
|
|
|
:columns="callColumns"
|
|
|
|
|
|
:data-source="calls"
|
|
|
|
|
|
:pagination="false"
|
|
|
|
|
|
row-key="id"
|
|
|
|
|
|
:scroll="{ x: 'max-content' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #bodyCell="{ column, record }">
|
|
|
|
|
|
<template v-if="column.key === 'جهت'">
|
|
|
|
|
|
{{ (record as VoipCallLogDto).direction === 'outbound' ? 'خروجی' : 'ورودی' }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="column.key === 'وضعیت'">
|
|
|
|
|
|
{{
|
|
|
|
|
|
statusLabel[(record as VoipCallLogDto).status] ??
|
|
|
|
|
|
(record as VoipCallLogDto).status
|
|
|
|
|
|
}}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="column.key === 'از / به'">
|
|
|
|
|
|
<span dir="ltr">
|
|
|
|
|
|
{{ (record as VoipCallLogDto).fromNumber || '—' }} →
|
|
|
|
|
|
{{ (record as VoipCallLogDto).toNumber || '—' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="column.key === 'مشتری'">
|
|
|
|
|
|
{{ (record as VoipCallLogDto).personName || '—' }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="column.key === 'مدت'">
|
|
|
|
|
|
{{
|
|
|
|
|
|
(record as VoipCallLogDto).durationSec != null
|
|
|
|
|
|
? `${toFaDigits((record as VoipCallLogDto).durationSec!)} ثانیه`
|
|
|
|
|
|
: '—'
|
|
|
|
|
|
}}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="column.key === 'خلاصه'">
|
|
|
|
|
|
<a-flex vertical :gap="4">
|
|
|
|
|
|
<a-tag
|
|
|
|
|
|
:color="summaryColor((record as VoipCallLogDto).summaryStatus)"
|
|
|
|
|
|
:bordered="false"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
summaryLabel[(record as VoipCallLogDto).summaryStatus] ??
|
|
|
|
|
|
(record as VoipCallLogDto).summaryStatus ??
|
|
|
|
|
|
'—'
|
|
|
|
|
|
}}
|
|
|
|
|
|
</a-tag>
|
|
|
|
|
|
<a-typography-text
|
|
|
|
|
|
v-if="(record as VoipCallLogDto).summary"
|
|
|
|
|
|
type="secondary"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
rewriteGregorianDates((record as VoipCallLogDto).summary!).slice(0, 120)
|
|
|
|
|
|
}}{{
|
|
|
|
|
|
(record as VoipCallLogDto).summary!.length > 120 ? '…' : ''
|
|
|
|
|
|
}}
|
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
<a-typography-text
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
(record as VoipCallLogDto).summaryError &&
|
|
|
|
|
|
(record as VoipCallLogDto).summaryStatus === 'failed'
|
|
|
|
|
|
"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ (record as VoipCallLogDto).summaryError }}
|
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
<RouterLink v-if="noteLink(record as VoipCallLogDto)" :to="noteLink(record as VoipCallLogDto)!">
|
|
|
|
|
|
مشاهده در پرونده
|
|
|
|
|
|
</RouterLink>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="column.key === 'زمان'">
|
|
|
|
|
|
{{ formatJalaliNumeric(new Date((record as VoipCallLogDto).startedAt)) }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-table>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-spin>
|
|
|
|
|
|
</a-flex>
|
2026-09-27 08:47:11 +03:30
|
|
|
|
</template>
|