Files
CRM_Montzar/apps/web/src/views/LeadDetailView.vue
T
MahdiandCursor 002f697dfe feat: add DISC profiles and a sales copilot as an org module
Help reps capture a customer's likely behavior style and get negotiation guidance, with the feature gated from super admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:04:26 +03:30

1415 lines
38 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatClockFa, formatJalaliNumeric, formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
import type {
ActivityDto,
ConvertLeadResult,
LeadDto,
LeadEventDto,
LeadStageDto,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { canUseBehavior } from '@/lib/behavior-access';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
type LeadDetail = LeadDto & { events: LeadEventDto[] };
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const canBehavior = computed(() => canUseBehavior(auth.user));
const copilotOpen = ref(false);
const id = computed(() => String(route.params.id));
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const okMsg = ref('');
const lead = ref<LeadDetail | null>(null);
const stages = ref<LeadStageDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const activities = ref<ActivityDto[]>([]);
const stageId = ref('');
const assigneeId = ref('');
const callOutcome = ref('');
const callBody = ref('');
const callFollowUp = ref('');
const noteBody = ref('');
const followUpAt = ref('');
const followUpBody = ref('');
const editing = ref(false);
const moreOpen = ref(false);
const smsOpen = ref(false);
const copied = ref(false);
const narrow = ref(typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches);
let narrowMq: MediaQueryList | null = null;
const editForm = ref({
title: '',
contactName: '',
mobile: '',
phone: '',
city: '',
region: '',
source: '',
instagram: '',
birthDate: '',
trade: '',
notes: '',
});
function formatDate(value: string | null | undefined) {
if (!value) return '—';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
if (!e.response) return 'اتصال به API برقرار نشد.';
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [leadRes, stagesRes, usersRes, actRes] = await Promise.all([
api.get<LeadDetail>(`/leads/${id.value}`),
api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api
.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', leadId: id.value, limit: 30 },
})
.catch(() => null),
]);
lead.value = leadRes.data;
activities.value = actRes?.data.items ?? [];
stages.value = stagesRes.data;
users.value = usersRes.data.filter((u) => u.isActive !== false);
stageId.value = leadRes.data.stageId;
assigneeId.value = leadRes.data.assigneeId ?? '';
} catch (e) {
error.value = apiError(e, 'بارگذاری لید ناموفق بود.');
} finally {
loading.value = false;
}
}
function startEdit() {
if (!lead.value) return;
editing.value = true;
editForm.value = {
title: lead.value.title,
contactName: lead.value.contactName ?? '',
mobile: lead.value.mobile,
phone: lead.value.phone ?? '',
city: lead.value.city ?? '',
region: lead.value.region ?? '',
source: lead.value.source ?? '',
instagram: lead.value.instagram ?? '',
birthDate: lead.value.birthDate ? formatLocalDateInput(new Date(lead.value.birthDate)) : '',
trade: lead.value.trade ?? '',
notes: lead.value.notes ?? '',
};
}
async function saveEdit() {
if (!editForm.value.title.trim() || !editForm.value.mobile.trim()) {
error.value = 'عنوان و موبایل الزامی است.';
return;
}
busy.value = true;
error.value = '';
try {
await api.patch(`/leads/${id.value}`, {
title: editForm.value.title.trim(),
contactName: editForm.value.contactName.trim() || null,
mobile: editForm.value.mobile.trim(),
phone: editForm.value.phone.trim() || null,
city: editForm.value.city.trim() || null,
region: editForm.value.region.trim() || null,
source: editForm.value.source.trim() || null,
instagram: editForm.value.instagram.trim() || null,
birthDate: editForm.value.birthDate
? parseLocalInput(editForm.value.birthDate)?.toISOString() ?? null
: null,
trade: editForm.value.trade.trim() || null,
notes: editForm.value.notes.trim() || null,
});
okMsg.value = 'لید ویرایش شد.';
editing.value = false;
await load();
} catch (e) {
error.value = apiError(e, 'ویرایش لید ناموفق بود.');
} finally {
busy.value = false;
}
}
async function changeStage() {
if (!stageId.value || stageId.value === lead.value?.stageId) return;
busy.value = true;
error.value = '';
try {
await api.patch(`/leads/${id.value}/move`, { stageId: stageId.value });
okMsg.value = 'وضعیت به‌روز شد.';
await load();
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ناموفق بود.');
} finally {
busy.value = false;
}
}
async function assignLead() {
if (!assigneeId.value) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/assign', {
leadIds: [id.value],
assigneeId: assigneeId.value,
});
okMsg.value = 'کارشناس تخصیص داده شد.';
await load();
} catch (e) {
error.value = apiError(e, 'تخصیص ناموفق بود.');
} finally {
busy.value = false;
}
}
function localToIso(value: string): string | undefined {
const parsed = parseLocalInput(value);
if (!parsed || Number.isNaN(parsed.getTime())) return undefined;
return parsed.toISOString();
}
async function logCall() {
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/call`, {
outcome: callOutcome.value || undefined,
body: callBody.value || undefined,
nextFollowUpAt: callFollowUp.value ? localToIso(callFollowUp.value) : undefined,
});
callOutcome.value = '';
callBody.value = '';
callFollowUp.value = '';
okMsg.value = 'تماس ثبت شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ثبت تماس ناموفق بود.');
} finally {
busy.value = false;
}
}
async function addNote() {
if (!noteBody.value.trim()) return;
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/notes`, { body: noteBody.value.trim() });
noteBody.value = '';
okMsg.value = 'یادداشت ثبت شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ثبت یادداشت ناموفق بود.');
} finally {
busy.value = false;
}
}
async function setFollowUp() {
const nextFollowUpAt = localToIso(followUpAt.value);
if (!nextFollowUpAt) return;
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/follow-up`, {
nextFollowUpAt,
body: followUpBody.value || undefined,
});
followUpAt.value = '';
followUpBody.value = '';
okMsg.value = 'پیگیری تنظیم شد.';
await load();
} catch (e) {
error.value = apiError(e, 'تنظیم پیگیری ناموفق بود.');
} finally {
busy.value = false;
}
}
async function convertLead() {
if (!confirm('این لید به مشتری تبدیل شود؟')) return;
busy.value = true;
error.value = '';
try {
const { data } = await api.post<ConvertLeadResult>(`/leads/${id.value}/convert`, {
createIfMissing: true,
});
okMsg.value = data.createdPerson
? 'لید تبدیل و شخص جدید ساخته شد.'
: 'لید به مشتری موجود متصل شد.';
await load();
if (data.personId) {
void router.push({ name: 'person-detail', params: { id: data.personId } });
}
} catch (e) {
error.value = apiError(e, 'تبدیل به مشتری ناموفق بود.');
} finally {
busy.value = false;
}
}
async function archiveLead() {
if (!confirm('این لید بایگانی شود؟')) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/archive', { leadIds: [id.value] });
okMsg.value = 'لید بایگانی شد.';
void router.push({ name: 'leads' });
} catch (e) {
error.value = apiError(e, 'بایگانی ناموفق بود.');
} finally {
busy.value = false;
}
}
const telHref = computed(() => (lead.value?.mobile ? `tel:${lead.value.mobile}` : undefined));
const feed = computed(() => {
const rows: Array<{ key: string; title: string; by: string; at: string }> = [];
for (const ev of lead.value?.events ?? []) {
rows.push({
key: `ev-${ev.id}`,
title: ev.title,
by: ev.body?.trim() || 'توسط سیستم',
at: ev.createdAt,
});
}
for (const a of activities.value) {
const who = a.createdBy?.fullName || a.assignee?.fullName;
rows.push({
key: `act-${a.id}`,
title: a.title,
by: who ? `توسط ${who}` : 'توسط سیستم',
at: a.createdAt,
});
}
return rows.sort((a, b) => +new Date(b.at) - +new Date(a.at)).slice(0, 4);
});
function feedParts(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { day: '—', time: '' };
return { day: formatJalaliNumeric(d), time: formatClockFa(d) };
}
async function copyMobile() {
const value = lead.value?.mobile;
if (!value) return;
try {
await navigator.clipboard.writeText(value);
copied.value = true;
window.setTimeout(() => {
copied.value = false;
}, 1400);
} catch {
copied.value = false;
}
}
function syncNarrow() {
narrow.value = window.matchMedia('(max-width: 900px)').matches;
if (!narrow.value) moreOpen.value = false;
}
onMounted(() => {
narrowMq = window.matchMedia('(max-width: 900px)');
narrowMq.addEventListener('change', syncNarrow);
void load();
});
onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</script>
<template>
<section class="page" :class="{ editing, 'more-open': moreOpen }">
<div class="desk-only">
<UiPageHeader
:title="lead?.title || 'جزئیات لید'"
:lead="lead ? `موبایل ${lead.mobile}` : 'در حال بارگذاری…'"
:back-to="{ name: 'leads' }"
>
<template #actions>
<UiButton
v-if="lead && !editing"
variant="secondary"
size="sm"
:disabled="busy || !lead.mobile"
@click="smsOpen = true"
>
پیامک
</UiButton>
<UiButton
v-if="lead && !editing && canBehavior"
variant="secondary"
size="sm"
@click="copilotOpen = !copilotOpen"
>
کمک برای ادامه مذاکره
</UiButton>
<UiButton
v-if="lead && !editing"
variant="secondary"
size="sm"
:disabled="busy"
@click="startEdit"
>
ویرایش
</UiButton>
<UiButton
v-if="lead && !lead.convertedPersonId"
variant="primary"
size="sm"
:disabled="busy"
@click="convertLead"
>
تبدیل به مشتری
</UiButton>
<UiButton
v-if="lead && !lead.archivedAt"
variant="ghost"
size="sm"
:disabled="busy"
@click="archiveLead"
>
بایگانی
</UiButton>
</template>
</UiPageHeader>
</div>
<SalesCopilotPanel
v-if="lead && copilotOpen && canBehavior"
:person-id="lead.convertedPersonId"
:lead-id="lead.id"
:customer-name="lead.contactName || lead.title"
:stage-name="lead.stageName"
:phone="lead.mobile"
:sms-endpoint="`/leads/${lead.id}/sms`"
:sms-history-endpoint="`/leads/${lead.id}/sms-history`"
/>
<button
v-if="moreOpen"
class="sheet-back"
type="button"
aria-label="بستن"
@click="moreOpen = false"
/>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="lead">
<div v-if="narrow" class="mob">
<article class="m-hero">
<div class="m-top">
<span class="m-mark" aria-hidden="true">
<UiIcon name="bag" :size="22" />
</span>
<div class="m-id">
<div class="m-name-row">
<h2>{{ lead.title }}</h2>
<span class="m-pill"><i />{{ lead.stageName }}</span>
</div>
<button class="m-phone" type="button" @click="copyMobile">
<span dir="ltr">{{ toFaDigits(lead.mobile) }}</span>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="8" y="8" width="11" height="11" rx="2" />
<path d="M5 15V6a2 2 0 0 1 2-2h9" />
</svg>
<em v-if="copied">کپی شد</em>
</button>
</div>
<RouterLink class="m-back" :to="{ name: 'leads' }" aria-label="بازگشت">
<UiIcon name="chevron-left" :size="20" />
</RouterLink>
</div>
<div class="m-actions" :class="{ converted: !!lead.convertedPersonId }">
<a class="m-btn mint" :href="telHref">
<UiIcon name="phone" :size="16" />
تماس
</a>
<button class="m-btn" type="button" @click="smsOpen = true">
<UiIcon name="chat" :size="16" />
پیامک
</button>
<button class="m-btn" type="button" @click="startEdit">
<UiIcon name="pencil" :size="16" />
ویرایش
</button>
<button class="m-btn icon" type="button" aria-label="بیشتر" @click="moreOpen = true">
<UiIcon name="dots" :size="18" />
</button>
<button
v-if="!lead.convertedPersonId"
class="m-btn solid"
type="button"
:disabled="busy"
@click="convertLead"
>
<UiIcon name="user-plus" :size="16" />
تبدیل به مشتری
</button>
</div>
</article>
<article v-if="!editing" class="m-card">
<header class="m-card-h">
<h3>اطلاعات لید</h3>
<button class="m-ico" type="button" aria-label="ویرایش اطلاعات" @click="startEdit">
<UiIcon name="document" :size="16" />
</button>
</header>
<dl class="m-grid">
<div class="cell">
<dt><UiIcon name="user" :size="14" /> نام</dt>
<dd>{{ lead.title }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="users" :size="14" /> مالک / مخاطب</dt>
<dd>{{ lead.contactName || '—' }}</dd>
</div>
<div class="cell hot">
<dt><UiIcon name="phone" :size="14" /> موبایل</dt>
<dd dir="ltr">{{ toFaDigits(lead.mobile) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="phone" :size="14" /> تلفن</dt>
<dd dir="ltr">{{ lead.phone ? toFaDigits(lead.phone) : '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="pin" :size="14" /> شهر</dt>
<dd>{{ lead.city || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="grid" :size="14" /> منطقه</dt>
<dd>{{ lead.region || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="bag" :size="14" /> صنف</dt>
<dd>{{ lead.trade || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="camera" :size="14" /> اینستاگرام</dt>
<dd dir="ltr">{{ lead.instagram || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="target" :size="14" /> منبع</dt>
<dd>{{ lead.source || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="flag" :size="14" /> وضعیت</dt>
<dd><span class="m-pill"><i />{{ lead.stageName }}</span></dd>
</div>
<div class="cell">
<dt><UiIcon name="user" :size="14" /> کارشناس</dt>
<dd>{{ lead.assigneeName || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="calendar" :size="14" /> تاریخ تولد</dt>
<dd>{{ formatDate(lead.birthDate) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="calendar" :size="14" /> تاریخ ثبت</dt>
<dd>{{ formatDate(lead.createdAt) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="chart" :size="14" /> روز در مرحله</dt>
<dd>{{ toFaDigits(lead.daysInStage) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="clock" :size="14" /> پیگیری بعدی</dt>
<dd>{{ formatDate(lead.nextFollowUpAt) }}</dd>
</div>
<div v-if="lead.addresses?.length" class="cell wide">
<dt><UiIcon name="pin" :size="14" /> آدرس‌ها</dt>
<dd>
<p v-for="(addr, i) in lead.addresses" :key="i" class="addr-line">{{ addr }}</p>
</dd>
</div>
</dl>
</article>
<article v-if="!editing" class="m-card">
<header class="m-card-h">
<h3>یادداشت</h3>
<button class="m-ico" type="button" aria-label="ویرایش یادداشت" @click="startEdit">
<UiIcon name="pencil" :size="16" />
</button>
</header>
<p class="m-note">{{ lead.notes?.trim() || 'یادداشتی ثبت نشده است.' }}</p>
</article>
<RelatedTasksPanel
v-if="!editing"
class="fancy"
:entity="{ type: 'lead', id: lead.id, label: lead.title }"
empty-title="هنوز کاری برای این لید ثبت نشده."
empty-hint="برای پیگیری بهتر، یک تسک ایجاد کنید."
/>
<article v-if="!editing" class="m-card">
<header class="m-card-h">
<h3>
<span class="m-ico static"><UiIcon name="clock" :size="16" /></span>
فعالیت‌ها
</h3>
<RouterLink class="m-add" :to="{ name: 'activity-new' }">
ایجاد فعالیت
<UiIcon name="plus" :size="14" />
</RouterLink>
</header>
<ol v-if="feed.length" class="m-feed">
<li v-for="row in feed" :key="row.key">
<span class="m-feed-ic"><UiIcon name="document" :size="16" /></span>
<div class="m-feed-body">
<strong>{{ row.title }}</strong>
<small>{{ row.by }}</small>
</div>
<time>
<b>{{ feedParts(row.at).day }}</b>
<span>{{ feedParts(row.at).time }}</span>
</time>
</li>
</ol>
<p v-else class="m-note">هنوز فعالیتی ثبت نشده است.</p>
<RouterLink class="m-all" :to="{ name: 'activities' }">
مشاهده همه فعالیت‌ها
<UiIcon name="arrow-left" :size="14" />
</RouterLink>
</article>
</div>
<div class="layout">
<div class="main">
<UiCard class="info-card" title="اطلاعات لید">
<div v-if="editing" class="edit-grid">
<UiField label="عنوان" required>
<UiInput v-model="editForm.title" />
</UiField>
<UiField label="مالک/مخاطب">
<UiInput v-model="editForm.contactName" />
</UiField>
<UiField label="موبایل" required>
<UiInput v-model="editForm.mobile" dir="ltr" />
</UiField>
<UiField label="تلفن">
<UiInput v-model="editForm.phone" dir="ltr" />
</UiField>
<UiField label="شهر">
<UiInput v-model="editForm.city" />
</UiField>
<UiField label="منطقه">
<UiInput v-model="editForm.region" />
</UiField>
<UiField label="منبع">
<UiInput v-model="editForm.source" />
</UiField>
<UiField label="اینستاگرام">
<UiInput v-model="editForm.instagram" dir="ltr" />
</UiField>
<UiField label="تاریخ تولد">
<JalaliDateInput v-model="editForm.birthDate" mode="date" />
</UiField>
<UiField label="صنف">
<UiInput v-model="editForm.trade" />
</UiField>
<UiField label="یادداشت" class="full">
<UiInput v-model="editForm.notes" />
</UiField>
<div class="edit-actions full">
<UiButton size="sm" :loading="busy" @click="saveEdit">ذخیره</UiButton>
<UiButton size="sm" variant="ghost" :disabled="busy" @click="editing = false">
انصراف
</UiButton>
</div>
</div>
<dl v-else class="meta">
<div><dt>نام</dt><dd>{{ lead.title }}</dd></div>
<div><dt>مالک/مخاطب</dt><dd>{{ lead.contactName || '—' }}</dd></div>
<div><dt>موبایل</dt><dd dir="ltr">{{ lead.mobile }}</dd></div>
<div><dt>تلفن</dt><dd dir="ltr">{{ lead.phone || '—' }}</dd></div>
<div><dt>شهر</dt><dd>{{ lead.city || '—' }}</dd></div>
<div><dt>منطقه</dt><dd>{{ lead.region || '—' }}</dd></div>
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ lead.instagram || '—' }}</dd></div>
<div><dt>صنف</dt><dd>{{ lead.trade || '—' }}</dd></div>
<div><dt>منبع</dt><dd>{{ lead.source || '—' }}</dd></div>
<div><dt>وضعیت</dt><dd>{{ lead.stageName }}</dd></div>
<div><dt>کارشناس</dt><dd>{{ lead.assigneeName || '—' }}</dd></div>
<div><dt>تاریخ تولد</dt><dd>{{ formatDate(lead.birthDate) }}</dd></div>
<div><dt>تاریخ ثبت</dt><dd>{{ formatDate(lead.createdAt) }}</dd></div>
<div><dt>آخرین تماس</dt><dd>{{ formatDate(lead.lastContactAt) }}</dd></div>
<div><dt>پیگیری بعدی</dt><dd>{{ formatDate(lead.nextFollowUpAt) }}</dd></div>
<div><dt>روز در مرحله</dt><dd>{{ toFaDigits(lead.daysInStage) }}</dd></div>
<div v-if="lead.addresses?.length" class="full">
<dt>آدرس‌ها</dt>
<dd>
<p v-for="(addr, i) in lead.addresses" :key="i" class="addr-line">{{ addr }}</p>
</dd>
</div>
<div v-if="lead.notes" class="full">
<dt>یادداشت</dt>
<dd>{{ lead.notes }}</dd>
</div>
</dl>
</UiCard>
<RelatedTasksPanel
v-if="!narrow"
:entity="{ type: 'lead', id: lead.id, label: lead.title }"
/>
<UiCard v-if="activities.length" class="desk-only" title="فعالیت‌های مرتبط">
<ul class="lead-acts">
<li v-for="a in activities" :key="a.id">
<RouterLink :to="{ name: 'activity-detail', params: { id: a.id } }">
<strong>{{ a.title }}</strong>
</RouterLink>
<span class="muted">
{{ a.typeDef?.name || ACTIVITY_TYPE_LABELS[a.type] }}
· {{ a.assignee?.fullName || 'بدون مسئول' }}
· {{ formatDate(a.dueAt) }}
· {{ a.status === 'done' ? 'انجام شد' : a.status === 'canceled' ? 'لغو شده' : 'باز' }}
</span>
</li>
</ul>
</UiCard>
<UiCard class="desk-only" title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id">
<strong>{{ ev.title }}</strong>
<span class="muted">{{ formatDate(ev.createdAt) }}</span>
<p v-if="ev.body">{{ ev.body }}</p>
</li>
</ul>
<UiEmptyState
v-else
title="رویدادی ثبت نشده."
description="تماس، یادداشت یا پیگیری اضافه کنید."
/>
</UiCard>
</div>
<aside class="side">
<button class="sheet-close" type="button" @click="moreOpen = false">بستن</button>
<UiCard v-if="canBehavior" title="مذاکره">
<UiButton size="sm" @click="copilotOpen = true; moreOpen = false">کمک برای ادامه مذاکره</UiButton>
</UiCard>
<UiCard title="پیامک">
<UiButton size="sm" :disabled="!lead.mobile" @click="smsOpen = true; moreOpen = false">
ارسال پیامک
</UiButton>
</UiCard>
<UiCard title="تغییر وضعیت">
<UiField label="وضعیت">
<UiSelect v-model="stageId">
<option v-for="s in stages" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</UiSelect>
</UiField>
<UiButton size="sm" :disabled="busy" @click="changeStage">اعمال</UiButton>
</UiCard>
<UiCard v-if="isAdmin" title="تخصیص کارشناس">
<UiField label="کارشناس">
<UiSelect v-model="assigneeId">
<option value="">انتخاب…</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</UiSelect>
</UiField>
<UiButton size="sm" :disabled="busy || !assigneeId" @click="assignLead">
تخصیص
</UiButton>
</UiCard>
<UiCard title="ثبت تماس">
<UiField label="نتیجه">
<UiInput v-model="callOutcome" placeholder="مثلاً پاسخ داد / اشغال" />
</UiField>
<UiField label="توضیح">
<UiTextarea v-model="callBody" :rows="4" placeholder="خلاصه مکالمه" />
</UiField>
<UiField label="پیگیری بعدی">
<JalaliDateInput v-model="callFollowUp" mode="datetime" />
</UiField>
<UiButton size="sm" :disabled="busy" @click="logCall">ثبت تماس</UiButton>
</UiCard>
<UiCard title="یادداشت">
<UiField label="متن">
<UiInput v-model="noteBody" placeholder="یادداشت…" />
</UiField>
<UiButton size="sm" :disabled="busy || !noteBody.trim()" @click="addNote">
ثبت یادداشت
</UiButton>
</UiCard>
<UiCard title="پیگیری بعدی">
<UiField label="زمان">
<JalaliDateInput v-model="followUpAt" mode="datetime" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="followUpBody" placeholder="اختیاری" />
</UiField>
<UiButton size="sm" :disabled="busy || !followUpAt" @click="setFollowUp">
ذخیره پیگیری
</UiButton>
</UiCard>
</aside>
</div>
<SmsComposeModal
:open="smsOpen"
title="ارسال پیامک"
:endpoint="`/leads/${lead.id}/sms`"
:history-endpoint="`/leads/${lead.id}/sms-history`"
:name="lead.contactName || lead.title"
:phone="lead.mobile || lead.phone || ''"
:record-title="lead.title"
:company="lead.contactName ? lead.title : ''"
@close="smsOpen = false"
/>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.layout {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(16rem, 20rem);
align-items: start;
}
.main,
.side {
display: grid;
gap: 1rem;
}
.edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
}
.edit-grid .full {
grid-column: 1 / -1;
}
.edit-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin: 0;
}
.meta .full {
grid-column: 1 / -1;
}
.meta dt {
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 0.15rem;
}
.meta dd {
margin: 0;
font-weight: 500;
}
.addr-line {
margin: 0 0 0.25rem;
}
.addr-line:last-child {
margin-bottom: 0;
}
.lead-acts {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.lead-acts li {
display: grid;
gap: 0.15rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid #f1f5f9;
}
.lead-acts li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.lead-acts a {
color: inherit;
text-decoration: none;
}
.lead-acts a:hover strong {
color: var(--accent, #0f766e);
}
.lead-acts .muted {
font-size: 0.78rem;
}
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.85rem;
}
.timeline li {
display: grid;
gap: 0.2rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--line);
}
.timeline li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.timeline p {
margin: 0;
color: var(--ink);
}
.native {
width: 100%;
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.65rem;
font: inherit;
background: #fff;
box-sizing: border-box;
}
.side :deep(.ui-card) {
display: grid;
gap: 0.65rem;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.ok {
color: #0f766e;
margin: 0;
}
.error {
color: #b91c1c;
margin: 0;
}
.sheet-back,
.sheet-close,
.mob {
display: none;
}
@media (max-width: 900px) {
.desk-only,
:deep(.desk-only) {
display: none !important;
}
.layout {
display: none;
grid-template-columns: 1fr;
}
.editing .layout,
.more-open .layout {
display: grid;
}
.more-open:not(.editing) .main {
display: none;
}
:deep(.info-card) {
display: none;
}
.editing :deep(.info-card) {
display: block;
}
.meta,
.edit-grid {
grid-template-columns: 1fr;
}
.mob {
display: grid;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.m-hero,
.m-card,
.m-top,
.m-id,
.m-actions,
.m-grid,
.m-name-row,
.mob :deep(.related) {
min-width: 0;
max-width: 100%;
}
.m-hero,
.m-card,
.mob :deep(.related) {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.m-hero {
display: grid;
gap: 0.85rem;
padding: 0.95rem 0.9rem 0.85rem;
}
.m-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.7rem;
align-items: start;
}
.m-mark {
width: 52px;
height: 52px;
border-radius: 16px;
display: grid;
place-items: center;
background: #e7f6f1;
color: #0f766e;
}
.m-id {
min-width: 0;
display: grid;
gap: 0.35rem;
padding-top: 0.1rem;
}
.m-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.m-name-row h2 {
margin: 0;
min-width: 0;
font-size: 1.15rem;
font-weight: 800;
line-height: 1.35;
color: #0f172a;
}
.m-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.12rem 0.55rem;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
font-size: 0.72rem;
font-weight: 700;
}
.m-pill i {
width: 6px;
height: 6px;
border-radius: 999px;
background: #14b8a6;
}
.m-phone {
justify-self: start;
width: auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 0;
background: none;
padding: 0;
color: #64748b;
font: inherit;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
}
.m-phone em {
font-style: normal;
color: #0f766e;
font-size: 0.72rem;
}
.m-back {
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: #94a3b8;
}
.m-actions,
.m-actions.converted {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) 36px;
gap: 0.35rem;
}
.m-actions .solid {
grid-column: 1 / -1;
}
.m-btn {
min-width: 0;
min-height: 40px;
border: 0;
border-radius: 12px;
background: #f3f5f7;
color: #334155;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0 0.45rem;
font: inherit;
font-size: 0.74rem;
font-weight: 700;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
}
.m-btn.mint {
background: #e7f6f1;
color: #0f766e;
}
.m-btn.solid {
background: #12856a;
color: #fff;
}
.m-btn.solid:disabled {
opacity: 0.6;
}
.m-btn.icon {
width: 40px;
padding: 0;
}
.m-card {
padding: 0.9rem 0.85rem 0.85rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
}
.m-card-h {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-width: 0;
}
.m-card-h h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.98rem;
font-weight: 800;
color: #0f172a;
}
.m-ico {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #e7f6f1;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.m-ico.static {
cursor: default;
}
.m-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
margin: 0;
}
.cell {
min-width: 0;
border: 1px solid #eef2f5;
border-radius: 12px;
padding: 0.55rem 0.6rem 0.6rem;
background: #fff;
}
.cell.wide {
grid-column: 1 / -1;
}
.cell.hot {
background: #e8f7f1;
border-color: #d7f0e6;
}
.cell.hot dt,
.cell.hot :deep(.ico) {
color: #0f766e;
}
.cell dt {
display: flex;
align-items: center;
gap: 0.28rem;
margin: 0 0 0.28rem;
color: #94a3b8;
font-size: 0.7rem;
font-weight: 600;
}
.cell dd {
margin: 0;
color: #0f172a;
font-size: 0.84rem;
font-weight: 700;
overflow-wrap: anywhere;
text-align: start;
}
.cell dd[dir='ltr'] {
text-align: right;
}
.m-note {
margin: 0;
min-width: 0;
color: #334155;
font-size: 0.86rem;
line-height: 1.7;
overflow-wrap: anywhere;
}
.m-add {
display: inline-flex;
align-items: center;
gap: 0.2rem;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
text-decoration: none;
font-size: 0.75rem;
font-weight: 700;
padding: 0.35rem 0.7rem;
}
.m-feed {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
}
.m-feed li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.6rem;
align-items: center;
}
.m-feed-ic {
width: 36px;
height: 36px;
border-radius: 12px;
background: #e7f6f1;
color: #0f766e;
display: grid;
place-items: center;
}
.m-feed-body {
min-width: 0;
display: grid;
gap: 0.1rem;
}
.m-feed-body strong {
overflow-wrap: anywhere;
font-size: 0.84rem;
font-weight: 800;
color: #0f172a;
}
.m-feed-body small {
color: #94a3b8;
font-size: 0.72rem;
}
.m-feed time {
display: grid;
justify-items: end;
gap: 0.1rem;
color: #64748b;
font-size: 0.72rem;
}
.m-feed time b {
font-weight: 700;
color: #334155;
}
.m-all {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
color: #64748b;
text-decoration: none;
font-size: 0.78rem;
font-weight: 700;
}
.side {
display: none;
}
.sheet-back {
display: block;
position: fixed;
inset: 0;
z-index: 60;
border: 0;
background: rgba(15, 23, 42, 0.35);
}
.more-open .side {
display: grid;
position: fixed;
z-index: 70;
inset-inline: 0;
bottom: 0;
max-height: min(78vh, 640px);
overflow: auto;
gap: 0.75rem;
margin: 0;
padding: 0.85rem 0.85rem calc(0.9rem + env(safe-area-inset-bottom));
background: #f8fafc;
border-radius: 18px 18px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.18);
}
.sheet-close {
display: inline-flex;
justify-self: start;
border: 0;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.8rem;
font: inherit;
font-weight: 700;
color: #334155;
}
}
</style>