Add Ask Mento, customer mood, and Pipecat speech-to-text.
Ship assistant answers, contact moods, voice dictation, and the related CRM UI so these can be deployed to production. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import { appendSpokenText } from '@/lib/spoken-text';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import type {
|
||||
@@ -483,7 +485,12 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
|
||||
<div v-show="tab === 'notes'" class="card note-card">
|
||||
<label for="description">توضیحات و یادداشت</label>
|
||||
<div class="note-head">
|
||||
<label for="description">توضیحات و یادداشت</label>
|
||||
<VoiceToTextButton
|
||||
@transcript="(text) => (description = appendSpokenText(description, text))"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
@@ -912,6 +919,13 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.note-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.note-card textarea {
|
||||
min-height: 12rem;
|
||||
resize: vertical;
|
||||
|
||||
+657
-302
File diff suppressed because it is too large
Load Diff
+1089
-213
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
@@ -13,21 +12,27 @@ import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue';
|
||||
import HomeRecentActivities from '@/components/dashboard/HomeRecentActivities.vue';
|
||||
import HomeMiniPipeline from '@/components/dashboard/HomeMiniPipeline.vue';
|
||||
import HomeTopCustomers from '@/components/dashboard/HomeTopCustomers.vue';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
ActivityDto,
|
||||
AskMentoResponse,
|
||||
AskMentoSourceDto,
|
||||
HomeDashboardDto,
|
||||
PaginatedResponse,
|
||||
PipelineBoardDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const home = ref<HomeDashboardDto | null>(null);
|
||||
const activities = ref<ActivityDto[]>([]);
|
||||
const board = ref<PipelineBoardDto | null>(null);
|
||||
const askQuery = ref('');
|
||||
const asking = ref(false);
|
||||
const askAnswer = ref('');
|
||||
const askError = ref('');
|
||||
const askSources = ref<AskMentoSourceDto[]>([]);
|
||||
|
||||
const firstName = computed(() => {
|
||||
const parts = auth.user?.fullName?.trim().split(/\s+/) ?? [];
|
||||
@@ -72,10 +77,28 @@ function formatTrendAmount(value: number) {
|
||||
return formatMoney(value, home.value?.currency ?? 'IRR');
|
||||
}
|
||||
|
||||
function submitAsk() {
|
||||
const q = askQuery.value.trim();
|
||||
if (!q) return;
|
||||
void router.push({ name: 'notes', query: { q } });
|
||||
async function submitAsk(preset?: string) {
|
||||
const q = (preset || askQuery.value).trim();
|
||||
if (!q || asking.value) return;
|
||||
if (preset) askQuery.value = preset;
|
||||
asking.value = true;
|
||||
askError.value = '';
|
||||
askAnswer.value = '';
|
||||
askSources.value = [];
|
||||
try {
|
||||
const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q });
|
||||
askAnswer.value = data.answer;
|
||||
askSources.value = data.sources ?? [];
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
askError.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'پرسش ناموفق بود');
|
||||
} else {
|
||||
askError.value = 'پرسش ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
asking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -128,7 +151,14 @@ onMounted(async () => {
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
<HomeSourcesChart :sources="home?.sources ?? []" />
|
||||
<HomeAiAssistant v-model:ask-query="askQuery" @ask="submitAsk" />
|
||||
<HomeAiAssistant
|
||||
v-model:ask-query="askQuery"
|
||||
:asking="asking"
|
||||
:answer="askAnswer"
|
||||
:error="askError"
|
||||
:sources="askSources"
|
||||
@ask="submitAsk"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bottom-grid">
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
|
||||
import AskMentoPanel from '@/components/AskMentoPanel.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import { appendSpokenText } from '@/lib/spoken-text';
|
||||
import type {
|
||||
DealDto,
|
||||
NoteAttachmentDto,
|
||||
@@ -290,6 +293,11 @@ onMounted(load);
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<AskMentoPanel
|
||||
:person-id="draftPersonId || undefined"
|
||||
placeholder="از منتو بپرس؛ مثلاً آخرین بار درباره قیمت چی گفت؟"
|
||||
/>
|
||||
|
||||
<div class="type-tabs">
|
||||
<button
|
||||
type="button"
|
||||
@@ -332,8 +340,10 @@ onMounted(load);
|
||||
/>
|
||||
<div class="composer-actions">
|
||||
<div class="left-actions">
|
||||
<VoiceToTextButton @transcript="(text) => (draftBody = appendSpokenText(draftBody, text))" />
|
||||
<button class="linkish" type="button" @click="showExtras = !showExtras">
|
||||
{{ showExtras ? 'بستن جزئیات' : 'شخص / معامله / یادآوری / برچسب' }}
|
||||
<span class="extras-full">{{ showExtras ? 'بستن جزئیات' : 'شخص / معامله / یادآوری / برچسب' }}</span>
|
||||
<span class="extras-short">{{ showExtras ? 'بستن جزئیات' : 'جزئیات بیشتر' }}</span>
|
||||
</button>
|
||||
<button class="linkish" type="button" @click="fileInput?.click()">
|
||||
پیوست فایل
|
||||
@@ -537,6 +547,13 @@ onMounted(load);
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.page > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
@@ -563,6 +580,7 @@ h1 {
|
||||
.meta,
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.search {
|
||||
@@ -570,6 +588,7 @@ h1 {
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.search input {
|
||||
@@ -581,6 +600,7 @@ h1 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -650,6 +670,7 @@ h1 {
|
||||
.search input,
|
||||
.composer input,
|
||||
.composer textarea,
|
||||
.composer select,
|
||||
.extras input,
|
||||
.extras select {
|
||||
border: 1px solid var(--line);
|
||||
@@ -658,6 +679,8 @@ h1 {
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.composer {
|
||||
@@ -668,6 +691,7 @@ h1 {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
@@ -739,6 +763,9 @@ h1 {
|
||||
text-align: start;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.att-size {
|
||||
@@ -758,6 +785,11 @@ h1 {
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.extras-short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.extras {
|
||||
@@ -766,6 +798,17 @@ h1 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.block h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
@@ -774,7 +817,7 @@ h1 {
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -785,6 +828,8 @@ h1 {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card header {
|
||||
@@ -816,6 +861,7 @@ h1 {
|
||||
.body {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -872,7 +918,7 @@ h1 {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
@media (max-width: 900px) {
|
||||
h1 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
@@ -882,6 +928,10 @@ h1 {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
@@ -896,20 +946,55 @@ h1 {
|
||||
.type-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 0.15rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.type-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.extras {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.extras-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.extras-short {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.composer-actions,
|
||||
.left-actions,
|
||||
.right {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.right .btn,
|
||||
.right .btn-ghost {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card footer {
|
||||
gap: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
margin-inline-start: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,10 @@ import axios from 'axios';
|
||||
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
import type { CompanyDto, CreatePersonRequest, PersonDto } from '@mento/shared';
|
||||
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
|
||||
|
||||
@@ -50,7 +52,7 @@ const position = ref('');
|
||||
const notes = ref('');
|
||||
|
||||
const companyId = ref('');
|
||||
|
||||
const mood = ref<PersonMood>('newcomer');
|
||||
const customFields = ref<Record<string, unknown>>({});
|
||||
const loyaltyPoints = ref(0);
|
||||
const loyaltyTierName = ref('');
|
||||
@@ -74,6 +76,7 @@ function fill(p: PersonDto) {
|
||||
notes.value = p.notes ?? '';
|
||||
|
||||
companyId.value = p.company?.id ?? '';
|
||||
mood.value = p.mood || 'newcomer';
|
||||
customFields.value = { ...(p.customFields ?? {}) };
|
||||
loyaltyPoints.value = p.loyaltyPoints ?? 0;
|
||||
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
|
||||
@@ -139,6 +142,7 @@ function payload(): CreatePersonRequest {
|
||||
notes: notes.value || undefined,
|
||||
|
||||
companyId: companyId.value || null,
|
||||
mood: mood.value,
|
||||
customFields: customFields.value,
|
||||
|
||||
};
|
||||
@@ -304,6 +308,26 @@ onMounted(load);
|
||||
|
||||
</div>
|
||||
|
||||
<div class="field wide">
|
||||
|
||||
<label>وضعیت مشتری</label>
|
||||
|
||||
<div class="moods">
|
||||
<button
|
||||
v-for="item in PERSON_MOODS"
|
||||
:key="item"
|
||||
class="mood"
|
||||
type="button"
|
||||
:class="{ on: mood === item }"
|
||||
@click="mood = item"
|
||||
>
|
||||
<CustomerMoodFace :mood="item" :size="44" />
|
||||
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="field wide">
|
||||
|
||||
<label for="companyId">شرکت</label>
|
||||
@@ -517,7 +541,37 @@ textarea {
|
||||
|
||||
}
|
||||
|
||||
.moods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.mood {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 56px;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mood.on {
|
||||
border-color: #14b8a6;
|
||||
background: #ecfdf5;
|
||||
box-shadow: 0 0 0 3px #ccfbf1;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.moods {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AskMentoResponse } from '@mento/shared';
|
||||
import { formatCompactMoney } from '@/lib/money';
|
||||
import {
|
||||
calendarDaysBetween,
|
||||
@@ -15,6 +16,7 @@ import { purchaseProbability } from '@/lib/person-insights';
|
||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
||||
import AskMentoPanel from '@/components/AskMentoPanel.vue';
|
||||
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
|
||||
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
|
||||
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
|
||||
@@ -62,6 +64,7 @@ const cards = ref<ProjectCardDto[]>([]);
|
||||
const moreOpen = ref(false);
|
||||
const fabOpen = ref(false);
|
||||
const tab = ref<TabKey>('summary');
|
||||
const aiBrief = ref('');
|
||||
|
||||
const moreItems: ProfileMoreItem[] = [
|
||||
{ key: 'info', label: 'ویرایش اطلاعات', to: { name: 'profile', query: { tab: 'info' } } },
|
||||
@@ -126,6 +129,7 @@ const probability = computed(() =>
|
||||
);
|
||||
|
||||
const smartText = computed(() => {
|
||||
if (aiBrief.value) return aiBrief.value;
|
||||
const overdue = activities.value.filter(
|
||||
(a) => a.status === 'open' && a.dueAt && new Date(a.dueAt).getTime() < Date.now(),
|
||||
).length;
|
||||
@@ -281,6 +285,7 @@ function onSmartCta() {
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
aiBrief.value = '';
|
||||
try {
|
||||
const [actRes, boardRes] = await Promise.all([
|
||||
api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||
@@ -296,6 +301,7 @@ async function load() {
|
||||
} catch {
|
||||
cards.value = [];
|
||||
}
|
||||
void loadAiBrief();
|
||||
} catch {
|
||||
error.value = 'بارگذاری پروفایل ناموفق بود.';
|
||||
} finally {
|
||||
@@ -303,6 +309,18 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAiBrief() {
|
||||
try {
|
||||
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
|
||||
question:
|
||||
'وضعیت کاری امروز من را خلاصه کن: فعالیتهای عقبافتاده، معاملات باز و مهمترین پیگیری بعدی.',
|
||||
});
|
||||
aiBrief.value = data.answer;
|
||||
} catch {
|
||||
aiBrief.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.tab, readTab, { immediate: true });
|
||||
watch(() => auth.user?.id, () => void load(), { immediate: true });
|
||||
</script>
|
||||
@@ -364,6 +382,7 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
|
||||
<p>{{ smartText }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<AskMentoPanel placeholder="از منتو درباره کار امروز بپرس..." />
|
||||
<button class="smart-cta" type="button" @click="onSmartCta">
|
||||
<span>{{ smartCta }}</span>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
|
||||
@@ -124,6 +124,8 @@ onMounted(load);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 640px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.back {
|
||||
color: var(--muted);
|
||||
@@ -145,6 +147,9 @@ label {
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
@@ -153,8 +158,24 @@ textarea {
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
h1 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user