Files
CRM_Montzar/apps/web/src/views/PersonProfileView.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +03:30

1184 lines
45 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import {
Alert as AAlert,
Badge as ABadge,
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
FloatButton as AFloatButton,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Menu as AMenu,
MenuItem as AMenuItem,
Modal,
Row as ARow,
Space as ASpace,
Tabs as ATabs,
TabPane as ATabPane,
Tag as ATag,
Timeline as ATimeline,
TimelineItem as ATimelineItem,
Typography,
} from 'ant-design-vue';
import {
CheckOutlined,
ClockCircleOutlined,
EditOutlined,
FileOutlined,
FilterOutlined,
MailOutlined,
MessageOutlined,
PhoneOutlined,
PlusOutlined,
ThunderboltOutlined,
DeleteOutlined,
} from '@/icons/ant-bridge';
import { CrmSkeletonCard } from '@/components/skeleton';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { formatCompactMoney, formatMoney } from '@/lib/money';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliNumeric,
rewriteGregorianDates,
toFaDigits,
} from '@/lib/jalali';
import {
buildSmartSummary,
isVipPerson,
purchaseProbability,
} from '@/lib/person-insights';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import { brand } from '@mento/shared';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import AskMentoPanel from '@/components/AskMentoPanel.vue';
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import MontifyStoresCard from '@/components/person-assets/MontifyStoresCard.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
DealDto,
InvoiceDto,
MemoryItemDto,
NoteAttachmentDto,
NoteDto,
PaginatedResponse,
PersonDto,
PersonAiSummaryDto,
PersonAccountingLinkDto,
PipelineBoardDto,
ProjectBoardBoardDto,
ProjectCardDto,
QuoteDto,
TicketDto,
} from '@mento/shared';
import { PERSON_GENDER_LABELS } from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
import DiscPanel from '@/components/behavior/DiscPanel.vue';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } =
Typography;
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info' | 'disc' | 'copilot';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
type TimelineRow = {
key: string;
title: string;
body?: string | null;
when: string;
tone: TimelineTone;
href: string;
at: number;
prior?: boolean;
};
type AttachmentRow = {
key: string;
noteId: string;
att: NoteAttachmentDto;
kind: 'pdf' | 'doc' | 'img' | 'file';
};
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const TABS = computed<Array<{ key: TabKey; label: string }>>(() => {
const items: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'ته‌حساب و دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
if (canBehavior.value) {
items.push(
{ key: 'disc', label: 'تحلیل رفتاری DISC' },
{ key: 'copilot', label: 'دستیار هوشمند فروش' },
);
}
return items;
});
const HISTORY_FILTERS: Array<{ key: HistoryFilter; label: string }> = [
{ key: 'all', label: 'همه' },
{ key: 'call', label: 'تماس' },
{ key: 'meeting', label: 'جلسه' },
{ key: 'task', label: 'یادآوری' },
{ key: 'note', label: 'یادداشت' },
{ key: 'quote', label: 'ایمیل' },
];
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const loading = ref(true);
const error = ref('');
const person = ref<PersonDto | null>(null);
const activities = ref<ActivityDto[]>([]);
const notes = ref<NoteDto[]>([]);
const quotes = ref<QuoteDto[]>([]);
const invoices = ref<InvoiceDto[]>([]);
const tickets = ref<TicketDto[]>([]);
const memories = ref<MemoryItemDto[]>([]);
const deals = ref<DealDto[]>([]);
const cards = ref<ProjectCardDto[]>([]);
const tahesabMoshtariCode = ref<number | null>(null);
const moreOpen = ref(false);
const smsOpen = ref(false);
const filterOpen = ref(false);
const historyFilter = ref<HistoryFilter>('all');
const tab = ref<TabKey>('activities');
const completing = ref(false);
const aiSummary = ref<PersonAiSummaryDto | null>(null);
const moreItems = computed<ProfileMoreItem[]>(() => [
{ key: 'edit', label: 'ویرایش اطلاعات', to: { name: 'person-edit', params: { id: id.value } } },
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new', query: { personId: id.value } } },
{ key: 'quote', label: 'پیش‌فاکتور جدید', to: { name: 'quote-new', query: { personId: id.value } } },
{ key: 'ticket', label: 'تیکت جدید', to: { name: 'ticket-new', query: { personId: id.value } } },
{ key: 'note', label: 'یادداشت جدید', to: { name: 'notes', query: { personId: id.value } } },
{ key: 'sms', label: 'ارسال پیامک' },
{ key: 'delete', label: 'حذف شخص', danger: true },
]);
function onMoreSelect(key: string) {
if (key === 'sms') smsOpen.value = true;
if (key === 'delete') void removePerson();
}
const fullName = computed(() => person.value?.fullName ?? 'مشتری');
const subtitle = computed(() => {
const bits = [person.value?.position, person.value?.company?.name].filter(Boolean);
return bits.join(' • ');
});
const vip = computed(() => isVipPerson(person.value));
const mood = computed(() => person.value?.mood || 'newcomer');
const moodLabel = computed(() => PERSON_MOOD_LABELS[mood.value]);
const phoneNumber = computed(() => person.value?.mobile || person.value?.phone || '');
const phoneDisplay = computed(() => (phoneNumber.value ? toFaDigits(phoneNumber.value) : '—'));
const telHref = computed(() => (phoneNumber.value ? `tel:${phoneNumber.value}` : ''));
const mailHref = computed(() =>
person.value?.email ? `mailto:${person.value.email}` : '',
);
const registeredAt = computed(() =>
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
);
const birthDateLabel = computed(() =>
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
);
const genderLabel = computed(() => {
const key = person.value?.gender;
if (key === 'male' || key === 'female' || key === 'none') return PERSON_GENDER_LABELS[key];
return '—';
});
const externalCode = computed(() => {
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
const fields = person.value?.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 '';
});
const SOURCE_LABELS: Record<string, string> = {
manual: 'دستی',
tahesab: 'ته‌حساب',
didar: 'دیدار',
excel: 'اکسل',
phone: 'مخاطبین گوشی',
lead_form: 'فرم لید',
other: 'سایر',
};
const importSourceLabel = computed(() => {
const src = person.value?.importSource || 'manual';
return SOURCE_LABELS[src] || src;
});
const previousSourceLabel = computed(() => {
const src = person.value?.previousImportSource;
if (!src) return '';
return SOURCE_LABELS[src] || src;
});
const priorArchiveOn = computed(() => Boolean(auth.user?.priorArchive));
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? null);
function isPriorActivity(row: ActivityDto) {
const cutoff = auth.user?.priorArchive?.cutoffAt;
if (!auth.user?.priorArchive || !cutoff) return false;
if (row.promotedFromPriorAt) return false;
return new Date(row.createdAt).getTime() < new Date(cutoff).getTime();
}
function isPriorDeal(row: DealDto) {
const cutoff = auth.user?.priorArchive?.cutoffAt;
if (!auth.user?.priorArchive || !cutoff) return false;
if (row.promotedFromPriorAt) return false;
return new Date(row.createdAt).getTime() < new Date(cutoff).getTime();
}
/** با لنز کار جاری فقط جاری؛ با بایگانی فقط قبلی؛ بدون قابلیت همه. */
function inActiveLensActivity(row: ActivityDto) {
if (!priorArchiveOn.value || !auth.user?.priorArchive?.cutoffAt) return true;
const prior = isPriorActivity(row);
return priorLens.value === 'archive' ? prior : !prior;
}
function inActiveLensDeal(row: DealDto) {
if (!priorArchiveOn.value || !auth.user?.priorArchive?.cutoffAt) return true;
const prior = isPriorDeal(row);
return priorLens.value === 'archive' ? prior : !prior;
}
const lensActivities = computed(() => activities.value.filter(inActiveLensActivity));
const lensDeals = computed(() => deals.value.filter(inActiveLensDeal));
const lastCallAt = computed(() => {
const calls = lensActivities.value
.filter((a) => a.type === 'call')
.map((a) =>
a.status === 'done' ? a.completedAt || a.dueAt || a.createdAt : a.createdAt,
)
.filter(Boolean)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
return calls[0] ?? null;
});
const daysSinceCall = computed(() =>
lastCallAt.value ? calendarDaysBetween(new Date(lastCallAt.value), new Date()) : null,
);
const nextActivity = computed(() => {
const now = Date.now();
return (
lensActivities.value
.filter((a) => a.status === 'open' && a.dueAt)
.sort((a, b) => new Date(a.dueAt!).getTime() - new Date(b.dueAt!).getTime())
.find((a) => new Date(a.dueAt!).getTime() >= now - 60 * 60 * 1000) ??
lensActivities.value
.filter((a) => a.status === 'open' && a.dueAt)
.sort((a, b) => new Date(a.dueAt!).getTime() - new Date(b.dueAt!).getTime())[0] ??
null
);
});
const openDeals = computed(() => lensDeals.value.filter((d) => d.status === 'open'));
const currentDeals = computed(() =>
priorLens.value === 'archive' ? [] : lensDeals.value,
);
const priorDeals = computed(() =>
priorLens.value === 'archive' ? lensDeals.value : [],
);
const lastQuote = computed(
() =>
[...quotes.value].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0] ?? null,
);
const probability = computed(() =>
purchaseProbability({
daysSinceContact: daysSinceCall.value,
openDealCount: openDeals.value.length,
quoteCount: quotes.value.length,
hasUpcoming: Boolean(nextActivity.value),
noteCount: notes.value.length,
}),
);
const isActiveCustomer = computed(() => {
if (openDeals.value.length) return true;
if (daysSinceCall.value != null && daysSinceCall.value <= 30) return true;
return false;
});
const smart = computed(() =>
buildSmartSummary({
memories: memories.value,
notes: notes.value,
lastQuoteLabel: lastQuote.value
? formatCompactMoney(lastQuote.value.totalAmount, lastQuote.value.currency)
: null,
daysSinceCall: daysSinceCall.value,
probabilityBand: probability.value.band,
}),
);
const shownSmart = computed(() => ({
text: rewriteGregorianDates(aiSummary.value?.text || smart.value.text),
cta: rewriteGregorianDates(aiSummary.value?.cta || smart.value.cta),
ctaKind: aiSummary.value?.ctaKind || smart.value.ctaKind,
}));
function typeTitle(type: string) {
if (type === 'call') return 'تماس';
if (type === 'meeting') return 'جلسه حضوری';
if (type === 'task') return 'یادآوری';
return 'یادداشت';
}
function historyWhen(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const days = calendarDaysBetween(d, new Date());
const clock = formatClockFa(d);
if (days === 0) return `امروز ${clock}`;
if (days === 1) return `دیروز ${clock}`;
return `${toFaDigits(days)} روز پیش ${clock}`;
}
function nextWhenLabel(iso: string | null | undefined) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const days = calendarDaysBetween(new Date(), d);
const clock = formatClockFa(d);
if (days === 0) return `${clock} - امروز`;
if (days === 1) return `${clock} - فردا`;
if (days === -1) return `${clock} - دیروز`;
return `${clock} - ${formatJalaliNumeric(d)}`;
}
function activityTone(a: ActivityDto): TimelineTone {
if (a.type === 'call') return 'call';
if (a.type === 'meeting') return 'meeting';
if (a.type === 'note') return 'note';
return 'task';
}
function timelineToneColor(tone: TimelineTone) {
if (tone === 'call') return 'green';
if (tone === 'meeting') return 'cyan';
if (tone === 'task') return 'blue';
if (tone === 'quote') return 'purple';
return 'gold';
}
const timeline = computed<TimelineRow[]>(() => {
const rows: TimelineRow[] = [];
for (const a of lensActivities.value) {
const at = new Date(a.dueAt || a.createdAt).getTime();
rows.push({
key: `a-${a.id}`,
title: typeTitle(a.type) || a.title,
body: a.description || a.title,
when: historyWhen(a.dueAt || a.createdAt),
tone: activityTone(a),
href: `/activities/${a.id}`,
at,
prior: isPriorActivity(a),
});
}
if (priorLens.value !== 'archive') {
for (const n of notes.value) {
rows.push({
key: `n-${n.id}`,
title: n.title?.trim() || 'یادداشت',
body: n.body,
when: historyWhen(n.createdAt),
tone: 'note',
href: '/notes',
at: new Date(n.createdAt).getTime(),
});
}
for (const q of quotes.value) {
rows.push({
key: `q-${q.id}`,
title: 'ایمیل ارسال شد',
body: `پیشنهاد شماره ${q.number} با مبلغ ${formatMoney(q.totalAmount, q.currency)} ارسال شد`,
when: historyWhen(q.createdAt),
tone: 'quote',
href: `/quotes/${q.id}`,
at: new Date(q.createdAt).getTime(),
});
}
}
return rows.sort((a, b) => b.at - a.at);
});
const visibleTimeline = computed(() => {
const rows =
historyFilter.value === 'all'
? timeline.value
: timeline.value.filter((row) => row.tone === historyFilter.value);
return rows.slice(0, 8);
});
function fileKind(name: string, mime: string): AttachmentRow['kind'] {
const n = name.toLowerCase();
if (mime.includes('pdf') || n.endsWith('.pdf')) return 'pdf';
if (n.endsWith('.doc') || n.endsWith('.docx') || mime.includes('word')) return 'doc';
if (mime.startsWith('image/')) return 'img';
return 'file';
}
const attachments = computed<AttachmentRow[]>(() =>
notes.value.flatMap((note) =>
(note.attachments ?? []).map((att) => ({
key: `${note.id}-${att.id}`,
noteId: note.id,
att,
kind: fileKind(att.fileName, att.mimeType),
})),
),
);
const activitiesEmpty = computed(() => !timeline.value.length);
const dealsEmpty = computed(() => !deals.value.length && !invoices.value.length);
const cardsEmpty = computed(() => !cards.value.length);
const infoEmpty = computed(() => {
const p = person.value;
if (!p) return true;
return (
!p.mobile &&
!p.phone &&
!p.email &&
!p.position &&
!p.website &&
!p.instagram &&
!p.birthDate &&
!p.referrer &&
!(p.addresses?.length) &&
!p.company &&
!externalCode.value &&
!p.notes?.trim() &&
!tickets.value.length
);
});
const dealStatusLabel: Record<string, string> = {
open: 'باز',
won: 'برنده',
lost: 'باخته',
};
const moodTagColor: Record<string, string> = {
happy: 'purple',
unhappy: 'red',
special: 'gold',
newcomer: 'cyan',
};
function formatSize(bytes: number) {
if (bytes < 1024) return `${toFaDigits(bytes)} B`;
if (bytes < 1024 * 1024) return `${toFaDigits((bytes / 1024).toFixed(1))} KB`;
return `${toFaDigits((bytes / (1024 * 1024)).toFixed(1))} MB`;
}
function readTab() {
const q = route.query.tab;
if (
q === 'activities' ||
q === 'tasks' ||
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
q === 'info' ||
q === 'disc' ||
q === 'copilot'
) {
if ((q === 'disc' || q === 'copilot') && !canBehavior.value) {
tab.value = 'activities';
return;
}
tab.value = q;
} else {
tab.value = 'activities';
}
}
function setTab(next: TabKey) {
tab.value = next;
void router.replace({ query: { ...route.query, tab: next === 'activities' ? undefined : next } });
}
function onTabChange(key: string | number) {
setTab(String(key) as TabKey);
}
async function load() {
if (!id.value) return;
loading.value = true;
error.value = '';
moreOpen.value = false;
filterOpen.value = false;
aiSummary.value = null;
tahesabMoshtariCode.value = null;
try {
const [personRes, actRes, noteRes, quoteRes, memRes, boardRes] = await Promise.all([
api.get<PersonDto>(`/people/${id.value}`),
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', personId: id.value, limit: 80 },
}),
api.get<PaginatedResponse<NoteDto>>('/notes', {
params: { personId: id.value, limit: 80 },
}),
api.get<PaginatedResponse<QuoteDto>>('/quotes', {
params: { personId: id.value, limit: 50 },
}),
api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
params: { personId: id.value, limit: 50 },
}),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
person.value = personRes.data;
activities.value = actRes.data.items;
notes.value = noteRes.data.items;
quotes.value = quoteRes.data.items;
memories.value = memRes.data.items;
deals.value = boardRes.data.stages
.flatMap((s) => s.deals)
.filter((d) => d.person?.id === id.value);
void loadAiSummary();
const extras = await Promise.allSettled([
api.get<PaginatedResponse<InvoiceDto>>('/invoices', {
params: { personId: id.value, limit: 50 },
}),
api.get<PaginatedResponse<TicketDto>>('/tickets', {
params: { personId: id.value, limit: 50 },
}),
api.get<ProjectBoardBoardDto>('/project-boards/board'),
api.get<PersonAccountingLinkDto[]>(
`/integrations/tahesab/people/${id.value}/links`,
),
]);
invoices.value =
extras[0].status === 'fulfilled' ? extras[0].value.data.items : [];
tickets.value =
extras[1].status === 'fulfilled' ? extras[1].value.data.items : [];
cards.value =
extras[2].status === 'fulfilled'
? extras[2].value.data.stages
.flatMap((s) => s.cards)
.filter((c) => c.person?.id === id.value)
: [];
if (extras[3].status === 'fulfilled' && extras[3].value.data.length) {
const preferred =
extras[3].value.data.find((l) => l.isDefaultConnection) ??
extras[3].value.data[0];
tahesabMoshtariCode.value = preferred?.moshtariCode ?? null;
}
} catch {
error.value = 'بارگذاری پرونده مشتری ناموفق بود.';
person.value = null;
} finally {
loading.value = false;
}
}
async function goCall() {
const number = phoneNumber.value;
if (!number) return;
try {
const { data } = await api.post<{ ok: boolean; mode: string; dialUrl: string }>(
'/integrations/voip/click-to-call',
{ number, personId: id.value },
);
if (data.mode === 'tel' && data.dialUrl) {
window.location.href = data.dialUrl;
}
} catch {
if (telHref.value) window.location.href = telHref.value;
}
}
function goAddActivity() {
void router.push({
name: 'activity-new',
query: { personId: id.value },
});
}
function onSmartCta() {
if (shownSmart.value.ctaKind === 'call' && telHref.value) {
goCall();
return;
}
void router.push({
name: 'activity-new',
query: {
personId: id.value,
type: 'call',
title: `تماس با ${fullName.value}`,
},
});
}
async function loadAiSummary() {
if (!id.value) return;
try {
const { data } = await api.get<PersonAiSummaryDto>(`/ai/people/${id.value}/summary`);
aiSummary.value = data;
} catch {
aiSummary.value = null;
}
}
async function completeNext() {
if (!nextActivity.value || completing.value) return;
completing.value = true;
error.value = '';
try {
await api.patch(`/activities/${nextActivity.value.id}/complete`);
await load();
} catch {
error.value = 'ثبت انجام فعالیت ناموفق بود.';
} finally {
completing.value = false;
}
}
function onTahesabLinkChanged(code: number | null) {
tahesabMoshtariCode.value = code;
}
async function downloadAttachment(noteId: string, att: NoteAttachmentDto) {
const { data } = await api.get<Blob>(
`/notes/${noteId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
);
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = att.fileName;
a.click();
URL.revokeObjectURL(url);
}
function removePerson() {
if (!id.value) return;
Modal.confirm({
title: 'این شخص حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود.';
}
},
});
}
function onHistoryFilter(key: string) {
historyFilter.value = key as HistoryFilter;
filterOpen.value = false;
}
watch(id, () => void load(), { immediate: true });
watch(() => route.query.tab, readTab, { immediate: true });
</script>
<template>
<a-row :gutter="[16, 16]">
<a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" gap="12">
<a-space direction="vertical" :size="0">
<RouterLink :to="{ name: 'contacts' }">
<a-typography-link>بازگشت به لیست مخاطبین</a-typography-link>
</RouterLink>
<a-typography-title :level="3" :style="{ margin: 0 }">جزئیات مخاطب</a-typography-title>
</a-space>
<a-space wrap>
<RouterLink :to="{ name: 'person-edit', params: { id } }" custom v-slot="{ navigate }">
<a-button @click="navigate">
<template #icon><EditOutlined /></template>
ویرایش
</a-button>
</RouterLink>
<a-button danger @click="removePerson">
<template #icon><DeleteOutlined /></template>
حذف
</a-button>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
</a-space>
</a-flex>
</a-col>
<a-col v-if="loading" :span="24">
<CrmSkeletonCard :loading="true" :rows="8" />
</a-col>
<a-col v-else-if="error && !person" :span="24">
<a-alert type="error" show-icon :message="error" />
</a-col>
<template v-else-if="person">
<a-col v-if="error" :span="24">
<a-alert type="error" show-icon :message="error" closable @close="error = ''" />
</a-col>
<a-col :span="24">
<a-card :bordered="false">
<a-row :gutter="[16, 16]" align="middle">
<a-col :xs="24" :md="16">
<a-space align="start" :size="16">
<CustomerMoodFace :mood="mood" :size="76" />
<div>
<a-space wrap>
<a-typography-title :level="4" :style="{ margin: 0 }">{{ fullName }}</a-typography-title>
<a-tag :color="moodTagColor[mood] || 'cyan'">{{ moodLabel }}</a-tag>
<a-tag v-if="vip" color="purple">VIP</a-tag>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
</a-space>
<a-typography-paragraph v-if="subtitle" type="secondary" :style="{ marginBottom: '8px' }">
{{ subtitle }}
</a-typography-paragraph>
<a-space wrap>
<a-tag v-if="tahesabMoshtariCode != null" color="cyan">
کد حسابداری {{ toFaDigits(String(tahesabMoshtariCode)) }}
</a-tag>
<a-tag>منبع: {{ importSourceLabel }}</a-tag>
<a-tag v-if="externalCode">
کد مبدأ: <bdi>{{ toFaDigits(externalCode) }}</bdi>
</a-tag>
<a-tag v-if="person.previousFullName">نام قبلی: {{ person.previousFullName }}</a-tag>
<a-tag v-if="previousSourceLabel" color="gold">
قبلاً در {{ previousSourceLabel }}
<template v-if="person.previousSourceRef">
— کد <bdi>{{ toFaDigits(person.previousSourceRef) }}</bdi>
</template>
</a-tag>
</a-space>
</div>
</a-space>
</a-col>
<a-col :xs="24" :md="8">
<a-space direction="vertical" style="width: 100%" :size="8">
<a-badge :status="isActiveCustomer ? 'success' : 'default'" :text="isActiveCustomer ? 'مشتری فعال' : 'غیرفعال'" />
<a-button v-if="phoneNumber" block :href="telHref || undefined" dir="ltr">
<template #icon><PhoneOutlined /></template>
{{ phoneDisplay }}
</a-button>
<a-button block :disabled="!phoneNumber" @click="smsOpen = true">
<template #icon><MessageOutlined /></template>
پیامک
</a-button>
<a-button v-if="person.email" block :href="mailHref || undefined" dir="ltr">
<template #icon><MailOutlined /></template>
{{ person.email }}
</a-button>
</a-space>
</a-col>
</a-row>
<a-descriptions :column="{ xs: 1, sm: 2, md: 3, lg: 4 }" bordered style="margin-top: 16px">
<a-descriptions-item label="گروه‌ها">
<CustomerGroupChips :groups="person.groups" empty="—" />
</a-descriptions-item>
<a-descriptions-item label="شرکت">{{ person.company?.name || '—' }}</a-descriptions-item>
<a-descriptions-item label="سمت">{{ person.position || '—' }}</a-descriptions-item>
<a-descriptions-item label="جنسیت">{{ genderLabel }}</a-descriptions-item>
<a-descriptions-item label="تاریخ تولد">{{ birthDateLabel }}</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">{{ registeredAt }}</a-descriptions-item>
<a-descriptions-item v-if="person.referrer" label="معرف">{{ person.referrer }}</a-descriptions-item>
<a-descriptions-item v-if="person.website" label="وب‌سایت">
<span dir="ltr">{{ person.website }}</span>
</a-descriptions-item>
<a-descriptions-item v-if="person.instagram" label="اینستاگرام">
<span dir="ltr">{{ person.instagram }}</span>
</a-descriptions-item>
<a-descriptions-item v-if="person.addresses?.length" label="آدرس‌ها" :span="2">
<div v-for="(addr, i) in person.addresses" :key="i">{{ addr }}</div>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
<a-col :span="24">
<a-tabs :active-key="tab" @change="onTabChange">
<a-tab-pane v-for="item in TABS" :key="item.key" :tab="item.label" />
</a-tabs>
</a-col>
<a-col v-show="tab === 'activities'" :span="24">
<ProfileEmptyState
v-if="activitiesEmpty"
title="هنوز فعالیتی ثبت نشده!"
description="برای شروع، اولین تماس، جلسه یا پیگیری این مشتری را ثبت کنید."
primary-label="افزودن اولین فعالیت"
:primary-to="{ name: 'activity-new', query: { personId: id } }"
secondary-label="افزودن اطلاعات مشتری"
:secondary-to="{ name: 'person-edit', params: { id } }"
secondary-icon="user-plus"
/>
<a-row v-else :gutter="[16, 16]">
<a-col :xs="24" :lg="15">
<a-card :bordered="false" title="تاریخچه فعالیت‌ها">
<template #extra>
<a-dropdown v-model:open="filterOpen" :trigger="['click']">
<a-button>
<template #icon><FilterOutlined /></template>
فیلتر
</a-button>
<template #overlay>
<a-menu :selected-keys="[historyFilter]" @click="({ key }) => onHistoryFilter(String(key))">
<a-menu-item v-for="item in HISTORY_FILTERS" :key="item.key">
{{ item.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<a-timeline v-if="visibleTimeline.length">
<a-timeline-item
v-for="row in visibleTimeline"
:key="row.key"
:color="timelineToneColor(row.tone)"
>
<RouterLink :to="row.href">
<a-typography-text type="secondary">{{ row.when }}</a-typography-text>
<div><a-typography-text strong>{{ row.title }}</a-typography-text></div>
<a-typography-paragraph v-if="row.body" type="secondary" :ellipsis="{ rows: 2 }" :style="{ marginBottom: 0 }">
{{ row.body }}
</a-typography-paragraph>
</RouterLink>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="موردی با این فیلتر نیست." />
<RouterLink :to="{ name: 'activities', query: { filter: 'all' } }">
<a-button type="link" block>مشاهده همه فعالیت‌ها</a-button>
</RouterLink>
</a-card>
</a-col>
<a-col :xs="24" :lg="9">
<a-space direction="vertical" style="width: 100%" :size="16">
<a-card v-if="nextActivity" :bordered="false">
<template #title>فعالیت بعدی</template>
<template #extra><a-tag color="gold">در انتظار</a-tag></template>
<a-typography-text strong>{{ typeTitle(nextActivity.type) }}</a-typography-text>
<div>
<a-space>
<ClockCircleOutlined />
<a-typography-text type="secondary">{{ nextWhenLabel(nextActivity.dueAt) }}</a-typography-text>
</a-space>
</div>
<a-typography-paragraph v-if="nextActivity.description || nextActivity.title" type="secondary">
{{ nextActivity.description || nextActivity.title }}
</a-typography-paragraph>
<a-button block type="primary" ghost :loading="completing" @click="completeNext">
<template #icon><CheckOutlined /></template>
{{ completing ? 'در حال ثبت…' : 'انجام شد' }}
</a-button>
</a-card>
<a-card :bordered="false" title="فایل‌های پیوست">
<a-list v-if="attachments.length" :data-source="attachments" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-button type="link" block style="text-align: start; height: auto; padding: 0" @click="downloadAttachment(item.noteId, item.att)">
<a-list-item-meta :title="item.att.fileName" :description="formatSize(item.att.sizeBytes)">
<template #avatar><FileOutlined /></template>
</a-list-item-meta>
</a-button>
</a-list-item>
</template>
</a-list>
<a-empty v-else description="فایلی پیوست نشده." />
</a-card>
</a-space>
</a-col>
</a-row>
</a-col>
<a-col v-show="tab === 'deals'" :span="24">
<ProfileEmptyState
v-if="dealsEmpty"
title="هنوز معامله‌ای ثبت نشده!"
description="اولین معامله این مشتری را در مسیر فروش ثبت کنید یا یک فعالیت پیگیری بسازید."
primary-label="افزودن معامله"
:primary-to="{ name: 'deal-new', query: { personId: id } }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
/>
<a-space v-if="!dealsEmpty" direction="vertical" style="width: 100%" :size="12">
<RouterLink
v-for="deal in currentDeals"
:key="deal.id"
:to="{ name: 'deal-detail', params: { id: deal.id } }"
>
<a-card :bordered="false" hoverable>
<a-flex justify="space-between" wrap="wrap" gap="8">
<a-typography-text strong>{{ deal.title }}</a-typography-text>
<a-space>
<a-typography-text type="secondary">
{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}
</a-typography-text>
<a-tag>{{ dealStatusLabel[deal.status] ?? deal.status }}</a-tag>
</a-space>
</a-flex>
</a-card>
</RouterLink>
<template v-if="priorDeals.length">
<a-typography-title :level="5">بایگانی اطلاعات قبل</a-typography-title>
<RouterLink
v-for="deal in priorDeals"
:key="deal.id"
:to="{ name: 'deal-detail', params: { id: deal.id } }"
>
<a-card :bordered="false" hoverable>
<a-flex justify="space-between" wrap="wrap" gap="8">
<a-typography-text strong>{{ deal.title }}</a-typography-text>
<a-space>
<a-typography-text type="secondary">
{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}
</a-typography-text>
<a-tag>{{ dealStatusLabel[deal.status] ?? deal.status }}</a-tag>
</a-space>
</a-flex>
</a-card>
</RouterLink>
</template>
<template v-if="invoices.length">
<a-typography-title :level="5">فاکتورها</a-typography-title>
<RouterLink
v-for="inv in invoices"
:key="inv.id"
:to="{ name: 'invoice-detail', params: { id: inv.id } }"
>
<a-card :bordered="false" hoverable>
<a-flex justify="space-between" wrap="wrap" gap="8">
<a-typography-text strong>{{ inv.number }} · {{ inv.title }}</a-typography-text>
<a-typography-text type="secondary">{{ formatMoney(inv.totalAmount, inv.currency) }}</a-typography-text>
</a-flex>
</a-card>
</RouterLink>
</template>
<RouterLink :to="{ name: 'deal-new', query: { personId: id } }">
<a-button type="link">معامله جدید</a-button>
</RouterLink>
</a-space>
</a-col>
<a-col v-show="tab === 'cards'" :span="24">
<ProfileEmptyState
v-if="cardsEmpty"
title="هنوز کارتی ثبت نشده!"
description="یک کارت پروژه برای پیگیری کار این مشتری بسازید."
primary-label="افزودن کارت"
:primary-to="{ name: 'project-card-new', query: { personId: id } }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
/>
<a-space v-if="!cardsEmpty" direction="vertical" style="width: 100%" :size="12">
<RouterLink
v-for="card in cards"
:key="card.id"
:to="{ name: 'project-card-detail', params: { id: card.id } }"
>
<a-card :bordered="false" hoverable>
<a-typography-text strong>{{ card.title }}</a-typography-text>
<a-typography-paragraph v-if="card.notes" type="secondary" :style="{ marginBottom: 0 }">
{{ card.notes }}
</a-typography-paragraph>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'project-card-new', query: { personId: id } }">
<a-button type="link">کارت جدید</a-button>
</RouterLink>
</a-space>
</a-col>
<a-col v-if="tab === 'tasks'" :span="24">
<RelatedTasksPanel :entity="{ type: 'person', id, label: fullName }" />
</a-col>
<a-col v-show="tab === 'assets'" :span="24">
<a-space direction="vertical" style="width: 100%" :size="16">
<InternalPartyBalance :person-id="id" />
<MontifyStoresCard :person-id="id" />
<PersonAssetsPanel
:person-id="id"
:person-name="fullName"
:person-phone="phoneNumber"
@link-changed="onTahesabLinkChanged"
/>
</a-space>
</a-col>
<a-col v-if="canBehavior && tab === 'disc'" :span="24">
<DiscPanel :person-id="id" @saved="load" />
</a-col>
<a-col v-if="canBehavior && tab === 'copilot'" :span="24">
<SalesCopilotPanel
:person-id="id"
:deal-id="typeof route.query.dealId === 'string' ? route.query.dealId : null"
:customer-name="fullName"
:disc-label="person.discReliable ? person.discLabel : null"
:stage-name="null"
:phone="phoneNumber"
:sms-endpoint="`/people/${id}/sms`"
:sms-history-endpoint="`/people/${id}/sms-history`"
/>
</a-col>
<a-col v-show="tab === 'info'" :span="24">
<a-space direction="vertical" style="width: 100%" :size="16">
<a-card :bordered="false">
<a-space align="start" :size="12">
<MentoAiAvatar :size="54" :show-bubble="false" />
<div>
<a-typography-title :level="5" :style="{ margin: 0 }">
<ThunderboltOutlined />
خلاصه هوشمند {{ brand.nameFa }}
</a-typography-title>
<a-typography-paragraph>{{ shownSmart.text }}</a-typography-paragraph>
</div>
</a-space>
<AskMentoPanel :person-id="id" :placeholder="`درباره ${fullName} بپرس...`" />
<a-button type="primary" ghost block style="margin-top: 12px" @click="onSmartCta">
{{ shownSmart.cta }}
</a-button>
</a-card>
<ProfileEmptyState
v-if="infoEmpty"
primary-label="افزودن اطلاعات مشتری"
:primary-to="{ name: 'person-edit', params: { id } }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
/>
<template v-if="!infoEmpty">
<a-card :bordered="false">
<a-descriptions :column="{ xs: 1, sm: 2 }" bordered>
<a-descriptions-item label="موبایل">{{ person.mobile ? toFaDigits(person.mobile) : '—' }}</a-descriptions-item>
<a-descriptions-item label="تلفن">{{ person.phone ? toFaDigits(person.phone) : '—' }}</a-descriptions-item>
<a-descriptions-item label="ایمیل">{{ person.email || '—' }}</a-descriptions-item>
<a-descriptions-item label="سمت">{{ person.position || '—' }}</a-descriptions-item>
<a-descriptions-item label="جنسیت">{{ genderLabel }}</a-descriptions-item>
<a-descriptions-item label="منبع ورود">{{ importSourceLabel }}</a-descriptions-item>
<a-descriptions-item label="کد مبدأ">
<span dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="نام قبلی">{{ person.previousFullName || '—' }}</a-descriptions-item>
<a-descriptions-item label="وب‌سایت">{{ person.website || '—' }}</a-descriptions-item>
<a-descriptions-item label="اینستاگرام">
<span dir="ltr">{{ person.instagram || '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="معرف">{{ person.referrer || '—' }}</a-descriptions-item>
<a-descriptions-item label="آدرس‌ها" :span="2">
<template v-if="person.addresses?.length">
<div v-for="(addr, i) in person.addresses" :key="i">{{ addr }}</div>
</template>
<template v-else>—</template>
</a-descriptions-item>
<a-descriptions-item label="شرکت">{{ person.company?.name || '—' }}</a-descriptions-item>
<a-descriptions-item label="مسئول">{{ person.owner?.fullName || '—' }}</a-descriptions-item>
<a-descriptions-item label="تاریخ تولد">{{ birthDateLabel }}</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">{{ registeredAt }}</a-descriptions-item>
<a-descriptions-item label="باشگاه">
{{ toFaDigits(person.loyaltyPoints) }} امتیاز
<span v-if="person.loyaltyTier"> · {{ person.loyaltyTier.name }}</span>
</a-descriptions-item>
<a-descriptions-item v-if="person.notes" label="یادداشت" :span="2">{{ person.notes }}</a-descriptions-item>
</a-descriptions>
</a-card>
<template v-if="tickets.length">
<a-typography-title :level="5">تیکت‌ها</a-typography-title>
<RouterLink
v-for="ticket in tickets"
:key="ticket.id"
:to="{ name: 'ticket-detail', params: { id: ticket.id } }"
>
<a-card :bordered="false" hoverable style="margin-bottom: 8px">
<a-flex justify="space-between" wrap="wrap" gap="8">
<a-typography-text strong>{{ ticket.number }} · {{ ticket.subject }}</a-typography-text>
<a-tag>{{ ticket.status }}</a-tag>
</a-flex>
</a-card>
</RouterLink>
</template>
<MentoMemoryPanel :person-id="id" />
<RouterLink :to="{ name: 'person-edit', params: { id } }">
<a-button type="link">ویرایش اطلاعات</a-button>
</RouterLink>
</template>
</a-space>
</a-col>
<SmsComposeModal
:open="smsOpen"
title="ارسال پیامک"
:endpoint="`/people/${id}/sms`"
:history-endpoint="`/people/${id}/sms-history`"
:name="fullName"
:phone="phoneNumber"
:record-title="fullName"
:company="person.company?.name ?? ''"
@close="smsOpen = false"
/>
<a-float-button
type="primary"
:disabled="!telHref"
:style="{ insetInlineEnd: '24px', bottom: '96px' }"
tooltip="تماس"
@click="goCall"
>
<template #icon><PhoneOutlined /></template>
</a-float-button>
<a-float-button
type="default"
:style="{ insetInlineEnd: '24px', bottom: '24px' }"
tooltip="افزودن فعالیت"
@click="goAddActivity"
>
<template #icon><PlusOutlined /></template>
</a-float-button>
</template>
</a-row>
</template>