Files
CRM_Montzar/apps/web/src/views/VoipSettingsView.vue
T
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30

394 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateVoipConnectionRequest,
VoipCallLogDto,
VoipConnectionDto,
} from '@mento/shared';
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
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');
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: 'ناموفق',
};
const summaryLabel: Record<string, string> = {
none: '—',
pending: 'در صف',
processing: 'در حال ساخت',
done: 'آماده',
failed: 'ناموفق',
skipped: 'رد شده',
};
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 ?? '')));
});
function noteLink(c: VoipCallLogDto): string | null {
if (!c.summaryNoteId) return null;
if (c.personId) return `/contacts/people/${c.personId}`;
return '/notes';
}
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';
}
}
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>
<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>
<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 = ''" />
<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="تماسی نیست. پس از ارسال رویداد از سانترال اینجا دیده می‌شوند."
/>
<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>
</template>