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:
@@ -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>
|
||||
Reference in New Issue
Block a user