Files
CRM_Montzar/apps/web/src/views/ContactsView.vue
T
MahdiandCursor 002f697dfe feat: add DISC profiles and a sales copilot as an org module
Help reps capture a customer's likely behavior style and get negotiation guidance, with the feature gated from super admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:04:26 +03:30

1527 lines
40 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 { 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 {
UiBusyOverlay,
UiButton,
UiDropdownMenu,
UiModal,
UiPageHeader,
UiPagination,
UiEmptyState,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
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 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);
</script>
<template>
<section class="page" @click="closePopups">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<UiPageHeader
title="مخاطبین"
lead="مدیریت و مشاهده اشخاص و شرکت‌ها"
>
<template #actions>
<RouterLink v-if="canBehavior" :to="{ name: 'disc-report' }">
<UiButton variant="outline" size="sm">گزارش DISC</UiButton>
</RouterLink>
<UiButton variant="ghost" size="sm" :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<ExcelImportFlow
:preview-path="importPreviewPath"
:import-path="importPath"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<UiButton variant="ghost" size="sm" :disabled="busy" :loading="loading">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</template>
</ExcelImportFlow>
<PhoneContactsImport
v-if="tab === 'people'"
:disabled="busy"
@start="onPhoneImportStart"
@done="onPhoneImportDone"
@fail="onPhoneImportFail"
@settled="onPhoneImportSettled"
>
<UiButton variant="ghost" size="sm" :disabled="busy">
<UiIcon name="phone" :size="15" />
<span class="btn-full">از مخاطبین گوشی</span>
<span class="btn-short">گوشی</span>
</UiButton>
</PhoneContactsImport>
<UiButton
variant="primary"
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
>
<UiIcon name="plus" :size="16" />
{{ tab === 'companies' ? 'شرکت جدید' : 'شخص جدید' }}
</UiButton>
</template>
</UiPageHeader>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'people' }" @click.stop="setTab('people')">
<UiIcon name="user" :size="16" />
اشخاص
<em v-if="people">{{ toFaDigits(people.total) }}</em>
</button>
<button type="button" :class="{ on: tab === 'companies' }" @click.stop="setTab('companies')">
<UiIcon name="building" :size="16" />
شرکت‌ها
<em v-if="companies">{{ toFaDigits(companies.total) }}</em>
</button>
</nav>
<div class="toolbar">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="16" />
<input
v-model="q"
type="search"
:placeholder="tab === 'people' ? 'جستجو در اشخاص…' : 'جستجو در شرکت‌ها…'"
/>
</form>
<div class="toolbar-tools">
<div class="pop">
<button class="tool" type="button" @click.stop="sortOpen = !sortOpen; filterOpen = false">
<UiIcon name="trend" :size="16" />
<span class="tool-label">مرتب‌سازی</span>
</button>
<ul v-if="sortOpen" class="menu">
<li><button type="button" :class="{ on: sort === 'name' }" @click.stop="setSort('name')">نام</button></li>
<li><button type="button" :class="{ on: sort === 'newest' }" @click.stop="setSort('newest')">جدیدترین</button></li>
</ul>
</div>
<div v-if="tab === 'people'" class="pop">
<button class="tool" type="button" @click.stop="filterOpen = !filterOpen; sortOpen = false">
<UiIcon name="filter" :size="16" />
<span class="tool-label">فیلتر</span>
</button>
<ul v-if="filterOpen" class="menu">
<li>
<button type="button" :class="{ on: moodFilter === 'all' }" @click.stop="setMood('all')">همه</button>
</li>
<li v-for="mood in PERSON_MOODS" :key="mood">
<button type="button" :class="{ on: moodFilter === mood }" @click.stop="setMood(mood)">
{{ moodLabel(mood) }}
</button>
</li>
</ul>
</div>
<TableColumnsMenu :table-key="tab === 'people' ? 'contacts.people' : 'contacts.companies'" />
</div>
</div>
<SavedFiltersBar
v-if="tab === 'people'"
scope="people"
:current-filters="peopleSavedFilters"
@apply="applyPeopleSavedFilters"
/>
<div v-if="tab === 'people' && sourceStats.length" class="source-stats">
<button
type="button"
class="source-chip"
:class="{ on: sourceFilter === 'all' }"
@click="setSource('all')"
>
همه منابع
</button>
<button
v-for="row in sourceStats"
:key="row.source"
type="button"
class="source-chip"
:class="{ on: sourceFilter === row.source }"
@click="setSource(row.source as PersonImportSource)"
>
{{ row.label }}
<em>{{ toFaDigits(row.count) }}</em>
</button>
</div>
<div v-if="tab === 'people' && canBehavior" class="source-stats">
<button type="button" class="source-chip" :class="{ on: !discFilter }" @click="setDisc('')">همه تیپ‌ها</button>
<button v-for="type in ['D', 'I', 'S', 'C']" :key="type" type="button" class="source-chip" :class="{ on: discFilter === type }" @click="setDisc(type)">
{{ type }}
</button>
</div>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="tab === 'people'">
<div v-if="people?.items.length" class="cards">
<article v-for="p in people.items" :key="`m-${p.id}`" class="m-card">
<CustomerMoodFace :mood="moodOf(p)" :size="48" />
<div class="m-body">
<div class="m-top">
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<DiscBadge v-if="canBehavior" :label="p.discLabel" />
<UiDropdownMenu
:open="menuId === `m:${p.id}`"
align="end"
@update:open="(v) => (menuId = v ? `m:${p.id}` : null)"
>
<template #trigger="{ toggle }">
<button class="more" type="button" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
<button
v-for="mood in PERSON_MOODS"
:key="mood"
type="button"
@click.stop="setMoodFor(p, mood)"
>
{{ moodLabel(mood) }}
</button>
</UiDropdownMenu>
</div>
<p class="role">
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
</p>
<p class="line">
<span>منبع: {{ sourceLabel(p.importSource || 'manual') }}</span>
<span v-if="externalCode(p)"> · کد: {{ externalCodeLabel(p) }}</span>
</p>
<p v-if="p.previousFullName" class="line muted">
نام قبلی: {{ p.previousFullName }}
</p>
<p v-if="p.mobile || p.phone" class="line" dir="ltr">
<UiIcon name="phone" :size="13" />
<span>{{ p.mobile || p.phone }}</span>
</p>
<p v-if="p.email" class="line" dir="ltr">
<UiIcon name="mail" :size="13" />
<span>{{ p.email }}</span>
</p>
<span class="badge" :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</span>
</div>
</article>
</div>
<div v-if="selectedIds.length" class="bulk-bar">
<span>{{ toFaDigits(selectedIds.length) }} شخص انتخاب شده</span>
<button type="button" class="bulk-clear" @click="selectedIds = []">لغو انتخاب</button>
<UiButton variant="danger" size="sm" :disabled="busy" @click="deleteSelected">
<UiIcon name="trash" :size="15" />
حذف انتخاب‌شده‌ها
</UiButton>
</div>
<div v-if="people?.items.length" class="table-wrap">
<table v-table-cols="'contacts.people'" class="data-table">
<thead>
<tr>
<th class="check-col">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
aria-label="انتخاب همه"
@change="toggleAll"
/>
</th>
<th class="num-col">#</th>
<th>وضعیت</th>
<th>نام شخص</th>
<th>منبع</th>
<th>کد مبدأ</th>
<th>سمت</th>
<th>شرکت</th>
<th>تلفن</th>
<th>ایمیل</th>
<th>تاریخ ثبت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(p, idx) in people.items" :key="p.id" :class="{ picked: selectedIds.includes(p.id) }">
<td class="check-col">
<input
type="checkbox"
:checked="selectedIds.includes(p.id)"
:aria-label="`انتخاب ${p.fullName}`"
@change="toggleOne(p.id)"
/>
</td>
<td class="num-col">{{ toFaDigits(rowOffset + idx + 1) }}</td>
<td>
<div class="status" :title="moodLabel(moodOf(p))">
<CustomerMoodFace :mood="moodOf(p)" :size="34" />
</div>
</td>
<td>
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<DiscBadge v-if="canBehavior" :label="p.discLabel" />
<div v-if="p.previousFullName" class="muted tiny">
قبلی: {{ p.previousFullName }}
</div>
</td>
<td>
{{ sourceLabel(p.importSource || 'manual') }}
<div v-if="p.previousImportSource" class="muted tiny">
قبلاً: {{ sourceLabel(p.previousImportSource) }}
<bdi v-if="p.previousSourceRef">{{ toFaDigits(p.previousSourceRef) }}</bdi>
</div>
</td>
<td dir="ltr">{{ externalCodeLabel(p) }}</td>
<td>{{ p.position || '—' }}</td>
<td>
<RouterLink
v-if="p.company"
:to="{ name: 'company-detail', params: { id: p.company.id } }"
>
{{ p.company.name }}
</RouterLink>
<span v-else class="muted">—</span>
</td>
<td>{{ p.mobile || p.phone || '—' }}</td>
<td>
<span class="mail">
{{ p.email || '—' }}
<UiIcon v-if="p.email" name="mail" :size="14" />
</span>
</td>
<td>{{ formatDate(p.createdAt) }}</td>
<td class="ops" @click.stop>
<UiDropdownMenu
:open="menuId === `t:${p.id}`"
align="end"
@update:open="(v) => (menuId = v ? `t:${p.id}` : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="more" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
<button
v-for="mood in PERSON_MOODS"
:key="mood"
type="button"
@click.stop="setMoodFor(p, mood)"
>
{{ moodLabel(mood) }}
</button>
</UiDropdownMenu>
</td>
</tr>
</tbody>
</table>
</div>
<UiEmptyState
v-else
title="شخصی ثبت نشده است."
description="اولین مخاطب یا مشتری را اضافه کنید."
/>
</template>
<template v-else>
<div v-if="companies?.items.length" class="cards">
<article v-for="c in companies.items" :key="`mc-${c.id}`" class="m-card">
<span class="co-ico"><UiIcon name="building" :size="22" /></span>
<div class="m-body">
<div class="m-top">
<RouterLink class="name" :to="{ name: 'company-detail', params: { id: c.id } }">
{{ c.name }}
</RouterLink>
</div>
<p v-if="c.phone" class="line" dir="ltr">
<UiIcon name="phone" :size="13" />
<span>{{ c.phone }}</span>
</p>
<p v-if="c.email" class="line" dir="ltr">
<UiIcon name="mail" :size="13" />
<span>{{ c.email }}</span>
</p>
<p v-if="c.sourceRef || c.code" class="line">
<span>
{{ c.sourceRef ? `کد دیدار: ${toFaDigits(c.sourceRef)}` : '' }}
{{ c.sourceRef && c.code ? ' • ' : '' }}
{{ c.code ? `کد: ${toFaDigits(c.code)}` : '' }}
</span>
</p>
<p class="role">
{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }}
{{ c.city || c.owner ? ' • ' : '' }}افراد: {{ toFaDigits(c.peopleCount) }}
</p>
</div>
</article>
</div>
<div v-if="companies?.items.length" class="table-wrap">
<table v-table-cols="'contacts.companies'" class="data-table">
<thead>
<tr>
<th class="num-col">#</th>
<th>نام شرکت</th>
<th>کد دیدار شرکت</th>
<th>کد شرکت</th>
<th>منبع</th>
<th>گروه بندی</th>
<th>استان</th>
<th>شهرستان</th>
<th>تلفن شرکت</th>
<th>ایمیل شرکت</th>
<th>شناسه ملی</th>
<th>شماره اقتصادی</th>
<th>مسئول شرکت</th>
<th>تاریخ ثبت</th>
<th>افراد</th>
</tr>
</thead>
<tbody>
<tr v-for="(c, idx) in companies.items" :key="c.id">
<td class="num-col">{{ toFaDigits(rowOffset + idx + 1) }}</td>
<td>
<RouterLink class="name" :to="{ name: 'company-detail', params: { id: c.id } }">
{{ c.name }}
</RouterLink>
</td>
<td dir="ltr">{{ c.sourceRef ? toFaDigits(c.sourceRef) : '—' }}</td>
<td dir="ltr">{{ c.code ? toFaDigits(c.code) : '—' }}</td>
<td>{{ sourceLabel(c.importSource || 'manual') }}</td>
<td>{{ c.groupName || '—' }}</td>
<td>{{ c.province || '—' }}</td>
<td>{{ c.city || '—' }}</td>
<td dir="ltr">{{ c.phone || '—' }}</td>
<td><span class="mail">{{ c.email || '—' }}</span></td>
<td dir="ltr">{{ c.nationalId || '—' }}</td>
<td dir="ltr">{{ c.economicCode || '—' }}</td>
<td>{{ c.owner?.fullName || '—' }}</td>
<td>{{ formatDate(c.registeredAt || c.createdAt) }}</td>
<td>{{ toFaDigits(c.peopleCount) }}</td>
</tr>
</tbody>
</table>
</div>
<UiEmptyState
v-else
title="شرکتی ثبت نشده است."
description="اولین شرکت یا سازمان را اضافه کنید."
/>
</template>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="setPage"
@update:page-size="onPageSize"
/>
<UiModal
:open="Boolean(contactModal)"
:title="contactModal === 'company' ? 'شرکت جدید' : 'شخص جدید'"
@close="contactModal = null"
>
<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"
/>
</UiModal>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: clip;
box-sizing: border-box;
}
.page > * {
min-width: 0;
max-width: 100%;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 0.75rem 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.head-copy {
min-width: 0;
flex: 1 1 12rem;
}
h1 {
margin: 0;
font-size: clamp(1.3rem, 4vw, 1.7rem);
}
.lead,
.muted,
.role,
.line {
color: #64748b;
overflow-wrap: anywhere;
word-break: break-word;
}
.lead {
margin: 0.25rem 0 0;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
flex: 0 1 auto;
min-width: 0;
}
.btn-short {
display: none;
}
.add-btn {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
flex-shrink: 0;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
}
.tabs {
display: flex;
gap: 0.35rem 1.2rem;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
border: 0;
background: none;
padding: 0.55rem 0.1rem;
font: inherit;
color: #94a3b8;
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.tabs .on {
color: #0f766e;
font-weight: 600;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.tabs em {
font-style: normal;
font-size: 0.75rem;
color: #94a3b8;
}
.source-stats {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--color-border, #ddd);
background: var(--color-surface, #fff);
border-radius: 999px;
padding: 0.25rem 0.7rem;
font-size: 0.82rem;
cursor: pointer;
}
.source-chip.on {
border-color: var(--color-primary, #2563eb);
color: var(--color-primary, #2563eb);
background: color-mix(in srgb, var(--color-primary, #2563eb) 10%, transparent);
}
.source-chip em {
font-style: normal;
font-weight: 700;
}
.tiny {
font-size: 0.75rem;
margin-top: 0.15rem;
}
.toolbar {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.toolbar-tools {
display: flex;
gap: 0.45rem;
align-items: center;
flex-shrink: 0;
}
.search {
flex: 1 1 14rem;
min-width: 180px;
max-width: 400px;
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
min-height: var(--control-h);
height: var(--control-h);
color: var(--muted);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
padding: 0;
}
.tool {
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
white-space: nowrap;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.tool:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.pop {
position: relative;
}
.menu {
position: absolute;
inset-inline-start: 0;
top: calc(100% + 6px);
z-index: 8;
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
min-width: min(12rem, calc(100vw - 2rem));
max-width: calc(100vw - 1.5rem);
}
.menu button,
.menu a {
width: 100%;
display: block;
border: 0;
background: none;
padding: 0.5rem 0.65rem;
border-radius: var(--radius);
font: inherit;
text-align: start;
color: inherit;
cursor: pointer;
}
.menu .on,
.menu button:hover,
.menu a:hover {
background: #f0fdfa;
color: #0f766e;
}
.ok {
margin: 0;
color: #0f766e;
}
.error {
margin: 0;
color: #b91c1c;
}
.table-wrap {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
th,
td {
text-align: start;
padding: 0.85rem 1rem;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
th {
font-size: 0.8rem;
color: #94a3b8;
background: #f8fafc;
}
.status {
display: flex;
align-items: center;
gap: 0.55rem;
}
.status strong {
font-size: 0.82rem;
}
.check-col {
width: 2.4rem;
padding-inline: 0.75rem 0.25rem;
}
.check-col input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: #0d9488;
cursor: pointer;
}
.num-col {
width: 2.6rem;
color: #94a3b8;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
padding-inline: 0.25rem;
}
tr.picked td {
background: #f0fdfa;
}
.bulk-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.6rem 0.85rem;
margin-bottom: 0.6rem;
border: 1px solid #99f6e4;
background: #f0fdfa;
border-radius: var(--radius);
font-size: var(--fs-sm);
color: #0f766e;
font-weight: 600;
}
.bulk-bar > :last-child {
margin-inline-start: auto;
}
.bulk-clear {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: var(--fs-xs);
color: #64748b;
text-decoration: underline;
cursor: pointer;
}
.name {
color: #0f766e;
font-weight: 600;
min-width: 0;
overflow-wrap: anywhere;
}
.mail {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.ops {
position: relative;
}
.more {
border: 0;
background: none;
color: #94a3b8;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--radius);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
}
.dot.happy,
strong.happy {
color: #7c3aed;
}
.dot.happy {
background: #8b5cf6;
}
.dot.unhappy,
strong.unhappy {
color: #e11d48;
}
.dot.unhappy {
background: #fb7185;
}
.dot.special,
strong.special {
color: #d97706;
}
.dot.special {
background: #f59e0b;
}
.dot.newcomer,
strong.newcomer {
color: #0f766e;
}
.cards {
display: none;
gap: 0.7rem;
}
.m-card {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.7rem;
align-items: start;
background: #fff;
border-radius: var(--radius);
padding: 0.85rem;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
min-width: 0;
overflow: hidden;
}
.m-top {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.m-top .name {
flex: 1 1 auto;
min-width: 0;
line-height: 1.35;
}
.m-body {
min-width: 0;
display: grid;
gap: 0.28rem;
}
.role,
.line {
margin: 0;
font-size: 0.8rem;
display: flex;
align-items: flex-start;
gap: 0.3rem;
min-width: 0;
}
.line :deep(svg),
.role :deep(svg) {
flex-shrink: 0;
margin-top: 0.15rem;
}
.line span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.badge {
justify-self: start;
border-radius: 999px;
padding: 0.28rem 0.6rem;
font-size: 0.72rem;
font-weight: 600;
margin-top: 0.25rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge.newcomer {
background: #ecfdf5;
color: #0f766e;
}
.badge.happy {
background: #f3e8ff;
color: #7c3aed;
}
.badge.unhappy {
background: #ffe4e6;
color: #e11d48;
}
.badge.special {
background: #fef3c7;
color: #d97706;
}
.co-ico {
width: 48px;
height: 48px;
border-radius: 50%;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0f766e;
flex-shrink: 0;
}
.empty {
margin: 0;
padding: 1.4rem;
color: #64748b;
background: #fff;
border-radius: var(--radius);
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
color: #64748b;
font-size: 0.8rem;
}
.pages {
display: flex;
gap: 0.3rem;
align-items: center;
flex-wrap: wrap;
}
.pages button {
min-width: 32px;
height: 32px;
border: 0;
background: #fff;
border-radius: var(--radius);
cursor: pointer;
}
.pages button.on {
background: #14b8a6;
color: #fff;
}
.pages button:disabled {
opacity: 0.4;
}
@media (max-width: 900px) {
.page {
gap: 0.85rem;
}
.page-head {
align-items: center;
}
:deep(.actions) .btn-full {
display: none;
}
:deep(.actions) .btn-short {
display: inline;
}
.add-btn {
width: var(--control-h);
height: var(--control-h);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search {
flex: 0 0 auto;
width: 100%;
}
.toolbar-tools {
width: 100%;
}
.toolbar-tools .pop {
flex: 1 1 auto;
}
.toolbar-tools .tool {
width: 100%;
justify-content: center;
}
.table-wrap {
display: none;
}
.cards {
display: grid;
}
.pager {
flex-direction: column;
align-items: stretch;
text-align: center;
gap: 0.65rem;
}
.pages {
justify-content: center;
}
}
@media (max-width: 520px) {
.actions .excel-btn {
display: none;
}
.tool-label {
display: none;
}
.toolbar-tools .tool {
width: auto;
flex: 1 1 auto;
min-width: 44px;
justify-content: center;
padding-inline: 0.65rem;
}
.m-card {
padding: 0.75rem;
gap: 0.6rem;
}
}
</style>