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

1777 lines
45 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import { formatCompactMoney, formatMoney } from '@/lib/money';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import {
buildSmartSummary,
isVipPerson,
purchaseProbability,
} from '@/lib/person-insights';
import UiIcon from '@/components/UiIcon.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import { brand } from '@mento/shared';
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 InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.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_MOOD_LABELS } from '@/lib/person-mood';
type TabKey = 'activities' | 'deals' | 'cards' | 'assets' | 'info';
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;
};
type AttachmentRow = {
key: string;
noteId: string;
att: NoteAttachmentDto;
kind: 'pdf' | 'doc' | 'img' | 'file';
};
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
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 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: 'delete', label: 'حذف شخص', danger: true },
]);
function onMoreSelect(key: string) {
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 emailDisplay = computed(() => person.value?.email || '—');
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 lastCallAt = computed(() => {
const calls = activities.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 (
activities.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) ??
activities.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(() => deals.value.filter((d) => d.status === 'open'));
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: aiSummary.value?.text || smart.value.text,
cta: 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 timelineIcon(tone: TimelineTone) {
if (tone === 'call') return 'phone' as const;
if (tone === 'meeting') return 'user' as const;
if (tone === 'task') return 'calendar' as const;
if (tone === 'quote') return 'mail' as const;
return 'document' as const;
}
const timeline = computed<TimelineRow[]>(() => {
const rows: TimelineRow[] = [];
for (const a of activities.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,
});
}
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.company &&
!p.notes?.trim() &&
!tickets.value.length
);
});
const dealStatusLabel: Record<string, string> = {
open: 'باز',
won: 'برنده',
lost: 'باخته',
};
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 === 'deals' ||
q === 'cards' ||
q === 'assets' ||
q === 'info'
) {
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 } });
}
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;
}
}
function closeMenus() {
moreOpen.value = false;
filterOpen.value = false;
}
function goCall() {
closeMenus();
if (telHref.value) window.location.href = telHref.value;
}
function goMail() {
closeMenus();
if (mailHref.value) window.location.href = mailHref.value;
}
function goAddActivity() {
closeMenus();
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);
}
async function removePerson() {
closeMenus();
if (!id.value || !confirm('این شخص حذف شود؟')) return;
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود.';
}
}
watch(id, () => void load(), { immediate: true });
watch(() => route.query.tab, readTab, { immediate: true });
</script>
<template>
<section class="page" @click="closeMenus">
<header class="desk-head">
<div class="title-block">
<RouterLink class="back-link" :to="{ name: 'contacts' }">
بازگشت به لیست مخاطبین
</RouterLink>
<h1>جزئیات مخاطب</h1>
</div>
<div class="desk-actions">
<RouterLink class="btn edit" :to="{ name: 'person-edit', params: { id } }">
<UiIcon name="pencil" :size="16" />
ویرایش
</RouterLink>
<button class="btn danger" type="button" @click.stop="removePerson">
<UiIcon name="trash" :size="16" />
حذف
</button>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
</div>
</header>
<header class="mob-head">
<RouterLink class="icon-btn" :to="{ name: 'contacts' }" aria-label="بازگشت">
<UiIcon name="chevron" :size="18" />
</RouterLink>
<h1>جزئیات مخاطب</h1>
<div class="mob-actions" @click.stop>
<button class="icon-btn danger" type="button" aria-label="حذف" @click="removePerson">
<UiIcon name="trash" :size="18" />
</button>
<RouterLink class="icon-btn" :to="{ name: 'person-edit', params: { id } }" aria-label="ویرایش">
<UiIcon name="pencil" :size="18" />
</RouterLink>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
</div>
</header>
<p v-if="loading" class="muted pad">در حال بارگذاری پرونده…</p>
<p v-else-if="error && !person" class="error pad">{{ error }}</p>
<template v-else-if="person">
<p v-if="error" class="error pad">{{ error }}</p>
<article class="hero desk-hero">
<div class="face-col">
<CustomerMoodFace :mood="mood" :size="104" />
<span class="mood-under" :class="mood">{{ moodLabel }}</span>
</div>
<div class="who">
<h2>
{{ fullName }}
<span v-if="tahesabMoshtariCode != null" class="tahesab-code" dir="rtl"
>کد حسابداری {{ toFaDigits(String(tahesabMoshtariCode)) }}</span
>
<span v-if="vip" class="vip-mark">VIP</span>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<p v-if="phoneNumber" class="line">
<UiIcon name="phone" :size="16" />
{{ phoneDisplay }}
</p>
<p v-if="person.email" class="line">
<UiIcon name="mail" :size="16" />
{{ person.email }}
</p>
</div>
<dl class="facts">
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
<div><dt>تلفن</dt><dd>{{ phoneDisplay }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ emailDisplay }}</dd></div>
<div><dt>تاریخ ثبت</dt><dd>{{ registeredAt }}</dd></div>
<div>
<dt>وضعیت</dt>
<dd class="status" :class="isActiveCustomer ? 'on' : 'off'">
<i />
{{ isActiveCustomer ? 'فعال' : 'غیرفعال' }}
</dd>
</div>
</dl>
</article>
<article class="hero mob-hero">
<span class="mood-badge" :class="mood">{{ moodLabel }}</span>
<div class="mob-id">
<CustomerMoodFace :mood="mood" :size="72" />
<div class="who">
<h2>
{{ fullName }}
<span v-if="tahesabMoshtariCode != null" class="tahesab-code" dir="rtl"
>کد حسابداری {{ toFaDigits(String(tahesabMoshtariCode)) }}</span
>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
</div>
</div>
<div class="contact-rows">
<div class="crow">
<UiIcon name="phone" :size="16" />
<span>{{ phoneDisplay }}</span>
<button
class="act-btn"
type="button"
:disabled="!telHref"
aria-label="تماس"
@click="goCall"
>
<UiIcon name="phone" :size="16" />
</button>
</div>
<div class="crow">
<UiIcon name="mail" :size="16" />
<span>{{ emailDisplay }}</span>
<button
class="act-btn"
type="button"
:disabled="!mailHref"
aria-label="ایمیل"
@click="goMail"
>
<UiIcon name="mail" :size="16" />
</button>
</div>
</div>
</article>
<nav class="tabs" aria-label="بخش‌های پرونده">
<button
v-for="item in TABS"
:key="item.key"
type="button"
:class="{ on: tab === item.key }"
@click="setTab(item.key)"
>
{{ item.label }}
</button>
</nav>
<div v-show="tab === 'activities'" class="panel" :class="{ 'is-empty': activitiesEmpty }">
<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"
/>
<div v-else class="act-grid">
<section class="card history">
<header class="block-head">
<h3>تاریخچه فعالیت‌ها</h3>
<div class="filter-wrap" @click.stop>
<button class="ghost-btn" type="button" @click="filterOpen = !filterOpen">
<UiIcon name="filter" :size="14" />
فیلتر
</button>
<ul v-if="filterOpen" class="filter-menu">
<li v-for="item in HISTORY_FILTERS" :key="item.key">
<button
type="button"
:class="{ on: historyFilter === item.key }"
@click="historyFilter = item.key; filterOpen = false"
>
{{ item.label }}
</button>
</li>
</ul>
</div>
</header>
<ol v-if="visibleTimeline.length" class="tl">
<li v-for="row in visibleTimeline" :key="row.key">
<RouterLink :to="row.href">
<span class="dot" :class="row.tone">
<UiIcon :name="timelineIcon(row.tone)" :size="14" />
</span>
<div>
<time>{{ row.when }}</time>
<strong>{{ row.title }}</strong>
<p v-if="row.body">{{ row.body }}</p>
</div>
</RouterLink>
</li>
</ol>
<p v-else class="empty">موردی با این فیلتر نیست.</p>
<RouterLink class="see-all" :to="{ name: 'activities', query: { filter: 'all' } }">
مشاهده همه فعالیت‌ها
</RouterLink>
</section>
<aside class="widgets">
<article v-if="nextActivity" class="card next">
<header class="block-head">
<h3>فعالیت بعدی</h3>
<span class="wait">در انتظار</span>
</header>
<div class="next-title">
<i class="next-dot" :class="nextActivity.type" />
<strong>{{ typeTitle(nextActivity.type) }}</strong>
</div>
<p class="next-when">
<UiIcon name="clock" :size="14" />
{{ nextWhenLabel(nextActivity.dueAt) }}
</p>
<p v-if="nextActivity.description || nextActivity.title" class="next-body">
{{ nextActivity.description || nextActivity.title }}
</p>
<button class="done-btn" type="button" :disabled="completing" @click="completeNext">
<UiIcon name="check-plain" :size="16" />
{{ completing ? 'در حال ثبت…' : 'انجام شد' }}
</button>
</article>
<article class="card files">
<header class="block-head">
<h3>فایل‌های پیوست</h3>
</header>
<ul v-if="attachments.length" class="file-list">
<li v-for="row in attachments" :key="row.key">
<button type="button" @click="downloadAttachment(row.noteId, row.att)">
<span class="file-ico" :class="row.kind">
<UiIcon name="document" :size="16" />
</span>
<span>
<strong>{{ row.att.fileName }}</strong>
<small>{{ formatSize(row.att.sizeBytes) }}</small>
</span>
</button>
</li>
</ul>
<p v-else class="empty">فایلی پیوست نشده.</p>
</article>
</aside>
</div>
</div>
<div v-show="tab === 'deals'" class="panel list-panel" :class="{ 'is-empty': dealsEmpty }">
<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 } }"
/>
<div v-if="!dealsEmpty">
<RouterLink
v-for="deal in deals"
:key="deal.id"
class="row-card"
:to="{ name: 'deal-detail', params: { id: deal.id } }"
>
<strong>{{ deal.title }}</strong>
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
</RouterLink>
<div v-if="invoices.length" class="subblock">
<h3>فاکتورها</h3>
<RouterLink
v-for="inv in invoices"
:key="inv.id"
class="row-card"
:to="{ name: 'invoice-detail', params: { id: inv.id } }"
>
<strong>{{ inv.number }} · {{ inv.title }}</strong>
<span>{{ formatMoney(inv.totalAmount, inv.currency) }}</span>
</RouterLink>
</div>
<RouterLink class="ghost-link" :to="{ name: 'deal-new', query: { personId: id } }">
معامله جدید
</RouterLink>
</div>
</div>
<div v-show="tab === 'cards'" class="panel list-panel" :class="{ 'is-empty': cardsEmpty }">
<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 } }"
/>
<div v-if="!cardsEmpty">
<RouterLink
v-for="card in cards"
:key="card.id"
class="row-card"
:to="{ name: 'project-card-detail', params: { id: card.id } }"
>
<strong>{{ card.title }}</strong>
<span v-if="card.notes">{{ card.notes }}</span>
</RouterLink>
<RouterLink class="ghost-link" :to="{ name: 'project-card-new', query: { personId: id } }">
کارت جدید
</RouterLink>
</div>
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
<article class="smart">
<header class="smart-head">
<MentoAiAvatar :size="54" :show-bubble="false" />
<div>
<h2>
<UiIcon name="sparkle" :size="14" />
خلاصه هوشمند {{ brand.nameFa }}
</h2>
<p>{{ shownSmart.text }}</p>
</div>
</header>
<AskMentoPanel
:person-id="id"
:placeholder="`درباره ${fullName} بپرس...`"
/>
<button class="smart-cta" type="button" @click="onSmartCta">
<span>{{ shownSmart.cta }}</span>
<UiIcon name="chevron" :size="16" />
</button>
</article>
<ProfileEmptyState
v-if="infoEmpty"
primary-label="افزودن اطلاعات مشتری"
:primary-to="{ name: 'person-edit', params: { id } }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
/>
<div v-if="!infoEmpty">
<dl>
<div><dt>موبایل</dt><dd>{{ person.mobile ? toFaDigits(person.mobile) : '—' }}</dd></div>
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
<div><dt>مسئول</dt><dd>{{ person.owner?.fullName || '—' }}</dd></div>
<div>
<dt>باشگاه</dt>
<dd>
{{ toFaDigits(person.loyaltyPoints) }} امتیاز
<span v-if="person.loyaltyTier"> · {{ person.loyaltyTier.name }}</span>
</dd>
</div>
<div v-if="person.notes" class="wide"><dt>یادداشت</dt><dd>{{ person.notes }}</dd></div>
</dl>
<div v-if="tickets.length" class="subblock">
<h3>تیکت‌ها</h3>
<RouterLink
v-for="ticket in tickets"
:key="ticket.id"
class="row-card"
:to="{ name: 'ticket-detail', params: { id: ticket.id } }"
>
<strong>{{ ticket.number }} · {{ ticket.subject }}</strong>
<span>{{ ticket.status }}</span>
</RouterLink>
</div>
<MentoMemoryPanel :person-id="id" />
<RouterLink class="ghost-link" :to="{ name: 'person-edit', params: { id } }">
ویرایش اطلاعات
</RouterLink>
</div>
</div>
</template>
<div v-if="person" class="dock">
<button class="fab-item call" type="button" :disabled="!telHref" @click="goCall">
<span class="fab-circle">
<UiIcon name="phone" :size="22" />
</span>
<span class="fab-label">تماس</span>
</button>
<button class="fab-item add" type="button" @click="goAddActivity">
<span class="fab-circle">
<UiIcon name="plus" :size="22" />
</span>
<span class="fab-label">افزودن فعالیت</span>
</button>
</div>
</section>
</template>
<style scoped>
.page {
width: 100%;
min-width: 0;
max-width: 100%;
margin: 0;
display: grid;
gap: 1rem;
}
.page > * {
min-width: 0;
max-width: 100%;
}
.desk-head {
display: none;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.title-block h1 {
margin: 0.15rem 0 0;
font-size: 1.45rem;
}
.back-link {
color: #0f766e;
font-size: 0.82rem;
font-weight: 700;
}
.desk-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: var(--radius);
padding: 0.55rem 0.9rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.btn.edit {
color: #0f766e;
border-color: #ccfbf1;
background: #f0fdfa;
}
.btn.danger {
color: #b91c1c;
border-color: #fecaca;
background: #fff;
}
.mob-head {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 0.4rem;
}
.mob-head h1 {
margin: 0;
text-align: center;
font-size: 1.02rem;
}
.mob-actions {
display: flex;
align-items: center;
gap: 0.15rem;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: none;
color: #334155;
display: grid;
place-items: center;
cursor: pointer;
}
.icon-btn.danger {
color: #b91c1c;
}
.mob-head .icon-btn:first-child :deep(.ico) {
transform: scaleX(-1);
}
.hero {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
min-width: 0;
}
.desk-hero {
display: none;
grid-template-columns: auto minmax(0, 1.2fr) minmax(240px, 0.9fr);
gap: 1.4rem;
align-items: center;
padding: 1.35rem 1.4rem;
}
.face-col {
display: grid;
justify-items: center;
gap: 0.65rem;
}
.mood-under,
.mood-badge {
border-radius: 999px;
padding: 0.22rem 0.7rem;
font-size: 0.72rem;
font-weight: 600;
background: #ecfdf5;
color: #0f766e;
}
.mood-under.happy,
.mood-badge.happy {
background: #f3e8ff;
color: #7c3aed;
}
.mood-under.unhappy,
.mood-badge.unhappy {
background: #ffe4e6;
color: #e11d48;
}
.mood-under.special,
.mood-badge.special {
background: #fef3c7;
color: #d97706;
}
.who {
min-width: 0;
}
.who h2 {
margin: 0;
font-size: 1.45rem;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.vip-mark {
font-size: 0.68rem;
background: #ede9fe;
color: #6d28d9;
border-radius: 999px;
padding: 0.12rem 0.45rem;
}
.tahesab-code {
font-size: 0.78rem;
font-weight: 700;
color: #0f766e;
background: color-mix(in srgb, #0f766e 12%, transparent);
border-radius: var(--radius);
padding: 0.12rem 0.5rem;
letter-spacing: 0;
}
.sub {
margin: 0.25rem 0 0.7rem;
color: #64748b;
font-size: 0.9rem;
}
.line {
margin: 0.35rem 0 0;
display: flex;
align-items: center;
gap: 0.45rem;
color: #0f766e;
font-weight: 700;
}
.facts {
margin: 0;
display: grid;
gap: 0.45rem;
}
.facts > div {
display: grid;
grid-template-columns: 5.6rem minmax(0, 1fr);
gap: 0.5rem;
font-size: 0.86rem;
}
.facts dt {
color: #94a3b8;
}
.facts dd {
margin: 0;
color: #0f172a;
overflow-wrap: anywhere;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 700;
}
.status i {
width: 8px;
height: 8px;
border-radius: 999px;
background: #94a3b8;
}
.status.on {
color: #15803d;
}
.status.on i {
background: #22c55e;
}
.mob-hero {
position: relative;
padding: 1.05rem 1rem 0.95rem;
display: grid;
gap: 0.85rem;
}
.mood-badge {
position: absolute;
top: 0.85rem;
inset-inline-end: 0.9rem;
}
.mob-id {
display: flex;
align-items: center;
gap: 0.8rem;
padding-inline-end: 7.5rem;
}
.mob-hero .who h2 {
font-size: 1.18rem;
}
.contact-rows {
display: grid;
gap: 0.45rem;
}
.crow {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 36px;
align-items: center;
gap: 0.55rem;
color: #0f766e;
font-weight: 700;
font-size: 0.9rem;
}
.crow span {
overflow-wrap: anywhere;
}
.act-btn {
width: 36px;
height: 36px;
border: 0;
border-radius: var(--radius);
background: #ecfdf5;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.act-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tabs {
display: flex;
gap: 0;
overflow-x: auto;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
flex: 1 1 0;
min-width: 0;
border: 0;
background: none;
padding: 0.6rem 0.2rem 0.72rem;
color: #64748b;
font: inherit;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
box-shadow: inset 0 -2px 0 transparent;
}
.tabs button.on {
color: #0f766e;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.panel.is-empty {
display: grid;
place-items: center;
min-height: min(52vh, 460px);
}
.act-grid {
display: grid;
gap: 1rem;
grid-template-areas:
'next'
'history'
'files';
}
.history {
grid-area: history;
}
.next {
grid-area: next;
}
.files {
grid-area: files;
display: none;
}
.card {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem 0.95rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.widgets {
display: contents;
}
.block-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
position: relative;
}
.block-head h3 {
margin: 0;
font-size: 0.95rem;
}
.filter-wrap {
position: relative;
}
.ghost-btn,
.ghost-link,
.see-all {
border: 0;
background: none;
color: #0f766e;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.see-all,
.ghost-link {
display: block;
text-align: center;
margin-top: 0.85rem;
padding: 0.35rem 0 0.1rem;
}
.filter-menu {
position: absolute;
inset-inline-end: 0;
top: calc(100% + 6px);
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
min-width: 8.5rem;
z-index: 4;
}
.filter-menu button {
width: 100%;
border: 0;
background: none;
text-align: start;
padding: 0.5rem 0.65rem;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
}
.filter-menu button.on,
.filter-menu button:hover {
background: #f0fdfa;
color: #0f766e;
}
.tl {
list-style: none;
margin: 0;
padding: 0;
}
.tl li {
position: relative;
}
.tl li:not(:last-child)::before {
content: '';
position: absolute;
top: 34px;
bottom: -6px;
inset-inline-start: 15px;
width: 2px;
background: #e2e8f0;
}
.tl a {
display: flex;
gap: 0.75rem;
padding: 0.5rem 0;
color: inherit;
}
.dot {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.dot.call { background: #dcfce7; color: #15803d; }
.dot.meeting { background: #d1fae5; color: #0f766e; }
.dot.task { background: #dbeafe; color: #1d4ed8; }
.dot.note { background: #fef9c3; color: #a16207; }
.dot.quote { background: #ede9fe; color: #6d28d9; }
.tl time {
display: block;
color: #94a3b8;
font-size: 0.75rem;
}
.tl strong {
display: block;
margin-top: 0.12rem;
font-size: 0.9rem;
}
.tl p {
margin: 0.18rem 0 0;
color: #64748b;
font-size: 0.8rem;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wait {
background: #fef3c7;
color: #b45309;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
padding: 0.18rem 0.55rem;
}
.next-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.02rem;
}
.next-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #ef4444;
}
.next-dot.meeting { background: #14b8a6; }
.next-dot.task { background: #2563eb; }
.next-dot.note { background: #ca8a04; }
.next-when {
margin: 0.45rem 0 0;
display: flex;
align-items: center;
gap: 0.35rem;
color: #64748b;
font-size: 0.84rem;
}
.next-body {
margin: 0.45rem 0 0;
color: #475569;
font-size: 0.84rem;
line-height: 1.65;
}
.done-btn {
margin-top: 0.85rem;
width: 100%;
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
border-radius: var(--radius);
padding: 0.62rem 0.8rem;
font: inherit;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
}
.done-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.file-list button {
width: 100%;
border: 0;
background: #f8fafc;
border-radius: var(--radius);
padding: 0.55rem 0.65rem;
display: flex;
align-items: center;
gap: 0.65rem;
text-align: start;
font: inherit;
cursor: pointer;
}
.file-list strong,
.file-list small {
display: block;
}
.file-list small {
color: #94a3b8;
font-size: 0.74rem;
margin-top: 0.1rem;
}
.file-ico {
width: 36px;
height: 36px;
border-radius: var(--radius);
display: grid;
place-items: center;
background: #fee2e2;
color: #b91c1c;
flex-shrink: 0;
}
.file-ico.doc { background: #dbeafe; color: #1d4ed8; }
.file-ico.img { background: #dcfce7; color: #15803d; }
.file-ico.file { background: #f1f5f9; color: #475569; }
.row-card {
display: grid;
gap: 0.15rem;
background: #fff;
border-radius: var(--radius);
padding: 0.85rem 0.95rem;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
}
.row-card span,
.row-card em {
color: #64748b;
font-size: 0.8rem;
font-style: normal;
}
.list-panel,
.info-panel {
display: grid;
gap: 0.65rem;
}
.info-panel dl {
margin: 0;
background: #fff;
border-radius: var(--radius);
padding: 0.4rem 1rem;
}
.info-panel dl > div {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.6rem;
padding: 0.7rem 0;
border-bottom: 1px solid #f1f5f9;
}
.info-panel dt {
color: #94a3b8;
font-size: 0.8rem;
}
.info-panel dd {
margin: 0;
}
.subblock {
display: grid;
gap: 0.5rem;
margin-top: 0.4rem;
}
.smart {
background: linear-gradient(135deg, #ecfeff, #f5f3ff);
border: 1px solid #e0e7ff;
border-radius: var(--radius);
padding: 0.95rem 1rem 0.85rem;
display: grid;
gap: 0.75rem;
}
.smart-head {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.smart-head h2 {
margin: 0 0 0.4rem;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.35rem;
color: #6d28d9;
}
.smart-head p {
margin: 0;
color: #334155;
font-size: 0.86rem;
line-height: 1.75;
}
.smart-cta {
width: 100%;
border: 0;
border-radius: var(--radius);
background: #ccfbf1;
color: #0f766e;
font-weight: 700;
padding: 0.72rem 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.smart-cta :deep(.ico) {
transform: scaleX(-1);
}
.empty,
.muted {
color: #64748b;
}
.error {
color: #b91c1c;
}
.pad {
padding: 0.4rem 0;
}
.dock {
position: fixed;
inset-inline: 1.1rem;
bottom: calc(0.7rem + env(safe-area-inset-bottom));
z-index: 12;
display: flex;
justify-content: space-between;
align-items: flex-end;
pointer-events: none;
}
.fab-item {
pointer-events: auto;
border: 0;
background: none;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.38rem;
cursor: pointer;
}
.fab-circle {
width: 56px;
height: 56px;
border-radius: 999px;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 12px 24px rgba(15, 118, 110, 0.28);
}
.fab-item.call .fab-circle {
background: #14b8a6;
}
.fab-item.add .fab-circle {
background: #0f766e;
}
.fab-label {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
color: #0f766e;
white-space: nowrap;
}
.fab-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (min-width: 960px) {
.page {
padding-bottom: 1.2rem;
gap: 1.1rem;
}
.desk-head,
.desk-hero {
display: flex;
}
.desk-hero {
display: grid;
}
.mob-head,
.mob-hero,
.dock {
display: none;
}
.files {
display: grid;
}
.act-grid {
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.9fr);
grid-template-areas: 'history widgets';
align-items: start;
}
.widgets {
display: grid;
gap: 1rem;
grid-area: widgets;
align-content: start;
}
.next,
.files {
grid-area: auto;
}
.tabs button {
flex: 0 0 auto;
padding-inline: 0.85rem;
}
.info-panel.is-empty {
min-height: auto;
}
}
@media (max-width: 599px) {
.tabs button {
font-size: 0.78rem;
padding: 0.5rem 0.1rem 0.62rem;
}
.info-panel dl > div {
grid-template-columns: 1fr;
gap: 0.12rem;
}
}
</style>