Add standalone Leads module for prospect outreach before customers.
Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
<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('');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 && !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="اطلاعات لید">
|
||||
<dl 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>{{ 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.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.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user