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

1807 lines
59 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, 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,
EditOutlined,
ExportOutlined,
FileExcelOutlined,
FilterOutlined,
MoreOutlined,
PhoneOutlined,
PlusOutlined,
SearchOutlined,
UnorderedListOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import {
formatJalaliNumeric,
formatRelativeContact,
toFaDigits,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.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 {
ImportContactsResult,
LeadDto,
LeadImportJobDto,
LeadStageDto,
LeadStatsDto,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { brand } 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 StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal';
/** ردیف آمار صفحه لیدها — ترتیب و نام‌ها مطابق طرح */
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 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 sourceOptions = ref<string[]>([]);
const selected = ref<Set<string>>(new Set());
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: '',
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 sourceFilter = computed(() =>
typeof route.query.source === 'string' ? route.query.source : '',
);
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 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 setCityFilter(value: string) {
patchQuery({ city: value || undefined, page: undefined });
}
function setSourceFilter(value: string) {
patchQuery({ source: value || 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;
}
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 sources = new Set(sourceOptions.value);
for (const lead of items) {
if (lead.city?.trim()) cities.add(lead.city.trim());
if (lead.source?.trim()) sources.add(lead.source.trim());
}
cityOptions.value = [...cities].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,
source: sourceFilter.value || undefined,
unassigned: unassigned.value ? '1' : 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 ? 'ویرایش لید' : 'ثبت لید جدید',
);
function openCreate() {
editingLeadId.value = null;
createError.value = '';
createForm.value = {
title: '',
contactName: '',
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: lead.title,
contactName: lead.contactName ?? '',
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,
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 = '';
}
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.source,
route.query.unassigned,
],
() => {
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();
});
</script>
<template>
<a-row :gutter="[24, 24]" @click="closePopups">
<a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
<a-typography-text type="secondary">
مدیریت و پیگیری تمام لیدهای ورودی
</a-typography-text>
</div>
<a-space :size="8" wrap>
<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>
<a-button type="primary" @click.stop="openCreate">
<template #icon><PlusOutlined /></template>
لید جدید
</a-button>
</a-space>
</a-flex>
</a-col>
<a-col :span="24">
<a-row :gutter="[12, 12]">
<a-col :xs="12" :sm="8" :md="4">
<a-card
:bordered="true"
hoverable
:style="{
borderColor: !stageId ? token.colorPrimary : undefined,
cursor: 'pointer',
}"
@click="setStageFilter('')"
>
<a-flex align="center" :gap="12">
<a-avatar :style="{ background: token.colorPrimaryBg, color: token.colorPrimary }">
<template #icon><UserOutlined /></template>
</a-avatar>
<div>
<a-typography-text type="secondary">همه لیدها</a-typography-text>
<div>
<a-typography-text strong>{{ formatCount(totalLeadsCount) }}</a-typography-text>
</div>
</div>
</a-flex>
</a-card>
</a-col>
<a-col
v-for="card in stageCards"
:key="card.key"
:xs="12"
:sm="8"
:md="4"
>
<a-card
:bordered="true"
hoverable
:style="{
borderColor: isStatActive(card) ? token.colorPrimary : undefined,
cursor: card.stageId ? 'pointer' : 'not-allowed',
opacity: card.stageId ? 1 : 0.55,
}"
@click="card.stageId && selectStatCard(card)"
>
<a-typography-text type="secondary">{{ card.label }}</a-typography-text>
<div>
<a-typography-text strong>{{ formatCount(card.count) }}</a-typography-text>
</div>
</a-card>
</a-col>
</a-row>
</a-col>
<a-col :span="24" @click.stop>
<a-card :bordered="true">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<a-flex align="center" wrap="wrap" :gap="8" flex="1 1 auto">
<a-space :size="8">
<FilterOutlined />
<a-typography-text strong>فیلترها</a-typography-text>
</a-space>
<a-select
:value="sort"
style="width: 11rem"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو..."
enter-button="جستجو"
style="width: 16rem; max-width: 100%"
@search="onSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input-search>
<a-select
:value="cityFilter || undefined"
allow-clear
placeholder="همه مناطق"
style="width: 9.5rem"
:options="cityOptions.map((c) => ({ value: c, label: c }))"
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
/>
<a-select
:value="sourceFilter || undefined"
allow-clear
placeholder="همه منابع"
style="width: 9.5rem"
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
/>
<a-select
:value="stageId || undefined"
allow-clear
placeholder="همه وضعیت"
style="width: 9.5rem"
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
@update:value="(v) => setStageFilter(v == null ? '' : String(v))"
/>
</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-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="isAdmin">
<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>
<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 :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
placeholder="بدون مسئول"
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>