Files
CRM_Montzar/apps/web/src/components/NoteAiSuggestionPanel.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

270 lines
8.1 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, reactive, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } =
Typography;
const props = defineProps<{
noteId: string;
hasPerson: boolean;
}>();
const emit = defineEmits<{
applied: [];
dismissed: [];
}>();
const loading = ref(false);
const applying = ref(false);
const error = ref('');
const suggestion = ref<NoteAiSuggestionDto | null>(null);
const applyNextAction = ref(true);
const selectedMemory = reactive<Record<number, boolean>>({});
const nextTitle = ref('');
const nextDueAt = ref('');
const memoryEdits = reactive<Record<number, string>>({});
const payload = computed(() => suggestion.value?.payload ?? null);
const isPending = computed(() => suggestion.value?.status === 'pending');
const memoryItems = computed(() => payload.value?.memoryItems ?? []);
watch(
() => props.noteId,
() => {
void load();
},
{ immediate: true },
);
watch(payload, (value) => {
if (!value) return;
applyNextAction.value = Boolean(value.nextAction);
nextTitle.value = value.nextAction?.title ?? '';
nextDueAt.value = value.nextAction?.dueAt
? value.nextAction.dueAt.slice(0, 16)
: '';
value.memoryItems.forEach((item, index) => {
selectedMemory[index] = true;
memoryEdits[index] = item.content;
});
});
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<NoteAiSuggestionDto | null>(
`/notes/${props.noteId}/ai-suggestion`,
);
suggestion.value = data;
} catch {
suggestion.value = null;
} finally {
loading.value = false;
}
}
async function analyze() {
loading.value = true;
error.value = '';
try {
const { data } = await api.post<NoteAiSuggestionDto>(
`/notes/${props.noteId}/analyze`,
);
suggestion.value = data;
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تحلیل ناموفق بود');
} else {
error.value = 'تحلیل ناموفق بود';
}
await load();
} finally {
loading.value = false;
}
}
async function apply() {
if (!payload.value || !isPending.value) return;
applying.value = true;
error.value = '';
try {
const memoryIndices = memoryItems.value
.map((_, index) => index)
.filter((index) => selectedMemory[index]);
await api.post(`/notes/${props.noteId}/ai-suggestion/apply`, {
applyNextAction: applyNextAction.value && Boolean(nextTitle.value.trim()),
memoryIndices: props.hasPerson ? memoryIndices : [],
nextAction: applyNextAction.value
? {
title: nextTitle.value.trim(),
description: payload.value.nextAction?.description,
dueAt: nextDueAt.value
? new Date(nextDueAt.value).toISOString()
: null,
}
: undefined,
memoryItems: memoryIndices.map((index) => ({
index,
content: memoryEdits[index]?.trim() || memoryItems.value[index]?.content,
})),
});
await load();
emit('applied');
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'اعمال پیشنهاد ناموفق بود');
} else {
error.value = 'اعمال پیشنهاد ناموفق بود';
}
} finally {
applying.value = false;
}
}
async function dismiss() {
applying.value = true;
error.value = '';
try {
await api.post(`/notes/${props.noteId}/ai-suggestion/dismiss`);
await load();
emit('dismissed');
} catch {
error.value = 'رد پیشنهاد ناموفق بود';
} finally {
applying.value = false;
}
}
function formatPayloadSummary(value: NoteAiPayloadDto | null) {
if (!value?.summary) return '';
return rewriteGregorianDates(value.summary);
}
</script>
<template>
<a-card :bordered="true" :style="{ marginTop: '12px' }">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>پیشنهاد {{ brand.aiName }}</a-typography-text>
<a-button
v-if="!isPending"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? '…' : 'تحلیل مجدد' }}
</a-button>
<a-button
v-else-if="!suggestion"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</a-button>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginTop: '8px' }" />
<a-typography-text v-else-if="loading && !suggestion" type="secondary">
در حال تحلیل یادداشت…
</a-typography-text>
<a-flex v-else-if="suggestion?.status === 'failed'" vertical :gap="4">
<a-alert
type="error"
show-icon
:message="suggestion.errorMessage || 'تحلیل ناموفق بود'"
/>
<a-button type="link" @click="analyze">تلاش دوباره</a-button>
</a-flex>
<a-button
v-else-if="!suggestion"
type="link"
:disabled="loading"
@click="analyze"
>
تحلیل این یادداشت با AI
</a-button>
<template v-else-if="isPending && payload">
<a-typography-paragraph
v-if="formatPayloadSummary(payload)"
type="secondary"
:style="{ marginTop: '8px' }"
>
{{ formatPayloadSummary(payload) }}
</a-typography-paragraph>
<a-flex v-if="payload.nextAction" vertical :gap="8" :style="{ marginTop: '8px' }">
<a-checkbox v-model:checked="applyNextAction">کار بعدی</a-checkbox>
<a-input v-model:value="nextTitle" placeholder="عنوان کار بعدی" style="width: 100%" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" style="width: 100%" />
</a-flex>
<a-flex v-if="memoryItems.length" vertical :gap="8" :style="{ marginTop: '12px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">
حافظه مشتری (با تأیید شما ذخیره می‌شود)
</a-typography-title>
<a-alert
v-if="!hasPerson"
type="warning"
show-icon
message="برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد."
/>
<a-flex
v-for="(_, index) in memoryItems"
:key="index"
:gap="8"
align="flex-start"
>
<a-checkbox
v-model:checked="selectedMemory[index]"
:disabled="!hasPerson"
/>
<a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" />
</a-flex>
</a-flex>
<a-space :size="8" wrap :style="{ marginTop: '12px' }">
<a-button type="primary" :disabled="applying" :loading="applying" @click="apply">
اعمال انتخاب‌شده
</a-button>
<a-button :disabled="applying" @click="dismiss">رد کردن</a-button>
</a-space>
</template>
<a-typography-text
v-else-if="suggestion?.status === 'applied'"
type="success"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد اعمال شد.
</a-typography-text>
<a-typography-text
v-else-if="suggestion?.status === 'dismissed'"
type="secondary"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد رد شد.
</a-typography-text>
</a-card>
</template>