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:
2026-11-04 10:05:28 +03:30
co-authored by Cursor
parent b11affbdfd
commit cb3a488b21
46 changed files with 6024 additions and 7679 deletions
+259 -277
View File
@@ -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>