2026-09-27 17:05:11 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-27 19:51:02 +03:30
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
2026-10-18 19:48:16 +03:30
|
|
|
|
import { useRoute, useRouter } from 'vue-router';
|
2026-09-27 17:05:11 +03:30
|
|
|
|
import axios from 'axios';
|
2026-10-18 19:39:59 +03:30
|
|
|
|
import {
|
|
|
|
|
|
Alert as AAlert,
|
|
|
|
|
|
Avatar as AAvatar,
|
2026-11-07 11:02:47 +03:30
|
|
|
|
Badge as ABadge,
|
2026-10-18 19:39:59 +03:30
|
|
|
|
Button as AButton,
|
|
|
|
|
|
Card as ACard,
|
|
|
|
|
|
Checkbox as ACheckbox,
|
|
|
|
|
|
Col as ACol,
|
2026-11-07 11:02:47 +03:30
|
|
|
|
Drawer as ADrawer,
|
2026-10-18 19:39:59 +03:30
|
|
|
|
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,
|
|
|
|
|
|
} from '@ant-design/icons-vue';
|
2026-09-27 17:05:11 +03:30
|
|
|
|
import { api } from '@/lib/api';
|
2026-09-27 18:22:35 +03:30
|
|
|
|
import {
|
2026-10-03 12:29:11 +03:30
|
|
|
|
formatJalaliNumeric,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
formatRelativeContact,
|
|
|
|
|
|
toFaDigits,
|
|
|
|
|
|
} from '@/lib/jalali';
|
2026-09-27 17:05:11 +03:30
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
|
|
import { canManageSettings } from '@/lib/roles';
|
2026-11-03 13:15:21 +03:30
|
|
|
|
import { canAssignToOthers } from '@/lib/assign-access';
|
2026-10-18 19:39:59 +03:30
|
|
|
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
2026-10-05 11:56:49 +03:30
|
|
|
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
2026-11-07 11:02:47 +03:30
|
|
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
2026-09-29 14:45:19 +03:30
|
|
|
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
2026-09-27 19:51:02 +03:30
|
|
|
|
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
|
|
|
|
|
import {
|
|
|
|
|
|
filterPhoneContacts,
|
|
|
|
|
|
isContactPickerSupported,
|
|
|
|
|
|
parseVCardFile,
|
|
|
|
|
|
pickPhoneContacts,
|
|
|
|
|
|
sortPhoneContacts,
|
|
|
|
|
|
type PhoneContactDraft,
|
|
|
|
|
|
} from '@/lib/phone-contacts';
|
2026-09-27 17:05:11 +03:30
|
|
|
|
import type {
|
2026-09-29 14:57:10 +03:30
|
|
|
|
ImportContactsResult,
|
2026-09-27 17:05:11 +03:30
|
|
|
|
LeadDto,
|
|
|
|
|
|
LeadImportJobDto,
|
|
|
|
|
|
LeadStageDto,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
LeadStatsDto,
|
2026-09-27 17:05:11 +03:30
|
|
|
|
OrgUserDto,
|
2026-10-31 12:49:42 +03:30
|
|
|
|
PersonGender,
|
2026-09-27 17:05:11 +03:30
|
|
|
|
PaginatedResponse,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-10-31 12:49:42 +03:30
|
|
|
|
import { brand, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
|
2026-09-27 17:05:11 +03:30
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
|
|
|
|
|
Typography;
|
|
|
|
|
|
const { token } = theme.useToken();
|
|
|
|
|
|
const screens = Grid.useBreakpoint();
|
|
|
|
|
|
const isMobile = computed(() => !screens.value.md);
|
|
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const PAGE_SIZE = 10;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
|
|
|
|
|
|
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
|
2026-11-07 11:02:47 +03:30
|
|
|
|
type FollowUpKey =
|
|
|
|
|
|
| ''
|
|
|
|
|
|
| 'due_today'
|
|
|
|
|
|
| 'overdue'
|
|
|
|
|
|
| 'called_today'
|
|
|
|
|
|
| 'missed_today'
|
|
|
|
|
|
| 'no_follow_up';
|
2026-09-27 18:22:35 +03:30
|
|
|
|
type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal';
|
2026-09-27 17:05:11 +03:30
|
|
|
|
|
2026-11-07 11:02:47 +03:30
|
|
|
|
const FOLLOW_UP_OPTIONS: Array<{ value: FollowUpKey; label: string }> = [
|
|
|
|
|
|
{ value: '', label: 'همه پیگیریها' },
|
|
|
|
|
|
{ value: 'due_today', label: 'قرار امروز' },
|
|
|
|
|
|
{ value: 'overdue', label: 'عقبافتاده' },
|
|
|
|
|
|
{ value: 'called_today', label: 'امروز تماس گرفته' },
|
|
|
|
|
|
{ value: 'missed_today', label: 'امروز باید میگرفت و نگرفته' },
|
|
|
|
|
|
{ value: 'no_follow_up', label: 'بدون پیگیری' },
|
|
|
|
|
|
];
|
|
|
|
|
|
const UNASSIGNED_VALUE = '__unassigned__';
|
|
|
|
|
|
|
2026-09-27 21:17:42 +03:30
|
|
|
|
/** ردیف آمار صفحه لیدها — ترتیب و نامها مطابق طرح */
|
|
|
|
|
|
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';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const isAdmin = computed(() => canManageSettings(auth.user?.role));
|
2026-11-03 13:15:21 +03:30
|
|
|
|
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
|
2026-09-27 17:05:11 +03:30
|
|
|
|
|
|
|
|
|
|
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const busy = ref(false);
|
2026-10-03 12:29:11 +03:30
|
|
|
|
const pageBusy = ref(false);
|
|
|
|
|
|
const busyMessage = ref('لطفاً صبر کنید…');
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const okMsg = ref('');
|
|
|
|
|
|
const data = ref<PaginatedResponse<LeadDto> | null>(null);
|
|
|
|
|
|
const stages = ref<LeadStageDto[]>([]);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const stats = ref<LeadStatsDto | null>(null);
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const users = ref<OrgUserDto[]>([]);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const cityOptions = ref<string[]>([]);
|
2026-11-07 11:02:47 +03:30
|
|
|
|
const regionOptions = ref<string[]>([]);
|
|
|
|
|
|
const tradeOptions = ref<string[]>([]);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const sourceOptions = ref<string[]>([]);
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const selected = ref<Set<string>>(new Set());
|
2026-11-07 11:02:47 +03:30
|
|
|
|
const moreFiltersOpen = ref(false);
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const sortOpen = ref(false);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const rowMenuId = ref<string | null>(null);
|
2026-09-27 21:17:42 +03:30
|
|
|
|
const logoMenuId = ref<string | null>(null);
|
|
|
|
|
|
const logoUrls = ref<Record<string, string>>({});
|
|
|
|
|
|
const logoBusyId = ref<string | null>(null);
|
|
|
|
|
|
const logoTargetId = ref<string | null>(null);
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const leadCols = useTableColumns(() => 'leads');
|
|
|
|
|
|
const LEAD_COL_LABELS = [
|
|
|
|
|
|
'نام / شرکت',
|
|
|
|
|
|
'شماره تماس',
|
|
|
|
|
|
'شهر / منطقه',
|
|
|
|
|
|
'منبع لید',
|
|
|
|
|
|
'تاریخ ثبت',
|
|
|
|
|
|
'وضعیت',
|
|
|
|
|
|
'مسئول',
|
|
|
|
|
|
'آخرین فعالیت',
|
|
|
|
|
|
'اقدامات',
|
|
|
|
|
|
] as const;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const assignOpen = ref(false);
|
|
|
|
|
|
const stageOpen = ref(false);
|
|
|
|
|
|
const assignUserId = ref('');
|
|
|
|
|
|
const bulkStageId = ref('');
|
|
|
|
|
|
const importJob = ref<LeadImportJobDto | null>(null);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const createOpen = ref(false);
|
|
|
|
|
|
const createBusy = ref(false);
|
|
|
|
|
|
const createError = ref('');
|
2026-09-27 20:18:44 +03:30
|
|
|
|
const editingLeadId = ref<string | null>(null);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const createForm = ref({
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
contactName: '',
|
2026-10-31 12:49:42 +03:30
|
|
|
|
gender: 'none' as PersonGender,
|
|
|
|
|
|
showGenderInName: false,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
mobile: '',
|
|
|
|
|
|
city: '',
|
|
|
|
|
|
region: '',
|
|
|
|
|
|
source: '',
|
2026-10-03 12:29:11 +03:30
|
|
|
|
instagram: '',
|
|
|
|
|
|
addresses: [''] as string[],
|
2026-09-27 18:22:35 +03:30
|
|
|
|
stageId: '',
|
|
|
|
|
|
assigneeId: '',
|
|
|
|
|
|
});
|
2026-09-27 19:51:02 +03:30
|
|
|
|
const contactPickerSupported = isContactPickerSupported();
|
|
|
|
|
|
const pickingContact = ref(false);
|
|
|
|
|
|
const sourceOpen = ref(false);
|
|
|
|
|
|
const pickOpen = ref(false);
|
|
|
|
|
|
const pickDrafts = ref<PhoneContactDraft[]>([]);
|
|
|
|
|
|
const pickQuery = ref('');
|
2026-09-27 17:05:11 +03:30
|
|
|
|
let importTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
|
2026-09-27 19:51:02 +03:30
|
|
|
|
const visiblePickDrafts = computed(() =>
|
|
|
|
|
|
filterPhoneContacts(pickDrafts.value, pickQuery.value),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
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 : '',
|
|
|
|
|
|
);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const cityFilter = computed(() =>
|
|
|
|
|
|
typeof route.query.city === 'string' ? route.query.city : '',
|
|
|
|
|
|
);
|
2026-11-07 11:02:47 +03:30
|
|
|
|
const regionFilter = computed(() =>
|
|
|
|
|
|
typeof route.query.region === 'string' ? route.query.region : '',
|
|
|
|
|
|
);
|
|
|
|
|
|
const tradeFilter = computed(() =>
|
|
|
|
|
|
typeof route.query.trade === 'string' ? route.query.trade : '',
|
|
|
|
|
|
);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const sourceFilter = computed(() =>
|
|
|
|
|
|
typeof route.query.source === 'string' ? route.query.source : '',
|
|
|
|
|
|
);
|
2026-11-07 11:02:47 +03:30
|
|
|
|
const followUpFilter = computed<FollowUpKey>(() => {
|
|
|
|
|
|
const value = route.query.followUp;
|
|
|
|
|
|
if (
|
|
|
|
|
|
value === 'due_today' ||
|
|
|
|
|
|
value === 'overdue' ||
|
|
|
|
|
|
value === 'called_today' ||
|
|
|
|
|
|
value === 'missed_today' ||
|
|
|
|
|
|
value === 'no_follow_up'
|
|
|
|
|
|
) {
|
|
|
|
|
|
return value;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
|
|
|
|
|
});
|
|
|
|
|
|
const createdFromFilter = computed(() =>
|
|
|
|
|
|
typeof route.query.createdFrom === 'string' ? route.query.createdFrom : '',
|
|
|
|
|
|
);
|
|
|
|
|
|
const createdToFilter = computed(() =>
|
|
|
|
|
|
typeof route.query.createdTo === 'string' ? route.query.createdTo : '',
|
|
|
|
|
|
);
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const unassigned = computed(
|
|
|
|
|
|
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
|
|
|
|
|
|
);
|
2026-11-07 11:02:47 +03:30
|
|
|
|
const archivedFilter = computed(
|
|
|
|
|
|
() => route.query.archived === '1' || route.query.archived === 'true',
|
|
|
|
|
|
);
|
|
|
|
|
|
const convertedFilter = computed(
|
|
|
|
|
|
() => route.query.converted === '1' || route.query.converted === 'true',
|
|
|
|
|
|
);
|
|
|
|
|
|
const assigneeSelectValue = computed(() => {
|
|
|
|
|
|
if (unassigned.value) return UNASSIGNED_VALUE;
|
|
|
|
|
|
return assigneeId.value || undefined;
|
|
|
|
|
|
});
|
|
|
|
|
|
const moreFiltersCount = computed(() =>
|
|
|
|
|
|
[
|
|
|
|
|
|
cityFilter.value,
|
|
|
|
|
|
regionFilter.value,
|
|
|
|
|
|
tradeFilter.value,
|
|
|
|
|
|
sourceFilter.value,
|
|
|
|
|
|
createdFromFilter.value,
|
|
|
|
|
|
createdToFilter.value,
|
|
|
|
|
|
archivedFilter.value ? '1' : '',
|
|
|
|
|
|
convertedFilter.value ? '1' : '',
|
|
|
|
|
|
].filter(Boolean).length,
|
|
|
|
|
|
);
|
|
|
|
|
|
/** در موبایل مرتبسازی و مسئول و پیگیری هم داخل کشو هستند. */
|
|
|
|
|
|
const mobileFilterBadgeCount = computed(() => {
|
|
|
|
|
|
const extra = isMobile.value
|
|
|
|
|
|
? [
|
|
|
|
|
|
sort.value !== 'updated' ? '1' : '',
|
|
|
|
|
|
assigneeSelectValue.value ? '1' : '',
|
|
|
|
|
|
followUpFilter.value,
|
|
|
|
|
|
].filter(Boolean).length
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
return moreFiltersCount.value + extra;
|
|
|
|
|
|
});
|
2026-09-27 17:05:11 +03:30
|
|
|
|
|
|
|
|
|
|
const total = computed(() => data.value?.total ?? 0);
|
|
|
|
|
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const rangeFrom = computed(() =>
|
|
|
|
|
|
total.value === 0 ? 0 : (page.value - 1) * PAGE_SIZE + 1,
|
|
|
|
|
|
);
|
|
|
|
|
|
const rangeTo = computed(() =>
|
|
|
|
|
|
Math.min(page.value * PAGE_SIZE, total.value),
|
|
|
|
|
|
);
|
2026-09-27 17:05:11 +03:30
|
|
|
|
const selectedIds = computed(() => [...selected.value]);
|
|
|
|
|
|
|
|
|
|
|
|
const sortLabels: Record<SortKey, string> = {
|
2026-09-27 18:22:35 +03:30
|
|
|
|
newest: 'تاریخ جدیدترین',
|
2026-09-27 17:05:11 +03:30
|
|
|
|
updated: 'آخرین بهروزرسانی',
|
|
|
|
|
|
follow_up: 'پیگیری بعدی',
|
|
|
|
|
|
last_contact: 'آخرین تماس',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-27 21:17:42 +03:30
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-09-27 18:22:35 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 21:17:42 +03:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-09-27 20:35:57 +03:30
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closePopups() {
|
|
|
|
|
|
sortOpen.value = false;
|
2026-09-27 18:22:35 +03:30
|
|
|
|
rowMenuId.value = null;
|
2026-09-27 21:17:42 +03:30
|
|
|
|
logoMenuId.value = null;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
assignOpen.value = false;
|
|
|
|
|
|
stageOpen.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 21:17:42 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
async function uploadLeadLogo(leadId: string, file: File) {
|
2026-09-27 21:17:42 +03:30
|
|
|
|
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}» ذخیره شد.`;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
message.success(okMsg.value);
|
2026-09-27 21:17:42 +03:30
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
function removeLeadLogo(leadId: string) {
|
2026-09-27 21:17:42 +03:30
|
|
|
|
logoMenuId.value = null;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-09-27 21:17:42 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-07 11:02:47 +03:30
|
|
|
|
function setAssigneeFilter(value: string | undefined) {
|
|
|
|
|
|
if (value === UNASSIGNED_VALUE) {
|
|
|
|
|
|
patchQuery({ assigneeId: undefined, unassigned: '1', page: undefined });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
patchQuery({
|
|
|
|
|
|
assigneeId: value || undefined,
|
|
|
|
|
|
unassigned: undefined,
|
|
|
|
|
|
page: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setFollowUpFilter(value: string | undefined) {
|
|
|
|
|
|
patchQuery({
|
|
|
|
|
|
followUp: value || undefined,
|
|
|
|
|
|
page: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
function setCityFilter(value: string) {
|
|
|
|
|
|
patchQuery({ city: value || undefined, page: undefined });
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-07 11:02:47 +03:30
|
|
|
|
function setRegionFilter(value: string) {
|
|
|
|
|
|
patchQuery({ region: value || undefined, page: undefined });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setTradeFilter(value: string) {
|
|
|
|
|
|
patchQuery({ trade: value || undefined, page: undefined });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
function setSourceFilter(value: string) {
|
|
|
|
|
|
patchQuery({ source: value || undefined, page: undefined });
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-07 11:02:47 +03:30
|
|
|
|
function setCreatedFromFilter(value: string) {
|
|
|
|
|
|
patchQuery({ createdFrom: value || undefined, page: undefined });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setCreatedToFilter(value: string) {
|
|
|
|
|
|
patchQuery({ createdTo: value || undefined, page: undefined });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setArchivedFilter(checked: boolean) {
|
|
|
|
|
|
patchQuery({ archived: checked ? '1' : undefined, page: undefined });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setConvertedFilter(checked: boolean) {
|
|
|
|
|
|
patchQuery({ converted: checked ? '1' : undefined, page: undefined });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearMoreFilters() {
|
|
|
|
|
|
patchQuery({
|
|
|
|
|
|
city: undefined,
|
|
|
|
|
|
region: undefined,
|
|
|
|
|
|
trade: undefined,
|
|
|
|
|
|
source: undefined,
|
|
|
|
|
|
createdFrom: undefined,
|
|
|
|
|
|
createdTo: undefined,
|
|
|
|
|
|
archived: undefined,
|
|
|
|
|
|
converted: undefined,
|
|
|
|
|
|
...(isMobile.value
|
|
|
|
|
|
? {
|
|
|
|
|
|
sort: undefined,
|
|
|
|
|
|
assigneeId: undefined,
|
|
|
|
|
|
unassigned: undefined,
|
|
|
|
|
|
followUp: undefined,
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
page: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 11:50:44 +03:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
function mergeFacets(items: LeadDto[]) {
|
|
|
|
|
|
const cities = new Set(cityOptions.value);
|
2026-11-07 11:02:47 +03:30
|
|
|
|
const regions = new Set(regionOptions.value);
|
|
|
|
|
|
const trades = new Set(tradeOptions.value);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const sources = new Set(sourceOptions.value);
|
|
|
|
|
|
for (const lead of items) {
|
|
|
|
|
|
if (lead.city?.trim()) cities.add(lead.city.trim());
|
2026-11-07 11:02:47 +03:30
|
|
|
|
if (lead.region?.trim()) regions.add(lead.region.trim());
|
|
|
|
|
|
if (lead.trade?.trim()) trades.add(lead.trade.trim());
|
2026-09-27 18:22:35 +03:30
|
|
|
|
if (lead.source?.trim()) sources.add(lead.source.trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
cityOptions.value = [...cities].sort((a, b) => a.localeCompare(b, 'fa'));
|
2026-11-07 11:02:47 +03:30
|
|
|
|
regionOptions.value = [...regions].sort((a, b) => a.localeCompare(b, 'fa'));
|
|
|
|
|
|
tradeOptions.value = [...trades].sort((a, b) => a.localeCompare(b, 'fa'));
|
2026-09-27 18:22:35 +03:30
|
|
|
|
sourceOptions.value = [...sources].sort((a, b) => a.localeCompare(b, 'fa'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
async function loadMeta() {
|
2026-09-27 18:22:35 +03:30
|
|
|
|
const [stagesRes, usersRes, statsRes] = await Promise.all([
|
2026-09-27 17:05:11 +03:30
|
|
|
|
api.get<LeadStageDto[]>('/leads/stages'),
|
|
|
|
|
|
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
2026-09-27 18:22:35 +03:30
|
|
|
|
api.get<LeadStatsDto>('/leads/stats').catch(() => ({ data: null })),
|
2026-09-27 17:05:11 +03:30
|
|
|
|
]);
|
|
|
|
|
|
stages.value = stagesRes.data;
|
|
|
|
|
|
users.value = usersRes.data.filter((u) => u.isActive !== false);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
stats.value = statsRes.data;
|
|
|
|
|
|
if (!createForm.value.stageId && stages.value[0]) {
|
|
|
|
|
|
createForm.value.stageId = stages.value[0].id;
|
|
|
|
|
|
}
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
city: cityFilter.value || undefined,
|
2026-11-07 11:02:47 +03:30
|
|
|
|
region: regionFilter.value || undefined,
|
|
|
|
|
|
trade: tradeFilter.value || undefined,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
source: sourceFilter.value || undefined,
|
2026-09-27 17:05:11 +03:30
|
|
|
|
unassigned: unassigned.value ? '1' : undefined,
|
2026-11-07 11:02:47 +03:30
|
|
|
|
archived: archivedFilter.value ? '1' : undefined,
|
|
|
|
|
|
converted: convertedFilter.value ? '1' : undefined,
|
|
|
|
|
|
followUp: followUpFilter.value || undefined,
|
|
|
|
|
|
createdFrom: createdFromFilter.value || undefined,
|
|
|
|
|
|
createdTo: createdToFilter.value || undefined,
|
2026-09-27 17:05:11 +03:30
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
data.value = res;
|
2026-09-27 18:22:35 +03:30
|
|
|
|
mergeFacets(res.items);
|
2026-09-27 17:05:11 +03:30
|
|
|
|
selected.value = new Set(
|
|
|
|
|
|
[...selected.value].filter((id) => res.items.some((l) => l.id === id)),
|
|
|
|
|
|
);
|
2026-09-27 21:17:42 +03:30
|
|
|
|
void loadLogos(res.items);
|
2026-09-27 18:22:35 +03:30
|
|
|
|
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;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value =
|
|
|
|
|
|
axios.isAxiosError(e) && !e.response
|
|
|
|
|
|
? 'اتصال به API برقرار نشد.'
|
|
|
|
|
|
: 'بارگذاری لیدها ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 20:18:44 +03:30
|
|
|
|
const createModalTitle = computed(() =>
|
|
|
|
|
|
editingLeadId.value ? 'ویرایش لید' : 'ثبت لید جدید',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-31 12:49:42 +03:30
|
|
|
|
const genderOptions = PERSON_GENDERS.map((key) => ({
|
|
|
|
|
|
value: key,
|
|
|
|
|
|
label: PERSON_GENDER_LABELS[key],
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
function bareLeadName(value: string) {
|
|
|
|
|
|
const split = splitPersonHonorific(value, '');
|
|
|
|
|
|
return [split.firstName, split.lastName].filter(Boolean).join(' ');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => createForm.value.gender,
|
|
|
|
|
|
(value) => {
|
|
|
|
|
|
if (value === 'none') createForm.value.showGenderInName = false;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
function openCreate() {
|
2026-09-27 20:18:44 +03:30
|
|
|
|
editingLeadId.value = null;
|
2026-09-27 18:22:35 +03:30
|
|
|
|
createError.value = '';
|
|
|
|
|
|
createForm.value = {
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
contactName: '',
|
2026-10-31 12:49:42 +03:30
|
|
|
|
gender: 'none',
|
|
|
|
|
|
showGenderInName: false,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
mobile: '',
|
|
|
|
|
|
city: '',
|
|
|
|
|
|
region: '',
|
|
|
|
|
|
source: '',
|
2026-10-03 12:29:11 +03:30
|
|
|
|
instagram: '',
|
|
|
|
|
|
addresses: [''],
|
2026-09-27 18:22:35 +03:30
|
|
|
|
stageId: stages.value[0]?.id ?? '',
|
|
|
|
|
|
assigneeId: '',
|
|
|
|
|
|
};
|
2026-09-27 19:51:02 +03:30
|
|
|
|
closePick();
|
|
|
|
|
|
closeSource();
|
2026-09-27 18:22:35 +03:30
|
|
|
|
createOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 12:29:11 +03:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 20:18:44 +03:30
|
|
|
|
function openEdit(lead: LeadDto) {
|
|
|
|
|
|
rowMenuId.value = null;
|
|
|
|
|
|
editingLeadId.value = lead.id;
|
|
|
|
|
|
createError.value = '';
|
|
|
|
|
|
createForm.value = {
|
2026-10-31 12:49:42 +03:30
|
|
|
|
title: bareLeadName(lead.title),
|
|
|
|
|
|
contactName: bareLeadName(lead.contactName ?? ''),
|
|
|
|
|
|
gender: lead.gender && lead.gender !== 'none' ? lead.gender : 'none',
|
|
|
|
|
|
showGenderInName: !!lead.showGenderInName && lead.gender !== 'none',
|
2026-09-27 20:18:44 +03:30
|
|
|
|
mobile: lead.mobile,
|
|
|
|
|
|
city: lead.city ?? '',
|
|
|
|
|
|
region: lead.region ?? '',
|
|
|
|
|
|
source: lead.source ?? '',
|
2026-10-03 12:29:11 +03:30
|
|
|
|
instagram: lead.instagram ?? '',
|
|
|
|
|
|
addresses: lead.addresses?.length ? [...lead.addresses] : [''],
|
2026-09-27 20:18:44 +03:30
|
|
|
|
stageId: lead.stageId,
|
|
|
|
|
|
assigneeId: lead.assigneeId ?? '',
|
|
|
|
|
|
};
|
|
|
|
|
|
closePick();
|
|
|
|
|
|
closeSource();
|
|
|
|
|
|
createOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
function deleteLead(lead: LeadDto) {
|
2026-09-27 20:18:44 +03:30
|
|
|
|
rowMenuId.value = null;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-09-27 20:18:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 19:51:02 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-10-18 19:39:59 +03:30
|
|
|
|
sourceOpen.value = true;
|
|
|
|
|
|
// help/vcf path opened via nested modal upload
|
2026-09-27 19:51:02 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const beforeContactVcfUpload: UploadProps['beforeUpload'] = (file) => {
|
|
|
|
|
|
void onContactVcfFile(file as File);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function onContactVcfFile(file: File) {
|
2026-09-27 19:51:02 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
async function submitCreate() {
|
|
|
|
|
|
createError.value = '';
|
2026-10-13 14:45:29 +03:30
|
|
|
|
const mobile = createForm.value.mobile.trim();
|
|
|
|
|
|
const company = createForm.value.title.trim();
|
|
|
|
|
|
const person = createForm.value.contactName.trim();
|
|
|
|
|
|
if (!mobile) {
|
|
|
|
|
|
createError.value = 'موبایل الزامی است.';
|
2026-09-27 18:22:35 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
createBusy.value = true;
|
2026-09-27 20:18:44 +03:30
|
|
|
|
const payload = {
|
2026-10-13 14:45:29 +03:30
|
|
|
|
title: company || person || mobile,
|
|
|
|
|
|
mobile,
|
|
|
|
|
|
contactName: person || null,
|
2026-10-31 12:49:42 +03:30
|
|
|
|
gender: createForm.value.gender,
|
|
|
|
|
|
showGenderInName: createForm.value.gender !== 'none' && createForm.value.showGenderInName,
|
2026-09-27 20:18:44 +03:30
|
|
|
|
city: createForm.value.city.trim() || null,
|
|
|
|
|
|
region: createForm.value.region.trim() || null,
|
|
|
|
|
|
source: createForm.value.source.trim() || null,
|
2026-10-03 12:29:11 +03:30
|
|
|
|
instagram: createForm.value.instagram.trim() || null,
|
|
|
|
|
|
addresses: createForm.value.addresses.map((a) => a.trim()).filter(Boolean),
|
2026-09-27 20:18:44 +03:30
|
|
|
|
stageId: createForm.value.stageId || undefined,
|
|
|
|
|
|
assigneeId: createForm.value.assigneeId || null,
|
|
|
|
|
|
};
|
2026-09-27 18:22:35 +03:30
|
|
|
|
try {
|
2026-09-27 20:18:44 +03:30
|
|
|
|
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 } });
|
|
|
|
|
|
}
|
2026-09-27 18:22:35 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
createError.value =
|
|
|
|
|
|
axios.isAxiosError(e) && (e.response?.data as { message?: string })?.message
|
|
|
|
|
|
? String((e.response?.data as { message?: string }).message)
|
2026-09-27 20:18:44 +03:30
|
|
|
|
: editingLeadId.value
|
|
|
|
|
|
? 'ویرایش لید ناموفق بود.'
|
|
|
|
|
|
: 'ثبت لید ناموفق بود.';
|
2026-09-27 18:22:35 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
createBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
function archiveSelected() {
|
2026-09-27 17:05:11 +03:30
|
|
|
|
if (!selectedIds.value.length) return;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function exportExcel() {
|
|
|
|
|
|
busy.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = true;
|
|
|
|
|
|
busyMessage.value = 'در حال تهیه خروجی اکسل…';
|
2026-09-27 17:05:11 +03:30
|
|
|
|
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;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
if (job.status === 'pending' || job.status === 'processing') {
|
|
|
|
|
|
pageBusy.value = true;
|
|
|
|
|
|
busyMessage.value = `در حال ورود لیدها… ${toFaDigits(job.processedCount)} از ${toFaDigits(job.totalCount || 0)}`;
|
|
|
|
|
|
}
|
2026-09-27 17:05:11 +03:30
|
|
|
|
if (job.status === 'done' || job.status === 'failed' || job.status === 'completed') {
|
|
|
|
|
|
stopImportPoll();
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
okMsg.value = `ورود: موفق ${toFaDigits(job.successCount)} · تکراری ${toFaDigits(job.duplicateCount)} · خطا ${toFaDigits(job.errorCount)}`;
|
|
|
|
|
|
await load();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
stopImportPoll();
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
error.value = 'پیگیری وضعیت ورود ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startImportPoll(jobId: string) {
|
|
|
|
|
|
stopImportPoll();
|
|
|
|
|
|
void pollImport(jobId);
|
|
|
|
|
|
importTimer = setInterval(() => void pollImport(jobId), 1500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 14:45:19 +03:30
|
|
|
|
function onExcelImportStart() {
|
2026-09-27 17:05:11 +03:30
|
|
|
|
busy.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
okMsg.value = '';
|
2026-09-29 14:45:19 +03:30
|
|
|
|
error.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 14:57:10 +03:30
|
|
|
|
function onExcelImportDone(result: ImportContactsResult) {
|
|
|
|
|
|
const job = result as unknown as LeadImportJobDto;
|
2026-09-29 14:45:19 +03:30
|
|
|
|
if (!job?.id) {
|
2026-09-27 17:05:11 +03:30
|
|
|
|
error.value = 'ورود فایل ناموفق بود.';
|
2026-09-29 14:45:19 +03:30
|
|
|
|
return;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
2026-09-29 14:45:19 +03:30
|
|
|
|
importJob.value = job;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = true;
|
|
|
|
|
|
busyMessage.value = 'در حال ورود لیدها…';
|
2026-09-29 14:45:19 +03:30
|
|
|
|
startImportPoll(job.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onExcelImportFail(message: string) {
|
|
|
|
|
|
error.value = message;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onExcelImportSettled() {
|
|
|
|
|
|
busy.value = false;
|
2026-09-27 17:05:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 19:51:02 +03:30
|
|
|
|
function onPhoneImportStart() {
|
|
|
|
|
|
busy.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = true;
|
|
|
|
|
|
busyMessage.value = 'در حال ورود مخاطبین گوشی…';
|
2026-09-27 19:51:02 +03:30
|
|
|
|
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;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-27 19:51:02 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
function goDetail(id: string) {
|
|
|
|
|
|
void router.push({ name: 'lead-detail', params: { id } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
function callLead(lead: LeadDto) {
|
|
|
|
|
|
const mobile = lead.mobile?.replace(/\D/g, '');
|
|
|
|
|
|
if (!mobile) return;
|
|
|
|
|
|
window.location.href = `tel:${mobile}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
watch(
|
|
|
|
|
|
() => [
|
|
|
|
|
|
route.query.q,
|
|
|
|
|
|
route.query.page,
|
|
|
|
|
|
route.query.sort,
|
|
|
|
|
|
route.query.stageId,
|
|
|
|
|
|
route.query.assigneeId,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
route.query.city,
|
2026-11-07 11:02:47 +03:30
|
|
|
|
route.query.region,
|
|
|
|
|
|
route.query.trade,
|
2026-09-27 18:22:35 +03:30
|
|
|
|
route.query.source,
|
2026-09-27 17:05:11 +03:30
|
|
|
|
route.query.unassigned,
|
2026-11-07 11:02:47 +03:30
|
|
|
|
route.query.archived,
|
|
|
|
|
|
route.query.converted,
|
|
|
|
|
|
route.query.followUp,
|
|
|
|
|
|
route.query.createdFrom,
|
|
|
|
|
|
route.query.createdTo,
|
2026-09-27 17:05:11 +03:30
|
|
|
|
],
|
|
|
|
|
|
() => {
|
|
|
|
|
|
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
|
|
|
|
|
void load();
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 17:05:11 +03:30
|
|
|
|
onMounted(async () => {
|
2026-10-18 19:39:59 +03:30
|
|
|
|
registerTableColumns('leads', [...LEAD_COL_LABELS]);
|
2026-09-27 17:05:11 +03:30
|
|
|
|
await loadMeta();
|
|
|
|
|
|
await load();
|
|
|
|
|
|
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
|
|
|
|
|
|
if (jobId) startImportPoll(jobId);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 21:17:42 +03:30
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
stopImportPoll();
|
|
|
|
|
|
revokeLogoUrls();
|
|
|
|
|
|
});
|
2026-09-27 17:05:11 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<a-row :gutter="[16, 16]" @click="closePopups">
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-col :span="24">
|
|
|
|
|
|
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-space :size="8" wrap>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<template v-if="isMobile">
|
|
|
|
|
|
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
|
|
|
|
|
<a-button :disabled="busy">
|
|
|
|
|
|
<template #icon><MoreOutlined /></template>
|
|
|
|
|
|
بیشتر
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-button>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<template #overlay>
|
|
|
|
|
|
<a-menu>
|
|
|
|
|
|
<a-menu-item
|
|
|
|
|
|
key="export"
|
|
|
|
|
|
:disabled="busy || (pageBusy && busyMessage.includes('خروجی'))"
|
|
|
|
|
|
@click="exportExcel"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ExportOutlined />
|
|
|
|
|
|
خروجی اکسل
|
|
|
|
|
|
</a-menu-item>
|
|
|
|
|
|
<a-menu-item key="excel" :disabled="busy">
|
|
|
|
|
|
<ExcelImportFlow
|
|
|
|
|
|
preview-path="/leads/import/preview"
|
|
|
|
|
|
import-path="/leads/import/file"
|
|
|
|
|
|
:disabled="busy"
|
|
|
|
|
|
@start="onExcelImportStart"
|
|
|
|
|
|
@done="onExcelImportDone"
|
|
|
|
|
|
@fail="onExcelImportFail"
|
|
|
|
|
|
@settled="onExcelImportSettled"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #default="{ loading: excelLoading }">
|
|
|
|
|
|
<a-flex align="center" :gap="8" :style="{ opacity: excelLoading ? 0.6 : 1 }">
|
|
|
|
|
|
<FileExcelOutlined />
|
|
|
|
|
|
ورود از اکسل
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ExcelImportFlow>
|
|
|
|
|
|
</a-menu-item>
|
|
|
|
|
|
<a-menu-item key="phone" :disabled="busy">
|
|
|
|
|
|
<PhoneContactsImport
|
|
|
|
|
|
target="leads"
|
|
|
|
|
|
:disabled="busy"
|
|
|
|
|
|
@start="onPhoneImportStart"
|
|
|
|
|
|
@done="onPhoneImportDone"
|
|
|
|
|
|
@fail="onPhoneImportFail"
|
|
|
|
|
|
@settled="onPhoneImportSettled"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-flex align="center" :gap="8">
|
|
|
|
|
|
<PhoneOutlined />
|
|
|
|
|
|
از مخاطبین گوشی
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</PhoneContactsImport>
|
|
|
|
|
|
</a-menu-item>
|
|
|
|
|
|
</a-menu>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-dropdown>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
|
|
|
|
|
|
<template #icon><ExportOutlined /></template>
|
|
|
|
|
|
خروجی اکسل
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-button>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<ExcelImportFlow
|
|
|
|
|
|
preview-path="/leads/import/preview"
|
|
|
|
|
|
import-path="/leads/import/file"
|
|
|
|
|
|
:disabled="busy"
|
|
|
|
|
|
@start="onExcelImportStart"
|
|
|
|
|
|
@done="onExcelImportDone"
|
|
|
|
|
|
@fail="onExcelImportFail"
|
|
|
|
|
|
@settled="onExcelImportSettled"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #default="{ loading: excelLoading }">
|
|
|
|
|
|
<a-button :disabled="busy" :loading="excelLoading">
|
|
|
|
|
|
<template #icon><FileExcelOutlined /></template>
|
|
|
|
|
|
ورود از اکسل
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ExcelImportFlow>
|
|
|
|
|
|
<PhoneContactsImport
|
|
|
|
|
|
target="leads"
|
|
|
|
|
|
:disabled="busy"
|
|
|
|
|
|
@start="onPhoneImportStart"
|
|
|
|
|
|
@done="onPhoneImportDone"
|
|
|
|
|
|
@fail="onPhoneImportFail"
|
|
|
|
|
|
@settled="onPhoneImportSettled"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-button :disabled="busy">
|
|
|
|
|
|
<template #icon><PhoneOutlined /></template>
|
|
|
|
|
|
از مخاطبین گوشی
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</PhoneContactsImport>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
v-if="isMobile"
|
|
|
|
|
|
value="list"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button value="list">
|
|
|
|
|
|
<UnorderedListOutlined />
|
|
|
|
|
|
</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="board">
|
|
|
|
|
|
<AppstoreOutlined />
|
|
|
|
|
|
</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-button type="primary" @click.stop="openCreate">
|
|
|
|
|
|
<template #icon><PlusOutlined /></template>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
{{ isMobile ? 'جدید' : 'لید جدید' }}
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-space>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-col>
|
2026-09-27 18:22:35 +03:30
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-col :span="24" @click.stop>
|
|
|
|
|
|
<a-card :bordered="true">
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<a-flex vertical :gap="12">
|
|
|
|
|
|
<a-flex v-if="!isMobile" justify="space-between" align="center" wrap="wrap" :gap="8">
|
|
|
|
|
|
<a-flex wrap="wrap" :gap="8" align="center" flex="1 1 auto">
|
|
|
|
|
|
<a-button :type="!stageId ? 'primary' : 'default'" @click="setStageFilter('')">
|
|
|
|
|
|
همه لیدها
|
|
|
|
|
|
<a-typography-text
|
|
|
|
|
|
:type="!stageId ? undefined : 'secondary'"
|
|
|
|
|
|
:style="{ color: !stageId ? 'inherit' : undefined, marginInlineStart: '6px' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatCount(totalLeadsCount) }}
|
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button
|
|
|
|
|
|
v-for="card in stageCards"
|
|
|
|
|
|
:key="card.key"
|
|
|
|
|
|
:type="isStatActive(card) ? 'primary' : 'default'"
|
|
|
|
|
|
:disabled="!card.stageId"
|
|
|
|
|
|
@click="selectStatCard(card)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ card.label }}
|
|
|
|
|
|
<a-typography-text
|
|
|
|
|
|
:type="isStatActive(card) ? undefined : 'secondary'"
|
|
|
|
|
|
:style="{ color: isStatActive(card) ? 'inherit' : undefined, marginInlineStart: '6px' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatCount(card.count) }}
|
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-flex>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-space :size="8">
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<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>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-space>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
</a-flex>
|
|
|
|
|
|
|
|
|
|
|
|
<a-flex v-if="isMobile" vertical :gap="8">
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-input-search
|
|
|
|
|
|
v-model:value="q"
|
|
|
|
|
|
allow-clear
|
2026-11-07 11:02:47 +03:30
|
|
|
|
placeholder="جستجو نام، موبایل، شهر…"
|
|
|
|
|
|
style="width: 100%"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
@search="onSearch"
|
2026-10-03 12:29:11 +03:30
|
|
|
|
>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<template #enterButton>
|
|
|
|
|
|
<a-button type="primary">
|
|
|
|
|
|
<template #icon><SearchOutlined /></template>
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</template>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-input-search>
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<a-badge
|
|
|
|
|
|
:count="mobileFilterBadgeCount"
|
|
|
|
|
|
:offset="[-2, 2]"
|
|
|
|
|
|
:style="{ width: '100%', display: 'block' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-button block @click="moreFiltersOpen = true">
|
|
|
|
|
|
<template #icon><FilterOutlined /></template>
|
|
|
|
|
|
فیلترها
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-badge>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
<a-flex v-else align="center" wrap="wrap" :gap="8">
|
|
|
|
|
|
<a-input-search
|
|
|
|
|
|
v-model:value="q"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
allow-clear
|
2026-11-07 11:02:47 +03:30
|
|
|
|
placeholder="جستجو نام، موبایل، شهر…"
|
|
|
|
|
|
style="width: 15rem; max-width: 100%"
|
|
|
|
|
|
@search="onSearch"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #enterButton>
|
|
|
|
|
|
<a-button type="primary">
|
|
|
|
|
|
<template #icon><SearchOutlined /></template>
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-input-search>
|
|
|
|
|
|
<a-badge :count="mobileFilterBadgeCount" :offset="[-2, 2]">
|
|
|
|
|
|
<a-button @click="moreFiltersOpen = true">
|
|
|
|
|
|
<template #icon><FilterOutlined /></template>
|
|
|
|
|
|
فیلترها
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-badge>
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="sort"
|
|
|
|
|
|
style="width: 10rem"
|
|
|
|
|
|
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
|
|
|
|
|
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
/>
|
|
|
|
|
|
<a-select
|
2026-11-07 11:02:47 +03:30
|
|
|
|
:value="assigneeSelectValue"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
allow-clear
|
2026-11-07 11:02:47 +03:30
|
|
|
|
show-search
|
|
|
|
|
|
option-filter-prop="label"
|
|
|
|
|
|
placeholder="مسئول"
|
|
|
|
|
|
style="width: 10rem"
|
|
|
|
|
|
:options="[
|
|
|
|
|
|
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
|
|
|
|
|
|
...users.map((u) => ({ value: u.id, label: u.fullName })),
|
|
|
|
|
|
]"
|
|
|
|
|
|
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
/>
|
|
|
|
|
|
<a-select
|
2026-11-07 11:02:47 +03:30
|
|
|
|
:value="followUpFilter || undefined"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
allow-clear
|
2026-11-07 11:02:47 +03:30
|
|
|
|
placeholder="پیگیری"
|
|
|
|
|
|
style="width: 13rem"
|
|
|
|
|
|
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
|
|
|
|
|
|
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
/>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
</a-col>
|
2026-09-27 19:51:02 +03:30
|
|
|
|
|
2026-11-07 11:02:47 +03:30
|
|
|
|
<a-drawer
|
|
|
|
|
|
v-model:open="moreFiltersOpen"
|
|
|
|
|
|
:title="isMobile ? 'فیلترها' : 'فیلترهای بیشتر'"
|
|
|
|
|
|
placement="left"
|
|
|
|
|
|
:width="isMobile ? '100%' : 380"
|
|
|
|
|
|
:destroy-on-close="false"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-form layout="vertical">
|
|
|
|
|
|
<template v-if="isMobile">
|
|
|
|
|
|
<a-form-item label="مرتبسازی">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="sort"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
|
|
|
|
|
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item label="مسئول">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="assigneeSelectValue"
|
|
|
|
|
|
allow-clear
|
|
|
|
|
|
show-search
|
|
|
|
|
|
option-filter-prop="label"
|
|
|
|
|
|
placeholder="همه مسئولان"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
:options="[
|
|
|
|
|
|
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
|
|
|
|
|
|
...users.map((u) => ({ value: u.id, label: u.fullName })),
|
|
|
|
|
|
]"
|
|
|
|
|
|
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item label="پیگیری">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="followUpFilter || undefined"
|
|
|
|
|
|
allow-clear
|
|
|
|
|
|
placeholder="همه پیگیریها"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
|
|
|
|
|
|
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-form-item label="شهر">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="cityFilter || undefined"
|
|
|
|
|
|
allow-clear
|
|
|
|
|
|
show-search
|
|
|
|
|
|
placeholder="همه شهرها"
|
|
|
|
|
|
:options="cityOptions.map((c) => ({ value: c, label: c }))"
|
|
|
|
|
|
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item label="منطقه">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="regionFilter || undefined"
|
|
|
|
|
|
allow-clear
|
|
|
|
|
|
show-search
|
|
|
|
|
|
placeholder="همه مناطق"
|
|
|
|
|
|
:options="regionOptions.map((r) => ({ value: r, label: r }))"
|
|
|
|
|
|
@update:value="(v) => setRegionFilter(v == null ? '' : String(v))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item label="صنف">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="tradeFilter || undefined"
|
|
|
|
|
|
allow-clear
|
|
|
|
|
|
show-search
|
|
|
|
|
|
placeholder="همه اصناف"
|
|
|
|
|
|
:options="tradeOptions.map((t) => ({ value: t, label: t }))"
|
|
|
|
|
|
@update:value="(v) => setTradeFilter(v == null ? '' : String(v))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item label="منبع">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
:value="sourceFilter || undefined"
|
|
|
|
|
|
allow-clear
|
|
|
|
|
|
show-search
|
|
|
|
|
|
placeholder="همه منابع"
|
|
|
|
|
|
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
|
|
|
|
|
|
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item label="از تاریخ ثبت">
|
|
|
|
|
|
<JalaliDateInput
|
|
|
|
|
|
:model-value="createdFromFilter"
|
|
|
|
|
|
placeholder="از تاریخ"
|
|
|
|
|
|
@update:model-value="setCreatedFromFilter"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item label="تا تاریخ ثبت">
|
|
|
|
|
|
<JalaliDateInput
|
|
|
|
|
|
:model-value="createdToFilter"
|
|
|
|
|
|
placeholder="تا تاریخ"
|
|
|
|
|
|
@update:model-value="setCreatedToFilter"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<a-checkbox
|
|
|
|
|
|
:checked="convertedFilter"
|
|
|
|
|
|
@update:checked="setConvertedFilter"
|
|
|
|
|
|
>
|
|
|
|
|
|
فقط تبدیلشدهها
|
|
|
|
|
|
</a-checkbox>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<a-checkbox
|
|
|
|
|
|
:checked="archivedFilter"
|
|
|
|
|
|
@update:checked="setArchivedFilter"
|
|
|
|
|
|
>
|
|
|
|
|
|
فقط بایگانیشدهها
|
|
|
|
|
|
</a-checkbox>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<a-space>
|
|
|
|
|
|
<a-button :disabled="!mobileFilterBadgeCount" @click="clearMoreFilters">
|
|
|
|
|
|
پاک کردن
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button type="primary" @click="moreFiltersOpen = false">
|
|
|
|
|
|
بستن
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-space>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-drawer>
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<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>
|
2026-11-03 13:15:21 +03:30
|
|
|
|
<template v-if="canAssignOthers">
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<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>
|
2026-11-03 13:15:21 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
<template v-if="isAdmin">
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<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>
|
2026-09-27 19:51:02 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<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>
|
2026-10-31 12:49:42 +03:30
|
|
|
|
<a-col :xs="24" :md="12">
|
|
|
|
|
|
<a-form-item label="جنسیت">
|
|
|
|
|
|
<a-select v-model:value="createForm.gender" :options="genderOptions" />
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
<a-col :xs="24" :md="12">
|
|
|
|
|
|
<a-form-item
|
|
|
|
|
|
label="نمایش در نام"
|
|
|
|
|
|
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره میشود. اگر نام مخاطب خالی باشد روی عنوان مینشیند و با تبدیل به مشتری به تهحساب هم میرود."
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-checkbox
|
|
|
|
|
|
v-model:checked="createForm.showGenderInName"
|
|
|
|
|
|
:disabled="createForm.gender === 'none'"
|
|
|
|
|
|
>
|
|
|
|
|
|
نمایش در نام
|
|
|
|
|
|
</a-checkbox>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-col>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<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
|
2026-11-03 13:15:21 +03:30
|
|
|
|
:disabled="!canAssignOthers"
|
|
|
|
|
|
:placeholder="canAssignOthers ? 'بدون مسئول' : 'خودتان'"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
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">
|
2026-09-27 19:51:02 +03:30
|
|
|
|
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتبشده الفبایی).
|
|
|
|
|
|
یکی را انتخاب کنید تا موبایل (و در صورت خالی بودن، نام) پر شود.
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</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>
|
2026-09-27 19:51:02 +03:30
|
|
|
|
|
2026-09-27 18:22:35 +03:30
|
|
|
|
<template #footer>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-button :disabled="createBusy" @click="createOpen = false">انصراف</a-button>
|
|
|
|
|
|
<a-button type="primary" :disabled="createBusy" :loading="createBusy" @click="submitCreate">
|
2026-09-27 20:18:44 +03:30
|
|
|
|
{{
|
2026-10-18 19:39:59 +03:30
|
|
|
|
editingLeadId
|
|
|
|
|
|
? 'ذخیره تغییرات'
|
|
|
|
|
|
: 'ثبت لید'
|
2026-09-27 20:18:44 +03:30
|
|
|
|
}}
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-button>
|
2026-09-27 18:22:35 +03:30
|
|
|
|
</template>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-modal>
|
|
|
|
|
|
</a-row>
|
2026-09-27 17:05:11 +03:30
|
|
|
|
</template>
|