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>
This commit is contained in:
@@ -9,16 +9,43 @@ import type {
|
||||
VoipCallLogDto,
|
||||
VoipConnectionDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
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);
|
||||
@@ -57,12 +84,39 @@ const summaryLabel: Record<string, string> = {
|
||||
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 = '';
|
||||
@@ -137,275 +191,203 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="تلفن سازمانی"
|
||||
description="ثبت خودکار تماس، خلاصه گفتگو و اعلان تماس ورودی"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
<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>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<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 = ''" />
|
||||
|
||||
<UiCard title="اتصال جدید">
|
||||
<p class="hint">
|
||||
سانترال یا سرویس 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 هگز).
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="form.label" placeholder="سانترال اصلی" />
|
||||
</UiField>
|
||||
<UiField label="رمز وبهوک (اختیاری)">
|
||||
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
|
||||
</UiField>
|
||||
<UiField label="آدرس Click-to-Call (اختیاری)">
|
||||
<UiInput
|
||||
v-model="form.clickToCallUrl"
|
||||
placeholder="https://pbx.example/originate?num={number}"
|
||||
<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="تماسی نیست. پس از ارسال رویداد از سانترال اینجا دیده میشوند."
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="اتصالها">
|
||||
<UiEmptyState
|
||||
v-if="!loading && !items.length"
|
||||
title="اتصالی نیست"
|
||||
description="یک اتصال برای دریافت رویداد تماس بسازید."
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="c in items" :key="c.id" class="item">
|
||||
<div>
|
||||
<strong>{{ c.label }}</strong>
|
||||
<p class="muted">
|
||||
تماسهای ثبتشده {{ toFaDigits(c.callCount) }}
|
||||
·
|
||||
{{
|
||||
c.lastEventAt
|
||||
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
|
||||
: 'هنوز رویدادی نیامده'
|
||||
}}
|
||||
</p>
|
||||
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p>
|
||||
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="webhookUrl" class="webhook">
|
||||
<p class="hint">آدرس وبهوک:</p>
|
||||
<code>{{ webhookUrl }}</code>
|
||||
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="آخرین تماسها">
|
||||
<UiEmptyState
|
||||
v-if="!calls.length"
|
||||
title="تماسی نیست"
|
||||
description="پس از ارسال رویداد از سانترال اینجا دیده میشوند."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="voip.calls" align="end" />
|
||||
</div>
|
||||
<table v-table-cols="'voip.calls'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>جهت</th>
|
||||
<th>وضعیت</th>
|
||||
<th>از / به</th>
|
||||
<th>مشتری</th>
|
||||
<th>مدت</th>
|
||||
<th>خلاصه</th>
|
||||
<th>زمان</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in calls" :key="c.id">
|
||||
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
|
||||
<td>{{ statusLabel[c.status] ?? c.status }}</td>
|
||||
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
|
||||
<td>{{ c.personName || '—' }}</td>
|
||||
<td>
|
||||
{{
|
||||
c.durationSec != null
|
||||
? `${toFaDigits(c.durationSec)} ثانیه`
|
||||
: '—'
|
||||
}}
|
||||
</td>
|
||||
<td class="summary-cell">
|
||||
<span
|
||||
class="summary-badge"
|
||||
:data-status="c.summaryStatus || 'none'"
|
||||
>
|
||||
{{ summaryLabel[c.summaryStatus] ?? c.summaryStatus ?? '—' }}
|
||||
</span>
|
||||
<p v-if="c.summary" class="summary-preview">
|
||||
{{ rewriteGregorianDates(c.summary).slice(0, 120) }}{{ c.summary.length > 120 ? '…' : '' }}
|
||||
</p>
|
||||
<p v-if="c.summaryError && c.summaryStatus === 'failed'" class="warn">
|
||||
{{ c.summaryError }}
|
||||
</p>
|
||||
<RouterLink
|
||||
v-if="noteLink(c)"
|
||||
class="note-link"
|
||||
:to="noteLink(c)!"
|
||||
>
|
||||
مشاهده در پرونده
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(c.startedAt)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.hint code {
|
||||
background: #f1f5f9;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.webhook {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.webhook code {
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.warn {
|
||||
color: #b45309;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.summary-cell {
|
||||
max-width: 16rem;
|
||||
}
|
||||
.summary-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.summary-badge[data-status='done'] {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
.summary-badge[data-status='pending'],
|
||||
.summary-badge[data-status='processing'] {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.summary-badge[data-status='failed'] {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.summary-preview {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.note-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user