Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell. Co-authored-by: Cursor <cursoragent@cursor.com>
1202 lines
45 KiB
Vue
1202 lines
45 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import {
|
||
Alert as AAlert,
|
||
Button as AButton,
|
||
Card as ACard,
|
||
Checkbox as ACheckbox,
|
||
Col as ACol,
|
||
Divider as ADivider,
|
||
Dropdown as ADropdown,
|
||
Empty as AEmpty,
|
||
Flex as AFlex,
|
||
Form as AForm,
|
||
FormItem as AFormItem,
|
||
Grid,
|
||
InputSearch as AInputSearch,
|
||
List as AList,
|
||
ListItem as AListItem,
|
||
Menu as AMenu,
|
||
MenuItem as AMenuItem,
|
||
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,
|
||
theme,
|
||
} from 'ant-design-vue';
|
||
import type { RadioChangeEvent, TableColumnsType } from 'ant-design-vue';
|
||
import {
|
||
BankOutlined,
|
||
DeleteOutlined,
|
||
DownloadOutlined,
|
||
EditOutlined,
|
||
FileExcelOutlined,
|
||
IdcardOutlined,
|
||
MailOutlined,
|
||
MoreOutlined,
|
||
PhoneOutlined,
|
||
PlusOutlined,
|
||
UserOutlined,
|
||
} from '@/icons/ant-bridge';
|
||
import { api } from '@/lib/api';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||
import PersonFormView from '@/views/PersonFormView.vue';
|
||
import CompanyFormView from '@/views/CompanyFormView.vue';
|
||
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
|
||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||
import type {
|
||
CompanyDto,
|
||
ImportContactsResult,
|
||
PaginatedResponse,
|
||
PersonDto,
|
||
PersonImportSource,
|
||
PersonImportSourceStatDto,
|
||
PersonMood,
|
||
} from '@mento/shared';
|
||
import {
|
||
brand,
|
||
PERSON_IMPORT_SOURCE_LABELS,
|
||
PERSON_IMPORT_SOURCES,
|
||
} from '@mento/shared';
|
||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||
import { canUseBehavior } from '@/lib/behavior-access';
|
||
import DiscBadge from '@/components/behavior/DiscBadge.vue';
|
||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||
|
||
type Tab = 'people' | 'companies';
|
||
type SortKey = 'name' | 'newest';
|
||
|
||
const pageSize = usePageSize('contacts', 10);
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
const tab = computed<Tab>(() =>
|
||
route.query.tab === 'companies' ? 'companies' : 'people',
|
||
);
|
||
const page = computed(() => {
|
||
const raw = Number(route.query.page);
|
||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||
});
|
||
const sort = computed<SortKey>(() => (route.query.sort === 'newest' ? 'newest' : 'name'));
|
||
const moodFilter = computed<PersonMood | 'all'>(() => {
|
||
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
|
||
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
|
||
});
|
||
const auth = useAuthStore();
|
||
const canBehavior = computed(() => canUseBehavior(auth.user));
|
||
const discFilter = computed(() => {
|
||
const value = typeof route.query.disc === 'string' ? route.query.disc.toUpperCase() : '';
|
||
return /^[DISC](\/[DISC]){0,3}$/.test(value) ? value : '';
|
||
});
|
||
const sourceFilter = computed<PersonImportSource | 'all'>(() => {
|
||
const value = typeof route.query.source === 'string' ? route.query.source : 'all';
|
||
return PERSON_IMPORT_SOURCES.includes(value as PersonImportSource)
|
||
? (value as PersonImportSource)
|
||
: 'all';
|
||
});
|
||
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 importMsg = ref('');
|
||
const people = ref<PaginatedResponse<PersonDto> | null>(null);
|
||
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
|
||
const sourceStats = ref<PersonImportSourceStatDto[]>([]);
|
||
const menuId = ref<string | null>(null);
|
||
const sortOpen = ref(false);
|
||
const filterOpen = ref(false);
|
||
const contactModal = ref<'person' | 'company' | null>(null);
|
||
|
||
const total = computed(() =>
|
||
tab.value === 'people' ? people.value?.total ?? 0 : companies.value?.total ?? 0,
|
||
);
|
||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
||
const rowOffset = computed(() => (page.value - 1) * pageSize.value);
|
||
|
||
const selectedIds = ref<string[]>([]);
|
||
const pageIds = computed(() => people.value?.items.map((p) => p.id) ?? []);
|
||
const allSelected = computed(
|
||
() => pageIds.value.length > 0 && pageIds.value.every((id) => selectedIds.value.includes(id)),
|
||
);
|
||
const someSelected = computed(() => selectedIds.value.length > 0 && !allSelected.value);
|
||
|
||
function toggleAll() {
|
||
selectedIds.value = allSelected.value ? [] : [...pageIds.value];
|
||
}
|
||
|
||
function toggleOne(id: string) {
|
||
selectedIds.value = selectedIds.value.includes(id)
|
||
? selectedIds.value.filter((x) => x !== id)
|
||
: [...selectedIds.value, id];
|
||
}
|
||
|
||
async function deleteSelected() {
|
||
const ids = [...selectedIds.value];
|
||
if (!ids.length) return;
|
||
if (!confirm(`${toFaDigits(ids.length)} شخص انتخابشده حذف شوند؟ این کار قابل بازگشت نیست.`)) {
|
||
return;
|
||
}
|
||
busy.value = true;
|
||
pageBusy.value = true;
|
||
busyMessage.value = 'در حال حذف اشخاص…';
|
||
try {
|
||
const { data } = await api.post<{ deleted: number }>('/people/bulk-delete', { ids });
|
||
importMsg.value = `${toFaDigits(data.deleted)} شخص حذف شد.`;
|
||
selectedIds.value = [];
|
||
if (data.deleted >= pageIds.value.length && page.value > 1) setPage(page.value - 1);
|
||
else await load();
|
||
} catch {
|
||
error.value = 'حذف اشخاص ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
pageBusy.value = false;
|
||
}
|
||
}
|
||
|
||
function onPageSize(size: number) {
|
||
pageSize.value = size;
|
||
if (page.value === 1) void load();
|
||
}
|
||
|
||
const peopleSavedFilters = computed(() => ({
|
||
q: q.value.trim() || undefined,
|
||
sort: sort.value === 'name' ? undefined : sort.value,
|
||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||
source: sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||
disc: discFilter.value || undefined,
|
||
}));
|
||
|
||
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
||
const nextQ = typeof filters.q === 'string' ? filters.q : '';
|
||
q.value = nextQ;
|
||
const nextSort = filters.sort === 'newest' ? 'newest' : 'name';
|
||
const nextMood =
|
||
typeof filters.mood === 'string' &&
|
||
PERSON_MOODS.includes(filters.mood as PersonMood)
|
||
? (filters.mood as PersonMood)
|
||
: 'all';
|
||
const nextSource =
|
||
typeof filters.source === 'string' &&
|
||
PERSON_IMPORT_SOURCES.includes(filters.source as PersonImportSource)
|
||
? (filters.source as PersonImportSource)
|
||
: 'all';
|
||
patchQuery({
|
||
q: nextQ.trim() || undefined,
|
||
sort: nextSort === 'name' ? undefined : nextSort,
|
||
mood: nextMood === 'all' ? undefined : nextMood,
|
||
source: nextSource === 'all' ? undefined : nextSource,
|
||
page: undefined,
|
||
tab: undefined,
|
||
});
|
||
}
|
||
|
||
function sourceLabel(source: string) {
|
||
if (source in PERSON_IMPORT_SOURCE_LABELS) {
|
||
return PERSON_IMPORT_SOURCE_LABELS[source as PersonImportSource];
|
||
}
|
||
return source || 'دستی';
|
||
}
|
||
|
||
function moodOf(person: PersonDto): PersonMood {
|
||
return person.mood || 'newcomer';
|
||
}
|
||
|
||
function moodLabel(mood: PersonMood) {
|
||
return PERSON_MOOD_LABELS[mood];
|
||
}
|
||
|
||
/** کد شخص در نرمافزار مبدأ (دیدار و مشابه) */
|
||
function externalCode(person: PersonDto): string {
|
||
if (person.sourceRef?.trim()) return person.sourceRef.trim();
|
||
const fields = person.customFields ?? {};
|
||
for (const key of ['didarId', 'customerCode'] as const) {
|
||
const raw = fields[key];
|
||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function externalCodeLabel(person: PersonDto): string {
|
||
const code = externalCode(person);
|
||
return code ? toFaDigits(code) : '—';
|
||
}
|
||
|
||
function formatDate(value: string) {
|
||
try {
|
||
return formatJalaliNumeric(new Date(value));
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function closePopups() {
|
||
menuId.value = null;
|
||
sortOpen.value = false;
|
||
filterOpen.value = false;
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
closePopups();
|
||
try {
|
||
if (tab.value === 'people') {
|
||
const [peopleRes, statsRes] = await Promise.all([
|
||
api.get<PaginatedResponse<PersonDto>>('/people', {
|
||
params: {
|
||
q: q.value || undefined,
|
||
page: page.value,
|
||
limit: pageSize.value,
|
||
sort: sort.value,
|
||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||
importSource:
|
||
sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||
disc: discFilter.value || undefined,
|
||
},
|
||
}),
|
||
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
|
||
data: [] as PersonImportSourceStatDto[],
|
||
})),
|
||
]);
|
||
people.value = peopleRes.data;
|
||
sourceStats.value = statsRes.data;
|
||
const visible = new Set(peopleRes.data.items.map((p) => p.id));
|
||
selectedIds.value = selectedIds.value.filter((id) => visible.has(id));
|
||
} else {
|
||
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', {
|
||
params: { q: q.value || undefined, page: page.value, limit: pageSize.value },
|
||
});
|
||
companies.value = data;
|
||
}
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e) && !e.response) {
|
||
error.value = 'اتصال به API برقرار نشد.';
|
||
} else {
|
||
error.value = 'بارگذاری مخاطبین ناموفق بود.';
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function patchQuery(next: Record<string, string | undefined>) {
|
||
const query = { ...route.query, ...next };
|
||
for (const key of Object.keys(query)) {
|
||
if (query[key] == null || query[key] === '') delete query[key];
|
||
}
|
||
void router.replace({ name: 'contacts', query });
|
||
}
|
||
|
||
function setTab(next: Tab) {
|
||
patchQuery({
|
||
tab: next === 'people' ? undefined : next,
|
||
page: undefined,
|
||
mood: next === 'people' ? (moodFilter.value === 'all' ? undefined : moodFilter.value) : undefined,
|
||
});
|
||
}
|
||
|
||
function onSearch() {
|
||
patchQuery({ q: q.value.trim() || undefined, page: undefined });
|
||
}
|
||
|
||
function setSort(next: SortKey) {
|
||
sortOpen.value = false;
|
||
patchQuery({ sort: next === 'name' ? undefined : next, page: undefined });
|
||
}
|
||
|
||
function setMood(next: PersonMood | 'all') {
|
||
filterOpen.value = false;
|
||
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
|
||
}
|
||
|
||
function setDisc(next: string) {
|
||
patchQuery({ disc: next || undefined, page: undefined });
|
||
}
|
||
|
||
function setSource(next: PersonImportSource | 'all') {
|
||
patchQuery({
|
||
source: next === 'all' ? undefined : next,
|
||
page: undefined,
|
||
});
|
||
}
|
||
|
||
function setPage(next: number) {
|
||
if (next < 1 || next > pageCount.value) return;
|
||
patchQuery({ page: next === 1 ? undefined : String(next) });
|
||
}
|
||
|
||
async function setMoodFor(person: PersonDto, mood: PersonMood) {
|
||
menuId.value = null;
|
||
try {
|
||
await api.patch(`/people/${person.id}`, { mood });
|
||
await load();
|
||
} catch {
|
||
error.value = 'تغییر وضعیت مشتری ناموفق بود.';
|
||
}
|
||
}
|
||
|
||
async function exportExcel() {
|
||
busy.value = true;
|
||
pageBusy.value = true;
|
||
busyMessage.value = 'در حال تهیه خروجی اکسل…';
|
||
importMsg.value = '';
|
||
error.value = '';
|
||
try {
|
||
const path = tab.value === 'people' ? '/people/export' : '/companies/export';
|
||
const { data } = await api.get<Blob>(path, {
|
||
params: { q: q.value || undefined },
|
||
responseType: 'blob',
|
||
});
|
||
const url = URL.createObjectURL(data);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download =
|
||
tab.value === 'people' ? `${brand.filePrefix}-people.xlsx` : `${brand.filePrefix}-companies.xlsx`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
error.value = 'خروجی اکسل ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
pageBusy.value = false;
|
||
}
|
||
}
|
||
|
||
const importPreviewPath = computed(() =>
|
||
tab.value === 'people' ? '/people/import/preview' : '/companies/import/preview',
|
||
);
|
||
const importPath = computed(() =>
|
||
tab.value === 'people' ? '/people/import' : '/companies/import',
|
||
);
|
||
|
||
function onImportStart() {
|
||
busy.value = true;
|
||
pageBusy.value = false;
|
||
importMsg.value = '';
|
||
error.value = '';
|
||
}
|
||
|
||
async function onImportDone(data: ImportContactsResult) {
|
||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||
}`;
|
||
if (data.errors.length) {
|
||
error.value = data.errors
|
||
.slice(0, 3)
|
||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||
.join(' | ');
|
||
}
|
||
await load();
|
||
}
|
||
|
||
function onPhoneImportStart() {
|
||
busy.value = true;
|
||
pageBusy.value = true;
|
||
busyMessage.value = 'در حال ورود مخاطبین گوشی…';
|
||
importMsg.value = '';
|
||
error.value = '';
|
||
}
|
||
|
||
async function onPhoneImportDone(data: {
|
||
created: number;
|
||
skipped: number;
|
||
failed: number;
|
||
}) {
|
||
importMsg.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 onImportFail(message: string) {
|
||
error.value = message;
|
||
}
|
||
|
||
function onImportSettled() {
|
||
busy.value = false;
|
||
pageBusy.value = false;
|
||
}
|
||
|
||
watch(
|
||
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source],
|
||
() => {
|
||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||
void load();
|
||
},
|
||
);
|
||
|
||
onMounted(load);
|
||
|
||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||
const { token } = theme.useToken();
|
||
const screens = Grid.useBreakpoint();
|
||
const isCompact = computed(() => !screens.value.lg);
|
||
|
||
const contactModalOpen = computed({
|
||
get: () => contactModal.value !== null,
|
||
set: (open: boolean) => {
|
||
if (!open) contactModal.value = null;
|
||
},
|
||
});
|
||
|
||
const PEOPLE_COL_LABELS = [
|
||
'#',
|
||
'وضعیت',
|
||
'نام شخص',
|
||
'منبع',
|
||
'کد مبدأ',
|
||
'سمت',
|
||
'شرکت',
|
||
'تلفن',
|
||
'ایمیل',
|
||
'تاریخ ثبت',
|
||
];
|
||
const COMPANY_COL_LABELS = [
|
||
'#',
|
||
'نام شرکت',
|
||
'کد دیدار شرکت',
|
||
'کد شرکت',
|
||
'منبع',
|
||
'گروه بندی',
|
||
'استان',
|
||
'شهرستان',
|
||
'تلفن شرکت',
|
||
'ایمیل شرکت',
|
||
'شناسه ملی',
|
||
'شماره اقتصادی',
|
||
'مسئول شرکت',
|
||
'تاریخ ثبت',
|
||
'افراد',
|
||
];
|
||
registerTableColumns('contacts.people', PEOPLE_COL_LABELS);
|
||
registerTableColumns('contacts.companies', COMPANY_COL_LABELS);
|
||
const peopleCols = useTableColumns(() => 'contacts.people');
|
||
const companyCols = useTableColumns(() => 'contacts.companies');
|
||
|
||
function shownColumns<T>(cols: TableColumnsType<T>, visible: (label: string) => boolean) {
|
||
return cols.filter((col) => {
|
||
const title = typeof col.title === 'string' ? col.title : '';
|
||
if (!title || col.key === 'select' || col.key === 'actions') return true;
|
||
return visible(title);
|
||
});
|
||
}
|
||
|
||
const peopleColumns = computed<TableColumnsType<PersonDto>>(() =>
|
||
shownColumns(
|
||
[
|
||
{ title: '', key: 'select', width: 48 },
|
||
{ title: '#', key: 'row', width: 72 },
|
||
{ title: 'وضعیت', key: 'mood', width: 88 },
|
||
{ title: 'نام شخص', key: 'name' },
|
||
{ title: 'منبع', key: 'source' },
|
||
{ title: 'کد مبدأ', key: 'code', width: 120 },
|
||
{ title: 'سمت', key: 'position' },
|
||
{ title: 'شرکت', key: 'company' },
|
||
{ title: 'تلفن', key: 'phone' },
|
||
{ title: 'ایمیل', key: 'email' },
|
||
{ title: 'تاریخ ثبت', key: 'createdAt', width: 130 },
|
||
{ title: '', key: 'actions', width: 56 },
|
||
],
|
||
(label) => peopleCols.isVisible(label),
|
||
),
|
||
);
|
||
|
||
const companyColumns = computed<TableColumnsType<CompanyDto>>(() =>
|
||
shownColumns(
|
||
[
|
||
{ title: '#', key: 'row', width: 72 },
|
||
{ title: 'نام شرکت', key: 'name' },
|
||
{ title: 'کد دیدار شرکت', key: 'sourceRef' },
|
||
{ title: 'کد شرکت', key: 'code' },
|
||
{ title: 'منبع', key: 'source' },
|
||
{ title: 'گروه بندی', key: 'group' },
|
||
{ title: 'استان', key: 'province' },
|
||
{ title: 'شهرستان', key: 'city' },
|
||
{ title: 'تلفن شرکت', key: 'phone' },
|
||
{ title: 'ایمیل شرکت', key: 'email' },
|
||
{ title: 'شناسه ملی', key: 'nationalId' },
|
||
{ title: 'شماره اقتصادی', key: 'economicCode' },
|
||
{ title: 'مسئول شرکت', key: 'owner' },
|
||
{ title: 'تاریخ ثبت', key: 'createdAt', width: 130 },
|
||
{ title: 'افراد', key: 'peopleCount', width: 80 },
|
||
],
|
||
(label) => companyCols.isVisible(label),
|
||
),
|
||
);
|
||
|
||
const sortOptions = [
|
||
{ value: 'name', label: 'نام' },
|
||
{ value: 'newest', label: 'جدیدترین' },
|
||
];
|
||
|
||
const moodOptions = computed(() => [
|
||
{ value: 'all', label: 'همه' },
|
||
...PERSON_MOODS.map((mood) => ({ value: mood, label: moodLabel(mood) })),
|
||
]);
|
||
|
||
const sourceOptions = computed(() => [
|
||
{ value: 'all', label: 'همه منابع' },
|
||
...sourceStats.value.map((row) => ({
|
||
value: row.source,
|
||
label: `${row.label} (${toFaDigits(row.count)})`,
|
||
})),
|
||
]);
|
||
|
||
const discChipItems = computed(() => {
|
||
const items = [
|
||
{ value: '', label: 'همه تیپها' },
|
||
...['D', 'I', 'S', 'C'].map((value) => ({ value, label: value })),
|
||
];
|
||
if (discFilter.value && !items.some((item) => item.value === discFilter.value)) {
|
||
items.push({ value: discFilter.value, label: discFilter.value });
|
||
}
|
||
return items;
|
||
});
|
||
|
||
function moodTagColor(mood: PersonMood) {
|
||
if (mood === 'happy') return 'purple';
|
||
if (mood === 'unhappy') return 'error';
|
||
if (mood === 'special') return 'warning';
|
||
return 'success';
|
||
}
|
||
|
||
function onTabChange(event: RadioChangeEvent) {
|
||
setTab(event.target.value === 'companies' ? 'companies' : 'people');
|
||
}
|
||
|
||
function onSortChange(value: unknown) {
|
||
setSort(value === 'newest' ? 'newest' : 'name');
|
||
}
|
||
|
||
function onMoodChange(value: unknown) {
|
||
setMood(
|
||
typeof value === 'string' && PERSON_MOODS.includes(value as PersonMood)
|
||
? (value as PersonMood)
|
||
: 'all',
|
||
);
|
||
}
|
||
|
||
function onSourceChange(value: unknown) {
|
||
setSource(
|
||
typeof value === 'string' && PERSON_IMPORT_SOURCES.includes(value as PersonImportSource)
|
||
? (value as PersonImportSource)
|
||
: 'all',
|
||
);
|
||
}
|
||
|
||
function onPersonMenu(id: string, prefix: 't' | 'm', open: boolean) {
|
||
menuId.value = open ? `${prefix}:${id}` : null;
|
||
}
|
||
|
||
function onTablePage(nextPage: number, nextSize: number) {
|
||
const size = Number(nextSize);
|
||
if (size !== pageSize.value) onPageSize(size);
|
||
if (nextPage !== page.value) setPage(nextPage);
|
||
}
|
||
|
||
function pageTotalText(totalCount: number, range: [number, number]) {
|
||
return `نمایش ${toFaDigits(range[0])} تا ${toFaDigits(range[1])} از ${toFaDigits(totalCount)}`;
|
||
}
|
||
|
||
function peopleCustomRow(record: PersonDto) {
|
||
return {
|
||
style: selectedIds.value.includes(record.id)
|
||
? { background: token.value.colorPrimaryBg }
|
||
: undefined,
|
||
};
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<a-flex vertical :gap="16" @click="closePopups">
|
||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||
<div>
|
||
<a-typography-title :level="3" :style="{ margin: 0 }">مخاطبین</a-typography-title>
|
||
</div>
|
||
<a-space wrap>
|
||
<RouterLink v-if="canBehavior" :to="{ name: 'disc-report' }">
|
||
<a-button>گزارش DISC</a-button>
|
||
</RouterLink>
|
||
<a-button
|
||
:disabled="busy"
|
||
:loading="pageBusy && busyMessage.includes('خروجی')"
|
||
@click.stop="exportExcel"
|
||
>
|
||
<template #icon><DownloadOutlined /></template>
|
||
{{ isCompact ? 'خروجی' : 'خروجی اکسل' }}
|
||
</a-button>
|
||
<ExcelImportFlow
|
||
:preview-path="importPreviewPath"
|
||
:import-path="importPath"
|
||
:disabled="busy"
|
||
@start="onImportStart"
|
||
@done="onImportDone"
|
||
@fail="onImportFail"
|
||
@settled="onImportSettled"
|
||
>
|
||
<template #default="{ loading: excelLoading }">
|
||
<a-button :disabled="busy" :loading="excelLoading">
|
||
<template #icon><FileExcelOutlined /></template>
|
||
{{ isCompact ? 'ورود' : 'ورود از اکسل' }}
|
||
</a-button>
|
||
</template>
|
||
</ExcelImportFlow>
|
||
<PhoneContactsImport
|
||
v-if="tab === 'people'"
|
||
:disabled="busy"
|
||
@start="onPhoneImportStart"
|
||
@done="onPhoneImportDone"
|
||
@fail="onPhoneImportFail"
|
||
@settled="onPhoneImportSettled"
|
||
>
|
||
<a-button :disabled="busy">
|
||
<template #icon><PhoneOutlined /></template>
|
||
{{ isCompact ? 'گوشی' : 'از مخاطبین گوشی' }}
|
||
</a-button>
|
||
</PhoneContactsImport>
|
||
<a-button type="primary" @click="contactModal = tab === 'companies' ? 'company' : 'person'">
|
||
<template #icon><PlusOutlined /></template>
|
||
{{ tab === 'companies' ? 'شرکت جدید' : 'شخص جدید' }}
|
||
</a-button>
|
||
</a-space>
|
||
</a-flex>
|
||
|
||
<a-spin :spinning="pageBusy" :tip="busyMessage">
|
||
<a-card :bordered="false">
|
||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||
<a-radio-group :value="tab" button-style="solid" @change="onTabChange">
|
||
<a-radio-button value="people">
|
||
<UserOutlined />
|
||
اشخاص
|
||
<template v-if="people"> {{ toFaDigits(people.total) }}</template>
|
||
</a-radio-button>
|
||
<a-radio-button value="companies">
|
||
<BankOutlined />
|
||
شرکتها
|
||
<template v-if="companies"> {{ toFaDigits(companies.total) }}</template>
|
||
</a-radio-button>
|
||
</a-radio-group>
|
||
<TableColumnsMenu
|
||
:table-key="tab === 'people' ? 'contacts.people' : 'contacts.companies'"
|
||
/>
|
||
</a-flex>
|
||
|
||
<a-divider orientation="left">جستجو و فیلتر</a-divider>
|
||
|
||
<a-form layout="vertical" @submit.prevent="onSearch">
|
||
<a-row :gutter="[16, 16]">
|
||
<a-col :xs="24" :md="tab === 'people' ? 8 : 12">
|
||
<a-form-item label="جستجو">
|
||
<a-input-search
|
||
v-model:value="q"
|
||
allow-clear
|
||
:placeholder="tab === 'people' ? 'جستجو در اشخاص…' : 'جستجو در شرکتها…'"
|
||
style="width: 100%"
|
||
@search="onSearch"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="tab === 'people' ? 8 : 12">
|
||
<a-form-item label="مرتبسازی">
|
||
<a-select
|
||
:value="sort"
|
||
:options="sortOptions"
|
||
style="width: 100%"
|
||
@update:value="onSortChange"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col v-if="tab === 'people'" :xs="24" :md="8">
|
||
<a-form-item label="وضعیت">
|
||
<a-select
|
||
:value="moodFilter"
|
||
:options="moodOptions"
|
||
style="width: 100%"
|
||
@update:value="onMoodChange"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col v-if="tab === 'people' && sourceStats.length" :xs="24" :md="8">
|
||
<a-form-item label="منبع">
|
||
<a-select
|
||
:value="sourceFilter"
|
||
:options="sourceOptions"
|
||
style="width: 100%"
|
||
@update:value="onSourceChange"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col v-if="tab === 'people' && canBehavior" :xs="24" :md="sourceStats.length ? 16 : 24">
|
||
<a-form-item label="تیپ DISC">
|
||
<a-space wrap :size="8">
|
||
<a-button
|
||
v-for="chip in discChipItems"
|
||
:key="chip.value || 'all'"
|
||
:type="(chip.value ? discFilter === chip.value : !discFilter) ? 'primary' : 'default'"
|
||
@click="setDisc(chip.value)"
|
||
>
|
||
{{ chip.label }}
|
||
</a-button>
|
||
</a-space>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
|
||
<SavedFiltersBar
|
||
v-if="tab === 'people'"
|
||
scope="people"
|
||
:current-filters="peopleSavedFilters"
|
||
@apply="applyPeopleSavedFilters"
|
||
/>
|
||
|
||
<a-divider orientation="left">فهرست</a-divider>
|
||
|
||
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
|
||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||
|
||
<CrmSkeleton v-if="!error" :loading="loading">
|
||
<CrmSkeletonTable :rows="8" :columns="10" :card="false">
|
||
<template v-if="tab === 'people'">
|
||
<template v-if="people?.items.length">
|
||
<a-alert v-if="selectedIds.length" type="info" show-icon>
|
||
<template #message>
|
||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||
<span>{{ toFaDigits(selectedIds.length) }} شخص انتخاب شده</span>
|
||
<a-space>
|
||
<a-button type="link" @click="selectedIds = []">لغو انتخاب</a-button>
|
||
<a-button danger :disabled="busy" @click="deleteSelected">
|
||
<template #icon><DeleteOutlined /></template>
|
||
حذف انتخابشدهها
|
||
</a-button>
|
||
</a-space>
|
||
</a-flex>
|
||
</template>
|
||
</a-alert>
|
||
|
||
<a-list v-if="isCompact" :data-source="people.items" :row-key="(item: PersonDto) => item.id">
|
||
<template #renderItem="{ item }">
|
||
<a-list-item>
|
||
<a-flex align="flex-start" :gap="12" style="width: 100%">
|
||
<CustomerMoodFace :mood="moodOf(item)" :size="48" />
|
||
<div style="min-width: 0; flex: 1">
|
||
<a-flex justify="space-between" align="flex-start" :gap="8">
|
||
<a-space wrap :size="8">
|
||
<RouterLink
|
||
v-slot="{ href, navigate }"
|
||
custom
|
||
:to="{ name: 'person-detail', params: { id: item.id } }"
|
||
>
|
||
<a-typography-link :href="href" @click="navigate">{{ item.fullName }}</a-typography-link>
|
||
</RouterLink>
|
||
<DiscBadge v-if="canBehavior" :label="item.discLabel" />
|
||
</a-space>
|
||
<a-dropdown
|
||
:trigger="['click']"
|
||
:open="menuId === `m:${item.id}`"
|
||
@click.stop
|
||
@update:open="(open: boolean) => onPersonMenu(item.id, 'm', open)"
|
||
>
|
||
<a-button type="text" aria-label="عملیات" @click.stop>
|
||
<template #icon><MoreOutlined /></template>
|
||
</a-button>
|
||
<template #overlay>
|
||
<a-menu>
|
||
<a-menu-item
|
||
key="detail"
|
||
@click="
|
||
router.push({
|
||
name: 'person-detail',
|
||
params: { id: item.id },
|
||
})
|
||
"
|
||
>
|
||
<template #icon><IdcardOutlined /></template>
|
||
پرونده
|
||
</a-menu-item>
|
||
<a-menu-item
|
||
key="edit"
|
||
@click="
|
||
router.push({
|
||
name: 'person-edit',
|
||
params: { id: item.id },
|
||
})
|
||
"
|
||
>
|
||
<template #icon><EditOutlined /></template>
|
||
ویرایش
|
||
</a-menu-item>
|
||
<a-menu-item
|
||
v-for="mood in PERSON_MOODS"
|
||
:key="mood"
|
||
@click="setMoodFor(item, mood)"
|
||
>
|
||
<template #icon>
|
||
<CustomerMoodFace :mood="mood" :size="18" />
|
||
</template>
|
||
{{ moodLabel(mood) }}
|
||
</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
</a-flex>
|
||
<div>
|
||
<a-typography-text type="secondary">
|
||
{{ [item.position, item.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
|
||
</a-typography-text>
|
||
</div>
|
||
<div>
|
||
<a-typography-text type="secondary">
|
||
منبع: {{ sourceLabel(item.importSource || 'manual') }}
|
||
<template v-if="externalCode(item)"> · کد: {{ externalCodeLabel(item) }}</template>
|
||
</a-typography-text>
|
||
</div>
|
||
<div v-if="item.previousFullName">
|
||
<a-typography-text type="secondary">نام قبلی: {{ item.previousFullName }}</a-typography-text>
|
||
</div>
|
||
<div v-if="item.mobile || item.phone" dir="ltr">
|
||
<a-space :size="4">
|
||
<PhoneOutlined />
|
||
<span>{{ item.mobile || item.phone }}</span>
|
||
</a-space>
|
||
</div>
|
||
<div v-if="item.email" dir="ltr">
|
||
<a-space :size="4">
|
||
<MailOutlined />
|
||
<span>{{ item.email }}</span>
|
||
</a-space>
|
||
</div>
|
||
<a-tag :color="moodTagColor(moodOf(item))">{{ moodLabel(moodOf(item)) }}</a-tag>
|
||
</div>
|
||
</a-flex>
|
||
</a-list-item>
|
||
</template>
|
||
</a-list>
|
||
|
||
<a-table
|
||
v-else
|
||
:columns="peopleColumns"
|
||
:data-source="people.items"
|
||
:pagination="false"
|
||
:row-key="(row: PersonDto) => row.id"
|
||
:scroll="{ x: 1100 }"
|
||
:custom-row="peopleCustomRow"
|
||
>
|
||
<template #headerCell="{ column }">
|
||
<a-checkbox
|
||
v-if="column.key === 'select'"
|
||
:checked="allSelected"
|
||
:indeterminate="someSelected"
|
||
aria-label="انتخاب همه"
|
||
@change="toggleAll"
|
||
/>
|
||
<template v-else>{{ column.title }}</template>
|
||
</template>
|
||
<template #bodyCell="{ column, record, index }">
|
||
<template v-if="column.key === 'select'">
|
||
<a-checkbox
|
||
:checked="selectedIds.includes(record.id)"
|
||
:aria-label="`انتخاب ${record.fullName}`"
|
||
@click.stop
|
||
@change="toggleOne(record.id)"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'row'">
|
||
{{ toFaDigits(rowOffset + Number(index) + 1) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'mood'">
|
||
<CustomerMoodFace :mood="moodOf(record as PersonDto)" :size="34" />
|
||
</template>
|
||
<template v-else-if="column.key === 'name'">
|
||
<RouterLink
|
||
v-slot="{ href, navigate }"
|
||
custom
|
||
:to="{ name: 'person-detail', params: { id: record.id } }"
|
||
>
|
||
<a-typography-link :href="href" @click="navigate">{{ record.fullName }}</a-typography-link>
|
||
</RouterLink>
|
||
<DiscBadge v-if="canBehavior" :label="record.discLabel" />
|
||
<div v-if="record.previousFullName">
|
||
<a-typography-text type="secondary">قبلی: {{ record.previousFullName }}</a-typography-text>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'source'">
|
||
{{ sourceLabel(record.importSource || 'manual') }}
|
||
<div v-if="record.previousImportSource">
|
||
<a-typography-text type="secondary">
|
||
قبلاً: {{ sourceLabel(record.previousImportSource) }}
|
||
<bdi v-if="record.previousSourceRef">{{ toFaDigits(record.previousSourceRef) }}</bdi>
|
||
</a-typography-text>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'code'">
|
||
<span dir="ltr">{{ externalCodeLabel(record as PersonDto) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'position'">
|
||
{{ record.position || '—' }}
|
||
</template>
|
||
<template v-else-if="column.key === 'company'">
|
||
<RouterLink
|
||
v-if="record.company"
|
||
v-slot="{ href, navigate }"
|
||
custom
|
||
:to="{ name: 'company-detail', params: { id: record.company.id } }"
|
||
>
|
||
<a-typography-link :href="href" @click="navigate">{{ record.company.name }}</a-typography-link>
|
||
</RouterLink>
|
||
<a-typography-text v-else type="secondary">—</a-typography-text>
|
||
</template>
|
||
<template v-else-if="column.key === 'phone'">
|
||
<span dir="ltr">{{ record.mobile || record.phone || '—' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'email'">
|
||
<a-space v-if="record.email" :size="4">
|
||
<span dir="ltr">{{ record.email }}</span>
|
||
<MailOutlined />
|
||
</a-space>
|
||
<template v-else>—</template>
|
||
</template>
|
||
<template v-else-if="column.key === 'createdAt'">
|
||
{{ formatDate(record.createdAt) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<a-dropdown
|
||
:trigger="['click']"
|
||
:open="menuId === `t:${record.id}`"
|
||
@click.stop
|
||
@update:open="(open: boolean) => onPersonMenu(record.id, 't', open)"
|
||
>
|
||
<a-button type="text" aria-label="عملیات" @click.stop>
|
||
<template #icon><MoreOutlined /></template>
|
||
</a-button>
|
||
<template #overlay>
|
||
<a-menu>
|
||
<a-menu-item
|
||
key="detail"
|
||
@click="
|
||
router.push({
|
||
name: 'person-detail',
|
||
params: { id: record.id },
|
||
})
|
||
"
|
||
>
|
||
<template #icon><IdcardOutlined /></template>
|
||
پرونده
|
||
</a-menu-item>
|
||
<a-menu-item
|
||
key="edit"
|
||
@click="
|
||
router.push({
|
||
name: 'person-edit',
|
||
params: { id: record.id },
|
||
})
|
||
"
|
||
>
|
||
<template #icon><EditOutlined /></template>
|
||
ویرایش
|
||
</a-menu-item>
|
||
<a-menu-item
|
||
v-for="mood in PERSON_MOODS"
|
||
:key="mood"
|
||
@click="setMoodFor(record as PersonDto, mood)"
|
||
>
|
||
<template #icon>
|
||
<CustomerMoodFace :mood="mood" :size="18" />
|
||
</template>
|
||
{{ moodLabel(mood) }}
|
||
</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</template>
|
||
<a-empty
|
||
v-else-if="!loading"
|
||
description="شخصی ثبت نشده است. اولین مخاطب یا مشتری را اضافه کنید."
|
||
/>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<template v-if="companies?.items.length">
|
||
<a-list v-if="isCompact" :data-source="companies.items" :row-key="(item: CompanyDto) => item.id">
|
||
<template #renderItem="{ item }">
|
||
<a-list-item>
|
||
<a-flex align="flex-start" :gap="12">
|
||
<BankOutlined />
|
||
<div>
|
||
<RouterLink
|
||
v-slot="{ href, navigate }"
|
||
custom
|
||
:to="{ name: 'company-detail', params: { id: item.id } }"
|
||
>
|
||
<a-typography-link :href="href" @click="navigate">{{ item.name }}</a-typography-link>
|
||
</RouterLink>
|
||
<div v-if="item.phone" dir="ltr">
|
||
<a-space :size="4">
|
||
<PhoneOutlined />
|
||
<span>{{ item.phone }}</span>
|
||
</a-space>
|
||
</div>
|
||
<div v-if="item.email" dir="ltr">
|
||
<a-space :size="4">
|
||
<MailOutlined />
|
||
<span>{{ item.email }}</span>
|
||
</a-space>
|
||
</div>
|
||
<div v-if="item.sourceRef || item.code">
|
||
<a-typography-text type="secondary">
|
||
{{ item.sourceRef ? `کد دیدار: ${toFaDigits(item.sourceRef)}` : '' }}
|
||
{{ item.sourceRef && item.code ? ' • ' : '' }}
|
||
{{ item.code ? `کد: ${toFaDigits(item.code)}` : '' }}
|
||
</a-typography-text>
|
||
</div>
|
||
<div>
|
||
<a-typography-text type="secondary">
|
||
{{ [item.city, item.owner?.fullName].filter(Boolean).join(' • ') }}
|
||
{{ item.city || item.owner ? ' • ' : '' }}افراد: {{ toFaDigits(item.peopleCount) }}
|
||
</a-typography-text>
|
||
</div>
|
||
</div>
|
||
</a-flex>
|
||
</a-list-item>
|
||
</template>
|
||
</a-list>
|
||
<a-table
|
||
v-else
|
||
:columns="companyColumns"
|
||
:data-source="companies.items"
|
||
:pagination="false"
|
||
:row-key="(row: CompanyDto) => row.id"
|
||
:scroll="{ x: 1600 }"
|
||
>
|
||
<template #bodyCell="{ column, record, index }">
|
||
<template v-if="column.key === 'row'">
|
||
{{ toFaDigits(rowOffset + Number(index) + 1) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'name'">
|
||
<RouterLink
|
||
v-slot="{ href, navigate }"
|
||
custom
|
||
:to="{ name: 'company-detail', params: { id: record.id } }"
|
||
>
|
||
<a-typography-link :href="href" @click="navigate">{{ record.name }}</a-typography-link>
|
||
</RouterLink>
|
||
</template>
|
||
<template v-else-if="column.key === 'sourceRef'">
|
||
<span dir="ltr">{{ record.sourceRef ? toFaDigits(record.sourceRef) : '—' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'code'">
|
||
<span dir="ltr">{{ record.code ? toFaDigits(record.code) : '—' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'source'">
|
||
{{ sourceLabel(record.importSource || 'manual') }}
|
||
</template>
|
||
<template v-else-if="column.key === 'group'">
|
||
{{ record.groupName || '—' }}
|
||
</template>
|
||
<template v-else-if="column.key === 'province'">
|
||
{{ record.province || '—' }}
|
||
</template>
|
||
<template v-else-if="column.key === 'city'">
|
||
{{ record.city || '—' }}
|
||
</template>
|
||
<template v-else-if="column.key === 'phone'">
|
||
<span dir="ltr">{{ record.phone || '—' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'email'">
|
||
<span dir="ltr">{{ record.email || '—' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'nationalId'">
|
||
<span dir="ltr">{{ record.nationalId || '—' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'economicCode'">
|
||
<span dir="ltr">{{ record.economicCode || '—' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'owner'">
|
||
{{ record.owner?.fullName || '—' }}
|
||
</template>
|
||
<template v-else-if="column.key === 'createdAt'">
|
||
{{ formatDate(record.registeredAt || record.createdAt) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'peopleCount'">
|
||
{{ toFaDigits(record.peopleCount) }}
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</template>
|
||
<a-empty
|
||
v-else-if="!loading"
|
||
description="شرکتی ثبت نشده است. اولین شرکت یا سازمان را اضافه کنید."
|
||
/>
|
||
</template>
|
||
</CrmSkeletonTable>
|
||
</CrmSkeleton>
|
||
|
||
<a-flex v-if="total" justify="flex-start" :style="{ marginTop: `${token.margin}px` }">
|
||
<a-pagination
|
||
:current="page"
|
||
:page-size="pageSize"
|
||
:total="total"
|
||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||
show-size-changer
|
||
:show-total="pageTotalText"
|
||
@change="onTablePage"
|
||
/>
|
||
</a-flex>
|
||
</a-card>
|
||
</a-spin>
|
||
</a-flex>
|
||
|
||
<a-modal
|
||
v-model:open="contactModalOpen"
|
||
:title="contactModal === 'company' ? 'شرکت جدید' : 'شخص جدید'"
|
||
:footer="null"
|
||
:width="960"
|
||
>
|
||
<PersonFormView
|
||
v-if="contactModal === 'person'"
|
||
embedded
|
||
@saved="contactModal = null; void load()"
|
||
@cancel="contactModal = null"
|
||
/>
|
||
<CompanyFormView
|
||
v-else-if="contactModal === 'company'"
|
||
embedded
|
||
@saved="contactModal = null; void load()"
|
||
@cancel="contactModal = null"
|
||
/>
|
||
</a-modal>
|
||
</template>
|
||
|
||
|