Files
CRM_Montzar/apps/web/src/views/SmsCampaignsView.vue
T
MahdiandCursor d62e004e90 feat: SMS campaigns for people or leads with filters and batching
Add audience tabs, province/city filters, select-all ids, dynamic SMS tokens, and 50-sized create/send chunks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 20:54:32 +03:30

778 lines
20 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 {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
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 pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
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 = 'بارگذاری مخاطب ناموفق بود.';
});
});
watch(audience, (next, prev) => {
if (next === prev) return;
if (selectedIds.value.length) {
const okSwitch = window.confirm(
'با تعویض تب، انتخاب‌های فعلی پاک می‌شود. ادامه می‌دهید؟',
);
if (!okSwitch) {
audience.value = prev;
return;
}
}
selectedIds.value = [];
q.value = '';
province.value = '';
city.value = '';
region.value = '';
stageId.value = '';
page.value = 1;
void loadList();
});
function toggleId(id: string) {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((x) => x !== id);
} else {
selectedIds.value = [...selectedIds.value, id];
}
}
function selectVisible() {
const ids =
audience.value === 'people'
? people.value.map((p) => p.id)
: leads.value.map((l) => l.id);
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;
}
const confirmed = window.confirm(
`${toFaDigits(data.total)} مخاطب مطابق فیلتر انتخاب شود؟`,
);
if (!confirmed) return;
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 = 'لغو ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="پیامک گروهی"
description="ارسال انبوه پیامک به اشخاص یا لیدهای فیلترشده"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="کمپین جدید">
<div class="form-grid">
<UiField label="نام کمپین">
<UiInput v-model="name" placeholder="مثلاً اطلاع‌رسانی جشنواره" />
</UiField>
<label class="check">
<input v-model="queueNow" type="checkbox" />
ارسال فوری پس از ذخیره
</label>
</div>
<div class="body-block">
<div class="body-tools">
<span class="muted">متن پیامک</span>
<div class="pop-wrap">
<button
class="pill"
type="button"
@click="fieldsOpen = !fieldsOpen"
>
<b>{ }</b>
فیلدهای پویا
</button>
<div v-if="fieldsOpen" class="pop">
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
type="button"
@click="insertField(item.token)"
>
<code>{{ item.token }}</code>
<span>{{ item.label }}</span>
</button>
</div>
</div>
</div>
<UiTextarea
v-model="body"
:rows="4"
placeholder="متن پیامک را بنویسید… از فیلدهای پویا استفاده کنید. لینک http(s) برای هر مخاطب یکتا می‌شود."
/>
</div>
<div class="tabs" role="tablist">
<button
type="button"
role="tab"
:class="{ on: audience === 'people' }"
@click="audience = 'people'"
>
اشخاص
</button>
<button
type="button"
role="tab"
:class="{ on: audience === 'leads' }"
@click="audience = 'leads'"
>
لیدها
</button>
</div>
<div class="filters">
<UiField label="جستجو">
<UiInput v-model="q" placeholder="نام یا موبایل…" />
</UiField>
<UiField v-if="audience === 'people'" label="استان">
<UiInput v-model="province" placeholder="مثلاً اصفهان" />
</UiField>
<UiField :label="audience === 'people' ? 'شهر' : 'شهر'">
<UiInput v-model="city" placeholder="شهر…" />
</UiField>
<UiField v-if="audience === 'leads'" label="منطقه / استان">
<UiInput v-model="region" placeholder="منطقه یا استان…" />
</UiField>
<UiField v-if="audience === 'leads'" label="مرحله">
<select v-model="stageId" class="select">
<option value="">همه</option>
<option v-for="s in stages" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
</UiField>
<label class="check filter-check">
<input v-model="hasMobile" type="checkbox" />
فقط با موبایل
</label>
</div>
<div class="people-head">
<p class="muted">
انتخاب‌شده: {{ toFaDigits(selectedIds.length) }} نفر · نتایج فیلتر:
{{ toFaDigits(total) }}
</p>
<div class="head-actions">
<UiButton variant="secondary" @click="selectVisible">
انتخاب صفحه
</UiButton>
<UiButton variant="secondary" @click="selectAllFiltered">
انتخاب همهٔ نتایج فیلتر
</UiButton>
</div>
</div>
<p v-if="listLoading" class="muted">در حال بارگذاری…</p>
<div class="people-list">
<template v-if="audience === 'people'">
<label v-for="p in people" :key="p.id" class="person">
<input
type="checkbox"
:checked="selectedIds.includes(p.id)"
@change="toggleId(p.id)"
/>
<span>
<strong>{{ p.fullName }}</strong>
<small>
{{ p.mobile || 'بدون موبایل' }}
<template v-if="p.company">
· {{ p.company.name }}
<template v-if="p.company.city || p.company.province">
·
{{
[p.company.city, p.company.province].filter(Boolean).join('، ')
}}
</template>
</template>
</small>
</span>
</label>
</template>
<template v-else>
<label v-for="l in leads" :key="l.id" class="person">
<input
type="checkbox"
:checked="selectedIds.includes(l.id)"
@change="toggleId(l.id)"
/>
<span>
<strong>{{ l.contactName || l.title }}</strong>
<small>
{{ l.mobile || 'بدون موبایل' }}
<template v-if="l.city || l.region">
· {{ [l.city, l.region].filter(Boolean).join('، ') }}
</template>
· {{ l.stageName }}
</small>
</span>
</label>
</template>
<p
v-if="!listLoading && !(audience === 'people' ? people.length : leads.length)"
class="muted empty-list"
>
موردی پیدا نشد.
</p>
</div>
<div class="pager">
<UiButton
variant="secondary"
:disabled="page <= 1"
@click="page -= 1"
>
قبلی
</UiButton>
<span class="muted">
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(pageCount) }}
</span>
<UiButton
variant="secondary"
:disabled="page >= pageCount"
@click="page += 1"
>
بعدی
</UiButton>
</div>
<UiButton :loading="saving" @click="createCampaign">ساخت کمپین</UiButton>
</UiCard>
<UiCard title="کمپین‌ها">
<UiEmptyState
v-if="!loading && !campaigns.length"
title="کمپینی نیست"
description="اولین کمپین پیامک گروهی را بسازید."
/>
<div v-else class="campaigns">
<article v-for="c in campaigns" :key="c.id" class="campaign">
<div>
<strong>{{ c.name }}</strong>
<p class="muted">{{ c.body }}</p>
<p class="meta">
{{ statusLabel[c.status] ?? c.status }}
· کل {{ toFaDigits(c.totalCount) }}
· ارسال {{ toFaDigits(c.sentCount) }}
· ناموفق {{ toFaDigits(c.failedCount) }}
· ردشده {{ toFaDigits(c.skippedCount) }}
· {{ formatJalaliNumeric(new Date(c.createdAt)) }}
</p>
</div>
<div class="actions">
<UiButton
variant="secondary"
@click="router.push({ name: 'sms-campaign-report', params: { id: c.id } })"
>
گزارش
</UiButton>
<UiButton
v-if="c.status === 'draft' || c.status === 'cancelled'"
variant="secondary"
@click="queue(c.id)"
>
صف ارسال
</UiButton>
<UiButton
v-if="c.status === 'queued' || c.status === 'sending' || c.status === 'draft'"
variant="secondary"
@click="cancel(c.id)"
>
لغو
</UiButton>
</div>
</article>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 0.75rem;
}
.check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: #334155;
min-height: var(--control-h);
}
.filter-check {
align-self: end;
margin-bottom: 0.35rem;
}
.body-block {
margin-bottom: 0.75rem;
}
.body-tools {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.pop-wrap {
position: relative;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-weight: 700;
cursor: pointer;
color: #0f172a;
}
.pill:hover {
background: #f8fafc;
}
.pop {
position: absolute;
inset-inline-end: 0;
top: calc(100% + 0.35rem);
z-index: 20;
min-width: 220px;
display: grid;
gap: 0.2rem;
padding: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.pop button {
display: flex;
justify-content: space-between;
gap: 0.75rem;
border: 0;
background: transparent;
padding: 0.45rem 0.55rem;
border-radius: 8px;
cursor: pointer;
text-align: start;
}
.pop button:hover {
background: #f1f5f9;
}
.pop code {
font-weight: 700;
color: #0f766e;
}
.tabs {
display: flex;
gap: 0.35rem;
margin: 0.5rem 0 0.75rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
border: 0;
background: transparent;
padding: 0.55rem 0.9rem;
font-weight: 700;
color: #64748b;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tabs button.on {
color: #0f766e;
border-bottom-color: #0f766e;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.65rem;
margin-bottom: 0.5rem;
}
.select {
width: 100%;
min-height: var(--control-h);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0 0.65rem;
background: #fff;
}
.people-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.head-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.people-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.45rem;
max-height: 360px;
overflow: auto;
margin: 0.75rem 0 0.75rem;
padding: 0.35rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.person {
display: flex;
gap: 0.45rem;
align-items: flex-start;
padding: 0.45rem;
border-radius: var(--radius);
cursor: pointer;
}
.person:hover {
background: #f8fafc;
}
.person span {
display: grid;
}
.person small {
color: #64748b;
}
.empty-list {
grid-column: 1 / -1;
padding: 0.75rem;
}
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.campaigns {
display: grid;
gap: 0.75rem;
}
.campaign {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: start;
}
.muted,
.meta {
color: #64748b;
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.form-grid,
.campaign {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>