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

607 lines
17 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, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiSelect,
} from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type {
ConvertLeadResult,
LeadDto,
LeadEventDto,
LeadStageDto,
OrgUserDto,
} from '@mento/shared';
type LeadDetail = LeadDto & { events: LeadEventDto[] };
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
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 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 editForm = ref({
title: '',
contactName: '',
mobile: '',
phone: '',
city: '',
region: '',
source: '',
instagram: '',
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] = await Promise.all([
api.get<LeadDetail>(`/leads/${id.value}`),
api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
lead.value = leadRes.data;
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 ?? '',
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,
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;
}
}
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 || 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() {
if (!followUpAt.value) return;
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/follow-up`, {
nextFollowUpAt: new Date(followUpAt.value).toISOString(),
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;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<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"
@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>
<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 class="layout">
<div class="main">
<UiCard 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="صنف">
<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.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 :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
<UiCard 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">
<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="توضیح">
<UiInput v-model="callBody" placeholder="خلاصه مکالمه" />
</UiField>
<UiField label="پیگیری بعدی">
<input v-model="callFollowUp" class="native" type="datetime-local" />
</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="زمان">
<input v-model="followUpAt" class="native" type="datetime-local" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="followUpBody" placeholder="اختیاری" />
</UiField>
<UiButton size="sm" :disabled="busy || !followUpAt" @click="setFollowUp">
ذخیره پیگیری
</UiButton>
</UiCard>
</aside>
</div>
</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;
}
.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;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.meta {
grid-template-columns: 1fr;
}
}
</style>