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

1775 lines
44 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 { 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 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 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">
<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" />
خلاصه هوشمند منتو
</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: 1180px;
margin: 0 auto;
padding-bottom: 6.4rem;
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: 12px;
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: 22px;
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: 800;
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: 8px;
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: 10px;
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: 18px;
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: 12px;
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: 8px;
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: 800;
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: 12px;
padding: 0.62rem 0.8rem;
font: inherit;
font-weight: 800;
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: 12px;
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: 10px;
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: 14px;
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: 16px;
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: 20px;
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: 12px;
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: 800;
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>