Files
CRM_Montzar/apps/web/src/views/ContactsView.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
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>
2026-11-05 14:46:35 +03:30

1202 lines
45 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, 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>