Files
CRM_Montzar/apps/web/src/components/dashboard/HomeAiAssistant.vue
T

605 lines
16 KiB
Vue
Raw Normal View History

<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';
2026-10-13 18:04:53 +03:30
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
2026-09-23 17:18:04 +03:30
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];
2026-09-23 17:18:04 +03:30
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);
2026-09-23 17:30:30 +03:30
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,
);
}
2026-09-23 17:30:30 +03:30
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() ? [] : ['عنوان فعالیت'];
2026-09-23 17:18:04 +03:30
}
2026-09-23 17:30:30 +03:30
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;
2026-09-23 17:30:30 +03:30
if (!iso) return '';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '' : formatLocalDateTimeInput(date);
});
2026-09-23 17:30:30 +03:30
function onDueLocal(value: string) {
2026-09-23 17:30:30 +03:30
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();
2026-09-23 17:18:04 +03:30
}
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;
2026-09-23 17:18:04 +03:30
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)
? 'دستور را کامل نفهمیدم. نام و مشخصات را واضح‌تر بگویید تا قبل از ساخت، تأیید کنید.'
2026-10-13 18:04:53 +03:30
: 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;
2026-09-23 17:18:04 +03:30
emit('clear');
} catch (e) {
voiceError.value = apiMessage(e, 'انجام دستور ناموفق بود.');
} finally {
confirming.value = false;
}
}
function cancelDraft() {
2026-09-23 17:18:04 +03:30
if (confirming.value) return;
draft.value = null;
}
2026-09-23 17:18:04 +03:30
2026-09-23 17:30:30 +03:30
function closeAnswer() {
answerOpen.value = false;
emit('clear');
}
2026-09-23 17:18:04 +03:30
watch(
() => props.asking,
(active) => {
if (!active) return;
voiceDone.value = '';
voiceError.value = '';
2026-09-23 17:30:30 +03:30
answerOpen.value = false;
},
);
watch([voiceBusy, confirming], () => {
emit('busy', voiceBusy.value || confirming.value);
});
watch([voiceError, voiceDone], ([error, done]) => {
emit('notice', { error, done });
});
defineExpose({ submitFromInput: onTypedSubmit });
2026-09-23 17:30:30 +03:30
watch(
() => props.answer,
(value) => {
answerOpen.value = Boolean(value?.trim());
2026-09-23 17:18:04 +03:30
},
);
</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>
2026-09-23 17:18:04 +03:30
<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>
2026-09-23 17:18:04 +03:30
<UiModal
:open="Boolean(draft && draft.intent !== 'none')"
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
@close="cancelDraft"
>
2026-10-13 18:04:53 +03:30
<p v-if="draft" class="modal-lead">{{ rewriteGregorianDates(draft.summary) }}</p>
2026-09-23 17:30:30 +03:30
<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" />
2026-09-23 17:30:30 +03:30
</label>
<p v-if="draft.activity.personName && !draft.activity.personId" class="warn">
در فهرست مشتریان پرونده‌ای با این نام پیدا نشد. فعالیت ذخیره می‌شود، ولی داخل پروندهٔ آن شخص دیده نمی‌شود.
2026-09-23 17:30:30 +03:30
</p>
<label>توضیح <textarea v-model="draft.activity.description" rows="2" /></label>
</div>
2026-09-23 17:18:04 +03:30
<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>
2026-09-23 17:30:30 +03:30
<UiModal :open="answerOpen && Boolean(answer)" title="پاسخ دستیار" @close="closeAnswer">
2026-10-13 18:04:53 +03:30
<p class="modal-lead answer-body">{{ rewriteGregorianDates(answer || '') }}</p>
2026-09-23 17:30:30 +03:30
<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;
}
2026-09-23 17:18:04 +03:30
.modal-lead {
margin: 0 0 0.85rem;
color: #334155;
font-size: 0.9rem;
line-height: 1.7;
}
2026-09-23 17:30:30 +03:30
.answer-body {
white-space: pre-wrap;
}
2026-09-23 17:30:30 +03:30
.edit-hint {
margin: -0.35rem 0 0.75rem;
color: #64748b;
2026-09-23 17:18:04 +03:30
font-size: 0.78rem;
2026-09-23 17:30:30 +03:30
}
.modal-fields {
display: grid;
gap: 0.55rem;
}
.modal-fields label {
display: grid;
gap: 0.25rem;
color: #64748b;
font-size: 0.75rem;
2026-09-23 17:18:04 +03:30
font-weight: 700;
}
2026-09-23 17:30:30 +03:30
.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;
2026-09-23 17:18:04 +03:30
color: #0f172a;
2026-09-23 17:30:30 +03:30
font: inherit;
2026-09-23 17:18:04 +03:30
font-size: 0.88rem;
2026-09-23 17:30:30 +03:30
font-weight: 500;
padding: 0.5rem 0.65rem;
}
.modal-fields textarea {
resize: vertical;
2026-09-23 17:18:04 +03:30
}
.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>