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>
This commit is contained in:
2026-10-18 21:04:26 +03:30
co-authored by Cursor
parent 96befd3867
commit 002f697dfe
27 changed files with 2838 additions and 26 deletions
@@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{
label: string | null | undefined;
}>();
</script>
<template>
<span v-if="label" class="disc-badge" :title="`تیپ رفتاری احتمالی ${label}`">{{ label }}</span>
</template>
<style scoped>
.disc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: #ecfeff;
color: #0f766e;
border: 1px solid #99f6e4;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1.4;
}
</style>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
DISC_CONFIDENCE_LABELS,
DISC_GUIDES,
DISC_QUESTIONS,
DISC_TYPE_SHORT,
DISC_TYPES,
discGuideFor,
scoreDisc,
type DiscAnswers,
type DiscAssessmentDto,
type DiscType,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton } from '@/components/ui';
const props = defineProps<{ personId: string }>();
const emit = defineEmits<{ saved: [] }>();
const history = ref<DiscAssessmentDto[]>([]);
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const editing = ref(false);
const answers = ref<DiscAnswers>({});
const note = ref('');
const compareA = ref('');
const compareB = ref('');
const preview = computed(() => scoreDisc(answers.value));
const guide = computed(() =>
preview.value.reliable ? discGuideFor(preview.value.primaryTypes) : null,
);
const latest = computed(() => history.value[0] ?? null);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<DiscAssessmentDto[]>(`/behavior/people/${props.personId}/disc`);
history.value = data;
compareA.value = data[0]?.id ?? '';
compareB.value = data[1]?.id ?? '';
} catch {
error.value = 'تاریخچهٔ ارزیابی بارگذاری نشد.';
} finally {
loading.value = false;
}
}
function start() {
answers.value = {};
note.value = '';
editing.value = true;
error.value = '';
}
function choose(questionId: string, type: DiscType) {
answers.value = { ...answers.value, [questionId]: type };
}
function skip(questionId: string) {
const next = { ...answers.value };
delete next[questionId];
answers.value = next;
}
async function save() {
saving.value = true;
error.value = '';
try {
await api.post(`/behavior/people/${props.personId}/disc`, {
answers: answers.value,
note: note.value,
});
editing.value = false;
await load();
emit('saved');
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } }).response?.data?.message;
error.value = typeof message === 'string' ? message : 'ذخیرهٔ ارزیابی ناموفق بود.';
} finally {
saving.value = false;
}
}
function row(id: string) {
return history.value.find((item) => item.id === id) ?? null;
}
watch(() => props.personId, () => void load(), { immediate: true });
function when(value: string) {
return formatJalaliNumeric(new Date(value));
}
</script>
<template>
<section class="disc">
<p class="banner">
این ابزار برای کمک به فروش است و آزمون روان‌شناختی یا تشخیص شخصیت نیست. درصدها فقط سهم پاسخ‌های همین فرم هستند.
</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری ارزیابی‌ها…</p>
<article v-else-if="latest && !editing" class="card">
<header class="head">
<div>
<h3>تیپ غالب احتمالی: {{ latest.reliable ? latest.label : 'نامشخص' }}</h3>
<p v-if="latest.secondaryLabel">تیپ دوم: {{ latest.secondaryLabel }}</p>
<p class="muted">
اطمینان تخمینی: {{ DISC_CONFIDENCE_LABELS[latest.confidence] }}
· {{ when(latest.createdAt) }}
· {{ latest.assessorName }}
</p>
</div>
<UiButton size="sm" variant="primary" @click="start">شروع ارزیابی DISC</UiButton>
</header>
<ul class="bars">
<li v-for="type in DISC_TYPES" :key="type">
<span>{{ type }} · {{ DISC_TYPE_SHORT[type] }}</span>
<i><b :style="{ width: `${latest.percents[type]}%` }" /></i>
<em>{{ toFaDigits(latest.scores[type]) }} · {{ toFaDigits(latest.percents[type]) }}٪</em>
</li>
</ul>
<p v-if="latest.note" class="note">{{ latest.note }}</p>
</article>
<div v-else-if="!editing" class="card empty">
<p>هنوز ارزیابی رفتاری برای این مشتری ثبت نشده است.</p>
<UiButton size="sm" variant="primary" @click="start">شروع ارزیابی DISC</UiButton>
</div>
<form v-if="editing" class="card" @submit.prevent="save">
<header class="head">
<h3>ارزیابی جدید</h3>
<button type="button" class="link" @click="editing = false">انصراف</button>
</header>
<p class="muted">اگر شواهد کافی ندارید سؤال را رد کنید. با کمتر از چهار پاسخ، نتیجهٔ قابل اتکا ذخیره نمی‌شود ولی سابقه می‌ماند.</p>
<fieldset v-for="question in DISC_QUESTIONS" :key="question.id">
<legend>{{ toFaDigits(question.id) }}. {{ question.prompt }}</legend>
<label v-for="option in question.options" :key="option.key">
<input
type="radio"
:name="`q-${question.id}`"
:checked="answers[question.id] === option.type"
@change="choose(question.id, option.type)"
/>
<span><b>{{ option.key }}.</b> {{ option.text }}</span>
</label>
<button type="button" class="link" @click="skip(question.id)">رد کردن این سؤال</button>
</fieldset>
<div class="preview">
<h4>{{ preview.reliable ? `نتیجهٔ پیش‌نویس: ${preview.label}` : preview.label }}</h4>
<p v-if="preview.secondaryLabel">تیپ دوم احتمالی: {{ preview.secondaryLabel }}</p>
<ul class="bars">
<li v-for="type in DISC_TYPES" :key="type">
<span>{{ type }}</span>
<i><b :style="{ width: `${preview.percents[type]}%` }" /></i>
<em>{{ toFaDigits(preview.scores[type]) }}</em>
</li>
</ul>
<p class="muted">{{ preview.shareNote }}</p>
<template v-if="guide">
<h4>راهنمای احتمالی فروش</h4>
<p class="muted">این پیشنهادها را با رفتار واقعی مشتری تطبیق دهید.</p>
<ul>
<li v-for="line in guide.negotiation" :key="line">{{ line }}</li>
</ul>
<h4>ارائه</h4>
<ul>
<li v-for="line in guide.presentation" :key="line">{{ line }}</li>
</ul>
<h4>پیگیری</h4>
<ul>
<li v-for="line in guide.followUp" :key="line">{{ line }}</li>
</ul>
</template>
<label class="note-field">
یادداشت رفتاری و شواهد
<textarea v-model="note" rows="3" maxlength="4000" placeholder="چه رفتاری را واقعاً دیدید؟" />
</label>
</div>
<UiButton type="submit" variant="primary" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'تأیید و ذخیره در پرونده' }}
</UiButton>
</form>
<article v-if="history.length" class="card">
<h3>تاریخچه</h3>
<ul class="history">
<li v-for="item in history" :key="item.id">
<strong>{{ item.reliable ? item.label : 'ناکافی' }}</strong>
<span>{{ when(item.createdAt) }} · {{ item.assessorName }}</span>
<span>D{{ toFaDigits(item.scores.D) }} I{{ toFaDigits(item.scores.I) }} S{{ toFaDigits(item.scores.S) }} C{{ toFaDigits(item.scores.C) }}</span>
</li>
</ul>
<div v-if="history.length > 1" class="compare">
<label>
مقایسه
<select v-model="compareA">
<option v-for="item in history" :key="item.id" :value="item.id">{{ when(item.createdAt) }} · {{ item.label }}</option>
</select>
</label>
<label>
با
<select v-model="compareB">
<option v-for="item in history" :key="item.id" :value="item.id">{{ when(item.createdAt) }} · {{ item.label }}</option>
</select>
</label>
<table v-if="row(compareA) && row(compareB)">
<thead>
<tr><th>تیپ</th><th>اول</th><th>دوم</th></tr>
</thead>
<tbody>
<tr v-for="type in DISC_TYPES" :key="type">
<td>{{ type }}</td>
<td>{{ toFaDigits(row(compareA)!.scores[type]) }}</td>
<td>{{ toFaDigits(row(compareB)!.scores[type]) }}</td>
</tr>
</tbody>
</table>
</div>
</article>
<p v-if="latest" class="muted tiny">نقاط قوت احتمالی آخرین تیپ: {{ latest.primaryTypes.map((type) => DISC_GUIDES[type].strengths.join('، ')).join(' / ') || '—' }}</p>
</section>
</template>
<style scoped>
.disc { display: flex; flex-direction: column; gap: 0.8rem; }
.banner, .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 0.9rem 1rem; }
.banner { background: #f8fafc; color: #475569; font-size: 0.86rem; }
.head, .history li, .compare { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; flex-wrap: wrap; }
h3, h4 { margin: 0 0 0.35rem; }
.muted, .tiny { color: #64748b; font-size: 0.82rem; }
.error { color: #b91c1c; }
fieldset { border: 0; margin: 0.8rem 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
legend { font-weight: 700; margin-bottom: 0.25rem; }
label { display: flex; gap: 0.45rem; align-items: flex-start; }
.bars { list-style: none; padding: 0; margin: 0.5rem 0; display: flex; flex-direction: column; gap: 0.35rem; }
.bars li { display: grid; grid-template-columns: 7.5rem 1fr auto; gap: 0.45rem; align-items: center; font-size: 0.82rem; }
.bars i { display: block; height: 8px; background: #e2e8f0; border-radius: 99px; overflow: hidden; }
.bars b { display: block; height: 100%; background: #0f766e; }
.note-field, .compare { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.6rem; }
textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 10px; padding: 0.5rem; font: inherit; }
.link { border: 0; background: transparent; color: #0f766e; cursor: pointer; padding: 0; }
.history { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.45rem; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #e2e8f0; text-align: right; padding: 0.3rem; }
.empty { display: flex; justify-content: space-between; gap: 0.75rem; align-items: center; }
</style>
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
COPILOT_GOAL_LABELS,
COPILOT_GOALS,
COPILOT_TOPIC_LABELS,
COPILOT_TOPICS,
FOLLOW_UP_MESSAGE_KINDS,
FOLLOW_UP_MESSAGE_LABELS,
followUpMessage,
type CopilotAnalysis,
type CopilotFacts,
type CopilotGoal,
type CopilotTopic,
type FollowUpMessageKind,
type SalesCopilotSessionDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
const props = defineProps<{
personId?: string | null;
leadId?: string | null;
dealId?: string | null;
customerName?: string | null;
discLabel?: string | null;
stageName?: string | null;
phone?: string | null;
smsEndpoint?: string | null;
smsHistoryEndpoint?: string | null;
}>();
const topic = ref<CopilotTopic>('objection');
const goal = ref<CopilotGoal>('find_objection');
const statement = ref('');
const behaviorNotes = ref('');
const analysis = ref<CopilotAnalysis | null>(null);
const responseText = ref('');
const agentNote = ref('');
const callOutcome = ref('');
const followUpAt = ref('');
const messageKind = ref<FollowUpMessageKind>('whatsapp_after_call');
const messageText = ref('');
const busy = ref(false);
const error = ref('');
const savedId = ref('');
const history = ref<SalesCopilotSessionDto[]>([]);
const smsOpen = ref(false);
const step = ref<'form' | 'result'>('form');
const facts = computed<CopilotFacts>(() => ({
customerName: props.customerName,
stageName: props.stageName,
disc: props.discLabel
? {
label: props.discLabel,
primaryTypes: props.discLabel.split('/').filter((part): part is 'D' | 'I' | 'S' | 'C' =>
part === 'D' || part === 'I' || part === 'S' || part === 'C',
),
reliable: true,
assessedAt: new Date().toISOString(),
}
: null,
}));
const localMessage = computed(() =>
analysis.value ? followUpMessage(messageKind.value, facts.value, analysis.value) : '',
);
watch(localMessage, (value) => {
messageText.value = value;
});
async function loadHistory() {
try {
const { data } = await api.get<SalesCopilotSessionDto[]>('/behavior/copilot/sessions', {
params: {
personId: props.personId || undefined,
leadId: props.leadId || undefined,
dealId: props.dealId || undefined,
},
});
history.value = data;
} catch {
history.value = [];
}
}
async function analyze() {
busy.value = true;
error.value = '';
try {
const { data } = await api.post<CopilotAnalysis>('/behavior/copilot/analyze', payload());
analysis.value = data;
responseText.value = data.suggested_response;
followUpAt.value = data.suggested_follow_up_date ?? '';
step.value = 'result';
messageText.value = followUpMessage(messageKind.value, facts.value, data);
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } }).response?.data?.message;
error.value = typeof message === 'string' ? message : 'تحلیل مکالمه ناموفق بود.';
} finally {
busy.value = false;
}
}
function payload() {
return {
personId: props.personId || undefined,
leadId: props.leadId || undefined,
dealId: props.dealId || undefined,
topic: topic.value,
goal: goal.value,
customerStatement: statement.value,
behaviorNotes: behaviorNotes.value,
};
}
async function save(status: 'confirmed' | 'dismissed') {
if (!analysis.value) return;
busy.value = true;
error.value = '';
try {
const { data } = await api.post<SalesCopilotSessionDto>('/behavior/copilot/sessions', {
...payload(),
status,
confirmedResponse: responseText.value,
nextAction: analysis.value.next_best_action,
followUpAt: followUpAt.value || null,
callOutcome: callOutcome.value,
agentNote: agentNote.value,
messageDraft: messageText.value,
});
savedId.value = status === 'confirmed' ? data.id : '';
await loadHistory();
} catch {
error.value = 'ثبت نتیجه ناموفق بود.';
} finally {
busy.value = false;
}
}
async function createTask() {
if (!savedId.value) {
await save('confirmed');
}
if (!savedId.value) return;
busy.value = true;
try {
await api.post(`/behavior/copilot/sessions/${savedId.value}/follow-up`, {
dueAt: followUpAt.value || undefined,
});
error.value = '';
await loadHistory();
} catch {
error.value = 'ساخت وظیفهٔ پیگیری ناموفق بود.';
} finally {
busy.value = false;
}
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
error.value = 'کپی در این مرورگر ممکن نشد.';
}
}
watch(
() => [props.personId, props.leadId, props.dealId],
() => void loadHistory(),
{ immediate: true },
);
</script>
<template>
<section class="copilot">
<p class="banner">
دستیار مربی فروش است. پیشنهادش تا وقتی تأیید نکنید در پرونده به‌عنوان واقعیت ذخیره نمی‌شود و پیام بیرونی هم بدون تأیید شما ارسال نمی‌شود.
</p>
<article class="card summary">
<div>
<strong>{{ customerName || 'مخاطب' }}</strong>
<span v-if="stageName">مرحله: {{ stageName }}</span>
</div>
<span v-if="discLabel" class="pill">DISC احتمالی {{ discLabel }}</span>
<span v-else class="muted">پروفایل DISC تازه و معتبر نیست</span>
</article>
<form v-show="step === 'form'" class="card" @submit.prevent="analyze">
<label>
موضوع مکالمه
<select v-model="topic">
<option v-for="item in COPILOT_TOPICS" :key="item" :value="item">{{ COPILOT_TOPIC_LABELS[item] }}</option>
</select>
</label>
<label>
هدف شما
<select v-model="goal">
<option v-for="item in COPILOT_GOALS" :key="item" :value="item">{{ COPILOT_GOAL_LABELS[item] }}</option>
</select>
</label>
<label>
حرف مشتری
<textarea v-model="statement" rows="4" required placeholder="جملهٔ دقیق مشتری، اعتراض یا دغدغه‌اش" />
</label>
<label>
نشانه‌های رفتاری مشاهده‌شده
<textarea v-model="behaviorNotes" rows="2" placeholder="مثلاً عجله داشت، جزئیات خواست، یا خواست مشورت کند" />
</label>
<UiButton type="submit" variant="primary" :disabled="busy">تحلیل مکالمه و پیشنهاد راهکار</UiButton>
</form>
<div v-if="analysis && step === 'result'" class="stack">
<article class="card">
<header class="head">
<h3>تحلیل</h3>
<button type="button" class="link" @click="step = 'form'">ویرایش ورودی</button>
</header>
<p>{{ analysis.customer_statement_summary }}</p>
<p v-if="analysis.objection_label"><b>دستهٔ احتمالی:</b> {{ analysis.objection_label }}</p>
<ul>
<li v-for="reason in analysis.possible_underlying_reasons" :key="reason">{{ reason }}</li>
</ul>
<p v-if="analysis.missing_information.length" class="warn">
اطلاعات ناموجود: {{ analysis.missing_information.join('؛ ') }}
</p>
<p class="muted">{{ analysis.confidence_note }}</p>
<p v-if="analysis.disc_context" class="muted">{{ analysis.disc_context.note }}</p>
</article>
<article class="card">
<h3>سؤال پیشنهادی</h3>
<p>{{ analysis.recommended_question }}</p>
<button type="button" class="link" @click="copy(analysis.recommended_question)">کپی سؤال</button>
</article>
<article class="card">
<h3>پاسخ پیشنهادی</h3>
<textarea v-model="responseText" rows="5" />
<div class="actions">
<UiButton size="sm" variant="outline" type="button" @click="copy(responseText)">کپی پاسخ</UiButton>
<UiButton size="sm" variant="outline" type="button" :disabled="busy" @click="analyze">تولید پاسخ جدید</UiButton>
</div>
<p class="warn">خطای رایج: {{ analysis.common_mistake }}</p>
</article>
<article class="card">
<h3>راهبرد</h3>
<ul>
<li v-for="line in analysis.sales_strategy" :key="line">{{ line }}</li>
</ul>
<h3>اقدام بعدی</h3>
<p>{{ analysis.next_best_action }}</p>
<label>
تاریخ پیگیری
<input v-model="followUpAt" type="date" />
</label>
<label>
نتیجهٔ تماس
<input v-model="callOutcome" type="text" maxlength="500" />
</label>
<label>
یادداشت کارشناس
<textarea v-model="agentNote" rows="2" />
</label>
</article>
<article class="card">
<h3>متن پیگیری</h3>
<select v-model="messageKind">
<option v-for="kind in FOLLOW_UP_MESSAGE_KINDS" :key="kind" :value="kind">{{ FOLLOW_UP_MESSAGE_LABELS[kind] }}</option>
</select>
<textarea v-model="messageText" rows="4" />
<div class="actions">
<UiButton size="sm" variant="outline" type="button" @click="copy(messageText)">کپی متن</UiButton>
<UiButton v-if="smsEndpoint && phone" size="sm" variant="outline" type="button" @click="smsOpen = true">
ارسال پیامک با تأیید
</UiButton>
</div>
</article>
<div class="actions">
<UiButton variant="primary" type="button" :disabled="busy" @click="save('confirmed')">ثبت نتیجه</UiButton>
<UiButton variant="outline" type="button" :disabled="busy" @click="createTask">ایجاد وظیفه پیگیری</UiButton>
<UiButton variant="ghost" type="button" :disabled="busy" @click="save('dismissed')">رد پیشنهاد</UiButton>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<article v-if="history.length" class="card">
<h3>نتایج ثبت‌شده</h3>
<ul class="history">
<li v-for="item in history" :key="item.id">
<strong>{{ item.status === 'confirmed' ? 'تأییدشده' : 'ردشده' }}</strong>
<span>{{ formatJalaliNumeric(new Date(item.createdAt)) }} · {{ item.userName }}</span>
<span>{{ item.analysis.objection_label || 'بدون دسته' }}</span>
<span v-if="item.activityId">وظیفه ساخته شد</span>
</li>
</ul>
<p class="muted">{{ toFaDigits(history.length) }} نتیجه در تاریخچه مانده است.</p>
</article>
<SmsComposeModal
v-if="smsEndpoint && phone"
:open="smsOpen"
:endpoint="smsEndpoint"
:history-endpoint="smsHistoryEndpoint || ''"
:name="customerName || ''"
:phone="phone"
:default-text="messageText"
title="ارسال پیامک پیگیری"
@close="smsOpen = false"
/>
</section>
</template>
<style scoped>
.copilot, .stack { display: flex; flex-direction: column; gap: 0.75rem; }
.banner, .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 0.9rem 1rem; }
.banner { background: #f8fafc; color: #475569; font-size: 0.86rem; }
.summary, .head, .actions, .history li { display: flex; justify-content: space-between; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
label { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.55rem; font-weight: 700; }
textarea, select, input { font: inherit; font-weight: 400; border: 1px solid #cbd5e1; border-radius: 10px; padding: 0.5rem; width: 100%; }
.pill { background: #ecfeff; color: #0f766e; border-radius: 999px; padding: 0.1rem 0.55rem; font-size: 0.78rem; font-weight: 800; }
.muted { color: #64748b; font-size: 0.82rem; font-weight: 400; }
.warn { color: #9a3412; }
.error { color: #b91c1c; }
.link { border: 0; background: transparent; color: #0f766e; cursor: pointer; }
.history { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; }
h3 { margin: 0 0 0.4rem; }
ul { margin: 0.3rem 0 0; padding-inline-start: 1.1rem; }
</style>
+10
View File
@@ -0,0 +1,10 @@
import type { AuthUserDto } from '@mento/shared';
export function canUseBehavior(user: AuthUserDto | null | undefined): boolean {
if (!user) return false;
const modules = user.enabledModules;
if (modules && !modules.includes('behavior')) return false;
if (user.role === 'owner' || user.role === 'admin') return true;
const permissions = user.permissions ?? [];
return permissions.includes('behavior') || permissions.includes('sales');
}
+9
View File
@@ -82,6 +82,11 @@ export const router = createRouter({
name: 'reports',
component: () => import('@/views/ReportsView.vue'),
},
{
path: 'behavior/disc',
name: 'disc-report',
component: () => import('@/views/DiscReportView.vue'),
},
{
path: 'sales/forecast',
name: 'sales-forecast',
@@ -563,6 +568,10 @@ router.beforeEach((to) => {
) {
return { name: 'home' };
}
if (String(to.path).startsWith('/behavior')) {
const modules = auth.user?.enabledModules;
if (modules && !modules.includes('behavior')) return { name: 'home' };
}
if (String(to.path).startsWith('/finance')) {
const modules = auth.user?.enabledModules;
if (modules && !modules.includes('treasury')) return { name: 'home' };
+32 -5
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
@@ -36,6 +37,8 @@ import {
PERSON_IMPORT_SOURCES,
} from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
type Tab = 'people' | 'companies';
@@ -58,6 +61,12 @@ const moodFilter = computed<PersonMood | 'all'>(() => {
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
});
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const discFilter = computed(() => {
const value = typeof route.query.disc === 'string' ? route.query.disc.toUpperCase() : '';
return /^[DISC](\/[DISC]){0,3}$/.test(value) ? value : '';
});
const sourceFilter = computed<PersonImportSource | 'all'>(() => {
const value = typeof route.query.source === 'string' ? route.query.source : 'all';
return PERSON_IMPORT_SOURCES.includes(value as PersonImportSource)
@@ -135,6 +144,7 @@ const peopleSavedFilters = computed(() => ({
sort: sort.value === 'name' ? undefined : sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
source: sourceFilter.value === 'all' ? undefined : sourceFilter.value,
disc: discFilter.value || undefined,
}));
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
@@ -223,6 +233,7 @@ async function load() {
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
importSource:
sourceFilter.value === 'all' ? undefined : sourceFilter.value,
disc: discFilter.value || undefined,
},
}),
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
@@ -280,6 +291,10 @@ function setMood(next: PersonMood | 'all') {
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
}
function setDisc(next: string) {
patchQuery({ disc: next || undefined, page: undefined });
}
function setSource(next: PersonImportSource | 'all') {
patchQuery({
source: next === 'all' ? undefined : next,
@@ -394,7 +409,7 @@ function onImportSettled() {
}
watch(
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood],
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
@@ -412,6 +427,9 @@ onMounted(load);
lead="مدیریت و مشاهده اشخاص و شرکت‌ها"
>
<template #actions>
<RouterLink v-if="canBehavior" :to="{ name: 'disc-report' }">
<UiButton variant="outline" size="sm">گزارش DISC</UiButton>
</RouterLink>
<UiButton variant="ghost" size="sm" :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
@@ -538,6 +556,13 @@ onMounted(load);
</button>
</div>
<div v-if="tab === 'people' && canBehavior" class="source-stats">
<button type="button" class="source-chip" :class="{ on: !discFilter }" @click="setDisc('')">همه تیپ‌ها</button>
<button v-for="type in ['D', 'I', 'S', 'C']" :key="type" type="button" class="source-chip" :class="{ on: discFilter === type }" @click="setDisc(type)">
{{ type }}
</button>
</div>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
@@ -551,6 +576,7 @@ onMounted(load);
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<DiscBadge v-if="canBehavior" :label="p.discLabel" />
<UiDropdownMenu
:open="menuId === `m:${p.id}`"
align="end"
@@ -648,10 +674,11 @@ onMounted(load);
</div>
</td>
<td>
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<div v-if="p.previousFullName" class="muted tiny">
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<DiscBadge v-if="canBehavior" :label="p.discLabel" />
<div v-if="p.previousFullName" class="muted tiny">
قبلی: {{ p.previousFullName }}
</div>
</td>
+8
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canUseBehavior } from '@/lib/behavior-access';
import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
@@ -60,6 +61,7 @@ const emit = defineEmits<{
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const id = computed(() => {
if (props.embedded) return props.dealId ?? '';
return typeof route.params.id === 'string' ? route.params.id : '';
@@ -552,6 +554,12 @@ onMounted(load);
>
<UiButton variant="outline">صدور فاکتور</UiButton>
</RouterLink>
<RouterLink
v-if="!isNew && personId && canBehavior"
:to="{ name: 'person-detail', params: { id: personId }, query: { tab: 'copilot', dealId: id } }"
>
<UiButton variant="outline">کمک برای ادامه مذاکره</UiButton>
</RouterLink>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { DiscDistributionRow } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiPageHeader } from '@/components/ui';
const rows = ref<DiscDistributionRow[]>([]);
const error = ref('');
const loading = ref(true);
const exporting = ref(false);
onMounted(async () => {
try {
const { data } = await api.get<DiscDistributionRow[]>('/behavior/disc/distribution');
rows.value = data;
} catch {
error.value = 'گزارش توزیع تیپ‌ها بارگذاری نشد.';
} finally {
loading.value = false;
}
});
async function exportExcel() {
exporting.value = true;
error.value = '';
try {
const { data } = await api.get<Blob>('/behavior/disc/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const link = document.createElement('a');
link.href = url;
link.download = 'disc.xlsx';
link.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
exporting.value = false;
}
}
const total = () => rows.value.reduce((sum, row) => sum + row.count, 0);
</script>
<template>
<section class="page">
<UiPageHeader title="توزیع تیپ‌های DISC" lead="فقط ارزیابی‌های تأییدشده و قابل اتکا، در محدودهٔ دسترسی شما" :back-to="{ name: 'contacts' }">
<template #actions>
<UiButton variant="outline" :disabled="exporting" @click="exportExcel">خروجی اکسل</UiButton>
</template>
</UiPageHeader>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="error" class="error">{{ error }}</p>
<p v-else-if="!rows.length" class="muted">هنوز تیپ قابل اتکایی ثبت نشده است.</p>
<ul v-else class="list">
<li v-for="row in rows" :key="row.label">
<RouterLink :to="{ name: 'contacts', query: { disc: row.label } }">{{ row.label }}</RouterLink>
<span>{{ toFaDigits(row.count) }}</span>
<i><b :style="{ width: `${total() ? Math.round((row.count / total()) * 100) : 0}%` }" /></i>
</li>
</ul>
<p class="muted">درصدها سهم مشتریان دارای ارزیابی معتبر هستند، نه دقت یک آزمون روان‌شناسی.</p>
</section>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 0.8rem; }
.list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.55rem; }
.list li { display: grid; grid-template-columns: 6rem auto 1fr; gap: 0.6rem; align-items: center; }
.list i { height: 8px; background: #e2e8f0; border-radius: 99px; overflow: hidden; }
.list b { display: block; height: 100%; background: #0f766e; }
.muted { color: #64748b; }
.error { color: #b91c1c; }
</style>
+25
View File
@@ -30,6 +30,8 @@ import type {
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[] };
@@ -37,6 +39,8 @@ 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);
@@ -382,6 +386,14 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
>
پیامک
</UiButton>
<UiButton
v-if="lead && !editing && canBehavior"
variant="secondary"
size="sm"
@click="copilotOpen = !copilotOpen"
>
کمک برای ادامه مذاکره
</UiButton>
<UiButton
v-if="lead && !editing"
variant="secondary"
@@ -412,6 +424,16 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</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"
@@ -719,6 +741,9 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<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">
ارسال پیامک
+50 -11
View File
@@ -48,8 +48,12 @@ import type {
TicketDto,
} from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import DiscPanel from '@/components/behavior/DiscPanel.vue';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info' | 'disc' | 'copilot';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
@@ -71,14 +75,25 @@ type AttachmentRow = {
kind: 'pdf' | 'doc' | 'img' | 'file';
};
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const TABS = computed<Array<{ key: TabKey; label: string }>>(() => {
const items: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
if (canBehavior.value) {
items.push(
{ key: 'disc', label: 'تحلیل رفتاری DISC' },
{ key: 'copilot', label: 'دستیار هوشمند فروش' },
);
}
return items;
});
const HISTORY_FILTERS: Array<{ key: HistoryFilter; label: string }> = [
{ key: 'all', label: 'همه' },
@@ -182,7 +197,6 @@ const previousSourceLabel = computed(() => {
return SOURCE_LABELS[src] || src;
});
const auth = useAuthStore();
const priorArchiveOn = computed(() => Boolean(auth.user?.priorArchive));
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? null);
@@ -451,8 +465,14 @@ function readTab() {
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
q === 'info'
q === 'info' ||
q === 'disc' ||
q === 'copilot'
) {
if ((q === 'disc' || q === 'copilot') && !canBehavior.value) {
tab.value = 'activities';
return;
}
tab.value = q;
} else {
tab.value = 'activities';
@@ -696,6 +716,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
<h2>{{ fullName }}</h2>
<span class="mood-under" :class="mood">{{ moodLabel }}</span>
<span v-if="vip" class="vip-mark">VIP</span>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
</div>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<div class="meta-chips">
@@ -787,6 +808,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
</div>
</div>
<div class="contact-rows">
@@ -1018,6 +1040,23 @@ watch(() => route.query.tab, readTab, { immediate: true });
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>
<div v-if="canBehavior && tab === 'disc'" class="panel">
<DiscPanel :person-id="id" @saved="load" />
</div>
<div v-if="canBehavior && tab === 'copilot'" class="panel">
<SalesCopilotPanel
:person-id="id"
:deal-id="typeof route.query.dealId === 'string' ? route.query.dealId : null"
:customer-name="fullName"
:disc-label="person.discReliable ? person.discLabel : null"
:stage-name="null"
:phone="phoneNumber"
:sms-endpoint="`/people/${id}/sms`"
:sms-history-endpoint="`/people/${id}/sms-history`"
/>
</div>
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
<article class="smart">
<header class="smart-head">