Files
CRM_Montzar/apps/web/src/views/ContactsView.vue
T

1964 lines
53 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, onBeforeUnmount, 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,
UiPagination,
UiEmptyState,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { Drawer as ADrawer } from 'ant-design-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,
} 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';
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
import CustomerGroupsManager from '@/components/CustomerGroupsManager.vue';
import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups';
import ContactsFilterPanel, { type ContactsFilterValue } from '@/components/ContactsFilterPanel.vue';
import { canManageSettings } from '@/lib/roles';
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'));
function queryList(key: string): string[] {
const raw = route.query[key];
if (typeof raw !== 'string') return [];
return [...new Set(raw.split(',').map((v) => v.trim()).filter(Boolean))];
}
function csv(list: string[]): string | undefined {
return list.length ? list.join(',') : undefined;
}
const moodFilters = computed(() =>
queryList('mood').filter((m): m is PersonMood => PERSON_MOODS.includes(m as PersonMood)),
);
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 sourceFilters = computed(() => queryList('source'));
const groupFilters = computed(() => queryList('group'));
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const canManageGroups = computed(() => canManageSettings(auth.user?.role));
const groupsManagerOpen = ref(false);
const filterValue = computed<ContactsFilterValue>(() => ({
groups: groupFilters.value,
sources: sourceFilters.value,
moods: moodFilters.value,
disc: discFilter.value,
}));
type FilterTag = { kind: 'group' | 'source' | 'mood' | 'disc'; value: string; label: string; color?: string };
const activeTags = computed<FilterTag[]>(() => {
const tags: FilterTag[] = groupFilters.value.map((id) => {
const g = customerGroups.value.find((x) => x.id === id);
return { kind: 'group', value: id, label: g?.name ?? 'گروه حذف‌شده', color: g?.color };
});
if (tab.value !== 'people') return tags;
for (const s of sourceFilters.value) {
tags.push({ kind: 'source', value: s, label: `منبع: ${sourceLabel(s)}` });
}
for (const m of moodFilters.value) {
tags.push({ kind: 'mood', value: m, label: moodLabel(m) });
}
if (discFilter.value) tags.push({ kind: 'disc', value: discFilter.value, label: `DISC: ${discFilter.value}` });
return tags;
});
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 moreOpen = ref(false);
const savedSheetOpen = ref(false);
const contactModal = ref<'person' | 'company' | null>(null);
/** هم‌راستا با بریک‌پوینت CSS صفحه (۹۰۰px) */
const mobileMq =
typeof window !== 'undefined' ? window.matchMedia('(max-width: 900px)') : null;
const isMobile = ref(mobileMq?.matches ?? false);
function onMobileChange(e: MediaQueryListEvent) {
isMobile.value = e.matches;
filterOpen.value = false;
sortOpen.value = false;
savedSheetOpen.value = false;
}
onMounted(() => mobileMq?.addEventListener('change', onMobileChange));
onBeforeUnmount(() => mobileMq?.removeEventListener('change', onMobileChange));
const SORT_OPTIONS = [
{ value: 'name', label: 'نام' },
{ value: 'newest', label: 'جدیدترین' },
];
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(() => {
const out: Record<string, string> = {};
const nextQ = q.value.trim();
if (nextQ) out.q = nextQ;
if (sort.value !== 'name') out.sort = sort.value;
const mood = csv(moodFilters.value);
if (mood) out.mood = mood;
const source = csv(sourceFilters.value);
if (source) out.source = source;
if (discFilter.value) out.disc = discFilter.value;
const group = csv(groupFilters.value);
if (group) out.group = group;
return out;
});
/** مقدار ذخیره‌شده: رشته یا آرایه → رشتهٔ کاما‌جدا برای URL */
function savedCsv(raw: unknown): string | undefined {
if (Array.isArray(raw)) {
const list = raw.map((v) => String(v).trim()).filter(Boolean);
return list.length ? list.join(',') : undefined;
}
if (typeof raw === 'string' && raw.trim()) return raw.trim();
return undefined;
}
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
const nextQ = typeof filters.q === 'string' ? filters.q : '';
q.value = nextQ;
// کلیدهای فیلتر را از نو می‌سازیم تا فیلتر قبلی روی صفحه نماند
const query: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(route.query)) {
if (['q', 'sort', 'mood', 'source', 'disc', 'group', 'page', 'tab'].includes(key)) continue;
if (value == null || value === '') continue;
if (Array.isArray(value)) {
const list = value.filter((v): v is string => typeof v === 'string' && v !== '');
if (list.length) query[key] = list;
} else if (typeof value === 'string') {
query[key] = value;
}
}
if (nextQ.trim()) query.q = nextQ.trim();
if (filters.sort === 'newest') query.sort = 'newest';
const mood = savedCsv(filters.mood);
if (mood) query.mood = mood;
const source = savedCsv(filters.source);
if (source) query.source = source;
const disc = savedCsv(filters.disc);
if (disc) query.disc = disc;
const group = savedCsv(filters.group);
if (group) query.group = group;
void router.replace({ name: 'contacts', query }).then(() => {
void load();
});
}
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;
moreOpen.value = false;
}
async function load() {
loading.value = true;
error.value = '';
closePopups();
void loadCustomerGroups(true).catch(() => undefined);
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: csv(moodFilters.value),
importSource: csv(sourceFilters.value),
disc: discFilter.value || undefined,
groupId: csv(groupFilters.value),
},
}),
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,
groupId: csv(groupFilters.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,
});
}
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 applyFilters(next: ContactsFilterValue, nextSort?: string) {
filterOpen.value = false;
const people = tab.value === 'people';
patchQuery({
group: csv(next.groups),
...(people
? { source: csv(next.sources), mood: csv(next.moods), disc: next.disc || undefined }
: {}),
// در موبایل مرتب‌سازی داخل همان شیت فیلتر است؛ یک patch تا query قبلی overwrite نشود
...(nextSort !== undefined
? { sort: nextSort === 'newest' ? 'newest' : undefined }
: {}),
page: undefined,
});
}
/** شمارنده روی دکمه فیلتر موبایل (فیلترها + مرتب‌سازی غیرپیش‌فرض) */
const mobileFilterCount = computed(
() => activeTags.value.length + (sort.value !== 'name' ? 1 : 0),
);
function openSavedSheet() {
moreOpen.value = false;
savedSheetOpen.value = true;
}
function applySavedFromSheet(filters: Record<string, unknown>) {
savedSheetOpen.value = false;
applyPeopleSavedFilters(filters);
}
function removeTag(tag: FilterTag) {
const without = (list: string[]) => csv(list.filter((v) => v !== tag.value));
if (tag.kind === 'group') patchQuery({ group: without(groupFilters.value), page: undefined });
else if (tag.kind === 'source') patchQuery({ source: without(sourceFilters.value), page: undefined });
else if (tag.kind === 'mood') patchQuery({ mood: without(moodFilters.value), page: undefined });
else patchQuery({ disc: undefined, page: undefined });
}
function clearFilters() {
patchQuery({
group: undefined,
...(tab.value === 'people' ? { source: undefined, mood: undefined, disc: undefined } : {}),
page: undefined,
});
}
async function onGroupsChanged() {
await load();
}
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, route.query.group],
() => {
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" />
<header class="page-head">
<div class="head-start">
<h1>مخاطبین</h1>
<nav class="tabs" aria-label="نوع مخاطب">
<button type="button" :class="{ on: tab === 'people' }" @click.stop="setTab('people')">
<UiIcon name="user" :size="15" />
اشخاص
<em v-if="people">{{ toFaDigits(people.total) }}</em>
</button>
<button type="button" :class="{ on: tab === 'companies' }" @click.stop="setTab('companies')">
<UiIcon name="building" :size="15" />
شرکت‌ها
<em v-if="companies">{{ toFaDigits(companies.total) }}</em>
</button>
</nav>
</div>
<div class="actions">
<div class="actions-desktop">
<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>
</div>
<div class="actions-more">
<UiDropdownMenu
:open="moreOpen"
align="end"
@update:open="(v) => (moreOpen = v)"
>
<template #trigger="{ toggle }">
<UiButton variant="ghost" size="sm" @click.stop="toggle">
بیشتر
<UiIcon name="dots-v" :size="14" />
</UiButton>
</template>
<RouterLink
v-if="canBehavior"
:to="{ name: 'disc-report' }"
@click="moreOpen = false"
>
گزارش DISC
</RouterLink>
<button type="button" :disabled="busy" @click.stop="moreOpen = false; void exportExcel()">
خروجی اکسل
</button>
<ExcelImportFlow
:preview-path="importPreviewPath"
:import-path="importPath"
:disabled="busy"
@start="moreOpen = false; onImportStart()"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<button type="button" :disabled="busy || loading">
{{ loading ? 'در حال ورود…' : 'ورود از اکسل' }}
</button>
</template>
</ExcelImportFlow>
<PhoneContactsImport
v-if="tab === 'people'"
:disabled="busy"
@start="moreOpen = false; onPhoneImportStart()"
@done="onPhoneImportDone"
@fail="onPhoneImportFail"
@settled="onPhoneImportSettled"
>
<button type="button" :disabled="busy">از مخاطبین گوشی</button>
</PhoneContactsImport>
<button v-if="tab === 'people'" type="button" @click.stop="openSavedSheet">
فیلترهای ذخیره‌شده
</button>
</UiDropdownMenu>
</div>
<UiButton
variant="primary"
size="sm"
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
>
<UiIcon name="plus" :size="16" />
{{ tab === 'companies' ? 'شرکت جدید' : 'شخص جدید' }}
</UiButton>
</div>
</header>
<!-- نوار ابزار تک‌ردیفه: جستجو + ابزار + فیلتر ذخیره‌شده -->
<div class="chrome">
<div class="search-row">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="15" />
<input
v-model="q"
type="search"
:placeholder="tab === 'people' ? 'جستجو در اشخاص…' : 'جستجو در شرکت‌ها…'"
/>
<!-- موبایل: فیلتر + مرتب‌سازی یک دکمه داخل جستجو -->
<button
v-if="isMobile"
class="search-filter"
:class="{ active: mobileFilterCount }"
type="button"
aria-label="فیلتر و مرتب‌سازی"
@click.stop="filterOpen = !filterOpen; sortOpen = false"
>
<UiIcon name="filter" :size="16" />
<b v-if="mobileFilterCount" class="tool-count">{{ toFaDigits(mobileFilterCount) }}</b>
</button>
</form>
<button
v-if="isMobile && canManageGroups"
class="tool groups-tool"
type="button"
title="مدیریت گروه‌های مشتری"
@click.stop="groupsManagerOpen = true"
>
<UiIcon name="settings" :size="15" />
<span class="tool-label">گروه‌ها</span>
</button>
</div>
<ContactsFilterPanel
v-if="isMobile"
sheet
:open="filterOpen"
:tab="tab"
:value="filterValue"
:groups="customerGroups"
:source-stats="sourceStats"
:can-behavior="canBehavior"
:sort="sort"
:sort-options="SORT_OPTIONS"
@apply="applyFilters"
@close="filterOpen = false"
/>
<div v-if="!isMobile" class="toolbar-tools">
<div class="pop">
<button class="tool" type="button" @click.stop="sortOpen = !sortOpen; filterOpen = false">
<UiIcon name="trend" :size="15" />
<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 class="pop">
<button
class="tool"
:class="{ active: activeTags.length }"
type="button"
@click.stop="filterOpen = !filterOpen; sortOpen = false"
>
<UiIcon name="filter" :size="15" />
<span class="tool-label">فیلتر</span>
<b v-if="activeTags.length" class="tool-count">{{ toFaDigits(activeTags.length) }}</b>
</button>
<ContactsFilterPanel
:open="filterOpen"
:tab="tab"
:value="filterValue"
:groups="customerGroups"
:source-stats="sourceStats"
:can-behavior="canBehavior"
@apply="applyFilters"
@close="filterOpen = false"
/>
</div>
<button
v-if="canManageGroups"
class="tool"
type="button"
title="مدیریت گروه‌های مشتری"
@click.stop="groupsManagerOpen = true"
>
<UiIcon name="settings" :size="15" />
<span class="tool-label">گروه‌ها</span>
</button>
<TableColumnsMenu :table-key="tab === 'people' ? 'contacts.people' : 'contacts.companies'" />
</div>
<div v-if="!isMobile && tab === 'people'" class="saved-slot">
<SavedFiltersBar
compact
scope="people"
:current-filters="peopleSavedFilters"
@apply="applyPeopleSavedFilters"
/>
</div>
</div>
<!-- موبایل: فیلترهای ذخیره‌شده از منوی «بیشتر» در شیت پایین -->
<a-drawer
v-if="isMobile && tab === 'people'"
v-model:open="savedSheetOpen"
placement="bottom"
title="فیلترهای ذخیره‌شده"
height="auto"
>
<SavedFiltersBar
scope="people"
:current-filters="peopleSavedFilters"
@apply="applySavedFromSheet"
/>
</a-drawer>
<div v-if="activeTags.length" class="filter-tags">
<span
v-for="tag in activeTags"
:key="`${tag.kind}:${tag.value}`"
class="filter-tag"
:style="tag.color ? { color: groupSwatch(tag.color).fg, background: groupSwatch(tag.color).bg } : undefined"
>
{{ tag.label }}
<button type="button" :aria-label="`حذف فیلتر ${tag.label}`" @click="removeTag(tag)">
<UiIcon name="x" :size="12" />
</button>
</span>
<button type="button" class="filter-clear" @click="clearFilters">پاک کردن</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">
<RouterLink class="m-main" :to="{ name: 'person-detail', params: { id: p.id } }">
<CustomerMoodFace :mood="moodOf(p)" :size="36" />
<div class="m-id">
<div class="m-title">
<span class="name" :class="{ unnamed: !p.fullName?.trim() }">
{{ p.fullName?.trim() || 'بدون نام' }}
</span>
<DiscBadge v-if="canBehavior" :label="p.discLabel" />
</div>
<div class="m-sub">
<span class="badge" :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</span>
<span v-if="p.position || p.company?.name" class="m-sub-text">
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') }}
</span>
</div>
</div>
</RouterLink>
<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" aria-label="گزینه‌ها" @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 class="m-meta">
<a
v-if="p.mobile || p.phone"
class="m-phone"
:href="`tel:${p.mobile || p.phone}`"
dir="ltr"
@click.stop
>
<UiIcon name="phone" :size="13" />
{{ p.mobile || p.phone }}
</a>
<span v-else class="m-muted">بدون شماره</span>
<span class="m-src">
{{ sourceLabel(p.importSource || 'manual') }}<template v-if="externalCode(p)"> · {{ externalCodeLabel(p) }}</template>
</span>
</div>
<div v-if="p.groups?.length || p.email || p.previousFullName" class="m-extra">
<CustomerGroupChips v-if="p.groups?.length" :groups="p.groups" />
<span v-if="p.email" class="m-line" dir="ltr">
<UiIcon name="mail" :size="12" />
<span>{{ p.email }}</span>
</span>
<span v-if="p.previousFullName" class="m-line m-muted">
نام قبلی: {{ p.previousFullName }}
</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>
<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="24" />
</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><CustomerGroupChips :groups="p.groups" empty="—" /></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 no-menu">
<RouterLink class="m-main" :to="{ name: 'company-detail', params: { id: c.id } }">
<span class="co-ico"><UiIcon name="building" :size="18" /></span>
<div class="m-id">
<div class="m-title">
<span class="name">{{ c.name }}</span>
</div>
<div v-if="c.city || c.owner" class="m-sub">
<span class="m-sub-text">
{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }}
</span>
</div>
</div>
</RouterLink>
<div class="m-meta">
<a
v-if="c.phone"
class="m-phone"
:href="`tel:${c.phone}`"
dir="ltr"
@click.stop
>
<UiIcon name="phone" :size="13" />
{{ c.phone }}
</a>
<span v-else class="m-muted">بدون شماره</span>
<span class="m-src">افراد: {{ toFaDigits(c.peopleCount) }}</span>
</div>
<div v-if="c.groups?.length || c.email || c.sourceRef || c.code" class="m-extra">
<CustomerGroupChips v-if="c.groups?.length" :groups="c.groups" />
<span v-if="c.sourceRef || c.code" class="m-line m-muted">
{{ [c.sourceRef ? `کد دیدار: ${toFaDigits(c.sourceRef)}` : '', c.code ? `کد: ${toFaDigits(c.code)}` : ''].filter(Boolean).join(' · ') }}
</span>
<span v-if="c.email" class="m-line" dir="ltr">
<UiIcon name="mail" :size="12" />
<span>{{ c.email }}</span>
</span>
</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>
<CustomerGroupChips v-if="c.groups?.length" :groups="c.groups" />
<span v-else>{{ c.groupName || '—' }}</span>
</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>
<CustomerGroupsManager
v-if="canManageGroups"
:open="groupsManagerOpen"
@close="groupsManagerOpen = false"
@changed="onGroupsChanged"
/>
</section>
</template>
<style scoped>
.page {
display: grid;
/* بدون این، ردیف‌های grid ارتفاع اضافهٔ صفحه را بین خودشان پخش می‌کنند */
align-content: start;
gap: 0.5rem;
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.45rem 0.75rem;
align-items: center;
flex-wrap: nowrap;
}
.head-start {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
flex: 1 1 auto;
}
h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.25;
}
.muted {
color: #64748b;
overflow-wrap: anywhere;
word-break: break-word;
}
.actions {
display: flex;
gap: 0.3rem;
align-items: center;
flex-wrap: nowrap;
justify-content: flex-end;
flex: 0 0 auto;
min-width: 0;
}
.actions-desktop {
display: flex;
gap: 0.3rem;
align-items: center;
flex-wrap: nowrap;
}
.actions-more {
display: none;
}
.btn-short {
display: none;
}
/* از عرض متوسط لیبل کوتاه تا اکشن‌ها به ردیف دوم نروند */
@media (max-width: 1280px) {
.actions .btn-full {
display: none;
}
.actions .btn-short {
display: inline;
}
}
.chrome {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.tabs {
display: inline-flex;
gap: 0.15rem;
padding: 0.15rem;
background: #f1f5f9;
border-radius: var(--radius);
flex-shrink: 0;
}
.tabs button {
border: 0;
background: transparent;
padding: 0.35rem 0.65rem;
font: inherit;
font-size: var(--fs-sm);
color: #64748b;
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
white-space: nowrap;
border-radius: calc(var(--radius) - 2px);
line-height: 1.2;
}
.tabs .on {
color: #0f766e;
font-weight: 600;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.tabs em {
font-style: normal;
font-size: 0.72rem;
color: #94a3b8;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.filter-tag {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.1rem 0.25rem 0.1rem 0.5rem;
border-radius: 999px;
background: #f0fdfa;
color: #0f766e;
font-size: 0.75rem;
font-weight: 600;
}
.filter-tag button {
display: inline-grid;
place-items: center;
width: 1rem;
height: 1rem;
border: 0;
border-radius: 50%;
background: rgba(15, 23, 42, 0.08);
color: inherit;
cursor: pointer;
padding: 0;
}
.filter-clear {
border: 0;
background: none;
color: #64748b;
font: inherit;
font-size: 0.75rem;
text-decoration: underline;
cursor: pointer;
}
.tool.active {
border-color: #14b8a6;
color: #0f766e;
background: #f0fdfa;
}
.tool-count {
min-width: 1.05rem;
height: 1.05rem;
border-radius: 999px;
background: #14b8a6;
color: #fff;
font-size: 0.68rem;
display: inline-grid;
place-items: center;
padding: 0 0.2rem;
}
.tiny {
font-size: 0.72rem;
margin-top: 0.1rem;
}
.toolbar-tools {
display: flex;
gap: 0.3rem;
align-items: center;
flex-shrink: 0;
}
/* دسکتاپ: wrapper شفاف تا جستجو مثل قبل آیتم مستقیم flex بماند */
.search-row {
display: contents;
}
.search-filter {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.2rem;
height: 28px;
padding: 0 0.4rem;
margin-inline-end: -0.3rem;
border: 0;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--ink-secondary);
cursor: pointer;
}
.search-filter.active {
color: #0f766e;
background: #f0fdfa;
}
.saved-slot {
/* فقط عرض؛ در ستون موبایل flex-basis نباید ارتفاع بسازد */
flex: 1 1 auto;
width: min(20rem, 100%);
min-width: 11rem;
max-width: 20rem;
margin-inline-start: auto;
height: auto;
min-height: 0;
overflow: visible;
}
.saved-slot :deep(.ant-space-compact) {
width: 100%;
max-height: var(--control-h, 36px);
}
.search {
flex: 1 1 12rem;
min-width: 10rem;
max-width: 22rem;
display: flex;
align-items: center;
gap: 0.35rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.6rem;
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.55rem;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 0.3rem;
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% + 4px);
z-index: 8;
margin: 0;
padding: 0.25rem;
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.4rem 0.55rem;
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;
font-size: var(--fs-sm);
color: #0f766e;
}
.error {
margin: 0;
font-size: var(--fs-sm);
color: #b91c1c;
}
.table-wrap {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 4px 14px 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.35rem 0.5rem;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
font-size: 0.8125rem;
line-height: 1.35;
}
th {
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
background: #f8fafc;
padding-block: 0.4rem;
}
.status {
display: flex;
align-items: center;
gap: 0.4rem;
}
.status strong {
font-size: 0.8rem;
}
.check-col {
width: 2rem;
padding-inline: 0.5rem 0.2rem;
}
.check-col input {
width: 0.95rem;
height: 0.95rem;
margin: 0;
accent-color: #0d9488;
cursor: pointer;
}
.num-col {
width: 2.2rem;
color: #94a3b8;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
padding-inline: 0.2rem;
}
tr.picked td {
background: #f0fdfa;
}
.bulk-bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.4rem 0.65rem;
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.5rem;
}
/* کارت موبایل: هدر (آواتار + نام + وضعیت) | منو، سپس ردیف تماس/منبع، سپس جزئیات اختیاری */
.m-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'main menu'
'meta meta'
'extra extra';
column-gap: 0.25rem;
background: #fff;
border: 1px solid #eef2f6;
border-radius: var(--radius);
padding: 0.55rem 0.6rem 0.5rem;
min-width: 0;
}
.m-card.no-menu {
grid-template-areas:
'main main'
'meta meta'
'extra extra';
}
.m-main {
grid-area: main;
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
color: inherit;
text-decoration: none;
}
.m-card > :deep(.ui-dd-anchor) {
grid-area: menu;
align-self: start;
margin-inline-end: -0.35rem;
margin-top: -0.2rem;
}
.m-id {
min-width: 0;
flex: 1 1 auto;
display: grid;
gap: 0.2rem;
}
.m-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.m-title .name {
min-width: 0;
font-size: 0.92rem;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-title .name.unnamed {
color: #94a3b8;
font-weight: 500;
}
.m-sub {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.m-sub-text {
min-width: 0;
font-size: 0.75rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-meta {
grid-area: meta;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.45rem;
border-top: 1px dashed #e2e8f0;
font-size: 0.78rem;
min-width: 0;
}
.m-phone {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-decoration: none;
white-space: nowrap;
}
.m-src {
min-width: 0;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-muted {
color: #94a3b8;
}
.m-extra {
grid-area: extra;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.6rem;
margin-top: 0.4rem;
font-size: 0.75rem;
min-width: 0;
}
.m-line {
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
max-width: 100%;
color: #64748b;
}
.m-line > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex-shrink: 0;
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-size: 0.68rem;
font-weight: 600;
line-height: 1.5;
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: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0f766e;
flex-shrink: 0;
}
:deep(.ui-pagination) {
padding-top: 0.35rem;
}
@media (max-width: 900px) {
.page {
gap: 0.45rem;
}
.page-head {
flex-wrap: wrap;
align-items: stretch;
}
.head-start {
width: 100%;
flex-wrap: wrap;
}
.actions {
width: 100%;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 0.35rem;
}
.actions-desktop {
display: none;
}
.actions-more {
display: inline-flex;
}
.tabs {
flex: 1 1 auto;
}
.tabs button {
flex: 1 1 auto;
justify-content: center;
}
.chrome {
flex-direction: column;
align-items: stretch;
gap: 0.35rem;
}
/* جستجو + گروه‌ها در یک ردیف، فاصله ۸px */
.search-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.search {
flex: 1 1 auto;
width: auto;
min-width: 0;
max-width: none;
}
.groups-tool {
flex-shrink: 0;
}
.toolbar-tools {
width: 100%;
flex-wrap: nowrap;
}
.toolbar-tools .pop {
flex: 1 1 auto;
}
.toolbar-tools .tool {
width: 100%;
justify-content: center;
}
.saved-slot {
flex: 0 0 auto;
width: 100%;
max-width: none;
min-width: 0;
margin-inline-start: 0;
height: auto;
}
.table-wrap {
display: none;
}
.cards {
display: grid;
}
}
@media (max-width: 520px) {
.tool-label {
display: none;
}
.toolbar-tools .tool {
width: auto;
flex: 1 1 auto;
min-width: 44px;
justify-content: center;
padding-inline: 0.5rem;
}
}
</style>