Files
CRM_Montzar/apps/web/src/views/LeadDetailView.vue
T
MahdiandCursor c337b0b4c7 fix(leads): keep a future follow-up as an activity on the lead
A scheduled follow-up only stored a date, and activities opened from a lead were not linked back, so they disappeared from the lead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 16:19:32 +03:30

1190 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, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Affix as AAffix,
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Divider as ADivider,
Drawer as ADrawer,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
Image as AImage,
FormItem as AFormItem,
Input as AInput,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Modal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Textarea as ATextarea,
Timeline as ATimeline,
TimelineItem as ATimelineItem,
Typography,
message,
theme,
} from 'ant-design-vue';
import {
ArrowLeftOutlined,
CopyOutlined,
EditOutlined,
MessageOutlined,
MoreOutlined,
PhoneOutlined,
PlusOutlined,
UserAddOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { isBirthDateAnswer, leadNoteFileIsImage, parseLeadFormNotes } from '@/lib/lead-form-notes';
import {
formatClockFa,
formatJalaliNumeric,
formatLocalDateInput,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
import type {
ActivityDto,
ConvertLeadResult,
LeadDto,
LeadEventDto,
LeadStageDto,
OrgUserDto,
PaginatedResponse,
PersonGender,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
import { canUseBehavior } from '@/lib/behavior-access';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const { token } = theme.useToken();
type LeadDetail = LeadDto & { events: LeadEventDto[] };
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
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: '',
gender: 'none' as PersonGender,
showGenderInName: false,
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;
}
}
const noteView = computed(() => parseLeadFormNotes(lead.value?.notes));
const formBirthDate = computed(
() => noteView.value.rows.find((row) => isBirthDateAnswer(row.label) && !row.file)?.text ?? '',
);
const shownBirthDate = computed(() => {
if (lead.value?.birthDate) return formatDate(lead.value.birthDate);
return formBirthDate.value || '—';
});
const answerRows = computed(() =>
noteView.value.rows.filter((row) => !isBirthDateAnswer(row.label)),
);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
const genderLabel = computed(() => {
const gender = lead.value?.gender;
const key = gender === 'male' || gender === 'female' ? gender : 'none';
const label = PERSON_GENDER_LABELS[key];
return lead.value?.showGenderInName && key !== 'none' ? `${label} · در نام` : label;
});
function bareLeadName(value: string) {
const split = splitPersonHonorific(value, '');
return [split.firstName, split.lastName].filter(Boolean).join(' ');
}
watch(
() => editForm.value.gender,
(value) => {
if (value === 'none') editForm.value.showGenderInName = false;
},
);
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;
moreOpen.value = false;
const gender =
lead.value.gender === 'male' || lead.value.gender === 'female' ? lead.value.gender : 'none';
editForm.value = {
title: bareLeadName(lead.value.title),
contactName: bareLeadName(lead.value.contactName ?? ''),
gender,
showGenderInName: !!lead.value.showGenderInName && gender !== 'none',
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,
gender: editForm.value.gender,
showGenderInName:
editForm.value.gender !== 'none' && editForm.value.showGenderInName,
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 || null,
trade: editForm.value.trade.trim() || null,
notes: editForm.value.notes.trim() || null,
});
okMsg.value = 'لید ویرایش شد.';
message.success(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 = 'وضعیت به‌روز شد.';
message.success(okMsg.value);
moreOpen.value = false;
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 = 'کارشناس تخصیص داده شد.';
message.success(okMsg.value);
moreOpen.value = false;
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 = 'تماس ثبت شد.';
message.success(okMsg.value);
moreOpen.value = false;
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 = 'یادداشت ثبت شد.';
message.success(okMsg.value);
moreOpen.value = false;
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 = 'پیگیری تنظیم شد.';
message.success(okMsg.value);
moreOpen.value = false;
await load();
} catch (e) {
error.value = apiError(e, 'تنظیم پیگیری ناموفق بود.');
} finally {
busy.value = false;
}
}
function convertLead() {
Modal.confirm({
title: 'این لید به مشتری تبدیل شود؟',
okText: 'تبدیل',
cancelText: 'انصراف',
async onOk() {
busy.value = true;
error.value = '';
try {
const { data } = await api.post<ConvertLeadResult>(`/leads/${id.value}/convert`, {
createIfMissing: true,
});
okMsg.value = data.createdPerson
? 'لید تبدیل و شخص جدید ساخته شد.'
: 'لید به مشتری موجود متصل شد.';
message.success(okMsg.value);
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;
}
},
});
}
function archiveLead() {
Modal.confirm({
title: 'این لید بایگانی شود؟',
okText: 'بایگانی',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
busy.value = true;
error.value = '';
try {
await api.post('/leads/archive', { leadIds: [id.value] });
okMsg.value = 'لید بایگانی شد.';
message.success(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));
function openTel() {
if (telHref.value) globalThis.location.href = telHref.value;
}
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;
message.success('کپی شد');
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>
<a-row :gutter="[24, 24]">
<a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<a-space :size="12" align="start">
<RouterLink :to="{ name: 'leads-board' }" custom v-slot="{ navigate }">
<a-button @click="navigate">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
</RouterLink>
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">
{{ lead?.title || 'جزئیات لید' }}
</a-typography-title>
<a-typography-text type="secondary">
{{ lead ? `موبایل ${lead.mobile}` : 'در حال بارگذاری…' }}
</a-typography-text>
</div>
</a-space>
<a-space v-if="lead" :size="8" wrap>
<a-button
v-if="!editing"
:disabled="busy || !lead.mobile"
@click="smsOpen = true"
>
<template #icon><MessageOutlined /></template>
پیامک
</a-button>
<a-button v-if="!editing && canBehavior" @click="copilotOpen = !copilotOpen">
کمک برای ادامه مذاکره
</a-button>
<a-button v-if="!editing" :disabled="busy" @click="startEdit">
<template #icon><EditOutlined /></template>
ویرایش
</a-button>
<a-button
v-if="!lead.convertedPersonId"
type="primary"
:disabled="busy"
@click="convertLead"
>
<template #icon><UserAddOutlined /></template>
تبدیل به مشتری
</a-button>
<a-button
v-if="!lead.archivedAt"
danger
:disabled="busy"
@click="archiveLead"
>
بایگانی
</a-button>
<a-button v-if="narrow" @click="moreOpen = true">
<template #icon><MoreOutlined /></template>
بیشتر
</a-button>
</a-space>
</a-flex>
</a-col>
<a-col v-if="lead && copilotOpen && canBehavior" :span="24">
<SalesCopilotPanel
: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`"
/>
</a-col>
<a-col v-if="okMsg" :span="24">
<a-alert type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
</a-col>
<a-col v-if="error" :span="24">
<a-alert type="error" show-icon :message="error" closable @close="error = ''" />
</a-col>
<a-col :span="24">
<a-spin :spinning="loading">
<template v-if="lead">
<a-row :gutter="[24, 24]">
<a-col :xs="24" :lg="17" :xl="18">
<a-card :bordered="false" title="اطلاعات لید">
<a-form v-if="editing" layout="vertical" :model="editForm">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" required>
<a-input v-model:value="editForm.title" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="مالک/مخاطب">
<a-input v-model:value="editForm.contactName" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="editForm.gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره می‌شود. اگر نام مخاطب خالی باشد روی عنوان می‌نشیند و با تبدیل به مشتری به ته‌حساب هم می‌رود."
>
<a-checkbox
v-model:checked="editForm.showGenderInName"
:disabled="editForm.gender === 'none'"
>
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="موبایل" required>
<a-input
v-model:value="editForm.mobile"
dir="ltr"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="تلفن">
<a-input
v-model:value="editForm.phone"
dir="ltr"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="شهر">
<a-input v-model:value="editForm.city" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="منطقه">
<a-input v-model:value="editForm.region" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="منبع">
<a-input v-model:value="editForm.source" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="اینستاگرام">
<a-input
v-model:value="editForm.instagram"
dir="ltr"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="تاریخ تولد">
<JalaliDateInput
v-model="editForm.birthDate"
mode="date"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="صنف">
<a-input v-model:value="editForm.trade" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea
v-model:value="editForm.notes"
:rows="3"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-space :size="8">
<a-button type="primary" :loading="busy" @click="saveEdit">
ذخیره
</a-button>
<a-button :disabled="busy" @click="editing = false">انصراف</a-button>
</a-space>
</a-col>
</a-row>
</a-form>
<template v-else>
<a-flex
v-if="narrow"
justify="space-between"
align="center"
wrap="wrap"
:gap="8"
:style="{ marginBottom: `${token.marginMD}px` }"
>
<a-space :size="8" wrap>
<a-tag color="processing">{{ lead.stageName }}</a-tag>
<a-button type="link" @click="copyMobile">
<template #icon><CopyOutlined /></template>
<span dir="ltr">{{ toFaDigits(lead.mobile) }}</span>
<template v-if="copied"> · کپی شد</template>
</a-button>
</a-space>
<a-space :size="8">
<a-button
v-if="telHref"
@click="openTel"
>
<template #icon><PhoneOutlined /></template>
تماس
</a-button>
<a-button @click="smsOpen = true">
<template #icon><MessageOutlined /></template>
پیامک
</a-button>
</a-space>
</a-flex>
<a-descriptions :column="{ xs: 1, sm: 2, md: 2 }" bordered size="small">
<a-descriptions-item label="نام">{{ lead.title }}</a-descriptions-item>
<a-descriptions-item label="مالک/مخاطب">
{{ lead.contactName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="جنسیت">{{ genderLabel }}</a-descriptions-item>
<a-descriptions-item label="موبایل">
<span dir="ltr">{{ lead.mobile }}</span>
</a-descriptions-item>
<a-descriptions-item label="تلفن">
<span dir="ltr">{{ lead.phone || '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="شهر">{{ lead.city || '—' }}</a-descriptions-item>
<a-descriptions-item label="منطقه">
{{ lead.region || '—' }}
</a-descriptions-item>
<a-descriptions-item label="اینستاگرام">
<span dir="ltr">{{ lead.instagram || '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="صنف">{{ lead.trade || '—' }}</a-descriptions-item>
<a-descriptions-item label="منبع">
{{ lead.source || '—' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">{{ lead.stageName }}</a-descriptions-item>
<a-descriptions-item label="کارشناس">
{{ lead.assigneeName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ تولد">
{{ shownBirthDate }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">
{{ formatDate(lead.createdAt) }}
</a-descriptions-item>
<a-descriptions-item label="آخرین تماس">
{{ formatDate(lead.lastContactAt) }}
</a-descriptions-item>
<a-descriptions-item label="پیگیری بعدی">
{{ formatDate(lead.nextFollowUpAt) }}
</a-descriptions-item>
<a-descriptions-item label="روز در مرحله">
{{ toFaDigits(lead.daysInStage) }}
</a-descriptions-item>
<a-descriptions-item
v-if="lead.addresses?.length"
label="آدرس‌ها"
:span="2"
>
<div v-for="(addr, i) in lead.addresses" :key="i">{{ addr }}</div>
</a-descriptions-item>
<template v-if="noteView.mode === 'answers'">
<a-descriptions-item
v-for="(row, index) in answerRows"
:key="`${row.label}-${index}`"
:label="row.label"
:span="2"
:label-style="{ whiteSpace: 'normal' }"
:content-style="{ whiteSpace: 'normal' }"
>
<a-space v-if="row.file" direction="vertical" :size="4">
<a-image
v-if="row.file.dataUrl && leadNoteFileIsImage(row.file)"
:src="row.file.dataUrl"
:alt="row.file.name"
:width="160"
/>
<a-typography-text>{{ row.file.name }}</a-typography-text>
</a-space>
<a-typography-paragraph
v-else
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="row.text"
style="margin-bottom: 0"
/>
</a-descriptions-item>
<a-descriptions-item
v-if="noteView.prose"
label="یادداشت"
:span="2"
>
<a-typography-paragraph
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="noteView.prose"
style="margin-bottom: 0"
/>
</a-descriptions-item>
</template>
<a-descriptions-item v-else-if="lead.notes" label="یادداشت" :span="2">
<a-typography-paragraph
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="lead.notes"
style="margin-bottom: 0"
/>
</a-descriptions-item>
</a-descriptions>
</template>
<template v-if="!editing">
<a-divider orientation="right">فعالیت‌های مرتبط</a-divider>
<a-list
v-if="activities.length"
:data-source="activities"
item-layout="horizontal"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<RouterLink
:to="{ name: 'activity-detail', params: { id: item.id } }"
>
{{ item.title }}
</RouterLink>
</template>
<template #description>
{{ item.typeDef?.name || ACTIVITY_TYPE_LABELS[item.type as keyof typeof ACTIVITY_TYPE_LABELS] }}
· {{ item.assignee?.fullName || 'بدون مسئول' }}
· {{ formatDate(item.dueAt) }}
·
{{
item.status === 'done'
? 'انجام شد'
: item.status === 'canceled'
? 'لغو شده'
: 'باز'
}}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-empty v-else description="فعالیت مرتبطی نیست." />
<RouterLink
:to="{ name: 'activity-new', query: { leadId: lead.id } }"
custom
v-slot="{ navigate }"
>
<a-button type="dashed" block @click="navigate">
<template #icon><PlusOutlined /></template>
ایجاد فعالیت
</a-button>
</RouterLink>
<a-divider orientation="right">جدول زمانی</a-divider>
<a-timeline v-if="lead.events.length">
<a-timeline-item v-for="ev in lead.events" :key="ev.id">
<a-typography-text strong>{{ ev.title }}</a-typography-text>
<div>
<a-typography-text type="secondary">
{{ formatDate(ev.createdAt) }}
</a-typography-text>
</div>
<a-typography-paragraph v-if="ev.body" type="secondary">
{{ ev.body }}
</a-typography-paragraph>
</a-timeline-item>
</a-timeline>
<a-empty
v-else
description="رویدادی ثبت نشده. تماس، یادداشت یا پیگیری اضافه کنید."
/>
<a-divider v-if="narrow" orientation="right">خلاصه فعالیت</a-divider>
<a-list v-if="narrow && feed.length" :data-source="feed">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.title" :description="item.by" />
<template #actions>
<a-typography-text type="secondary">
{{ feedParts(item.at).day }}
{{ feedParts(item.at).time }}
</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
<RouterLink
v-if="narrow"
:to="{ name: 'activities' }"
custom
v-slot="{ navigate }"
>
<a-button type="link" @click="navigate">
مشاهده همه فعالیت‌ها
</a-button>
</RouterLink>
</template>
</a-card>
<RelatedTasksPanel
v-if="!editing"
:entity="{ type: 'lead', id: lead.id, label: lead.title }"
empty-title="هنوز کاری برای این لید ثبت نشده."
empty-hint="برای پیگیری بهتر، یک تسک ایجاد کنید."
/>
</a-col>
<a-col :xs="24" :lg="7" :xl="6">
<a-affix v-if="!narrow" :offset-top="24">
<a-card
title="اقدامات سریع"
:bordered="false"
:body-style="{ maxHeight: 'calc(100vh - 140px)', overflowY: 'auto' }"
>
<a-form layout="vertical">
<a-divider orientation="right">پیامک</a-divider>
<a-button
block
:disabled="!lead.mobile"
@click="smsOpen = true"
>
<template #icon><MessageOutlined /></template>
ارسال پیامک
</a-button>
<a-divider orientation="right">تغییر وضعیت</a-divider>
<a-form-item label="وضعیت">
<a-select
v-model:value="stageId"
style="width: 100%"
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
/>
</a-form-item>
<a-button block :disabled="busy" :loading="busy" @click="changeStage">
اعمال وضعیت
</a-button>
<template v-if="canAssignOthers">
<a-divider orientation="right">تخصیص کارشناس</a-divider>
<a-form-item label="کارشناس">
<a-select
v-model:value="assigneeId"
allow-clear
placeholder="انتخاب…"
style="width: 100%"
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
</a-form-item>
<a-button
block
:disabled="busy || !assigneeId"
:loading="busy"
@click="assignLead"
>
تخصیص
</a-button>
</template>
<a-divider orientation="right">ثبت تماس</a-divider>
<a-form-item label="نتیجه">
<a-input
v-model:value="callOutcome"
placeholder="مثلاً پاسخ داد / اشغال"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="توضیح">
<a-textarea
v-model:value="callBody"
:rows="4"
placeholder="خلاصه مکالمه"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="پیگیری بعدی">
<JalaliDateInput
v-model="callFollowUp"
mode="datetime"
style="width: 100%"
/>
</a-form-item>
<a-button block :disabled="busy" :loading="busy" @click="logCall">
ثبت تماس
</a-button>
<a-divider orientation="right">یادداشت</a-divider>
<a-form-item label="متن">
<a-input
v-model:value="noteBody"
placeholder="یادداشت…"
style="width: 100%"
/>
</a-form-item>
<a-button
block
:disabled="busy || !noteBody.trim()"
:loading="busy"
@click="addNote"
>
ثبت یادداشت
</a-button>
<a-divider orientation="right">پیگیری بعدی</a-divider>
<a-form-item label="زمان">
<JalaliDateInput
v-model="followUpAt"
mode="datetime"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="توضیح">
<a-input
v-model:value="followUpBody"
placeholder="اختیاری"
style="width: 100%"
/>
</a-form-item>
<a-button
block
type="primary"
:disabled="busy || !followUpAt"
:loading="busy"
@click="setFollowUp"
>
ذخیره پیگیری
</a-button>
</a-form>
<a-divider orientation="right">خلاصه</a-divider>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="وضعیت">
{{ lead.stageName }}
</a-descriptions-item>
<a-descriptions-item label="کارشناس">
{{ lead.assigneeName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="پیگیری بعدی">
{{ formatDate(lead.nextFollowUpAt) }}
</a-descriptions-item>
<a-descriptions-item label="روز در مرحله">
{{ toFaDigits(lead.daysInStage) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-affix>
</a-col>
</a-row>
<a-drawer
v-model:open="moreOpen"
title="اقدامات سریع"
placement="bottom"
:height="'85vh'"
>
<a-form layout="vertical">
<a-divider orientation="right">پیامک</a-divider>
<a-button
block
:disabled="!lead.mobile"
@click="smsOpen = true; moreOpen = false"
>
ارسال پیامک
</a-button>
<a-button
v-if="canBehavior"
block
@click="copilotOpen = true; moreOpen = false"
>
کمک برای ادامه مذاکره
</a-button>
<a-divider orientation="right">تغییر وضعیت</a-divider>
<a-form-item label="وضعیت">
<a-select
v-model:value="stageId"
style="width: 100%"
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
/>
</a-form-item>
<a-button block :disabled="busy" @click="changeStage">اعمال وضعیت</a-button>
<template v-if="canAssignOthers">
<a-divider orientation="right">تخصیص کارشناس</a-divider>
<a-form-item label="کارشناس">
<a-select
v-model:value="assigneeId"
allow-clear
placeholder="انتخاب…"
style="width: 100%"
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
</a-form-item>
<a-button
block
:disabled="busy || !assigneeId"
@click="assignLead"
>
تخصیص
</a-button>
</template>
<a-divider orientation="right">ثبت تماس</a-divider>
<a-form-item label="نتیجه">
<a-input
v-model:value="callOutcome"
placeholder="مثلاً پاسخ داد / اشغال"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="توضیح">
<a-textarea
v-model:value="callBody"
:rows="4"
placeholder="خلاصه مکالمه"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="پیگیری بعدی">
<JalaliDateInput
v-model="callFollowUp"
mode="datetime"
style="width: 100%"
/>
</a-form-item>
<a-button block :disabled="busy" @click="logCall">ثبت تماس</a-button>
<a-divider orientation="right">یادداشت</a-divider>
<a-form-item label="متن">
<a-input
v-model:value="noteBody"
placeholder="یادداشت…"
style="width: 100%"
/>
</a-form-item>
<a-button
block
:disabled="busy || !noteBody.trim()"
@click="addNote"
>
ثبت یادداشت
</a-button>
<a-divider orientation="right">پیگیری بعدی</a-divider>
<a-form-item label="زمان">
<JalaliDateInput
v-model="followUpAt"
mode="datetime"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="توضیح">
<a-input
v-model:value="followUpBody"
placeholder="اختیاری"
style="width: 100%"
/>
</a-form-item>
<a-button
block
type="primary"
:disabled="busy || !followUpAt"
@click="setFollowUp"
>
ذخیره پیگیری
</a-button>
</a-form>
</a-drawer>
<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>
</a-spin>
</a-col>
</a-row>
</template>