Files
CRM_Montzar/apps/web/src/views/SmsCampaignsView.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

666 lines
22 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, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
FilteredIdsResponse,
LeadDto,
LeadStageDto,
PaginatedResponse,
PersonDto,
SmsCampaignDto,
SmsCampaignListResponse,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_FIELDS,
SMS_CAMPAIGN_CHUNK_SIZE,
} from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
ListItem as AListItem,
Menu as AMenu,
MenuItem as AMenuItem,
Modal,
Pagination as APagination,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Switch as ASwitch,
Table as ATable,
TabPane as ATabPane,
Tabs as ATabs,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType, TableProps } from 'ant-design-vue';
import {
ReloadOutlined,
SendOutlined,
StopOutlined,
BarChartOutlined,
} from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItemMeta = AList.Item.Meta;
type AudienceTab = 'people' | 'leads';
const router = useRouter();
const loading = ref(false);
const listLoading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const campaigns = ref<SmsCampaignDto[]>([]);
const audience = ref<AudienceTab>('people');
const selectedIds = ref<string[]>([]);
const name = ref('');
const body = ref('');
const queueNow = ref(true);
const fieldsOpen = ref(false);
const q = ref('');
const province = ref('');
const city = ref('');
const region = ref('');
const stageId = ref('');
const hasMobile = ref(true);
const page = ref(1);
const pageSize = 40;
const total = ref(0);
const people = ref<PersonDto[]>([]);
const leads = ref<LeadDto[]>([]);
const stages = ref<LeadStageDto[]>([]);
const statusLabel: Record<string, string> = {
draft: 'پیش‌نویس',
queued: 'در صف',
sending: 'در حال ارسال',
done: 'تمام‌شده',
cancelled: 'لغو شده',
};
const statusColor: Record<string, string> = {
draft: 'default',
queued: 'processing',
sending: 'warning',
done: 'success',
cancelled: 'error',
};
const stageOptions = computed(() => [
{ value: '', label: 'همه' },
...stages.value.map((s) => ({ value: s.id, label: s.name })),
]);
const pageIds = computed(() =>
audience.value === 'people'
? people.value.map((p) => p.id)
: leads.value.map((l) => l.id),
);
const tableData = computed(() =>
audience.value === 'people' ? people.value : leads.value,
);
const audienceColumns = computed<TableColumnsType>(() => [
{ title: 'نام', key: 'name' },
]);
const rowSelection = computed<TableProps['rowSelection']>(() => ({
selectedRowKeys: selectedIds.value.filter((id) => pageIds.value.includes(id)),
onChange: (keys) => {
const kept = selectedIds.value.filter((id) => !pageIds.value.includes(id));
selectedIds.value = [...kept, ...keys.map(String)];
},
}));
const filterParams = computed(() => {
const base: Record<string, string | number | boolean> = {
q: q.value.trim() || undefined,
hasMobile: hasMobile.value ? '1' : undefined,
} as Record<string, string | number | boolean>;
if (audience.value === 'people') {
if (province.value.trim()) base.province = province.value.trim();
if (city.value.trim()) base.city = city.value.trim();
} else {
if (city.value.trim()) base.city = city.value.trim();
if (region.value.trim()) base.region = region.value.trim();
if (stageId.value) base.stageId = stageId.value;
}
return Object.fromEntries(
Object.entries(base).filter(([, v]) => v !== undefined && v !== ''),
);
});
function cleanParams(extra: Record<string, unknown> = {}) {
const merged = { ...filterParams.value, ...extra };
return Object.fromEntries(
Object.entries(merged).filter(([, v]) => v !== undefined && v !== '' && v !== null),
);
}
async function loadList() {
listLoading.value = true;
try {
if (audience.value === 'people') {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: cleanParams({ page: page.value, limit: pageSize }),
});
people.value = data.items;
total.value = data.total;
leads.value = [];
} else {
const { data } = await api.get<PaginatedResponse<LeadDto>>('/leads', {
params: cleanParams({ page: page.value, limit: pageSize }),
});
leads.value = data.items;
total.value = data.total;
people.value = [];
}
} finally {
listLoading.value = false;
}
}
async function loadCampaigns() {
const { data } = await api.get<SmsCampaignListResponse>('/sms-campaigns');
campaigns.value = data.items;
}
async function loadStages() {
try {
const { data } = await api.get<LeadStageDto[]>('/leads/stages');
stages.value = data;
} catch {
stages.value = [];
}
}
async function load() {
loading.value = true;
error.value = '';
try {
await Promise.all([loadCampaigns(), loadList(), loadStages()]);
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول پیامک گروهی برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
}
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleReload() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void loadList().catch(() => {
error.value = 'بارگذاری مخاطب ناموفق بود.';
});
}, 300);
}
watch([q, province, city, region, stageId, hasMobile], scheduleReload);
watch(page, () => {
void loadList().catch(() => {
error.value = 'بارگذاری مخاطب ناموفق بود.';
});
});
function onAudienceChange(key: string | number) {
const next = String(key) as AudienceTab;
const prev = audience.value;
if (next === prev) return;
if (selectedIds.value.length) {
Modal.confirm({
title: 'تعویض تب مخاطب',
content: 'با تعویض تب، انتخاب‌های فعلی پاک می‌شود. ادامه می‌دهید؟',
okText: 'ادامه',
cancelText: 'انصراف',
onOk: () => {
audience.value = next;
selectedIds.value = [];
q.value = '';
province.value = '';
city.value = '';
region.value = '';
stageId.value = '';
page.value = 1;
void loadList();
},
});
return;
}
audience.value = next;
selectedIds.value = [];
q.value = '';
province.value = '';
city.value = '';
region.value = '';
stageId.value = '';
page.value = 1;
void loadList();
}
function selectVisible() {
const ids = pageIds.value;
selectedIds.value = [...new Set([...selectedIds.value, ...ids])];
}
async function selectAllFiltered() {
error.value = '';
try {
const path = audience.value === 'people' ? '/people/ids' : '/leads/ids';
const { data } = await api.get<FilteredIdsResponse>(path, {
params: cleanParams(),
});
if (!data.total) {
error.value = 'نتیجه‌ای با این فیلتر پیدا نشد.';
return;
}
Modal.confirm({
title: 'انتخاب همهٔ نتایج',
content: `${toFaDigits(data.total)} مخاطب مطابق فیلتر انتخاب شود؟`,
okText: 'انتخاب',
cancelText: 'انصراف',
onOk: () => {
selectedIds.value = data.ids;
ok.value = `${toFaDigits(data.ids.length)} مخاطب انتخاب شد.`;
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'انتخاب همه ناموفق بود.');
} else {
error.value = 'انتخاب همه ناموفق بود.';
}
}
}
function insertField(token: string) {
body.value = `${body.value}${token}`;
fieldsOpen.value = false;
}
function chunkIds(ids: string[]) {
const out: string[][] = [];
for (let i = 0; i < ids.length; i += SMS_CAMPAIGN_CHUNK_SIZE) {
out.push(ids.slice(i, i + SMS_CAMPAIGN_CHUNK_SIZE));
}
return out;
}
async function createCampaign() {
if (!name.value.trim() || body.value.trim().length < 2) {
error.value = 'نام و متن پیامک را کامل کنید.';
return;
}
if (!selectedIds.value.length) {
error.value = 'حداقل یک مخاطب انتخاب کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
const chunks = chunkIds(selectedIds.value);
const wantQueue = queueNow.value;
try {
const first = chunks[0]!;
const createBody =
audience.value === 'people'
? { personIds: first }
: { leadIds: first };
const { data: campaign } = await api.post<SmsCampaignDto>('/sms-campaigns', {
name: name.value.trim(),
body: body.value.trim(),
queueNow: false,
...createBody,
});
for (let i = 1; i < chunks.length; i += 1) {
const part = chunks[i]!;
await api.post(`/sms-campaigns/${campaign.id}/recipients`, {
...(audience.value === 'people'
? { personIds: part }
: { leadIds: part }),
});
}
if (wantQueue) {
await api.post(`/sms-campaigns/${campaign.id}/queue`);
}
name.value = '';
body.value = '';
selectedIds.value = [];
ok.value = wantQueue
? `کمپین با ${toFaDigits(chunks.reduce((n, c) => n + c.length, 0))} مخاطب در صف ارسال قرار گرفت.`
: 'کمپین به‌صورت پیش‌نویس ذخیره شد.';
await loadCampaigns();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ساخت کمپین ناموفق بود.');
} else {
error.value = 'ساخت کمپین ناموفق بود.';
}
} finally {
saving.value = false;
}
}
async function queue(id: string) {
try {
await api.post(`/sms-campaigns/${id}/queue`);
await loadCampaigns();
} catch {
error.value = 'صف‌بندی ناموفق بود.';
}
}
async function cancel(id: string) {
try {
await api.post(`/sms-campaigns/${id}/cancel`);
await loadCampaigns();
} catch {
error.value = 'لغو ناموفق بود.';
}
}
function goReport(id: string) {
void router.push({ name: 'sms-campaign-report', params: { id } });
}
function personLine(p: PersonDto) {
const bits: string[] = [p.mobile || 'بدون موبایل'];
if (p.company) {
bits.push(p.company.name);
const place = [p.company.city, p.company.province].filter(Boolean).join('، ');
if (place) bits.push(place);
}
return bits.join(' · ');
}
function leadLine(l: LeadDto) {
const bits: string[] = [l.mobile || 'بدون موبایل'];
const place = [l.city, l.region].filter(Boolean).join('، ');
if (place) bits.push(place);
bits.push(l.stageName);
return bits.join(' · ');
}
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-card :bordered="false" title="کمپین جدید">
<a-form layout="vertical" @finish="createCampaign">
<a-divider orientation="right">متن و تنظیمات</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام کمپین" required>
<a-input
v-model:value="name"
placeholder="مثلاً اطلاع‌رسانی جشنواره"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ارسال">
<a-space :size="8">
<a-switch v-model:checked="queueNow" />
<a-typography-text>ارسال فوری پس از ذخیره</a-typography-text>
</a-space>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="متن پیامک" required>
<template #extra>
<a-dropdown v-model:open="fieldsOpen" :trigger="['click']">
<a-button type="link">{ } فیلدهای پویا</a-button>
<template #overlay>
<a-menu>
<a-menu-item
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
@click="insertField(item.token)"
>
{{ item.token }} — {{ item.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<a-textarea
v-model:value="body"
:rows="4"
placeholder="متن پیامک را بنویسید… از فیلدهای پویا استفاده کنید. لینک http(s) برای هر مخاطب یکتا می‌شود."
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="right">مخاطبان</a-divider>
<a-tabs :active-key="audience" @change="onAudienceChange">
<a-tab-pane key="people" tab="اشخاص" />
<a-tab-pane key="leads" tab="لیدها" />
</a-tabs>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="نام یا موبایل…"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="audience === 'people'" :xs="24" :sm="12" :md="8">
<a-form-item label="استان">
<a-input
v-model:value="province"
allow-clear
placeholder="مثلاً اصفهان"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="شهر">
<a-input
v-model:value="city"
allow-clear
placeholder="شهر…"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="audience === 'leads'" :xs="24" :sm="12" :md="8">
<a-form-item label="منطقه / استان">
<a-input
v-model:value="region"
allow-clear
placeholder="منطقه یا استان…"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="audience === 'leads'" :xs="24" :sm="12" :md="8">
<a-form-item label="مرحله">
<a-select
v-model:value="stageId"
:options="stageOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="موبایل">
<a-space :size="8">
<a-checkbox v-model:checked="hasMobile" />
<a-typography-text>فقط با موبایل</a-typography-text>
</a-space>
</a-form-item>
</a-col>
</a-row>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginBottom: '12px' }">
<a-typography-text type="secondary">
انتخاب‌شده: {{ toFaDigits(selectedIds.length) }} نفر · نتایج فیلتر:
{{ toFaDigits(total) }}
</a-typography-text>
<a-space wrap :size="8">
<a-button @click="selectVisible">انتخاب صفحه</a-button>
<a-button @click="selectAllFiltered">انتخاب همهٔ نتایج فیلتر</a-button>
</a-space>
</a-flex>
<a-spin :spinning="listLoading">
<a-table
:columns="audienceColumns"
:data-source="tableData"
:row-selection="rowSelection"
:pagination="false"
row-key="id"
:locale="{ emptyText: 'موردی پیدا نشد.' }"
:scroll="{ y: 360 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<template v-if="audience === 'people'">
<a-typography-text strong>
{{ (record as PersonDto).fullName }}
</a-typography-text>
<div>
<a-typography-text type="secondary">
{{ personLine(record as PersonDto) }}
</a-typography-text>
</div>
</template>
<template v-else>
<a-typography-text strong>
{{ (record as LeadDto).contactName || (record as LeadDto).title }}
</a-typography-text>
<div>
<a-typography-text type="secondary">
{{ leadLine(record as LeadDto) }}
</a-typography-text>
</div>
</template>
</template>
</template>
</a-table>
</a-spin>
<a-flex justify="center" :style="{ marginTop: '12px', marginBottom: '16px' }">
<a-pagination
v-model:current="page"
:total="total"
:page-size="pageSize"
:show-size-changer="false"
/>
</a-flex>
<a-button type="primary" html-type="submit" :loading="saving" :disabled="saving">
ساخت کمپین
</a-button>
</a-form>
</a-card>
<a-card :bordered="false" title="کمپین‌ها" :style="{ marginTop: '16px' }">
<a-empty
v-if="!loading && !campaigns.length"
description="کمپینی نیست. اولین کمپین پیامک گروهی را بسازید."
/>
<a-list v-else :data-source="campaigns" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="(item as SmsCampaignDto).name"
:description="(item as SmsCampaignDto).body"
/>
<template #actions>
<a-tag
:color="statusColor[(item as SmsCampaignDto).status] || 'default'"
:bordered="false"
>
{{ statusLabel[(item as SmsCampaignDto).status] ?? (item as SmsCampaignDto).status }}
</a-tag>
<a-typography-text type="secondary">
کل {{ toFaDigits((item as SmsCampaignDto).totalCount) }}
· ارسال {{ toFaDigits((item as SmsCampaignDto).sentCount) }}
· ناموفق {{ toFaDigits((item as SmsCampaignDto).failedCount) }}
· ردشده {{ toFaDigits((item as SmsCampaignDto).skippedCount) }}
· {{ formatJalaliNumeric(new Date((item as SmsCampaignDto).createdAt)) }}
</a-typography-text>
<a-button @click="goReport((item as SmsCampaignDto).id)">
<template #icon><BarChartOutlined /></template>
گزارش
</a-button>
<a-button
v-if="(item as SmsCampaignDto).status === 'draft' || (item as SmsCampaignDto).status === 'cancelled'"
@click="queue((item as SmsCampaignDto).id)"
>
<template #icon><SendOutlined /></template>
صف ارسال
</a-button>
<a-button
v-if="
(item as SmsCampaignDto).status === 'queued' ||
(item as SmsCampaignDto).status === 'sending' ||
(item as SmsCampaignDto).status === 'draft'
"
danger
@click="cancel((item as SmsCampaignDto).id)"
>
<template #icon><StopOutlined /></template>
لغو
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</a-spin>
</a-flex>
</template>