Files
CRM_Montzar/apps/web/src/views/LeadsView.vue
T

2455 lines
84 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Grid,
Input as AInput,
InputSearch as AInputSearch,
Menu as AMenu,
MenuItem as AMenuItem,
Modal,
Modal as AModal,
Pagination as APagination,
RadioButton as ARadioButton,
RadioGroup as ARadioGroup,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Table as ATable,
Tag as ATag,
Typography,
Upload as AUpload,
message,
theme,
} from 'ant-design-vue';
import type { TableColumnsType, UploadProps } from 'ant-design-vue';
import {
AppstoreOutlined,
DeleteOutlined,
DownOutlined,
EditOutlined,
ExportOutlined,
FileExcelOutlined,
FilterOutlined,
MoreOutlined,
PhoneOutlined,
PlusOutlined,
SearchOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import {
formatJalaliNumeric,
formatRelativeContact,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
import {
filterPhoneContacts,
isContactPickerSupported,
parseVCardFile,
pickPhoneContacts,
sortPhoneContacts,
type PhoneContactDraft,
} from '@/lib/phone-contacts';
import type {
2026-09-29 14:57:10 +03:30
ImportContactsResult,
LeadDto,
LeadImportJobDto,
LeadStageDto,
LeadStatsDto,
OrgUserDto,
PersonGender,
PaginatedResponse,
} from '@mento/shared';
import { brand, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const PAGE_SIZE = 10;
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
type FollowUpKey =
| ''
| 'due_today'
| 'overdue'
| 'called_today'
| 'missed_today'
| 'no_follow_up';
type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal';
const FOLLOW_UP_OPTIONS: Array<{ value: FollowUpKey; label: string }> = [
{ value: '', label: 'همه پیگیری‌ها' },
{ value: 'due_today', label: 'قرار امروز' },
{ value: 'overdue', label: 'عقب‌افتاده' },
{ value: 'called_today', label: 'امروز تماس گرفته' },
{ value: 'missed_today', label: 'امروز باید می‌گرفت و نگرفته' },
{ value: 'no_follow_up', label: 'بدون پیگیری' },
];
const UNASSIGNED_VALUE = '__unassigned__';
/** ردیف آمار صفحه لیدها — ترتیب و نام‌ها مطابق طرح */
const LEAD_STAT_DEFS: Array<{
label: string;
/** نام وضعیت‌های متناظر در دیتابیس */
match: string[];
tint: StageTint;
slot: 'pair' | 'solo';
}> = [
{
label: 'جدید',
match: ['جدید'],
tint: 'amber',
slot: 'pair',
},
{
label: 'در حال پیگیری',
match: ['در حال پیگیری', 'پیگیری مجدد', 'تماس اولیه', 'تماس موفق'],
tint: 'peach',
slot: 'pair',
},
{
label: 'مذاکره',
match: ['مذاکره'],
tint: 'mint',
slot: 'solo',
},
{
label: 'آماده خرید',
match: ['آماده خرید'],
tint: 'violet',
slot: 'solo',
},
{
label: 'عدم پاسخ',
match: ['عدم پاسخ'],
tint: 'rose',
slot: 'solo',
},
];
type LeadStatCard = {
key: string;
label: string;
tint: StageTint;
count: number;
/** مرحله اصلی برای فیلتر */
stageId: string;
slot: 'pair' | 'solo';
};
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
const busy = ref(false);
const pageBusy = ref(false);
const busyMessage = ref('لطفاً صبر کنید…');
const error = ref('');
const okMsg = ref('');
const data = ref<PaginatedResponse<LeadDto> | null>(null);
const stages = ref<LeadStageDto[]>([]);
const stats = ref<LeadStatsDto | null>(null);
const users = ref<OrgUserDto[]>([]);
const cityOptions = ref<string[]>([]);
const regionOptions = ref<string[]>([]);
const tradeOptions = ref<string[]>([]);
const sourceOptions = ref<string[]>([]);
const selected = ref<Set<string>>(new Set());
const moreFiltersOpen = ref(false);
const sortOpen = ref(false);
const rowMenuId = ref<string | null>(null);
const logoMenuId = ref<string | null>(null);
const logoUrls = ref<Record<string, string>>({});
const logoBusyId = ref<string | null>(null);
const logoTargetId = ref<string | null>(null);
const leadCols = useTableColumns(() => 'leads');
const LEAD_COL_LABELS = [
'نام / شرکت',
'شماره تماس',
'شهر / منطقه',
'منبع لید',
'تاریخ ثبت',
'وضعیت',
'مسئول',
'آخرین فعالیت',
'اقدامات',
] as const;
const assignOpen = ref(false);
const stageOpen = ref(false);
const assignUserId = ref('');
const bulkStageId = ref('');
const importJob = ref<LeadImportJobDto | null>(null);
const createOpen = ref(false);
const createBusy = ref(false);
const createError = ref('');
const editingLeadId = ref<string | null>(null);
const createForm = ref({
title: '',
contactName: '',
gender: 'none' as PersonGender,
showGenderInName: false,
mobile: '',
city: '',
region: '',
source: '',
instagram: '',
addresses: [''] as string[],
stageId: '',
assigneeId: '',
});
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref('');
let importTimer: ReturnType<typeof setInterval> | null = null;
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
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 cityFilter = computed(() =>
typeof route.query.city === 'string' ? route.query.city : '',
);
const regionFilter = computed(() =>
typeof route.query.region === 'string' ? route.query.region : '',
);
const tradeFilter = computed(() =>
typeof route.query.trade === 'string' ? route.query.trade : '',
);
const sourceFilter = computed(() =>
typeof route.query.source === 'string' ? route.query.source : '',
);
const followUpFilter = computed<FollowUpKey>(() => {
const value = route.query.followUp;
if (
value === 'due_today' ||
value === 'overdue' ||
value === 'called_today' ||
value === 'missed_today' ||
value === 'no_follow_up'
) {
return value;
}
return '';
});
const createdFromFilter = computed(() =>
typeof route.query.createdFrom === 'string' ? route.query.createdFrom : '',
);
const createdToFilter = computed(() =>
typeof route.query.createdTo === 'string' ? route.query.createdTo : '',
);
const unassigned = computed(
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
);
const archivedFilter = computed(
() => route.query.archived === '1' || route.query.archived === 'true',
);
const convertedFilter = computed(
() => route.query.converted === '1' || route.query.converted === 'true',
);
const assigneeSelectValue = computed(() => {
if (unassigned.value) return UNASSIGNED_VALUE;
return assigneeId.value || undefined;
});
/** شمارش فیلترهای داخل پاپ‌آور، مثل فعالیت‌ها. */
const mobileFilterBadgeCount = computed(() =>
[
stageId.value,
assigneeSelectValue.value ? '1' : '',
followUpFilter.value,
cityFilter.value,
regionFilter.value,
tradeFilter.value,
sourceFilter.value,
createdFromFilter.value,
createdToFilter.value,
archivedFilter.value ? '1' : '',
convertedFilter.value ? '1' : '',
].filter(Boolean).length,
);
const filterBtnLabel = computed(() =>
mobileFilterBadgeCount.value
? `فیلتر (${toFaDigits(mobileFilterBadgeCount.value)})`
: 'فیلتر',
);
type LeadFacetKey = 'assignee' | 'stage' | 'source' | 'followUp' | 'city' | 'region' | 'trade';
const filterSection = ref<LeadFacetKey | 'createdFrom' | 'createdTo' | 'converted' | 'archived' | null>(
null,
);
const filterQuery = ref('');
const FACET_LIST_CAP = 40;
function normFilter(value: string) {
return value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک').toLowerCase();
}
function toggleFilterSection(name: NonNullable<typeof filterSection.value>) {
filterSection.value = filterSection.value === name ? null : name;
filterQuery.value = '';
}
const filterAnchor = ref<HTMLElement | null>(null);
const filterPopoverMaxHeight = ref('min(70vh, 32rem)');
const filterPanelStyle = computed(() => ({
position: 'absolute' as const,
top: 'calc(100% + 6px)',
insetInlineStart: '0',
zIndex: 1050,
width: 'min(20rem, calc(100vw - 1.5rem))',
maxHeight: filterPopoverMaxHeight.value,
overflow: 'auto',
background: token.value.colorBgElevated,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadowSecondary,
padding: `${token.value.paddingSM}px`,
}));
function placeFilterPopover() {
const el = filterAnchor.value;
if (!el) return;
const space = Math.floor(window.innerHeight - el.getBoundingClientRect().bottom - 12);
filterPopoverMaxHeight.value = `${Math.max(220, space)}px`;
}
function onFilterDocPointer(event: Event) {
const target = event.target;
if (!(target instanceof Node)) return;
if (filterAnchor.value?.contains(target)) return;
if (document.getElementById('jalali-picker')?.contains(target)) return;
moreFiltersOpen.value = false;
filterSection.value = null;
document.removeEventListener('pointerdown', onFilterDocPointer, true);
}
function toggleFilterPopover() {
if (moreFiltersOpen.value) {
moreFiltersOpen.value = false;
filterSection.value = null;
document.removeEventListener('pointerdown', onFilterDocPointer, true);
return;
}
placeFilterPopover();
moreFiltersOpen.value = true;
document.addEventListener('pointerdown', onFilterDocPointer, true);
}
function dateFacetLabel(value: string) {
if (!value) return 'همه';
const parsed = parseLocalInput(value);
return parsed ? formatJalaliNumeric(parsed) : value;
}
function facetValueStyle(active: boolean) {
return {
color: active ? token.value.colorPrimary : token.value.colorTextQuaternary,
fontWeight: active ? 700 : 400,
maxWidth: '9.5rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const;
}
const textFacets = computed(() => [
{
key: 'assignee' as const,
label: 'کارشناس',
value: unassigned.value
? 'بدون کارشناس'
: users.value.find((user) => user.id === assigneeId.value)?.fullName || 'همه',
active: Boolean(assigneeSelectValue.value),
current: assigneeSelectValue.value || '',
searchable: true,
options: [
{ value: '', label: 'همه' },
{ value: UNASSIGNED_VALUE, label: 'بدون کارشناس' },
...users.value.map((user) => ({ value: user.id, label: user.fullName })),
],
},
{
key: 'stage' as const,
label: 'مرحله',
value: stages.value.find((stage) => stage.id === stageId.value)?.name || 'همه',
active: Boolean(stageId.value),
current: stageId.value,
searchable: stages.value.length > 8,
options: [
{ value: '', label: 'همه' },
...stages.value.map((stage) => ({ value: stage.id, label: stage.name })),
],
},
{
key: 'source' as const,
label: 'منبع',
value: sourceFilter.value || 'همه',
active: Boolean(sourceFilter.value),
current: sourceFilter.value,
searchable: sourceOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...sourceOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'followUp' as const,
label: 'پیگیری',
value:
FOLLOW_UP_OPTIONS.find((item) => item.value === followUpFilter.value && item.value)?.label ||
'همه',
active: Boolean(followUpFilter.value),
current: followUpFilter.value,
searchable: false,
options: [
{ value: '', label: 'همه' },
...FOLLOW_UP_OPTIONS.filter((item) => item.value).map((item) => ({
value: item.value,
label: item.label,
})),
],
},
{
key: 'city' as const,
label: 'شهر',
value: cityFilter.value || 'همه',
active: Boolean(cityFilter.value),
current: cityFilter.value,
searchable: cityOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...cityOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'region' as const,
label: 'منطقه',
value: regionFilter.value || 'همه',
active: Boolean(regionFilter.value),
current: regionFilter.value,
searchable: regionOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...regionOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'trade' as const,
label: 'صنف',
value: tradeFilter.value || 'همه',
active: Boolean(tradeFilter.value),
current: tradeFilter.value,
searchable: tradeOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...tradeOptions.value.map((item) => ({ value: item, label: item })),
],
},
]);
function visibleFacetOptions(options: Array<{ value: string; label: string }>) {
const query = normFilter(filterQuery.value);
const rest = options.filter(
(item) => item.value !== '' && (!query || normFilter(item.label).includes(query)),
);
return {
options: [...options.filter((item) => item.value === ''), ...rest.slice(0, FACET_LIST_CAP)],
hidden: Math.max(0, rest.length - FACET_LIST_CAP),
empty: Boolean(query) && rest.length === 0,
};
}
function pickFacet(key: LeadFacetKey, value: string) {
const facet = textFacets.value.find((item) => item.key === key);
const next = facet && facet.current === value ? '' : value;
if (key === 'assignee') setAssigneeFilter(next || undefined);
else if (key === 'stage') setStageFilter(next);
else if (key === 'source') setSourceFilter(next);
else if (key === 'followUp') setFollowUpFilter(next || undefined);
else if (key === 'city') setCityFilter(next);
else if (key === 'region') setRegionFilter(next);
else if (key === 'trade') setTradeFilter(next);
filterSection.value = null;
}
function pickFlag(which: 'converted' | 'archived', on: boolean) {
const current = which === 'converted' ? convertedFilter.value : archivedFilter.value;
const next = on ? !current : false;
if (which === 'converted') setConvertedFilter(next);
else setArchivedFilter(next);
filterSection.value = null;
}
function facetOptionStyle(selected: boolean) {
return {
textAlign: 'start' as const,
background: selected ? token.value.colorPrimaryBg : 'transparent',
color: selected ? token.value.colorPrimary : token.value.colorText,
};
}
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const rangeFrom = computed(() =>
total.value === 0 ? 0 : (page.value - 1) * PAGE_SIZE + 1,
);
const rangeTo = computed(() =>
Math.min(page.value * PAGE_SIZE, total.value),
);
const selectedIds = computed(() => [...selected.value]);
const sortLabels: Record<SortKey, string> = {
newest: 'تاریخ جدیدترین',
updated: 'آخرین به‌روزرسانی',
follow_up: 'پیگیری بعدی',
last_contact: 'آخرین تماس',
};
const stageCards = computed((): LeadStatCard[] => {
return LEAD_STAT_DEFS.map((def) => {
const matched = stages.value.filter((s) =>
def.match.some((name) => s.name.trim() === name),
);
const count = matched.reduce((sum, s) => sum + (s.leadCount ?? 0), 0);
const primary =
matched.find((s) => s.name.trim() === def.match[0]) ?? matched[0];
return {
key: def.label,
label: def.label,
tint: def.tint,
count,
stageId: primary?.id ?? '',
slot: def.slot,
};
});
});
const totalLeadsCount = computed(() => {
if (stats.value?.total != null) return stats.value.total;
return stages.value.reduce((sum, s) => sum + (s.leadCount ?? 0), 0) || total.value;
});
function isStatActive(card: LeadStatCard) {
if (!card.stageId || !stageId.value) return false;
if (stageId.value === card.stageId) return true;
const def = LEAD_STAT_DEFS.find((d) => d.label === card.label);
if (!def) return false;
const matchedIds = stages.value
.filter((s) => def.match.some((name) => s.name.trim() === name))
.map((s) => s.id);
return matchedIds.includes(stageId.value);
}
function selectStatCard(card: LeadStatCard) {
if (!card.stageId) {
setStageFilter('');
return;
}
setStageFilter(isStatActive(card) ? '' : card.stageId);
}
function stageTint(color: string | null | undefined): StageTint {
const c = (color || '').toLowerCase();
if (c === 'amber' || c === 'yellow' || c === 'orange') return 'amber';
if (c === 'peach' || c === 'orange') return 'peach';
if (c === 'sky' || c === 'blue') return 'sky';
if (c === 'violet' || c === 'purple' || c === 'lavender') return 'violet';
if (c === 'rose' || c === 'red' || c === 'pink') return 'rose';
if (c === 'teal' || c === 'cyan') return 'teal';
if (c === 'mint' || c === 'green') return 'mint';
if (c.startsWith('#')) {
const hex = c.slice(1);
const n = parseInt(hex.length === 3 ? hex.replace(/(.)/g, '$1$1') : hex, 16);
if (!Number.isFinite(n)) return 'mint';
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
if (r > g && r > b) return 'rose';
if (b > r && b > g) return 'sky';
if (g > r && r > b) return 'amber';
return 'mint';
}
return 'mint';
}
function stageTintForLead(lead: LeadDto): StageTint {
const stage = stages.value.find((s) => s.id === lead.stageId);
return stageTint(stage?.color);
}
function initials(name: string | null | undefined) {
if (!name?.trim()) return '؟';
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0]!.slice(0, 1);
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`;
}
function formatPhone(raw: string | null | undefined) {
if (!raw) return '—';
const digits = raw.replace(/\D/g, '');
if (digits.length === 11 && digits.startsWith('09')) {
return toFaDigits(`${digits.slice(0, 4)} ${digits.slice(4, 7)} ${digits.slice(7)}`);
}
return toFaDigits(raw);
}
function formatCount(n: number) {
return toFaDigits(n.toLocaleString('en-US'));
}
function lastActivity(lead: LeadDto) {
if (lead.lastContactAt) {
return {
label: 'تماس تلفنی',
when: formatRelativeContact(lead.lastContactAt),
icon: 'phone' as const,
};
}
if (lead.nextFollowUpAt) {
return {
label: 'پیگیری بعدی',
when: formatRelativeContact(lead.nextFollowUpAt),
icon: 'calendar' as const,
};
}
return {
label: 'ثبت لید',
when: formatRelativeContact(lead.createdAt),
icon: 'document' as const,
};
}
function closePopups() {
sortOpen.value = false;
rowMenuId.value = null;
logoMenuId.value = null;
assignOpen.value = false;
stageOpen.value = false;
}
function revokeLogoUrls(except?: Set<string>) {
const next: Record<string, string> = {};
for (const [id, url] of Object.entries(logoUrls.value)) {
if (except?.has(id)) {
next[id] = url;
continue;
}
URL.revokeObjectURL(url);
}
logoUrls.value = next;
}
async function loadLogos(items: LeadDto[]) {
const keep = new Set(items.filter((l) => l.hasLogo).map((l) => l.id));
revokeLogoUrls(keep);
const pending = items.filter((l) => l.hasLogo && !logoUrls.value[l.id]);
await Promise.all(
pending.map(async (lead) => {
try {
const { data } = await api.get<Blob>(`/leads/${lead.id}/logo`, {
responseType: 'blob',
});
const url = URL.createObjectURL(data);
logoUrls.value = { ...logoUrls.value, [lead.id]: url };
} catch {
/* بدون لوگو */
}
}),
);
}
function openLogoPicker(leadId: string) {
logoMenuId.value = null;
logoTargetId.value = leadId;
}
async function uploadLeadLogo(leadId: string, file: File) {
logoBusyId.value = leadId;
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data: updated } = await api.post<LeadDto>(`/leads/${leadId}/logo`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (data.value) {
data.value = {
...data.value,
items: data.value.items.map((l) => (l.id === leadId ? { ...l, hasLogo: true } : l)),
};
}
const prev = logoUrls.value[leadId];
if (prev) URL.revokeObjectURL(prev);
const { data: blob } = await api.get<Blob>(`/leads/${leadId}/logo`, {
responseType: 'blob',
});
logoUrls.value = { ...logoUrls.value, [leadId]: URL.createObjectURL(blob) };
okMsg.value = `لوگوی «${updated.title}» ذخیره شد.`;
message.success(okMsg.value);
} catch (e) {
error.value = axios.isAxiosError(e)
? apiErrorMessage(e, 'آپلود لوگو ناموفق بود.')
: 'آپلود لوگو ناموفق بود.';
} finally {
logoBusyId.value = null;
}
}
function apiErrorMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function removeLeadLogo(leadId: string) {
logoMenuId.value = null;
Modal.confirm({
title: 'لوگوی این لید حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
logoBusyId.value = leadId;
error.value = '';
try {
await api.delete(`/leads/${leadId}/logo`);
const prev = logoUrls.value[leadId];
if (prev) URL.revokeObjectURL(prev);
const next = { ...logoUrls.value };
delete next[leadId];
logoUrls.value = next;
if (data.value) {
data.value = {
...data.value,
items: data.value.items.map((l) =>
l.id === leadId ? { ...l, hasLogo: false } : l,
),
};
}
okMsg.value = 'لوگو حذف شد.';
message.success(okMsg.value);
} catch {
error.value = 'حذف لوگو ناموفق بود.';
} finally {
logoBusyId.value = null;
}
},
});
}
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) {
patchQuery({ stageId: id || undefined, page: undefined });
}
function setAssigneeFilter(value: string | undefined) {
if (value === UNASSIGNED_VALUE) {
patchQuery({ assigneeId: undefined, unassigned: '1', page: undefined });
return;
}
patchQuery({
assigneeId: value || undefined,
unassigned: undefined,
page: undefined,
});
}
function setFollowUpFilter(value: string | undefined) {
patchQuery({
followUp: value || undefined,
page: undefined,
});
}
function setCityFilter(value: string) {
patchQuery({ city: value || undefined, page: undefined });
}
function setRegionFilter(value: string) {
patchQuery({ region: value || undefined, page: undefined });
}
function setTradeFilter(value: string) {
patchQuery({ trade: value || undefined, page: undefined });
}
function setSourceFilter(value: string) {
patchQuery({ source: value || undefined, page: undefined });
}
function setCreatedFromFilter(value: string) {
patchQuery({ createdFrom: value || undefined, page: undefined });
}
function setCreatedToFilter(value: string) {
patchQuery({ createdTo: value || undefined, page: undefined });
}
function setArchivedFilter(checked: boolean) {
patchQuery({ archived: checked ? '1' : undefined, page: undefined });
}
function setConvertedFilter(checked: boolean) {
patchQuery({ converted: checked ? '1' : undefined, page: undefined });
}
function clearMoreFilters() {
patchQuery({
stageId: undefined,
assigneeId: undefined,
unassigned: undefined,
followUp: undefined,
city: undefined,
region: undefined,
trade: undefined,
source: undefined,
createdFrom: undefined,
createdTo: undefined,
archived: undefined,
converted: undefined,
page: undefined,
});
filterSection.value = null;
}
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;
}
const LONG_PRESS_MS = 450;
const LONG_PRESS_MOVE_PX = 12;
let cardPressTimer: ReturnType<typeof setTimeout> | null = null;
let cardPressId: string | null = null;
let cardPressTriggered = false;
let cardPressX = 0;
let cardPressY = 0;
function clearCardPress() {
if (cardPressTimer) clearTimeout(cardPressTimer);
cardPressTimer = null;
cardPressId = null;
}
function onCardPointerDown(leadId: string, event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
clearCardPress();
cardPressTriggered = false;
cardPressId = leadId;
cardPressX = event.clientX;
cardPressY = event.clientY;
cardPressTimer = setTimeout(() => {
cardPressTriggered = true;
cardPressTimer = null;
toggleOne(leadId, !selected.value.has(leadId));
try {
navigator.vibrate?.(18);
} catch {
// ignore
}
}, LONG_PRESS_MS);
}
function onCardPointerMove(event: PointerEvent) {
if (!cardPressTimer || !cardPressId) return;
const dx = event.clientX - cardPressX;
const dy = event.clientY - cardPressY;
if (dx * dx + dy * dy > LONG_PRESS_MOVE_PX * LONG_PRESS_MOVE_PX) {
clearCardPress();
}
}
function onCardPointerEnd() {
clearCardPress();
}
function onCardClick(lead: LeadDto) {
if (cardPressTriggered) {
cardPressTriggered = false;
return;
}
if (selected.value.has(lead.id)) {
toggleOne(lead.id, false);
return;
}
goDetail(lead.id);
}
function mergeFacets(items: LeadDto[]) {
const cities = new Set(cityOptions.value);
const regions = new Set(regionOptions.value);
const trades = new Set(tradeOptions.value);
const sources = new Set(sourceOptions.value);
for (const lead of items) {
if (lead.city?.trim()) cities.add(lead.city.trim());
if (lead.region?.trim()) regions.add(lead.region.trim());
if (lead.trade?.trim()) trades.add(lead.trade.trim());
if (lead.source?.trim()) sources.add(lead.source.trim());
}
cityOptions.value = [...cities].sort((a, b) => a.localeCompare(b, 'fa'));
regionOptions.value = [...regions].sort((a, b) => a.localeCompare(b, 'fa'));
tradeOptions.value = [...trades].sort((a, b) => a.localeCompare(b, 'fa'));
sourceOptions.value = [...sources].sort((a, b) => a.localeCompare(b, 'fa'));
}
async function loadMeta() {
const [stagesRes, usersRes, statsRes] = await Promise.all([
api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api.get<LeadStatsDto>('/leads/stats').catch(() => ({ data: null })),
]);
stages.value = stagesRes.data;
users.value = usersRes.data.filter((u) => u.isActive !== false);
stats.value = statsRes.data;
if (!createForm.value.stageId && stages.value[0]) {
createForm.value.stageId = stages.value[0].id;
}
}
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,
city: cityFilter.value || undefined,
region: regionFilter.value || undefined,
trade: tradeFilter.value || undefined,
source: sourceFilter.value || undefined,
unassigned: unassigned.value ? '1' : undefined,
archived: archivedFilter.value ? '1' : undefined,
converted: convertedFilter.value ? '1' : undefined,
followUp: followUpFilter.value || undefined,
createdFrom: createdFromFilter.value || undefined,
createdTo: createdToFilter.value || undefined,
},
});
data.value = res;
mergeFacets(res.items);
selected.value = new Set(
[...selected.value].filter((id) => res.items.some((l) => l.id === id)),
);
void loadLogos(res.items);
const { data: nextStats } = await api
.get<LeadStatsDto>('/leads/stats')
.catch(() => ({ data: stats.value }));
if (nextStats) stats.value = nextStats;
const { data: nextStages } = await api.get<LeadStageDto[]>('/leads/stages');
stages.value = nextStages;
} catch (e) {
error.value =
axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری لیدها ناموفق بود.';
} finally {
loading.value = false;
}
}
const createModalTitle = computed(() =>
editingLeadId.value ? 'ویرایش لید' : 'ثبت لید جدید',
);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
function bareLeadName(value: string) {
const split = splitPersonHonorific(value, '');
return [split.firstName, split.lastName].filter(Boolean).join(' ');
}
watch(
() => createForm.value.gender,
(value) => {
if (value === 'none') createForm.value.showGenderInName = false;
},
);
function openCreate() {
editingLeadId.value = null;
createError.value = '';
createForm.value = {
title: '',
contactName: '',
gender: 'none',
showGenderInName: false,
mobile: '',
city: '',
region: '',
source: '',
instagram: '',
addresses: [''],
stageId: stages.value[0]?.id ?? '',
assigneeId: '',
};
closePick();
closeSource();
createOpen.value = true;
}
function addCreateAddress() {
createForm.value.addresses.push('');
}
function removeCreateAddress(index: number) {
if (createForm.value.addresses.length <= 1) {
createForm.value.addresses[0] = '';
return;
}
createForm.value.addresses.splice(index, 1);
}
function openEdit(lead: LeadDto) {
rowMenuId.value = null;
editingLeadId.value = lead.id;
createError.value = '';
createForm.value = {
title: bareLeadName(lead.title),
contactName: bareLeadName(lead.contactName ?? ''),
gender: lead.gender && lead.gender !== 'none' ? lead.gender : 'none',
showGenderInName: !!lead.showGenderInName && lead.gender !== 'none',
mobile: lead.mobile,
city: lead.city ?? '',
region: lead.region ?? '',
source: lead.source ?? '',
instagram: lead.instagram ?? '',
addresses: lead.addresses?.length ? [...lead.addresses] : [''],
stageId: lead.stageId,
assigneeId: lead.assigneeId ?? '',
};
closePick();
closeSource();
createOpen.value = true;
}
function deleteLead(lead: LeadDto) {
rowMenuId.value = null;
Modal.confirm({
title: `لید «${lead.title}» حذف (بایگانی) شود؟`,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
busy.value = true;
error.value = '';
try {
await api.post('/leads/archive', { leadIds: [lead.id] });
okMsg.value = 'لید حذف شد.';
message.success(okMsg.value);
selected.value = new Set(
[...selected.value].filter((id) => id !== lead.id),
);
await load();
} catch {
error.value = 'حذف لید ناموفق بود.';
} finally {
busy.value = false;
}
},
});
}
function applyContactDraft(draft: PhoneContactDraft) {
if (draft.mobile) {
createForm.value.mobile = draft.mobile;
} else if (draft.phone && !createForm.value.mobile.trim()) {
createForm.value.mobile = draft.phone;
}
if (!createForm.value.contactName.trim() && draft.fullName) {
createForm.value.contactName = draft.fullName;
}
if (!createForm.value.title.trim() && draft.fullName) {
createForm.value.title = draft.fullName;
}
if (!createForm.value.source.trim()) {
createForm.value.source = 'مخاطبین گوشی';
}
}
function draftLine(row: PhoneContactDraft) {
return [row.mobile, row.phone, row.email].filter(Boolean).join(' · ') || 'بدون شماره/ایمیل';
}
function closePick() {
pickOpen.value = false;
pickDrafts.value = [];
pickQuery.value = '';
}
function closeSource() {
sourceOpen.value = false;
}
function choosePick(row: PhoneContactDraft) {
applyContactDraft(row);
closePick();
}
async function openPickList(rows: PhoneContactDraft[]) {
pickDrafts.value = sortPhoneContacts(rows);
pickQuery.value = '';
sourceOpen.value = false;
pickOpen.value = false;
await nextTick();
pickOpen.value = true;
}
function fillFromPhoneContact() {
if (pickingContact.value || createBusy.value) return;
createError.value = '';
if (contactPickerSupported) {
sourceOpen.value = true;
return;
}
sourceOpen.value = true;
// help/vcf path opened via nested modal upload
}
async function pickFromDevice() {
if (pickingContact.value || createBusy.value) return;
pickingContact.value = true;
createError.value = '';
sourceOpen.value = false;
try {
const rows = await pickPhoneContacts({ multiple: true });
if (!rows.length) {
createError.value = 'مخاطبی انتخاب نشد.';
return;
}
await openPickList(rows);
} catch (e) {
const name = e instanceof DOMException ? e.name : '';
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
return;
}
createError.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
function pickFromVcf() {
sourceOpen.value = false;
}
const beforeContactVcfUpload: UploadProps['beforeUpload'] = (file) => {
void onContactVcfFile(file as File);
return false;
};
async function onContactVcfFile(file: File) {
pickingContact.value = true;
createError.value = '';
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
createError.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
createError.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
async function submitCreate() {
createError.value = '';
const mobile = createForm.value.mobile.trim();
const company = createForm.value.title.trim();
const person = createForm.value.contactName.trim();
if (!mobile) {
createError.value = 'موبایل الزامی است.';
return;
}
createBusy.value = true;
const payload = {
title: company || person || mobile,
mobile,
contactName: person || null,
gender: createForm.value.gender,
showGenderInName: createForm.value.gender !== 'none' && createForm.value.showGenderInName,
city: createForm.value.city.trim() || null,
region: createForm.value.region.trim() || null,
source: createForm.value.source.trim() || null,
instagram: createForm.value.instagram.trim() || null,
addresses: createForm.value.addresses.map((a) => a.trim()).filter(Boolean),
stageId: createForm.value.stageId || undefined,
assigneeId: createForm.value.assigneeId || null,
};
try {
if (editingLeadId.value) {
await api.patch(`/leads/${editingLeadId.value}`, payload);
createOpen.value = false;
okMsg.value = 'لید به‌روز شد.';
await load();
} else {
const { data: created } = await api.post<LeadDto>('/leads', payload);
createOpen.value = false;
okMsg.value = 'لید جدید ثبت شد.';
await load();
void router.push({ name: 'lead-detail', params: { id: created.id } });
}
} catch (e) {
createError.value =
axios.isAxiosError(e) && (e.response?.data as { message?: string })?.message
? String((e.response?.data as { message?: string }).message)
: editingLeadId.value
? 'ویرایش لید ناموفق بود.'
: 'ثبت لید ناموفق بود.';
} finally {
createBusy.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;
}
}
function archiveSelected() {
if (!selectedIds.value.length) return;
const count = selectedIds.value.length;
Modal.confirm({
title: `${toFaDigits(count)} لید بایگانی شود؟`,
okText: 'بایگانی',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
busy.value = true;
error.value = '';
try {
await api.post('/leads/archive', { leadIds: selectedIds.value });
okMsg.value = `${toFaDigits(count)} لید بایگانی شد.`;
message.success(okMsg.value);
selected.value = new Set();
await load();
} catch {
error.value = 'بایگانی ناموفق بود.';
} finally {
busy.value = false;
}
},
});
}
async function exportExcel() {
busy.value = true;
pageBusy.value = true;
busyMessage.value = 'در حال تهیه خروجی اکسل…';
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;
pageBusy.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 === 'pending' || job.status === 'processing') {
pageBusy.value = true;
busyMessage.value = `در حال ورود لیدها… ${toFaDigits(job.processedCount)} از ${toFaDigits(job.totalCount || 0)}`;
}
if (job.status === 'done' || job.status === 'failed' || job.status === 'completed') {
stopImportPoll();
pageBusy.value = false;
okMsg.value = `ورود: موفق ${toFaDigits(job.successCount)} · تکراری ${toFaDigits(job.duplicateCount)} · خطا ${toFaDigits(job.errorCount)}`;
await load();
}
} catch {
stopImportPoll();
pageBusy.value = false;
error.value = 'پیگیری وضعیت ورود ناموفق بود.';
}
}
function startImportPoll(jobId: string) {
stopImportPoll();
void pollImport(jobId);
importTimer = setInterval(() => void pollImport(jobId), 1500);
}
function onExcelImportStart() {
busy.value = true;
pageBusy.value = false;
okMsg.value = '';
error.value = '';
}
2026-09-29 14:57:10 +03:30
function onExcelImportDone(result: ImportContactsResult) {
const job = result as unknown as LeadImportJobDto;
if (!job?.id) {
error.value = 'ورود فایل ناموفق بود.';
return;
}
importJob.value = job;
pageBusy.value = true;
busyMessage.value = 'در حال ورود لیدها…';
startImportPoll(job.id);
}
function onExcelImportFail(message: string) {
error.value = message;
}
function onExcelImportSettled() {
busy.value = false;
}
function onPhoneImportStart() {
busy.value = true;
pageBusy.value = true;
busyMessage.value = 'در حال ورود مخاطبین گوشی…';
okMsg.value = '';
error.value = '';
}
async function onPhoneImportDone(data: {
created: number;
skipped: number;
failed: number;
}) {
okMsg.value = `از گوشی وارد شد: ${toFaDigits(data.created)} · تکراری: ${toFaDigits(data.skipped)}${
data.failed ? ` · ناموفق: ${toFaDigits(data.failed)}` : ''
}`;
await load();
}
function onPhoneImportFail(message: string) {
error.value = message;
}
function onPhoneImportSettled() {
busy.value = false;
pageBusy.value = false;
}
function goDetail(id: string) {
void router.push({ name: 'lead-detail', params: { id } });
}
function callLead(lead: LeadDto) {
const mobile = lead.mobile?.replace(/\D/g, '');
if (!mobile) return;
window.location.href = `tel:${mobile}`;
}
watch(
() => [
route.query.q,
route.query.page,
route.query.sort,
route.query.stageId,
route.query.assigneeId,
route.query.city,
route.query.region,
route.query.trade,
route.query.source,
route.query.unassigned,
route.query.archived,
route.query.converted,
route.query.followUp,
route.query.createdFrom,
route.query.createdTo,
],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
},
);
const tintColor = (tint: StageTint) => {
const map: Record<StageTint, string> = {
mint: 'success',
teal: 'cyan',
amber: 'warning',
peach: 'orange',
sky: 'processing',
violet: 'purple',
rose: 'error',
};
return map[tint] ?? 'default';
};
const tableColumns = computed<TableColumnsType<LeadDto>>(() => {
const all: TableColumnsType<LeadDto> = [
{
title: '',
key: 'select',
width: 48,
},
{
title: '#',
key: 'row',
width: 56,
customRender: ({ index }) => toFaDigits(rangeFrom.value + index),
},
{ title: 'نام / شرکت', key: 'title', dataIndex: 'title' },
{ title: 'شماره تماس', key: 'mobile', dataIndex: 'mobile' },
{ title: 'شهر / منطقه', key: 'city' },
{ title: 'منبع لید', key: 'source', dataIndex: 'source' },
{ title: 'تاریخ ثبت', key: 'createdAt', dataIndex: 'createdAt' },
{ title: 'وضعیت', key: 'stage', dataIndex: 'stageName' },
{ title: 'مسئول', key: 'assignee', dataIndex: 'assigneeName' },
{ title: 'آخرین فعالیت', key: 'activity' },
{ title: 'اقدامات', key: 'actions', width: 140 },
];
return all.filter((col) => {
const title = String(col.title ?? '');
if (!title || title === '#' || col.key === 'select') return true;
return leadCols.isVisible(title);
});
});
onMounted(async () => {
registerTableColumns('leads', [...LEAD_COL_LABELS]);
await loadMeta();
await load();
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
if (jobId) startImportPoll(jobId);
});
onBeforeUnmount(() => {
stopImportPoll();
revokeLogoUrls();
document.removeEventListener('pointerdown', onFilterDocPointer, true);
});
</script>
<template>
<a-row :gutter="[16, 16]" align="top" :style="{ alignContent: 'flex-start' }" @click="closePopups">
<a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
<a-space :size="8" wrap>
<template v-if="isMobile">
<a-dropdown :trigger="['click']" placement="bottomLeft">
<a-button :disabled="busy">
<template #icon><MoreOutlined /></template>
بیشتر
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
key="export"
:disabled="busy || (pageBusy && busyMessage.includes('خروجی'))"
@click="exportExcel"
>
<ExportOutlined />
خروجی اکسل
</a-menu-item>
<a-menu-item key="excel" :disabled="busy">
<ExcelImportFlow
preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<template #default="{ loading: excelLoading }">
<a-flex align="center" :gap="8" :style="{ opacity: excelLoading ? 0.6 : 1 }">
<FileExcelOutlined />
ورود از اکسل
</a-flex>
</template>
</ExcelImportFlow>
</a-menu-item>
<a-menu-item key="phone" :disabled="busy">
<PhoneContactsImport
target="leads"
:disabled="busy"
@start="onPhoneImportStart"
@done="onPhoneImportDone"
@fail="onPhoneImportFail"
@settled="onPhoneImportSettled"
>
<a-flex align="center" :gap="8">
<PhoneOutlined />
از مخاطبین گوشی
</a-flex>
</PhoneContactsImport>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<template v-else>
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
<template #icon><ExportOutlined /></template>
خروجی اکسل
</a-button>
<ExcelImportFlow
preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<template #default="{ loading: excelLoading }">
<a-button :disabled="busy" :loading="excelLoading">
<template #icon><FileExcelOutlined /></template>
ورود از اکسل
</a-button>
</template>
</ExcelImportFlow>
<PhoneContactsImport
target="leads"
:disabled="busy"
@start="onPhoneImportStart"
@done="onPhoneImportDone"
@fail="onPhoneImportFail"
@settled="onPhoneImportSettled"
>
<a-button :disabled="busy">
<template #icon><PhoneOutlined /></template>
از مخاطبین گوشی
</a-button>
</PhoneContactsImport>
</template>
<a-radio-group
v-if="isMobile"
value="list"
button-style="solid"
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
>
<a-radio-button value="list">
<UnorderedListOutlined />
</a-radio-button>
<a-radio-button value="board">
<AppstoreOutlined />
</a-radio-button>
</a-radio-group>
<a-button type="primary" @click.stop="openCreate">
<template #icon><PlusOutlined /></template>
{{ isMobile ? 'جدید' : 'لید جدید' }}
</a-button>
</a-space>
</a-flex>
</a-col>
<a-col :span="24" @click.stop>
<a-card :bordered="true">
<a-flex vertical :gap="12">
<a-flex v-if="!isMobile" justify="space-between" align="center" wrap="wrap" :gap="8">
<a-flex wrap="wrap" :gap="8" align="center" flex="1 1 auto">
<a-button :type="!stageId ? 'primary' : 'default'" @click="setStageFilter('')">
همه لیدها
<a-typography-text
:type="!stageId ? undefined : 'secondary'"
:style="{ color: !stageId ? 'inherit' : undefined, marginInlineStart: '6px' }"
>
{{ formatCount(totalLeadsCount) }}
</a-typography-text>
</a-button>
<a-button
v-for="card in stageCards"
:key="card.key"
:type="isStatActive(card) ? 'primary' : 'default'"
:disabled="!card.stageId"
@click="selectStatCard(card)"
>
{{ card.label }}
<a-typography-text
:type="isStatActive(card) ? undefined : 'secondary'"
:style="{ color: isStatActive(card) ? 'inherit' : undefined, marginInlineStart: '6px' }"
>
{{ formatCount(card.count) }}
</a-typography-text>
</a-button>
</a-flex>
<a-space :size="8">
<TableColumnsMenu table-key="leads" align="end" />
<a-radio-group
value="list"
button-style="solid"
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
>
<a-radio-button value="list">
<UnorderedListOutlined />
</a-radio-button>
<a-radio-button value="board">
<AppstoreOutlined />
</a-radio-button>
</a-radio-group>
</a-space>
</a-flex>
<a-flex :vertical="isMobile" align="center" wrap="wrap" :gap="8">
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو نام، موبایل، شهر…"
:style="isMobile ? { width: '100%' } : { width: '15rem', maxWidth: '100%' }"
@search="onSearch"
>
<template #enterButton>
<a-button type="primary">
<template #icon><SearchOutlined /></template>
</a-button>
</template>
</a-input-search>
<div
ref="filterAnchor"
:style="{ position: 'relative', width: isMobile ? '100%' : undefined }"
>
<a-button
:block="isMobile"
:style="mobileFilterBadgeCount
? {
borderColor: token.colorPrimary,
color: token.colorPrimary,
background: token.colorPrimaryBg,
}
: undefined"
@click.stop="toggleFilterPopover"
>
<a-flex align="center" justify="center" :gap="6">
<FilterOutlined />
<span>{{ filterBtnLabel }}</span>
<DownOutlined />
</a-flex>
</a-button>
<div v-if="moreFiltersOpen" data-lead-filter :style="filterPanelStyle" @click.stop>
<a-flex vertical :gap="10">
<div v-for="facet in textFacets" :key="facet.key">
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection(facet.key)"
@keydown.enter.prevent="toggleFilterSection(facet.key)"
>
<a-flex justify="space-between" align="center">
<span>{{ facet.label }}</span>
<a-typography-text :style="facetValueStyle(facet.active)">
{{ facet.value }}
</a-typography-text>
</a-flex>
</div>
<a-flex
v-if="filterSection === facet.key"
vertical
:gap="4"
:style="{ marginTop: '6px' }"
>
<a-input
v-if="facet.searchable"
v-model:value="filterQuery"
allow-clear
placeholder="جستجو"
/>
<div :style="{ maxHeight: '11.5rem', overflow: 'auto' }">
<a-button
v-for="option in visibleFacetOptions(facet.options).options"
:key="option.value || 'all'"
type="text"
block
:style="facetOptionStyle(facet.current === option.value)"
@click="pickFacet(facet.key, option.value)"
>
{{ option.label }}
</a-button>
<a-typography-text
v-if="visibleFacetOptions(facet.options).empty"
type="secondary"
>
موردی پیدا نشد
</a-typography-text>
</div>
<a-typography-text
v-if="visibleFacetOptions(facet.options).hidden"
type="secondary"
>
برای دیدن بقیه، نام را دقیق‌تر بنویسید
</a-typography-text>
</a-flex>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('createdFrom')"
@keydown.enter.prevent="toggleFilterSection('createdFrom')"
>
<a-flex justify="space-between" align="center">
<span>از تاریخ ثبت</span>
<a-typography-text :style="facetValueStyle(Boolean(createdFromFilter))">
{{ dateFacetLabel(createdFromFilter) }}
</a-typography-text>
</a-flex>
</div>
<div v-if="filterSection === 'createdFrom'" :style="{ marginTop: '6px' }">
<JalaliDateInput
:model-value="createdFromFilter"
placeholder="از تاریخ"
@update:model-value="(value: string) => { setCreatedFromFilter(value); filterSection = null }"
/>
</div>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('createdTo')"
@keydown.enter.prevent="toggleFilterSection('createdTo')"
>
<a-flex justify="space-between" align="center">
<span>تا تاریخ ثبت</span>
<a-typography-text :style="facetValueStyle(Boolean(createdToFilter))">
{{ dateFacetLabel(createdToFilter) }}
</a-typography-text>
</a-flex>
</div>
<div v-if="filterSection === 'createdTo'" :style="{ marginTop: '6px' }">
<JalaliDateInput
:model-value="createdToFilter"
placeholder="تا تاریخ"
@update:model-value="(value: string) => { setCreatedToFilter(value); filterSection = null }"
/>
</div>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('converted')"
@keydown.enter.prevent="toggleFilterSection('converted')"
>
<a-flex justify="space-between" align="center">
<span>تبدیل‌شده</span>
<a-typography-text :style="facetValueStyle(convertedFilter)">
{{ convertedFilter ? 'فقط' : 'همه' }}
</a-typography-text>
</a-flex>
</div>
<a-flex v-if="filterSection === 'converted'" vertical :style="{ marginTop: '6px' }">
<a-button
type="text"
block
:style="facetOptionStyle(!convertedFilter)"
@click="pickFlag('converted', false)"
>
همه
</a-button>
<a-button
type="text"
block
:style="facetOptionStyle(convertedFilter)"
@click="pickFlag('converted', true)"
>
فقط تبدیل‌شده‌ها
</a-button>
</a-flex>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('archived')"
@keydown.enter.prevent="toggleFilterSection('archived')"
>
<a-flex justify="space-between" align="center">
<span>بایگانی</span>
<a-typography-text :style="facetValueStyle(archivedFilter)">
{{ archivedFilter ? 'فقط' : 'همه' }}
</a-typography-text>
</a-flex>
</div>
<a-flex v-if="filterSection === 'archived'" vertical :style="{ marginTop: '6px' }">
<a-button
type="text"
block
:style="facetOptionStyle(!archivedFilter)"
@click="pickFlag('archived', false)"
>
همه
</a-button>
<a-button
type="text"
block
:style="facetOptionStyle(archivedFilter)"
@click="pickFlag('archived', true)"
>
فقط بایگانی‌شده‌ها
</a-button>
</a-flex>
</div>
<a-button
v-if="mobileFilterBadgeCount"
type="text"
danger
block
@click="clearMoreFilters"
>
پاک کردن فیلترها
</a-button>
</a-flex>
</div>
</div>
<a-select
:value="sort"
:style="isMobile ? { width: '100%' } : { width: '10rem' }"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
</a-flex>
</a-flex>
</a-card>
</a-col>
<a-col v-if="selectedIds.length" :span="24" @click.stop>
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
<a-space :size="8" wrap>
<a-typography-text>
{{ toFaDigits(selectedIds.length) }} مورد انتخاب‌شده
</a-typography-text>
<template v-if="canAssignOthers">
<a-select
v-model:value="assignUserId"
placeholder="تخصیص به کارشناس"
style="min-width: 12rem"
allow-clear
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
<a-button :disabled="!assignUserId || busy" @click="assignSelected">اعمال تخصیص</a-button>
</template>
<template v-if="isAdmin">
<a-select
v-model:value="bulkStageId"
placeholder="تغییر وضعیت"
style="min-width: 12rem"
allow-clear
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
/>
<a-button :disabled="!bulkStageId || busy" @click="bulkStageSelected">اعمال وضعیت</a-button>
</template>
<a-button danger :disabled="busy" @click="archiveSelected">بایگانی</a-button>
</a-space>
</a-card>
</a-col>
<a-col
v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'"
:span="24"
>
<a-alert
type="info"
show-icon
:message="`ورود فایل: ${toFaDigits(importJob.processedCount)} / ${toFaDigits(importJob.totalCount)}`"
/>
</a-col>
<a-col v-if="okMsg" :span="24">
<a-alert type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
</a-col>
<a-col v-if="error" :span="24">
<a-alert type="error" show-icon :message="error" closable @close="error = ''" />
</a-col>
<a-col :span="24">
<a-spin :spinning="loading || pageBusy" :tip="pageBusy ? busyMessage : undefined">
<template v-if="data?.items.length">
<a-row v-if="isMobile" :gutter="[12, 12]">
<a-col v-for="(lead, idx) in data.items" :key="`m-${lead.id}`" :span="24">
<a-card
:bordered="true"
hoverable
:style="{
borderColor: selected.has(lead.id) ? token.colorPrimary : undefined,
background: selected.has(lead.id) ? token.colorPrimaryBg : undefined,
}"
@pointerdown="onCardPointerDown(lead.id, $event)"
@pointermove="onCardPointerMove"
@pointerup="onCardPointerEnd"
@pointercancel="onCardPointerEnd"
@click="onCardClick(lead)"
>
<a-flex justify="space-between" align="start" :gap="8">
<a-space :size="8" align="start">
<a-tag>{{ toFaDigits(rangeFrom + idx) }}</a-tag>
<a-dropdown
:trigger="['click']"
@click.stop
>
<a-avatar
:src="logoUrls[lead.id]"
:style="{ cursor: 'pointer' }"
@click.stop
>
{{ initials(lead.title) }}
</a-avatar>
<template #overlay>
<a-menu>
<a-menu-item
@click="openLogoPicker(lead.id)"
>
<a-upload
:show-upload-list="false"
accept="image/jpeg,image/png,image/webp,image/gif,.jpg,.jpeg,.png,.webp,.gif"
:before-upload="(f) => { logoTargetId = lead.id; void uploadLeadLogo(lead.id, f as File); return false; }"
>
{{ lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو' }}
</a-upload>
</a-menu-item>
<a-menu-item
v-if="lead.hasLogo"
danger
@click="removeLeadLogo(lead.id)"
>
حذف لوگو
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div>
<a-typography-text strong>{{ lead.title }}</a-typography-text>
<div v-if="lead.contactName">
<a-typography-text type="secondary">{{ lead.contactName }}</a-typography-text>
</div>
</div>
</a-space>
<a-dropdown :trigger="['click']" @click.stop>
<a-button type="text" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="goDetail(lead.id)">مشاهده</a-menu-item>
<a-menu-item @click="openEdit(lead)">ویرایش</a-menu-item>
<a-menu-item>
<a :href="`tel:${lead.mobile}`">تماس</a>
</a-menu-item>
<a-menu-item danger @click="deleteLead(lead)">حذف</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-flex>
<a-flex justify="space-between" :gap="8" :style="{ marginTop: `${token.marginXS}px` }">
<a-button
v-if="lead.mobile"
type="link"
@click.stop="callLead(lead)"
>
<template #icon><PhoneOutlined /></template>
<span dir="ltr">{{ formatPhone(lead.mobile) }}</span>
</a-button>
<a-typography-text v-if="lead.city || lead.region" type="secondary">
{{ [lead.city, lead.region].filter(Boolean).join(' · ') }}
</a-typography-text>
</a-flex>
<a-flex justify="space-between" :gap="8" :style="{ marginTop: `${token.marginXS}px` }">
<a-space v-if="lead.assigneeName" :size="8">
<a-avatar>{{ initials(lead.assigneeName) }}</a-avatar>
<a-typography-text>{{ lead.assigneeName }}</a-typography-text>
</a-space>
<a-typography-text v-else type="secondary">بدون مسئول</a-typography-text>
<a-typography-text type="secondary">{{ lastActivity(lead).when }}</a-typography-text>
</a-flex>
<a-flex :gap="8" wrap="wrap" :style="{ marginTop: `${token.marginXS}px` }">
<a-tag :color="tintColor(stageTintForLead(lead))">{{ lead.stageName }}</a-tag>
<a-tag v-if="lead.source">{{ lead.source }}</a-tag>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-table
v-else
:columns="tableColumns"
:data-source="data.items"
:pagination="false"
:row-key="(r: LeadDto) => r.id"
:scroll="{ x: 1100 }"
:custom-row="(record: LeadDto) => ({ onClick: () => goDetail(record.id) })"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'select'">
<a-checkbox
:checked="selected.has((record as LeadDto).id)"
@click.stop
@change="(e: any) => toggleOne((record as LeadDto).id, !!e?.target?.checked)"
/>
</template>
<template v-else-if="column.key === 'title'">
<a-typography-text strong>{{ (record as LeadDto).title }}</a-typography-text>
<div v-if="(record as LeadDto).contactName">
<a-typography-text type="secondary">{{ (record as LeadDto).contactName }}</a-typography-text>
</div>
</template>
<template v-else-if="column.key === 'mobile'">
<span dir="ltr">{{ formatPhone((record as LeadDto).mobile) }}</span>
</template>
<template v-else-if="column.key === 'city'">
<a-typography-text>{{ (record as LeadDto).city || '—' }}</a-typography-text>
<div v-if="(record as LeadDto).region">
<a-typography-text type="secondary">{{ (record as LeadDto).region }}</a-typography-text>
</div>
</template>
<template v-else-if="column.key === 'source'">
<a-tag v-if="(record as LeadDto).source">{{ (record as LeadDto).source }}</a-tag>
<a-typography-text v-else type="secondary">—</a-typography-text>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ formatJalaliNumeric(new Date((record as LeadDto).createdAt)) }}
</template>
<template v-else-if="column.key === 'stage'">
<a-tag :color="tintColor(stageTintForLead(record as LeadDto))">
{{ (record as LeadDto).stageName }}
</a-tag>
</template>
<template v-else-if="column.key === 'assignee'">
<a-space v-if="(record as LeadDto).assigneeName" :size="8">
<a-avatar>{{ initials((record as LeadDto).assigneeName) }}</a-avatar>
{{ (record as LeadDto).assigneeName }}
</a-space>
<a-typography-text v-else type="secondary">—</a-typography-text>
</template>
<template v-else-if="column.key === 'activity'">
<div>
<a-typography-text strong>{{ lastActivity(record as LeadDto).label }}</a-typography-text>
</div>
<a-typography-text type="secondary">
{{ lastActivity(record as LeadDto).when }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4" @click.stop>
<a-dropdown :trigger="['click']">
<a-button type="text">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="goDetail((record as LeadDto).id)">مشاهده</a-menu-item>
<a-menu-item @click="openEdit(record as LeadDto)">ویرایش</a-menu-item>
<a-menu-item>
<a :href="`tel:${(record as LeadDto).mobile}`">تماس</a>
</a-menu-item>
<a-menu-item danger @click="deleteLead(record as LeadDto)">حذف</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button type="text" @click="callLead(record as LeadDto)">
<template #icon><PhoneOutlined /></template>
</a-button>
<a-button type="text" @click="goDetail((record as LeadDto).id)">
<template #icon><EditOutlined /></template>
</a-button>
</a-space>
</template>
</template>
</a-table>
<a-flex
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
:style="{ marginTop: `${token.marginMD}px` }"
>
<a-pagination
:current="page"
:total="total"
:page-size="PAGE_SIZE"
:show-size-changer="false"
@change="(p: number) => setPage(p)"
/>
<a-typography-text type="secondary">
نمایش {{ toFaDigits(rangeFrom) }} تا {{ toFaDigits(rangeTo) }} از
{{ formatCount(total) }} لید
</a-typography-text>
</a-flex>
</template>
<a-empty
v-else-if="!loading"
description="لیدی ثبت نشده است. از ورود فایل استفاده کنید یا لید جدید بسازید."
>
<a-space :size="8">
<a-button type="primary" @click="openCreate">ثبت لید جدید</a-button>
<ExcelImportFlow
v-if="isAdmin"
preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<template #default="{ loading: excelLoading }">
<a-button :disabled="busy" :loading="excelLoading">ورود از فایل</a-button>
</template>
</ExcelImportFlow>
</a-space>
</a-empty>
</a-spin>
</a-col>
<a-modal
v-model:open="createOpen"
:title="createModalTitle"
:confirm-loading="createBusy"
ok-text="ثبت"
cancel-text="انصراف"
@ok="submitCreate"
>
<a-form layout="vertical" :model="createForm" @click.stop>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام / شرکت">
<a-input v-model:value="createForm.title" placeholder="مثلاً فروشگاه آریا" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام مخاطب">
<a-input v-model:value="createForm.contactName" placeholder="مهدی احمدی" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="createForm.gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره می‌شود. اگر نام مخاطب خالی باشد روی عنوان می‌نشیند و با تبدیل به مشتری به ته‌حساب هم می‌رود."
>
<a-checkbox
v-model:checked="createForm.showGenderInName"
:disabled="createForm.gender === 'none'"
>
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="موبایل">
<template #extra>
<a-button
type="link"
:disabled="pickingContact || createBusy"
@click="fillFromPhoneContact"
>
<template #icon><PhoneOutlined /></template>
{{ pickingContact ? '…' : 'از مخاطبین' }}
</a-button>
</template>
<a-input
v-model:value="createForm.mobile"
placeholder="09121234567"
inputmode="tel"
dir="ltr"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="شهر">
<a-input v-model:value="createForm.city" placeholder="تهران" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="منطقه">
<a-input v-model:value="createForm.region" placeholder="بازار" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="منبع">
<a-input v-model:value="createForm.source" placeholder="سایت، نمایشگاه…" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="پیج اینستاگرام">
<a-input
v-model:value="createForm.instagram"
dir="ltr"
placeholder="@username یا لینک"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="آدرس‌ها">
<a-flex vertical :gap="8">
<a-flex
v-for="(_, idx) in createForm.addresses"
:key="idx"
:gap="8"
align="center"
>
<a-input
v-model:value="createForm.addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
style="width: 100%"
/>
<a-button @click="removeCreateAddress(idx)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-flex>
<a-button type="dashed" block @click="addCreateAddress">+ افزودن آدرس</a-button>
</a-flex>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="وضعیت">
<a-select
v-model:value="createForm.stageId"
style="width: 100%"
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="مسئول">
<a-select
v-model:value="createForm.assigneeId"
allow-clear
:disabled="!canAssignOthers"
:placeholder="canAssignOthers ? 'بدون مسئول' : 'خودتان'"
style="width: 100%"
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
</a-form-item>
</a-col>
</a-row>
<a-alert v-if="createError" type="error" show-icon :message="createError" />
</a-form>
<a-modal
v-model:open="sourceOpen"
title="از مخاطبین"
:footer="null"
@cancel="closeSource"
>
<a-typography-paragraph type="secondary">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block :disabled="pickingContact" @click="pickFromDevice">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeContactVcfUpload"
>
<a-button block :disabled="pickingContact" @click="pickFromVcf">از فایل .vcf</a-button>
</a-upload>
</a-flex>
</a-modal>
<a-modal
v-model:open="pickOpen"
title="انتخاب مخاطب"
:footer="null"
@cancel="closePick"
>
<a-typography-paragraph type="secondary">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا موبایل (و در صورت خالی بودن، نام) پر شود.
</a-typography-paragraph>
<a-input
v-model:value="pickQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
style="width: 100%; margin-bottom: 12px"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-flex vertical :gap="8" :style="{ maxHeight: 'min(48vh, 360px)', overflow: 'auto' }">
<a-button
v-for="row in visiblePickDrafts"
:key="row.key"
block
@click="choosePick(row)"
>
<a-flex vertical align="start">
<a-typography-text strong>{{ row.fullName }}</a-typography-text>
<a-typography-text type="secondary">{{ draftLine(row) }}</a-typography-text>
</a-flex>
</a-button>
<a-typography-text v-if="!visiblePickDrafts.length" type="secondary">
مخاطبی با این جستجو پیدا نشد.
</a-typography-text>
</a-flex>
<a-space :size="8" :style="{ marginTop: `${token.marginSM}px` }">
<a-button @click="closePick">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeContactVcfUpload"
>
<a-button>فایل دیگر</a-button>
</a-upload>
</a-space>
</a-modal>
<template #footer>
<a-button :disabled="createBusy" @click="createOpen = false">انصراف</a-button>
<a-button type="primary" :disabled="createBusy" :loading="createBusy" @click="submitCreate">
{{
editingLeadId
? 'ذخیره تغییرات'
: 'ثبت لید'
}}
</a-button>
</template>
</a-modal>
</a-row>
</template>