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:
@@ -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>
|
||||
|
||||
@@ -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 ? 'در حال ذخیره…' : 'ذخیره معامله' }}
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
ارسال پیامک
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user