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>
1527 lines
40 KiB
Vue
1527 lines
40 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { 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>
|
||
|