Files
CRM_Montzar/apps/web/src/components/dashboard/HomeAiAssistant.vue
T
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30

515 lines
17 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 { RobotOutlined } from '@ant-design/icons-vue';
import {
Alert as AAlert,
Button as AButton,
Col as ACol,
Form as AForm,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
import { api } from '@/lib/api';
import { brand, 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 { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const AFormItem = AForm.Item;
const ATextarea = AInput.TextArea;
const shortcuts = [
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
{ label: 'عقب‌افتاده‌ها', prompt: 'فعالیت‌های عقب‌افتاده چیست؟' },
{ label: 'پیگیری امروز', prompt: 'مهم‌ترین پیگیری امروز چیست؟' },
{ label: 'خلاصه فروش', prompt: 'خلاصه وضعیت فروش را بده' },
];
const activityTypeOptions = [
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'task', label: 'کار' },
{ value: 'note', label: 'یادداشت' },
];
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 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);
const draftOpen = computed({
get: () => Boolean(draft.value && draft.value.intent !== 'none'),
set: (open: boolean) => {
if (!open) cancelDraft();
},
});
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) {
if (/[؟?]/.test(text) || /چند|تعداد|چقدر|چنتا|چیست|چیه|کدام|کدوم|چرا|چطور|چگونه/.test(text)) {
return false;
}
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>
<section id="home-ai-assistant" class="bg-white rounded-2xl border border-slate-200/80 p-3 shadow-sm flex flex-col justify-between h-[330px] gap-3">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
<RobotOutlined />
</span>
<h3 class="font-bold text-slate-800 text-base truncate">{{ brand.aiNameFa }}</h3>
</div>
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag>
</div>
<div class="bg-emerald-50/50 border border-emerald-200/60 rounded-xl p-3 text-xs text-slate-600 leading-relaxed">
<template v-if="answer">{{ answer }}</template>
<template v-else-if="error">{{ error }}</template>
<template v-else>
<strong class="text-slate-800">پیشنهاد هوشمند امروز:</strong>
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
</template>
</div>
<div class="bg-slate-50 border border-slate-200/90 rounded-xl p-1.5 flex items-center gap-2">
<a-input
:value="askQuery"
:placeholder="brand.askPlaceholderFa"
:bordered="false"
:disabled="asking || voiceBusy || confirming"
class="flex-1"
@update:value="emit('update:askQuery', $event)"
@press-enter="onTypedSubmit"
/>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="asking || voiceBusy || confirming"
@transcript="onVoiceTranscript"
@recording="onVoiceRecording"
@error="voiceError = $event"
/>
<a-button
type="primary"
:disabled="asking || voiceBusy || confirming"
:loading="asking || voiceBusy"
aria-label="ارسال"
@click="onTypedSubmit"
>
بپرس
</a-button>
</div>
<div class="pt-3 border-t border-slate-100 flex items-center justify-between flex-wrap gap-2">
<span class="text-[11px] font-semibold text-slate-400">پیشنهادهای سریع</span>
<div class="flex flex-wrap gap-1.5">
<button
v-for="item in shortcuts"
:key="item.label"
type="button"
class="!text-[11px] !leading-4 !font-medium bg-emerald-50/70 text-emerald-700 border border-emerald-200/60 px-2 py-0.5 rounded-md disabled:opacity-50"
:disabled="asking"
@click="pick(item.prompt)"
>
{{ item.label }}
</button>
</div>
</div>
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" />
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" />
</section>
<a-modal
v-model:open="draftOpen"
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
destroy-on-close
@cancel="cancelDraft"
>
<a-typography-paragraph v-if="draft">
{{ rewriteGregorianDates(draft.summary) }}
</a-typography-paragraph>
<a-typography-text type="secondary">قبل از تأیید می‌توانید متن را اصلاح کنید.</a-typography-text>
<a-form v-if="draft?.person" layout="vertical" style="margin-top: 16px">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام">
<a-input v-model:value="draft.person.firstName" @update:value="onPersonNameEdit" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام خانوادگی">
<a-input v-model:value="draft.person.lastName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="موبایل">
<a-input v-model:value="draft.person.mobile" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تلفن">
<a-input v-model:value="draft.person.phone" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ایمیل">
<a-input v-model:value="draft.person.email" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="وب‌سایت">
<a-input v-model:value="draft.person.website" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="معرف">
<a-input v-model:value="draft.person.referrer" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سمت">
<a-input v-model:value="draft.person.position" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea v-model:value="draft.person.notes" :rows="2" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-form v-else-if="draft?.activity" layout="vertical" style="margin-top: 16px">
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="عنوان">
<a-input v-model:value="draft.activity.title" @update:value="onActivityTitleEdit" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نوع">
<a-select
v-model:value="draft.activity.type"
:options="activityTypeOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="زمان">
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="مشتری">
<a-input
v-model:value="draft.activity.personName"
@update:value="onActivityPersonEdit"
@blur="rematchActivityPerson"
/>
</a-form-item>
<a-alert
v-if="draft.activity.personName && !draft.activity.personId"
type="warning"
show-icon
message="در فهرست مشتریان پرونده‌ای با این نام پیدا نشد. فعالیت ذخیره می‌شود، ولی داخل پروندهٔ آن شخص دیده نمی‌شود."
/>
</a-col>
<a-col :span="24">
<a-form-item label="توضیح">
<a-textarea v-model:value="draft.activity.description" :rows="2" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-alert
v-if="draft?.missing.length"
type="error"
show-icon
:message="`ناقص است: ${draft.missing.join('، ')}`"
style="margin-top: 12px"
/>
<template #footer>
<a-button :disabled="confirming" @click="cancelDraft">انصراف</a-button>
<a-button
type="primary"
:disabled="!draft?.canConfirm || confirming"
:loading="confirming"
@click="confirmDraft"
>
تأیید و انجام
</a-button>
</template>
</a-modal>
<a-modal v-model:open="answerOpen" title="پاسخ دستیار" destroy-on-close @cancel="closeAnswer">
<a-typography-paragraph style="white-space: pre-wrap">
{{ rewriteGregorianDates(answer || '') }}
</a-typography-paragraph>
<a-space v-if="sources?.length" wrap>
<RouterLink
v-for="source in sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
@click="closeAnswer"
>
<a-tag color="processing">{{ source.label }}</a-tag>
</RouterLink>
</a-space>
<template #footer>
<a-button type="primary" @click="closeAnswer">بستن</a-button>
</template>
</a-modal>
</template>