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>