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

604 lines
17 KiB
Vue
Raw Normal View History

<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 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>
<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>