1807 lines
59 KiB
Vue
1807 lines
59 KiB
Vue
<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>
|