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>
394 lines
14 KiB
Vue
394 lines
14 KiB
Vue
<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>
|