Files
CRM_Montzar/apps/web/src/views/LeadsView.vue
T
MahdiandCursor c079295ff4 Add standalone Leads module for prospect outreach before customers.
Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 17:05:11 +03:30

832 lines
21 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, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import {
UiButton,
UiEmptyState,
UiPageHeader,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import type {
LeadDto,
LeadImportJobDto,
LeadStageDto,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
const PAGE_SIZE = 20;
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const okMsg = ref('');
const data = ref<PaginatedResponse<LeadDto> | null>(null);
const stages = ref<LeadStageDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const selected = ref<Set<string>>(new Set());
const sortOpen = ref(false);
const filterOpen = ref(false);
const assignOpen = ref(false);
const stageOpen = ref(false);
const assignUserId = ref('');
const bulkStageId = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const importJob = ref<LeadImportJobDto | null>(null);
let importTimer: ReturnType<typeof setInterval> | null = null;
const page = computed(() => {
const raw = Number(route.query.page);
return Number.isFinite(raw) && raw > 0 ? raw : 1;
});
const sort = computed<SortKey>(() => {
const s = route.query.sort;
if (s === 'newest' || s === 'follow_up' || s === 'last_contact') return s;
return 'updated';
});
const stageId = computed(() =>
typeof route.query.stageId === 'string' ? route.query.stageId : '',
);
const assigneeId = computed(() =>
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
);
const unassigned = computed(
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
);
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const allSelected = computed(
() =>
Boolean(data.value?.items.length) &&
data.value!.items.every((l) => selected.value.has(l.id)),
);
const selectedIds = computed(() => [...selected.value]);
const sortLabels: Record<SortKey, string> = {
updated: 'آخرین به‌روزرسانی',
newest: 'جدیدترین',
follow_up: 'پیگیری بعدی',
last_contact: 'آخرین تماس',
};
function formatDate(value: string | null) {
if (!value) return '—';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
function closePopups() {
sortOpen.value = false;
filterOpen.value = false;
assignOpen.value = false;
stageOpen.value = false;
}
function patchQuery(next: Record<string, string | undefined>) {
const query = { ...route.query, ...next };
for (const key of Object.keys(query)) {
if (query[key] == null || query[key] === '') delete query[key];
}
void router.replace({ name: 'leads', query });
}
function onSearch() {
patchQuery({ q: q.value.trim() || undefined, page: undefined });
}
function setSort(next: SortKey) {
sortOpen.value = false;
patchQuery({ sort: next === 'updated' ? undefined : next, page: undefined });
}
function setStageFilter(id: string) {
filterOpen.value = false;
patchQuery({ stageId: id || undefined, page: undefined });
}
function setAssigneeFilter(id: string) {
filterOpen.value = false;
patchQuery({
assigneeId: id || undefined,
unassigned: undefined,
page: undefined,
});
}
function setUnassigned(on: boolean) {
filterOpen.value = false;
patchQuery({
unassigned: on ? '1' : undefined,
assigneeId: undefined,
page: undefined,
});
}
function setPage(next: number) {
if (next < 1 || next > pageCount.value) return;
patchQuery({ page: next === 1 ? undefined : String(next) });
}
function toggleOne(id: string, checked: boolean) {
const next = new Set(selected.value);
if (checked) next.add(id);
else next.delete(id);
selected.value = next;
}
function toggleAll(checked: boolean) {
if (!checked) {
selected.value = new Set();
return;
}
selected.value = new Set(data.value?.items.map((l) => l.id) ?? []);
}
async function loadMeta() {
const [stagesRes, usersRes] = await Promise.all([
api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
stages.value = stagesRes.data;
users.value = usersRes.data.filter((u) => u.isActive !== false);
}
async function load() {
loading.value = true;
error.value = '';
closePopups();
try {
const { data: res } = await api.get<PaginatedResponse<LeadDto>>('/leads', {
params: {
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
sort: sort.value === 'updated' ? undefined : sort.value,
stageId: stageId.value || undefined,
assigneeId: assigneeId.value || undefined,
unassigned: unassigned.value ? '1' : undefined,
},
});
data.value = res;
selected.value = new Set(
[...selected.value].filter((id) => res.items.some((l) => l.id === id)),
);
} catch (e) {
error.value =
axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری لیدها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function assignSelected() {
if (!assignUserId.value || !selectedIds.value.length) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/assign', {
leadIds: selectedIds.value,
assigneeId: assignUserId.value,
});
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید تخصیص داده شد.`;
selected.value = new Set();
assignOpen.value = false;
await load();
} catch {
error.value = 'تخصیص ناموفق بود.';
} finally {
busy.value = false;
}
}
async function bulkStageSelected() {
if (!bulkStageId.value || !selectedIds.value.length) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/bulk-stage', {
leadIds: selectedIds.value,
stageId: bulkStageId.value,
});
okMsg.value = `وضعیت ${toFaDigits(selectedIds.value.length)} لید به‌روز شد.`;
selected.value = new Set();
stageOpen.value = false;
await load();
} catch {
error.value = 'تغییر وضعیت گروهی ناموفق بود.';
} finally {
busy.value = false;
}
}
async function archiveSelected() {
if (!selectedIds.value.length) return;
if (!confirm(`${toFaDigits(selectedIds.value.length)} لید بایگانی شود؟`)) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/archive', { leadIds: selectedIds.value });
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید بایگانی شد.`;
selected.value = new Set();
await load();
} catch {
error.value = 'بایگانی ناموفق بود.';
} finally {
busy.value = false;
}
}
async function exportExcel() {
busy.value = true;
error.value = '';
try {
const { data: blob } = await api.get<Blob>('/leads/export', {
responseType: 'blob',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-leads.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
}
}
function stopImportPoll() {
if (importTimer) {
clearInterval(importTimer);
importTimer = null;
}
}
async function pollImport(jobId: string) {
try {
const { data: job } = await api.get<LeadImportJobDto>(`/leads/imports/${jobId}`);
importJob.value = job;
if (job.status === 'done' || job.status === 'failed' || job.status === 'completed') {
stopImportPoll();
okMsg.value = `ورود: موفق ${toFaDigits(job.successCount)} · تکراری ${toFaDigits(job.duplicateCount)} · خطا ${toFaDigits(job.errorCount)}`;
await load();
}
} catch {
stopImportPoll();
error.value = 'پیگیری وضعیت ورود ناموفق بود.';
}
}
function startImportPoll(jobId: string) {
stopImportPoll();
void pollImport(jobId);
importTimer = setInterval(() => void pollImport(jobId), 1500);
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
error.value = '';
okMsg.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data: job } = await api.post<LeadImportJobDto>('/leads/import/file', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
importJob.value = job;
startImportPoll(job.id);
} catch {
error.value = 'ورود فایل ناموفق بود.';
} finally {
busy.value = false;
}
}
function goDetail(id: string) {
void router.push({ name: 'lead-detail', params: { id } });
}
watch(
() => [
route.query.q,
route.query.page,
route.query.sort,
route.query.stageId,
route.query.assigneeId,
route.query.unassigned,
],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
},
);
onMounted(async () => {
await loadMeta();
await load();
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
if (jobId) startImportPoll(jobId);
});
onBeforeUnmount(stopImportPoll);
</script>
<template>
<section class="page" @click="closePopups">
<UiPageHeader title="لیدها" lead="مدیریت سرنخ‌ها، تخصیص و پیگیری">
<template #actions>
<RouterLink class="link-btn" :to="{ name: 'leads-board' }">
<UiButton variant="ghost" size="sm">کاریز لیدها</UiButton>
</RouterLink>
<UiButton
v-if="isAdmin"
variant="ghost"
size="sm"
:disabled="busy"
@click.stop="exportExcel"
>
خروجی اکسل
</UiButton>
<UiButton
v-if="isAdmin"
variant="ghost"
size="sm"
:disabled="busy"
@click.stop="fileInput?.click()"
>
ورود از فایل
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls,.csv"
hidden
@change="onImportFile"
/>
</template>
</UiPageHeader>
<div class="toolbar">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="16" />
<input v-model="q" type="search" placeholder="جستجو نام، موبایل، شهر…" />
</form>
<div class="toolbar-tools">
<div class="pop">
<button
class="tool"
type="button"
@click.stop="sortOpen = !sortOpen; filterOpen = false"
>
<UiIcon name="trend" :size="16" />
<span class="tool-label">مرتب‌سازی</span>
</button>
<ul v-if="sortOpen" class="menu">
<li v-for="(label, key) in sortLabels" :key="key">
<button
type="button"
:class="{ on: sort === key }"
@click.stop="setSort(key as SortKey)"
>
{{ label }}
</button>
</li>
</ul>
</div>
<div class="pop">
<button
class="tool"
type="button"
@click.stop="filterOpen = !filterOpen; sortOpen = false"
>
<UiIcon name="filter" :size="16" />
<span class="tool-label">فیلتر</span>
</button>
<ul v-if="filterOpen" class="menu wide">
<li class="menu-label">وضعیت</li>
<li>
<button
type="button"
:class="{ on: !stageId }"
@click.stop="setStageFilter('')"
>
همه
</button>
</li>
<li v-for="s in stages" :key="s.id">
<button
type="button"
:class="{ on: stageId === s.id }"
@click.stop="setStageFilter(s.id)"
>
{{ s.name }}
</button>
</li>
<li class="menu-label">کارشناس</li>
<li>
<button
type="button"
:class="{ on: !assigneeId && !unassigned }"
@click.stop="setAssigneeFilter('')"
>
همه
</button>
</li>
<li>
<button
type="button"
:class="{ on: unassigned }"
@click.stop="setUnassigned(true)"
>
بدون کارشناس
</button>
</li>
<li v-for="u in users" :key="u.id">
<button
type="button"
:class="{ on: assigneeId === u.id }"
@click.stop="setAssigneeFilter(u.id)"
>
{{ u.fullName }}
</button>
</li>
</ul>
</div>
</div>
</div>
<div v-if="selectedIds.length" class="bulk-bar" @click.stop>
<span>{{ toFaDigits(selectedIds.length) }} مورد انتخاب‌شده</span>
<template v-if="isAdmin">
<div class="pop">
<UiButton size="sm" variant="ghost" @click="assignOpen = !assignOpen">
تخصیص
</UiButton>
<div v-if="assignOpen" class="menu panel">
<select v-model="assignUserId">
<option value="">انتخاب کارشناس…</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
<UiButton size="sm" :disabled="!assignUserId || busy" @click="assignSelected">
اعمال
</UiButton>
</div>
</div>
<div class="pop">
<UiButton size="sm" variant="ghost" @click="stageOpen = !stageOpen">
تغییر وضعیت
</UiButton>
<div v-if="stageOpen" class="menu panel">
<select v-model="bulkStageId">
<option value="">انتخاب وضعیت…</option>
<option v-for="s in stages" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
<UiButton size="sm" :disabled="!bulkStageId || busy" @click="bulkStageSelected">
اعمال
</UiButton>
</div>
</div>
</template>
<UiButton size="sm" variant="ghost" :disabled="busy" @click="archiveSelected">
بایگانی
</UiButton>
</div>
<p v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'" class="ok">
ورود فایل: {{ toFaDigits(importJob.processedCount) }} / {{ toFaDigits(importJob.totalCount) }}
</p>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="data?.items.length">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="check-col">
<input
type="checkbox"
:checked="allSelected"
@click.stop
@change="toggleAll(($event.target as HTMLInputElement).checked)"
/>
</th>
<th>نام</th>
<th>موبایل</th>
<th>شهر</th>
<th>وضعیت</th>
<th>کارشناس</th>
<th>آخرین تماس</th>
<th>پیگیری بعدی</th>
<th>منبع</th>
</tr>
</thead>
<tbody>
<tr
v-for="lead in data.items"
:key="lead.id"
class="row-click"
@click="goDetail(lead.id)"
>
<td class="check-col" @click.stop>
<input
type="checkbox"
:checked="selected.has(lead.id)"
@change="toggleOne(lead.id, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td>
<strong>{{ lead.title }}</strong>
<span v-if="lead.contactName" class="sub">{{ lead.contactName }}</span>
</td>
<td dir="ltr">{{ lead.mobile || '—' }}</td>
<td>{{ lead.city || '—' }}</td>
<td>
<span class="stage-pill">{{ lead.stageName }}</span>
</td>
<td>{{ lead.assigneeName || '—' }}</td>
<td>{{ formatDate(lead.lastContactAt) }}</td>
<td>{{ formatDate(lead.nextFollowUpAt) }}</td>
<td>{{ lead.source || '—' }}</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
@update:page="setPage"
/>
</template>
<UiEmptyState
v-else-if="!loading"
title="لیدی ثبت نشده است."
description="از ورود فایل استفاده کنید یا لید جدید بسازید."
/>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: clip;
box-sizing: border-box;
}
.link-btn {
text-decoration: none;
}
.toolbar {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.toolbar-tools {
display: flex;
gap: 0.45rem;
align-items: center;
}
.search {
flex: 1 1 14rem;
min-width: 180px;
max-width: 400px;
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
min-height: var(--control-h);
height: var(--control-h);
color: var(--muted);
box-sizing: border-box;
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.tool {
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
font: inherit;
color: var(--ink);
}
.pop {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 4px);
inset-inline-end: 0;
z-index: 20;
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
min-width: 11rem;
max-height: 18rem;
overflow: auto;
}
.menu.wide {
min-width: 14rem;
}
.menu.panel {
display: grid;
gap: 0.5rem;
padding: 0.65rem;
min-width: 14rem;
}
.menu button {
width: 100%;
border: 0;
background: none;
text-align: start;
padding: 0.45rem 0.55rem;
border-radius: calc(var(--radius) - 2px);
font: inherit;
cursor: pointer;
color: var(--ink);
}
.menu button.on,
.menu button:hover {
background: var(--accent-soft);
color: var(--accent);
}
.menu-label {
padding: 0.35rem 0.55rem 0.15rem;
font-size: 0.75rem;
color: var(--muted);
font-weight: 600;
}
.menu select {
width: 100%;
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.5rem;
font: inherit;
}
.bulk-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.55rem 0.75rem;
background: var(--accent-soft);
border-radius: var(--radius);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
.data-table th,
.data-table td {
padding: 0.65rem 0.75rem;
text-align: start;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.data-table th {
font-weight: 600;
color: var(--muted);
background: color-mix(in srgb, var(--surface-2, #f8fafc) 80%, #fff);
}
.row-click {
cursor: pointer;
}
.row-click:hover {
background: color-mix(in srgb, var(--accent-soft) 55%, #fff);
}
.check-col {
width: 2.25rem;
}
.sub {
display: block;
color: var(--muted);
font-size: 0.8rem;
font-weight: 400;
}
.stage-pill {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.8rem;
}
.muted {
color: var(--muted);
}
.ok {
color: #0f766e;
margin: 0;
}
.error {
color: #b91c1c;
margin: 0;
}
@media (max-width: 640px) {
.tool-label {
display: none;
}
}
</style>