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