Files
CRM_Montzar/apps/web/src/components/dashboard/HomeAiAssistant.vue
T
MahdiandCursor cf326d1ed9 fix: keep birthdays and show Shamsi dates
Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:04:53 +03:30

605 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
const props = defineProps<{
askQuery: string;
asking?: boolean;
answer?: string;
error?: string;
sources?: AskMentoSourceDto[];
}>();
const emit = defineEmits<{
'update:askQuery': [value: string];
ask: [preset?: string];
clear: [];
busy: [value: boolean];
notice: [value: { error: string; done: string }];
}>();
const shortcuts = [
{ label: 'معاملات باز و در خطر را بگو', icon: 'warning' as const },
{ label: 'فعالیت‌های عقب‌افتاده چیست؟', icon: 'calendar' as const },
{ label: 'مهم‌ترین پیگیری امروز چیست؟', icon: 'send' as const },
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const },
];
function sourceTo(source: AskMentoSourceDto) {
if (source.type === 'person') return { name: 'person-detail', params: { id: source.id } };
if (source.type === 'activity') return { name: 'activity-detail', params: { id: source.id } };
if (source.type === 'deal') return { name: 'deal-detail', params: { id: source.id } };
return { name: 'notes' };
}
function pick(label: string) {
emit('update:askQuery', label);
emit('ask', label);
}
const ACTIVITY_LABEL: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const voiceBuf = ref('');
const voiceBusy = ref(false);
const voiceError = ref('');
const voiceDone = ref('');
const draft = ref<VoiceCommandDraft | null>(null);
const confirming = ref(false);
const answerOpen = ref(false);
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) return fallback;
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
function onVoiceTranscript(text: string) {
voiceBuf.value = `${voiceBuf.value} ${text}`.trim();
}
function looksLikeAction(text: string) {
return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test(
text,
);
}
function refreshDraft() {
const current = draft.value;
if (!current) return;
if (current.intent === 'create_person' && current.person) {
current.missing = current.person.firstName.trim() ? [] : ['نام'];
} else if (current.intent === 'create_activity' && current.activity) {
current.missing = current.activity.title.trim() ? [] : ['عنوان فعالیت'];
}
current.canConfirm = current.missing.length === 0;
}
function onPersonNameEdit() {
refreshDraft();
}
function onActivityTitleEdit() {
refreshDraft();
}
function onActivityPersonEdit() {
if (draft.value?.activity) draft.value.activity.personId = null;
}
const dueLocal = computed(() => {
const iso = draft.value?.activity?.dueAt;
if (!iso) return '';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '' : formatLocalDateTimeInput(date);
});
function onDueLocal(value: string) {
if (!draft.value?.activity) return;
if (!value) {
draft.value.activity.dueAt = null;
return;
}
const date = new Date(value);
draft.value.activity.dueAt = Number.isNaN(date.getTime()) ? null : date.toISOString();
}
async function rematchActivityPerson() {
const activity = draft.value?.activity;
const name = activity?.personName.trim();
if (!activity || !name) {
if (activity) activity.personId = null;
return;
}
try {
const { data } = await api.get<{ items: Array<{ id: string; fullName: string }> }>('/people', {
params: { q: name, limit: 5 },
});
const exact = data.items.filter((row) => row.fullName.trim() === name);
const hit = exact.length === 1 ? exact[0] : data.items.length === 1 ? data.items[0] : null;
activity.personId = hit?.id ?? null;
} catch {
activity.personId = null;
}
}
async function parseCommand(text: string, fallbackToAsk: boolean) {
voiceBusy.value = true;
voiceError.value = '';
voiceDone.value = '';
draft.value = null;
emit('clear');
try {
const { data } = await api.post<VoiceCommandDraft>('/ai/voice-command', { text });
if (data.intent === 'none') {
if (fallbackToAsk && !looksLikeAction(text)) {
emit('ask', text);
return;
}
voiceError.value = looksLikeAction(text)
? 'دستور را کامل نفهمیدم. نام و مشخصات را واضح‌تر بگویید تا قبل از ساخت، تأیید کنید.'
: rewriteGregorianDates(data.summary);
return;
}
draft.value = data;
} catch (e) {
voiceError.value = apiMessage(e, 'فهمیدن دستور ناموفق بود.');
} finally {
voiceBusy.value = false;
}
}
async function onVoiceRecording(active: boolean) {
if (active) {
voiceBuf.value = '';
voiceError.value = '';
voiceDone.value = '';
draft.value = null;
return;
}
const text = voiceBuf.value.trim();
voiceBuf.value = '';
if (!text) return;
emit('update:askQuery', text);
await parseCommand(text, false);
}
async function onTypedSubmit() {
const text = props.askQuery.trim();
if (!text || props.asking || voiceBusy.value || confirming.value) return;
if (looksLikeAction(text)) {
await parseCommand(text, true);
return;
}
voiceError.value = '';
voiceDone.value = '';
draft.value = null;
emit('ask');
}
async function confirmDraft() {
const current = draft.value;
if (!current?.canConfirm || confirming.value) return;
confirming.value = true;
voiceError.value = '';
voiceDone.value = '';
try {
if (current.intent === 'create_person' && current.person) {
const p = current.person;
const { data } = await api.post<PersonDto>('/people', {
firstName: p.firstName,
lastName: p.lastName || undefined,
mobile: p.mobile || undefined,
phone: p.phone || undefined,
email: p.email || undefined,
website: p.website || undefined,
referrer: p.referrer || undefined,
position: p.position || undefined,
notes: p.notes || undefined,
});
voiceDone.value = `مشتری «${data.fullName}» ساخته شد.`;
} else if (current.intent === 'create_activity' && current.activity) {
const a = current.activity;
await api.post('/activities', {
title: a.title,
type: a.type,
description: a.description || undefined,
dueAt: a.dueAt,
personId: a.personId,
});
voiceDone.value = `فعالیت «${a.title}» ثبت شد.`;
}
draft.value = null;
emit('clear');
} catch (e) {
voiceError.value = apiMessage(e, 'انجام دستور ناموفق بود.');
} finally {
confirming.value = false;
}
}
function cancelDraft() {
if (confirming.value) return;
draft.value = null;
}
function closeAnswer() {
answerOpen.value = false;
emit('clear');
}
watch(
() => props.asking,
(active) => {
if (!active) return;
voiceDone.value = '';
voiceError.value = '';
answerOpen.value = false;
},
);
watch([voiceBusy, confirming], () => {
emit('busy', voiceBusy.value || confirming.value);
});
watch([voiceError, voiceDone], ([error, done]) => {
emit('notice', { error, done });
});
defineExpose({ submitFromInput: onTypedSubmit });
watch(
() => props.answer,
(value) => {
answerOpen.value = Boolean(value?.trim());
},
);
</script>
<template>
<article id="home-ai-assistant" class="panel">
<header class="panel-head">
<h2>
<span class="spark"><UiIcon name="sparkle" :size="15" /></span>
{{ brand.aiNameFa }}
</h2>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="asking || voiceBusy || confirming"
@transcript="onVoiceTranscript"
@recording="onVoiceRecording"
@error="voiceError = $event"
/>
</header>
<p class="ai-lead">چه کمکی می‌تونم بکنم؟</p>
<div class="ai-shortcuts">
<button
v-for="item in shortcuts"
:key="item.label"
class="ai-chip"
type="button"
:disabled="asking"
@click="pick(item.label)"
>
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
<span>{{ item.label }}</span>
</button>
</div>
<div v-if="voiceBusy" class="ai-answer"><p>در حال فهمیدن دستور…</p></div>
<div v-else-if="confirming" class="ai-answer"><p>در حال ثبت…</p></div>
<div v-else-if="voiceDone" class="ai-answer"><p>{{ voiceDone }}</p></div>
<p v-else-if="voiceError" class="ai-error">{{ voiceError }}</p>
<p v-else-if="error" class="ai-error">{{ error }}</p>
<div v-else-if="asking" class="ai-answer">
<p>در حال پاسخ…</p>
</div>
<form class="ai-input" @submit.prevent="onTypedSubmit">
<input
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking || voiceBusy || confirming"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming">
<UiIcon name="send" :size="15" />
</button>
</form>
<UiModal
:open="Boolean(draft && draft.intent !== 'none')"
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
@close="cancelDraft"
>
<p v-if="draft" class="modal-lead">{{ rewriteGregorianDates(draft.summary) }}</p>
<p class="edit-hint">قبل از تأیید می‌توانید متن را اصلاح کنید.</p>
<div v-if="draft?.person" class="modal-fields">
<label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label>
<label>نام خانوادگی <input v-model="draft.person.lastName" /></label>
<label>موبایل <input v-model="draft.person.mobile" dir="ltr" /></label>
<label>تلفن <input v-model="draft.person.phone" dir="ltr" /></label>
<label>ایمیل <input v-model="draft.person.email" dir="ltr" /></label>
<label>وب‌سایت <input v-model="draft.person.website" dir="ltr" /></label>
<label>معرف <input v-model="draft.person.referrer" /></label>
<label>سمت <input v-model="draft.person.position" /></label>
<label>یادداشت <textarea v-model="draft.person.notes" rows="2" /></label>
</div>
<div v-else-if="draft?.activity" class="modal-fields">
<label>عنوان <input v-model="draft.activity.title" @input="onActivityTitleEdit" /></label>
<label>
نوع
<select v-model="draft.activity.type">
<option v-for="(label, key) in ACTIVITY_LABEL" :key="key" :value="key">{{ label }}</option>
</select>
</label>
<label>
زمان
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
</label>
<label>
مشتری
<input v-model="draft.activity.personName" @input="onActivityPersonEdit" @change="rematchActivityPerson" />
</label>
<p v-if="draft.activity.personName && !draft.activity.personId" class="warn">
در فهرست مشتریان پرونده‌ای با این نام پیدا نشد. فعالیت ذخیره می‌شود، ولی داخل پروندهٔ آن شخص دیده نمی‌شود.
</p>
<label>توضیح <textarea v-model="draft.activity.description" rows="2" /></label>
</div>
<p v-if="draft?.missing.length" class="ai-error">ناقص است: {{ draft.missing.join('، ') }}</p>
<template #footer>
<UiButton variant="ghost" :disabled="confirming" @click="cancelDraft">انصراف</UiButton>
<UiButton variant="primary" :disabled="!draft?.canConfirm || confirming" :loading="confirming" @click="confirmDraft">
تأیید و انجام
</UiButton>
</template>
</UiModal>
<UiModal :open="answerOpen && Boolean(answer)" title="پاسخ دستیار" @close="closeAnswer">
<p class="modal-lead answer-body">{{ rewriteGregorianDates(answer || '') }}</p>
<div v-if="sources?.length" class="sources">
<RouterLink
v-for="source in sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
@click="closeAnswer"
>
{{ source.label }}
</RouterLink>
</div>
<template #footer>
<UiButton variant="primary" @click="closeAnswer">بستن</UiButton>
</template>
</UiModal>
</article>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
margin-bottom: 0.55rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.modal-lead {
margin: 0 0 0.85rem;
color: #334155;
font-size: 0.9rem;
line-height: 1.7;
}
.answer-body {
white-space: pre-wrap;
}
.edit-hint {
margin: -0.35rem 0 0.75rem;
color: #64748b;
font-size: 0.78rem;
}
.modal-fields {
display: grid;
gap: 0.55rem;
}
.modal-fields label {
display: grid;
gap: 0.25rem;
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
}
.modal-fields input,
.modal-fields select,
.modal-fields textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
font: inherit;
font-size: 0.88rem;
font-weight: 500;
padding: 0.5rem 0.65rem;
}
.modal-fields textarea {
resize: vertical;
}
.warn {
display: inline-block;
margin-inline-start: 0.35rem;
color: #b45309;
font-size: 0.75rem;
}
h2 {
margin: 0;
font-size: 0.92rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #0f172a;
}
.spark {
color: #eab308;
display: grid;
}
.ai-lead {
margin: 0 0 0.75rem;
padding: 0.55rem 0.75rem;
width: fit-content;
max-width: 100%;
background: #d1fae5;
color: #115e59;
font-size: 0.8rem;
font-weight: 600;
border-radius: 14px 14px 4px 14px;
}
.ai-shortcuts {
display: grid;
gap: 0.4rem;
margin-bottom: 0.8rem;
flex: 1;
}
.ai-chip {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.58rem 0.7rem;
border-radius: var(--radius);
background: #fff;
border: 1px solid #eef2f7;
color: #334155;
font-size: 0.76rem;
text-align: start;
font: inherit;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.ai-chip:hover:not(:disabled) {
background: #f0fdfa;
border-color: #99f6e4;
}
.ai-chip:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-ico {
width: 26px;
height: 26px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
place-items: center;
flex-shrink: 0;
}
.ai-input {
display: flex;
gap: 0.4rem;
margin-top: auto;
}
.ai-input input {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.58rem 0.75rem;
background: #f8fafc;
font-size: 0.8rem;
}
.ai-input button {
width: 40px;
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.ai-input button:disabled {
opacity: 0.55;
}
.ai-answer {
background: #f0fdfa;
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
margin-bottom: 0.75rem;
}
.ai-answer p {
margin: 0;
color: #134e4a;
font-size: 0.82rem;
line-height: 1.7;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.5rem;
}
.sources a {
font-size: 0.72rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.15rem 0.5rem;
}
.ai-error {
margin: 0 0 0.7rem;
color: #b91c1c;
font-size: 0.8rem;
}
</style>