Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell. Co-authored-by: Cursor <cursoragent@cursor.com>
1616 lines
51 KiB
Vue
1616 lines
51 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { useEscClose } from '@/lib/modal-stack';
|
||
import { useRoute } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
|
||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||
import { appendSpokenText } from '@/lib/spoken-text';
|
||
import {
|
||
calendarDaysBetween,
|
||
formatClockFa,
|
||
formatJalaliDay,
|
||
toFaDigits,
|
||
} from '@/lib/jalali';
|
||
import type {
|
||
ActivityDto,
|
||
DealDto,
|
||
NoteAttachmentDto,
|
||
NoteAiSuggestionDto,
|
||
NoteChecklistItemDto,
|
||
NoteDto,
|
||
NoteRevisionDto,
|
||
NoteType,
|
||
NoteVisibility,
|
||
PaginatedResponse,
|
||
PersonDto,
|
||
PipelineBoardDto,
|
||
} from '@mento/shared';
|
||
import { NOTE_VISIBILITY_LABELS } from '@mento/shared';
|
||
import {
|
||
Alert as AAlert,
|
||
Button as AButton,
|
||
Card as ACard,
|
||
Checkbox as ACheckbox,
|
||
Col as ACol,
|
||
Divider as ADivider,
|
||
Drawer as ADrawer,
|
||
Dropdown as ADropdown,
|
||
Empty as AEmpty,
|
||
Flex as AFlex,
|
||
Form as AForm,
|
||
FormItem as AFormItem,
|
||
Input as AInput,
|
||
Menu as AMenu,
|
||
MenuItem as AMenuItem,
|
||
Modal as AModal,
|
||
Modal,
|
||
Row as ARow,
|
||
Select as ASelect,
|
||
Space as ASpace,
|
||
Spin as ASpin,
|
||
Switch as ASwitch,
|
||
Tag as ATag,
|
||
Textarea as ATextarea,
|
||
Typography,
|
||
Upload as AUpload,
|
||
theme,
|
||
} from 'ant-design-vue';
|
||
import type { UploadProps } from 'ant-design-vue';
|
||
import {
|
||
AppstoreOutlined,
|
||
AudioOutlined,
|
||
CheckOutlined,
|
||
CloseOutlined,
|
||
EnvironmentOutlined,
|
||
FileOutlined,
|
||
FilterOutlined,
|
||
HistoryOutlined,
|
||
MoreOutlined,
|
||
PaperClipOutlined,
|
||
PictureOutlined,
|
||
PlusOutlined,
|
||
PushpinOutlined,
|
||
ThunderboltOutlined,
|
||
UnorderedListOutlined,
|
||
} from '@/icons/ant-bridge';
|
||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||
|
||
const ACheckableTag = ATag.CheckableTag;
|
||
|
||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||
Typography;
|
||
const { token } = theme.useToken();
|
||
|
||
type ViewMode = 'grid' | 'list';
|
||
type SortKey = 'updatedAt' | 'createdAt' | 'reminderAt';
|
||
type ColorKey = 'sand' | 'rose' | 'sky' | 'mint' | 'lavender' | 'default';
|
||
type OpenMode = 'default' | 'image' | 'mic' | 'attach' | 'audio' | 'checklist';
|
||
|
||
const route = useRoute();
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const error = ref('');
|
||
const q = ref('');
|
||
const typeFilter = ref<NoteType | 'all'>('all');
|
||
const visibilityFilter = ref<NoteVisibility | 'all'>('all');
|
||
const sortKey = ref<SortKey>('updatedAt');
|
||
const viewMode = ref<ViewMode>('grid');
|
||
const notes = ref<NoteDto[]>([]);
|
||
const people = ref<PersonDto[]>([]);
|
||
const deals = ref<DealDto[]>([]);
|
||
const activities = ref<ActivityDto[]>([]);
|
||
const pendingAi = ref<Record<string, boolean>>({});
|
||
const blobCache = ref<Record<string, string>>({});
|
||
const audioDurations = ref<Record<string, string>>({});
|
||
|
||
const editorOpen = ref(false);
|
||
const moreSheetOpen = ref(false);
|
||
const locationModalOpen = ref(false);
|
||
const locationDraft = ref('');
|
||
const showChecklistEditor = ref(false);
|
||
const editingId = ref<string | null>(null);
|
||
|
||
const draftTitle = ref('');
|
||
const draftBody = ref('');
|
||
const draftType = ref<NoteType>('general');
|
||
const draftColor = ref<ColorKey>('sand');
|
||
const draftTags = ref('');
|
||
const draftPersonId = ref('');
|
||
const draftDealId = ref('');
|
||
const draftActivityId = ref('');
|
||
const draftReminder = ref('');
|
||
const draftLocation = ref('');
|
||
const draftAnalyze = ref(true);
|
||
const draftPinned = ref(false);
|
||
const draftVisibility = ref<NoteVisibility>('team');
|
||
const draftChecklist = ref<NoteChecklistItemDto[]>([]);
|
||
const draftFiles = ref<File[]>([]);
|
||
const existingAttachments = ref<NoteAttachmentDto[]>([]);
|
||
|
||
const historyOpen = ref(false);
|
||
const historyLoading = ref(false);
|
||
const historyError = ref('');
|
||
const historyNoteId = ref<string | null>(null);
|
||
const historyItems = ref<NoteRevisionDto[]>([]);
|
||
|
||
const bodyTextarea = ref<{ focus?: () => void } | null>(null);
|
||
const imageUploadEl = ref<HTMLElement | null>(null);
|
||
const fileUploadEl = ref<HTMLElement | null>(null);
|
||
const audioUploadEl = ref<HTMLElement | null>(null);
|
||
const pendingPick = ref<OpenMode | null>(null);
|
||
|
||
const categoryChips: Array<{ value: NoteType | 'all'; label: string }> = [
|
||
{ value: 'all', label: 'همه' },
|
||
{ value: 'general', label: 'معمولی' },
|
||
{ value: 'follow_up', label: 'پیگیری' },
|
||
{ value: 'idea', label: 'ایده' },
|
||
{ value: 'suggestion', label: 'پیشنهاد' },
|
||
{ value: 'meeting', label: 'جلسه' },
|
||
{ value: 'personal', label: 'شخصی' },
|
||
];
|
||
|
||
const noteTypeOptions: Array<{ value: NoteType; label: string }> = [
|
||
{ value: 'general', label: 'معمولی' },
|
||
{ value: 'follow_up', label: 'پیگیری' },
|
||
{ value: 'idea', label: 'ایده' },
|
||
{ value: 'suggestion', label: 'پیشنهاد' },
|
||
{ value: 'meeting', label: 'جلسه' },
|
||
{ value: 'personal', label: 'شخصی' },
|
||
{ value: 'call_result', label: 'نتیجه تماس' },
|
||
{ value: 'important', label: 'نکته مهم' },
|
||
{ value: 'warning', label: 'هشدار' },
|
||
{ value: 'next_action', label: 'کار بعدی' },
|
||
];
|
||
|
||
const colorSwatches: Array<{ value: ColorKey; label: string }> = [
|
||
{ value: 'sand', label: 'شن' },
|
||
{ value: 'rose', label: 'گل' },
|
||
{ value: 'sky', label: 'آسمان' },
|
||
{ value: 'mint', label: 'نعناع' },
|
||
{ value: 'lavender', label: 'یاسی' },
|
||
{ value: 'default', label: 'سفید' },
|
||
];
|
||
|
||
const typeLabelMap: Record<string, string> = {
|
||
general: 'معمولی',
|
||
follow_up: 'پیگیری',
|
||
idea: 'ایده',
|
||
suggestion: 'پیشنهاد',
|
||
meeting: 'جلسه',
|
||
personal: 'شخصی',
|
||
call_result: 'نتیجه تماس',
|
||
important: 'نکته مهم',
|
||
warning: 'هشدار',
|
||
next_action: 'کار بعدی',
|
||
};
|
||
|
||
const sortOptions = [
|
||
{ value: 'updatedAt', label: 'جدیدترین' },
|
||
{ value: 'createdAt', label: 'تاریخ ایجاد' },
|
||
{ value: 'reminderAt', label: 'یادآوری' },
|
||
];
|
||
|
||
const personOptions = computed(() => [
|
||
{ value: '', label: '—' },
|
||
...people.value.map((p) => ({ value: p.id, label: p.fullName })),
|
||
]);
|
||
const dealOptions = computed(() => [
|
||
{ value: '', label: '—' },
|
||
...deals.value.map((d) => ({ value: d.id, label: d.title })),
|
||
]);
|
||
const activityOptions = computed(() => [
|
||
{ value: '', label: '—' },
|
||
...activities.value.map((a) => ({ value: a.id, label: a.title })),
|
||
]);
|
||
const visibilityOptions = [
|
||
{ value: 'team', label: NOTE_VISIBILITY_LABELS.team },
|
||
{ value: 'personal', label: NOTE_VISIBILITY_LABELS.personal },
|
||
];
|
||
|
||
const editorTitle = computed(() => (editingId.value ? 'ویرایش' : 'یادداشت جدید'));
|
||
const draftColorCss = computed(() => colorToCss(draftColor.value));
|
||
const historyNoteRevisionCount = computed(() => {
|
||
if (!editingId.value) return 0;
|
||
return notes.value.find((n) => n.id === editingId.value)?.revisionCount ?? 0;
|
||
});
|
||
const personName = (id: string) =>
|
||
people.value.find((p) => p.id === id)?.fullName ?? '';
|
||
|
||
/** Note color palette (data colors, not chrome). */
|
||
const NOTE_COLOR_HEX: Record<ColorKey, string> = {
|
||
sand: '#fff8e1',
|
||
rose: '#fce4ec',
|
||
sky: '#e3f2fd',
|
||
mint: '#e0f2f1',
|
||
lavender: '#f3e5f5',
|
||
default: '#ffffff',
|
||
};
|
||
|
||
function colorToCss(raw: string | null | undefined): string {
|
||
return NOTE_COLOR_HEX[normalizeColor(raw)];
|
||
}
|
||
|
||
function normalizeColor(raw: string | null | undefined): ColorKey {
|
||
const c = (raw || 'default').toLowerCase();
|
||
if (c === 'yellow' || c === 'sand') return 'sand';
|
||
if (c === 'pink' || c === 'rose') return 'rose';
|
||
if (c === 'blue' || c === 'sky') return 'sky';
|
||
if (c === 'teal' || c === 'mint' || c === 'green') return 'mint';
|
||
if (c === 'purple' || c === 'lavender') return 'lavender';
|
||
if (c === 'white' || c === 'default' || !c) return 'default';
|
||
if (c === 'sand' || c === 'rose' || c === 'sky' || c === 'mint' || c === 'lavender') {
|
||
return c;
|
||
}
|
||
return 'default';
|
||
}
|
||
|
||
function typeLabel(t: string) {
|
||
return typeLabelMap[t] ?? t;
|
||
}
|
||
|
||
function formatNoteTime(iso: string) {
|
||
try {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso;
|
||
const days = calendarDaysBetween(d, new Date());
|
||
if (days <= 0) return `${formatClockFa(d)} امروز`;
|
||
if (days === 1) return 'دیروز';
|
||
return formatJalaliDay(d);
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function parseTags(raw: string) {
|
||
return raw
|
||
.split(/[,\s#]+/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function newChecklistItem(text = ''): NoteChecklistItemDto {
|
||
return {
|
||
id: crypto.randomUUID().slice(0, 12),
|
||
text,
|
||
done: false,
|
||
};
|
||
}
|
||
|
||
function previewBody(body: string) {
|
||
const t = body.trim();
|
||
if (t.length <= 160) return t;
|
||
return `${t.slice(0, 160)}…`;
|
||
}
|
||
|
||
function isImageAtt(att: NoteAttachmentDto) {
|
||
return att.mimeType.startsWith('image/');
|
||
}
|
||
function isPdfAtt(att: NoteAttachmentDto) {
|
||
return att.mimeType === 'application/pdf' || /\.pdf$/i.test(att.fileName);
|
||
}
|
||
function isAudioAtt(att: NoteAttachmentDto) {
|
||
return att.mimeType.startsWith('audio/');
|
||
}
|
||
|
||
function firstImage(note: NoteDto) {
|
||
return note.attachments.find(isImageAtt) ?? null;
|
||
}
|
||
function firstPdf(note: NoteDto) {
|
||
return note.attachments.find(isPdfAtt) ?? null;
|
||
}
|
||
function firstAudio(note: NoteDto) {
|
||
return note.attachments.find(isAudioAtt) ?? null;
|
||
}
|
||
|
||
function blobKey(noteId: string, attId: string) {
|
||
return `${noteId}:${attId}`;
|
||
}
|
||
|
||
async function ensureBlob(noteId: string, att: NoteAttachmentDto) {
|
||
const key = blobKey(noteId, att.id);
|
||
if (blobCache.value[key]) return blobCache.value[key];
|
||
const { data } = await api.get<Blob>(
|
||
`/notes/${noteId}/attachments/${att.id}/download`,
|
||
{ responseType: 'blob' },
|
||
);
|
||
const url = URL.createObjectURL(data);
|
||
blobCache.value = { ...blobCache.value, [key]: url };
|
||
return url;
|
||
}
|
||
|
||
async function prefetchCardMedia(items: NoteDto[]) {
|
||
for (const note of items) {
|
||
const img = firstImage(note);
|
||
const audio = firstAudio(note);
|
||
try {
|
||
if (img) await ensureBlob(note.id, img);
|
||
if (audio) {
|
||
const url = await ensureBlob(note.id, audio);
|
||
probeAudioDuration(blobKey(note.id, audio.id), url);
|
||
}
|
||
} catch {
|
||
/* ignore missing blobs */
|
||
}
|
||
}
|
||
}
|
||
|
||
function probeAudioDuration(key: string, url: string) {
|
||
const el = new Audio(url);
|
||
el.addEventListener('loadedmetadata', () => {
|
||
const sec = Math.round(el.duration || 0);
|
||
if (!Number.isFinite(sec) || sec <= 0) return;
|
||
const mm = String(Math.floor(sec / 60)).padStart(2, '0');
|
||
const ss = String(sec % 60).padStart(2, '0');
|
||
audioDurations.value = {
|
||
...audioDurations.value,
|
||
[key]: toFaDigits(`${mm}:${ss}`),
|
||
};
|
||
});
|
||
}
|
||
|
||
function revokeAllBlobs() {
|
||
for (const url of Object.values(blobCache.value)) {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
blobCache.value = {};
|
||
}
|
||
|
||
async function refreshAiBadges(items: NoteDto[]) {
|
||
const slice = items.slice(0, 40);
|
||
const entries = await Promise.all(
|
||
slice.map(async (n) => {
|
||
try {
|
||
const { data } = await api.get<NoteAiSuggestionDto | null>(
|
||
`/notes/${n.id}/ai-suggestion`,
|
||
);
|
||
return [n.id, data?.status === 'pending'] as const;
|
||
} catch {
|
||
return [n.id, false] as const;
|
||
}
|
||
}),
|
||
);
|
||
const map: Record<string, boolean> = { ...pendingAi.value };
|
||
for (const [id, pending] of entries) map[id] = pending;
|
||
pendingAi.value = map;
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const [notesRes, peopleRes, boardRes] = await Promise.all([
|
||
api.get<PaginatedResponse<NoteDto>>('/notes', {
|
||
params: {
|
||
q: q.value || undefined,
|
||
noteType: typeFilter.value === 'all' ? undefined : typeFilter.value,
|
||
visibility:
|
||
visibilityFilter.value === 'all'
|
||
? undefined
|
||
: visibilityFilter.value,
|
||
sort: sortKey.value,
|
||
limit: 100,
|
||
},
|
||
}),
|
||
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
|
||
api.get<PipelineBoardDto>('/pipelines/board'),
|
||
]);
|
||
notes.value = notesRes.data.items;
|
||
people.value = peopleRes.data.items;
|
||
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
|
||
void prefetchCardMedia(notes.value);
|
||
void refreshAiBadges(notes.value);
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e) && !e.response) {
|
||
error.value = 'اتصال به API برقرار نشد.';
|
||
} else {
|
||
error.value = 'بارگذاری یادداشتها ناموفق بود.';
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadActivities() {
|
||
try {
|
||
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||
params: { limit: 50 },
|
||
});
|
||
activities.value = data.items;
|
||
} catch {
|
||
activities.value = [];
|
||
}
|
||
}
|
||
|
||
function resetDraft() {
|
||
draftTitle.value = '';
|
||
draftBody.value = '';
|
||
draftType.value = 'general';
|
||
draftColor.value = 'sand';
|
||
draftTags.value = '';
|
||
draftPersonId.value = typeof route.query.personId === 'string' ? route.query.personId : '';
|
||
draftDealId.value = '';
|
||
draftActivityId.value = '';
|
||
draftReminder.value = '';
|
||
draftLocation.value = '';
|
||
draftAnalyze.value = true;
|
||
draftPinned.value = false;
|
||
draftVisibility.value = 'team';
|
||
draftChecklist.value = [];
|
||
draftFiles.value = [];
|
||
existingAttachments.value = [];
|
||
showChecklistEditor.value = false;
|
||
editingId.value = null;
|
||
}
|
||
|
||
function clickUpload(el: HTMLElement | null) {
|
||
el?.querySelector<HTMLInputElement>('input[type="file"]')?.click();
|
||
}
|
||
|
||
function openEditor(mode: OpenMode = 'default', note?: NoteDto) {
|
||
if (note) {
|
||
editingId.value = note.id;
|
||
draftTitle.value = note.title ?? '';
|
||
draftBody.value = note.body;
|
||
draftType.value = (note.noteType as NoteType) || 'general';
|
||
draftColor.value = normalizeColor(note.color);
|
||
draftTags.value = note.tags.join(' ');
|
||
draftPersonId.value = note.person?.id ?? '';
|
||
draftDealId.value = note.deal?.id ?? '';
|
||
draftActivityId.value = note.activity?.id ?? '';
|
||
draftReminder.value = note.reminderAt ? note.reminderAt.slice(0, 16) : '';
|
||
draftLocation.value = note.locationText ?? '';
|
||
draftAnalyze.value = note.analyzeEnabled !== false;
|
||
draftPinned.value = note.pinned;
|
||
draftVisibility.value =
|
||
note.visibility === 'personal' ? 'personal' : 'team';
|
||
draftChecklist.value = [...(note.checklist ?? [])];
|
||
draftFiles.value = [];
|
||
existingAttachments.value = [...note.attachments];
|
||
showChecklistEditor.value = (note.checklist?.length ?? 0) > 0;
|
||
} else {
|
||
resetDraft();
|
||
if (mode === 'checklist') {
|
||
showChecklistEditor.value = true;
|
||
draftChecklist.value = [newChecklistItem()];
|
||
}
|
||
}
|
||
|
||
editorOpen.value = true;
|
||
moreSheetOpen.value = false;
|
||
pendingPick.value = mode;
|
||
|
||
void nextTick(() => {
|
||
bodyTextarea.value?.focus?.();
|
||
if (mode === 'image') clickUpload(imageUploadEl.value);
|
||
if (mode === 'attach') clickUpload(fileUploadEl.value);
|
||
if (mode === 'audio' || mode === 'mic') clickUpload(audioUploadEl.value);
|
||
pendingPick.value = null;
|
||
});
|
||
}
|
||
|
||
function closeEditor() {
|
||
editorOpen.value = false;
|
||
moreSheetOpen.value = false;
|
||
resetDraft();
|
||
}
|
||
|
||
function appendPickedFiles(files: File[], kind: 'image' | 'file' | 'audio') {
|
||
const filtered =
|
||
kind === 'image'
|
||
? files.filter((f) => f.type.startsWith('image/'))
|
||
: kind === 'audio'
|
||
? files.filter((f) => f.type.startsWith('audio/') || !f.type)
|
||
: files;
|
||
if (!filtered.length) return;
|
||
draftFiles.value = [...draftFiles.value, ...filtered].slice(0, 8);
|
||
}
|
||
|
||
const beforeImageUpload: UploadProps['beforeUpload'] = (file) => {
|
||
appendPickedFiles([file as File], 'image');
|
||
return false;
|
||
};
|
||
const beforeFileUpload: UploadProps['beforeUpload'] = (file) => {
|
||
appendPickedFiles([file as File], 'file');
|
||
return false;
|
||
};
|
||
const beforeAudioUpload: UploadProps['beforeUpload'] = (file) => {
|
||
appendPickedFiles([file as File], 'audio');
|
||
return false;
|
||
};
|
||
|
||
function removeDraftFile(index: number) {
|
||
draftFiles.value = draftFiles.value.filter((_, i) => i !== index);
|
||
}
|
||
|
||
async function removeExistingAttachment(attachmentId: string) {
|
||
if (!editingId.value) return;
|
||
Modal.confirm({
|
||
title: 'این پیوست حذف شود؟',
|
||
okText: 'حذف',
|
||
cancelText: 'انصراف',
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
await api.delete(`/notes/${editingId.value}/attachments/${attachmentId}`);
|
||
existingAttachments.value = existingAttachments.value.filter(
|
||
(a) => a.id !== attachmentId,
|
||
);
|
||
await load();
|
||
},
|
||
});
|
||
}
|
||
|
||
async function uploadFiles(noteId: string, files: File[]) {
|
||
for (const file of files) {
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
await api.post(`/notes/${noteId}/attachments`, form);
|
||
}
|
||
}
|
||
|
||
function addChecklistRow() {
|
||
draftChecklist.value = [...draftChecklist.value, newChecklistItem()];
|
||
}
|
||
|
||
function removeChecklistRow(id: string) {
|
||
draftChecklist.value = draftChecklist.value.filter((c) => c.id !== id);
|
||
}
|
||
|
||
function toggleDraftCheck(id: string) {
|
||
draftChecklist.value = draftChecklist.value.map((c) =>
|
||
c.id === id ? { ...c, done: !c.done } : c,
|
||
);
|
||
}
|
||
|
||
async function toggleCardChecklist(note: NoteDto, itemId: string) {
|
||
const checklist = (note.checklist ?? []).map((c) =>
|
||
c.id === itemId ? { ...c, done: !c.done } : c,
|
||
);
|
||
note.checklist = checklist;
|
||
try {
|
||
await api.patch(`/notes/${note.id}`, { checklist });
|
||
} catch {
|
||
await load();
|
||
}
|
||
}
|
||
|
||
async function saveNote() {
|
||
const body = draftBody.value.trim();
|
||
const checklist = draftChecklist.value.filter((c) => c.text.trim());
|
||
if (!body && !checklist.length) {
|
||
error.value = 'متن یادداشت یا چکلیست لازم است.';
|
||
return;
|
||
}
|
||
saving.value = true;
|
||
error.value = '';
|
||
try {
|
||
const payload = {
|
||
title: draftTitle.value.trim() || null,
|
||
body: body || (checklist.length ? 'چکلیست' : ''),
|
||
noteType: draftType.value,
|
||
visibility: draftVisibility.value,
|
||
color: draftColor.value === 'default' ? 'default' : draftColor.value,
|
||
tags: parseTags(draftTags.value),
|
||
personId: draftPersonId.value || null,
|
||
dealId: draftDealId.value || null,
|
||
activityId: draftActivityId.value || null,
|
||
reminderAt: draftReminder.value
|
||
? new Date(draftReminder.value).toISOString()
|
||
: null,
|
||
locationText: draftLocation.value.trim() || null,
|
||
analyzeEnabled: draftAnalyze.value,
|
||
pinned: draftPinned.value,
|
||
checklist,
|
||
};
|
||
const pending = [...draftFiles.value];
|
||
let noteId = editingId.value;
|
||
if (editingId.value) {
|
||
await api.patch(`/notes/${editingId.value}`, payload);
|
||
} else {
|
||
const { data } = await api.post<NoteDto>('/notes', {
|
||
...payload,
|
||
title: draftTitle.value.trim() || undefined,
|
||
});
|
||
noteId = data.id;
|
||
}
|
||
if (noteId && pending.length) {
|
||
await uploadFiles(noteId, pending);
|
||
draftFiles.value = [];
|
||
}
|
||
if (noteId && draftAnalyze.value && body.length >= 8) {
|
||
void api.post(`/notes/${noteId}/analyze`).catch(() => undefined);
|
||
}
|
||
closeEditor();
|
||
await load();
|
||
} 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 {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function openHistory(note: NoteDto) {
|
||
historyNoteId.value = note.id;
|
||
historyOpen.value = true;
|
||
historyLoading.value = true;
|
||
historyError.value = '';
|
||
historyItems.value = [];
|
||
try {
|
||
const { data } = await api.get<NoteRevisionDto[]>(
|
||
`/notes/${note.id}/revisions`,
|
||
);
|
||
historyItems.value = data;
|
||
} catch (e) {
|
||
historyError.value = axios.isAxiosError(e)
|
||
? String(
|
||
(e.response?.data as { message?: string })?.message ??
|
||
'بارگذاری تاریخچه ناموفق بود.',
|
||
)
|
||
: 'بارگذاری تاریخچه ناموفق بود.';
|
||
} finally {
|
||
historyLoading.value = false;
|
||
}
|
||
}
|
||
|
||
useEscClose(() => editorOpen.value, () => closeEditor());
|
||
useEscClose(() => moreSheetOpen.value, () => {
|
||
moreSheetOpen.value = false;
|
||
});
|
||
useEscClose(() => historyOpen.value, () => closeHistory());
|
||
|
||
function closeHistory() {
|
||
historyOpen.value = false;
|
||
historyNoteId.value = null;
|
||
historyItems.value = [];
|
||
historyError.value = '';
|
||
}
|
||
|
||
async function restoreRevision(revisionId: string) {
|
||
if (!historyNoteId.value) return;
|
||
Modal.confirm({
|
||
title: 'این نسخه جایگزین متن فعلی شود؟',
|
||
okText: 'بازگردانی',
|
||
cancelText: 'انصراف',
|
||
onOk: async () => {
|
||
try {
|
||
await api.post(
|
||
`/notes/${historyNoteId.value}/revisions/${revisionId}/restore`,
|
||
);
|
||
closeHistory();
|
||
await load();
|
||
} catch (e) {
|
||
historyError.value = axios.isAxiosError(e)
|
||
? String(
|
||
(e.response?.data as { message?: string })?.message ??
|
||
'بازگردانی ناموفق بود.',
|
||
)
|
||
: 'بازگردانی ناموفق بود.';
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
async function togglePin(id: string) {
|
||
await api.patch(`/notes/${id}/pin`);
|
||
await load();
|
||
if (editingId.value === id) {
|
||
const n = notes.value.find((x) => x.id === id);
|
||
if (n) draftPinned.value = n.pinned;
|
||
}
|
||
}
|
||
|
||
async function onPinClick() {
|
||
const next = !draftPinned.value;
|
||
draftPinned.value = next;
|
||
if (!editingId.value) return;
|
||
const note = notes.value.find((n) => n.id === editingId.value);
|
||
if (note && note.pinned === next) return;
|
||
try {
|
||
await api.patch(`/notes/${editingId.value}/pin`);
|
||
if (note) note.pinned = next;
|
||
} catch {
|
||
draftPinned.value = !next;
|
||
}
|
||
}
|
||
|
||
function removeNote(id: string) {
|
||
Modal.confirm({
|
||
title: 'این یادداشت حذف شود؟',
|
||
okText: 'حذف',
|
||
cancelText: 'انصراف',
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
await api.delete(`/notes/${id}`);
|
||
if (editingId.value === id) closeEditor();
|
||
await load();
|
||
},
|
||
});
|
||
}
|
||
|
||
function promptLocation() {
|
||
locationDraft.value = draftLocation.value;
|
||
locationModalOpen.value = true;
|
||
}
|
||
|
||
function confirmLocation() {
|
||
draftLocation.value = locationDraft.value.trim();
|
||
locationModalOpen.value = false;
|
||
}
|
||
|
||
function openMoreSheet() {
|
||
moreSheetOpen.value = true;
|
||
if (!activities.value.length) void loadActivities();
|
||
}
|
||
|
||
function toggleChecklistEditor() {
|
||
showChecklistEditor.value = !showChecklistEditor.value;
|
||
if (showChecklistEditor.value && !draftChecklist.value.length) {
|
||
draftChecklist.value = [newChecklistItem()];
|
||
}
|
||
}
|
||
|
||
function setTypeFilter(value: NoteType | 'all', checked: boolean) {
|
||
if (checked) typeFilter.value = value;
|
||
}
|
||
|
||
function setVisibilityFilter(value: NoteVisibility | 'all', checked: boolean) {
|
||
if (checked) visibilityFilter.value = value;
|
||
}
|
||
|
||
watch(typeFilter, () => {
|
||
void load();
|
||
});
|
||
watch(visibilityFilter, () => {
|
||
void load();
|
||
});
|
||
watch(sortKey, () => {
|
||
void load();
|
||
});
|
||
|
||
onMounted(() => {
|
||
if (typeof route.query.q === 'string' && route.query.q) q.value = route.query.q;
|
||
if (typeof route.query.personId === 'string' && route.query.personId) {
|
||
draftPersonId.value = route.query.personId;
|
||
}
|
||
void load();
|
||
void loadActivities();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
revokeAllBlobs();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<a-flex vertical :gap="16">
|
||
<a-typography-title :level="3" :style="{ margin: 0 }">یادداشتها</a-typography-title>
|
||
|
||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||
|
||
<a-card :bordered="false">
|
||
<a-flex vertical :gap="12">
|
||
<a-flex wrap="wrap" :gap="8" align="center" justify="space-between">
|
||
<a-space wrap :size="6">
|
||
<a-checkable-tag
|
||
v-for="chip in categoryChips"
|
||
:key="chip.value"
|
||
:checked="typeFilter === chip.value"
|
||
@change="(c: boolean) => setTypeFilter(chip.value, c)"
|
||
>
|
||
{{ chip.label }}
|
||
</a-checkable-tag>
|
||
<a-divider type="vertical" />
|
||
<a-checkable-tag
|
||
:checked="visibilityFilter === 'all'"
|
||
@change="(c: boolean) => setVisibilityFilter('all', c)"
|
||
>
|
||
همه دسترسیها
|
||
</a-checkable-tag>
|
||
<a-checkable-tag
|
||
:checked="visibilityFilter === 'team'"
|
||
@change="(c: boolean) => setVisibilityFilter('team', c)"
|
||
>
|
||
تیمی
|
||
</a-checkable-tag>
|
||
<a-checkable-tag
|
||
:checked="visibilityFilter === 'personal'"
|
||
@change="(c: boolean) => setVisibilityFilter('personal', c)"
|
||
>
|
||
فقط من
|
||
</a-checkable-tag>
|
||
</a-space>
|
||
|
||
<a-space :size="8" wrap>
|
||
<a-space :size="4">
|
||
<a-button
|
||
:type="viewMode === 'grid' ? 'primary' : 'default'"
|
||
title="شبکه"
|
||
@click="viewMode = 'grid'"
|
||
>
|
||
<template #icon><AppstoreOutlined /></template>
|
||
</a-button>
|
||
<a-button
|
||
:type="viewMode === 'list' ? 'primary' : 'default'"
|
||
title="لیست"
|
||
@click="viewMode = 'list'"
|
||
>
|
||
<template #icon><UnorderedListOutlined /></template>
|
||
</a-button>
|
||
</a-space>
|
||
<a-select
|
||
v-model:value="sortKey"
|
||
:options="sortOptions"
|
||
style="width: 140px"
|
||
aria-label="مرتبسازی"
|
||
/>
|
||
<a-button @click="load">
|
||
<template #icon><FilterOutlined /></template>
|
||
</a-button>
|
||
</a-space>
|
||
</a-flex>
|
||
|
||
<a-flex :gap="8" align="center">
|
||
<a-input
|
||
readonly
|
||
placeholder="یادداشتی بنویسید..."
|
||
:style="{ flex: 1 }"
|
||
@click="openEditor()"
|
||
@focus="openEditor()"
|
||
/>
|
||
<a-button @click="openEditor('image')">
|
||
<template #icon><PictureOutlined /></template>
|
||
</a-button>
|
||
<a-button @click="openEditor('mic')">
|
||
<template #icon><AudioOutlined /></template>
|
||
</a-button>
|
||
<a-button @click="openEditor('attach')">
|
||
<template #icon><PaperClipOutlined /></template>
|
||
</a-button>
|
||
<a-button type="primary" @click="openEditor()">
|
||
<template #icon><PlusOutlined /></template>
|
||
</a-button>
|
||
</a-flex>
|
||
</a-flex>
|
||
</a-card>
|
||
|
||
<CrmSkeleton :loading="loading">
|
||
<CrmSkeletonCard :rows="6">
|
||
<a-empty
|
||
v-if="!loading && !notes.length"
|
||
description="هنوز یادداشتی نیست — یکی اضافه کنید."
|
||
>
|
||
<a-button type="primary" @click="openEditor()">
|
||
<template #icon><PlusOutlined /></template>
|
||
یادداشت جدید
|
||
</a-button>
|
||
</a-empty>
|
||
|
||
<a-row v-else :gutter="[12, 12]" align="stretch">
|
||
<a-col
|
||
v-for="note in notes"
|
||
:key="note.id"
|
||
:xs="viewMode === 'list' ? 24 : 12"
|
||
:sm="viewMode === 'list' ? 24 : 12"
|
||
:md="viewMode === 'list' ? 24 : 8"
|
||
:lg="viewMode === 'list' ? 24 : 6"
|
||
:xl="viewMode === 'list' ? 24 : 6"
|
||
>
|
||
<div
|
||
role="button"
|
||
tabindex="0"
|
||
:style="{
|
||
background: colorToCss(note.color),
|
||
borderRadius: `${token.borderRadiusLG}px`,
|
||
border: `1px solid ${token.colorBorderSecondary}`,
|
||
boxShadow: token.boxShadowTertiary,
|
||
padding: `${token.paddingSM}px ${token.padding}px`,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: `${token.marginXS}px`,
|
||
width: '100%',
|
||
aspectRatio: viewMode === 'list' ? undefined : '1 / 1',
|
||
overflow: 'hidden',
|
||
boxSizing: 'border-box',
|
||
}"
|
||
@click="openEditor('default', note)"
|
||
@keydown.enter="openEditor('default', note)"
|
||
>
|
||
<a-flex
|
||
justify="space-between"
|
||
align="flex-start"
|
||
:gap="token.marginXXS"
|
||
:style="{ flexShrink: 0 }"
|
||
>
|
||
<a-typography-text
|
||
v-if="note.title"
|
||
strong
|
||
:ellipsis="viewMode === 'grid'"
|
||
:style="{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
fontSize: `${token.fontSizeLG}px`,
|
||
lineHeight: token.lineHeight,
|
||
wordBreak: 'break-word',
|
||
}"
|
||
>
|
||
{{ note.title }}
|
||
</a-typography-text>
|
||
<span v-else :style="{ flex: 1 }" />
|
||
<a-space :size="0" :style="{ flexShrink: 0 }" @click.stop>
|
||
<PushpinOutlined
|
||
v-if="note.pinned"
|
||
:style="{ color: token.colorPrimary, fontSize: '14px', padding: '4px' }"
|
||
/>
|
||
<a-dropdown :trigger="['click']">
|
||
<a-button type="text" @click.stop>
|
||
<template #icon><MoreOutlined /></template>
|
||
</a-button>
|
||
<template #overlay>
|
||
<a-menu>
|
||
<a-menu-item key="edit" @click="openEditor('default', note)">
|
||
ویرایش
|
||
</a-menu-item>
|
||
<a-menu-item key="pin" @click="togglePin(note.id)">
|
||
{{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }}
|
||
</a-menu-item>
|
||
<a-menu-item
|
||
v-if="(note.revisionCount ?? 0) > 0"
|
||
key="history"
|
||
@click="openHistory(note)"
|
||
>
|
||
تاریخچه ویرایش
|
||
</a-menu-item>
|
||
<a-menu-item key="delete" danger @click="removeNote(note.id)">
|
||
حذف
|
||
</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
</a-space>
|
||
</a-flex>
|
||
|
||
<div
|
||
:style="{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: `${token.marginXXS}px`,
|
||
}"
|
||
>
|
||
<a-flex
|
||
v-if="note.checklist?.length"
|
||
vertical
|
||
:gap="token.marginXXS"
|
||
:style="{ overflow: 'hidden' }"
|
||
@click.stop
|
||
>
|
||
<a-checkbox
|
||
v-for="item in note.checklist.slice(0, viewMode === 'list' ? 6 : 4)"
|
||
:key="item.id"
|
||
:checked="item.done"
|
||
@change="toggleCardChecklist(note, item.id)"
|
||
>
|
||
<a-typography-text
|
||
:delete="item.done"
|
||
:type="item.done ? 'secondary' : undefined"
|
||
:ellipsis="viewMode === 'grid'"
|
||
>
|
||
{{ item.text }}
|
||
</a-typography-text>
|
||
</a-checkbox>
|
||
<a-typography-text
|
||
v-if="note.checklist.length > (viewMode === 'list' ? 6 : 4)"
|
||
type="secondary"
|
||
>
|
||
+{{
|
||
toFaDigits(note.checklist.length - (viewMode === 'list' ? 6 : 4))
|
||
}}
|
||
مورد دیگر
|
||
</a-typography-text>
|
||
</a-flex>
|
||
<a-typography-paragraph
|
||
v-else-if="note.body"
|
||
:ellipsis="{ rows: viewMode === 'list' ? 6 : 4 }"
|
||
:style="{
|
||
marginBottom: 0,
|
||
color: token.colorText,
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
}"
|
||
>
|
||
{{ note.body }}
|
||
</a-typography-paragraph>
|
||
|
||
<img
|
||
v-if="firstImage(note) && blobCache[blobKey(note.id, firstImage(note)!.id)]"
|
||
:src="blobCache[blobKey(note.id, firstImage(note)!.id)]"
|
||
:alt="firstImage(note)!.fileName"
|
||
:style="{
|
||
width: '100%',
|
||
maxHeight: viewMode === 'list' ? '140px' : '72px',
|
||
objectFit: 'cover',
|
||
borderRadius: `${token.borderRadius}px`,
|
||
flexShrink: 0,
|
||
}"
|
||
/>
|
||
|
||
<a-tag v-if="firstPdf(note)" :bordered="false">
|
||
<FileOutlined />
|
||
{{ firstPdf(note)!.fileName }}
|
||
</a-tag>
|
||
|
||
<div
|
||
v-if="firstAudio(note) && blobCache[blobKey(note.id, firstAudio(note)!.id)]"
|
||
@click.stop
|
||
>
|
||
<audio
|
||
controls
|
||
preload="metadata"
|
||
:src="blobCache[blobKey(note.id, firstAudio(note)!.id)]"
|
||
:style="{ width: '100%', height: '32px' }"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<a-flex
|
||
v-if="
|
||
(note.tags && note.tags.length) ||
|
||
note.noteType !== 'general' ||
|
||
note.visibility === 'personal' ||
|
||
pendingAi[note.id]
|
||
"
|
||
wrap="wrap"
|
||
:gap="token.marginXXS"
|
||
:style="{ flexShrink: 0, overflow: 'hidden', maxHeight: '48px' }"
|
||
>
|
||
<a-tag
|
||
v-for="tag in note.tags ?? []"
|
||
:key="tag"
|
||
:bordered="false"
|
||
:style="{ margin: 0, background: token.colorFillSecondary }"
|
||
>
|
||
{{ tag }}
|
||
</a-tag>
|
||
<a-tag
|
||
v-if="note.noteType !== 'general'"
|
||
:bordered="false"
|
||
:style="{ margin: 0, background: token.colorFillSecondary }"
|
||
>
|
||
{{ typeLabel(String(note.noteType)) }}
|
||
</a-tag>
|
||
<a-tag
|
||
v-if="note.visibility === 'personal'"
|
||
:bordered="false"
|
||
:style="{ margin: 0, background: token.colorFillSecondary }"
|
||
>
|
||
{{ NOTE_VISIBILITY_LABELS.personal }}
|
||
</a-tag>
|
||
<a-tag
|
||
v-if="pendingAi[note.id]"
|
||
:bordered="false"
|
||
color="processing"
|
||
:style="{ margin: 0 }"
|
||
>
|
||
<ThunderboltOutlined />
|
||
AI
|
||
</a-tag>
|
||
</a-flex>
|
||
|
||
<a-typography-text
|
||
type="secondary"
|
||
:ellipsis="true"
|
||
:style="{ fontSize: `${token.fontSizeSM}px`, flexShrink: 0 }"
|
||
>
|
||
{{ formatNoteTime(note.updatedAt) }}
|
||
<template v-if="note.person"> · {{ note.person.fullName }}</template>
|
||
</a-typography-text>
|
||
</div>
|
||
</a-col>
|
||
|
||
<a-col
|
||
:xs="viewMode === 'list' ? 24 : 12"
|
||
:sm="viewMode === 'list' ? 24 : 12"
|
||
:md="viewMode === 'list' ? 24 : 8"
|
||
:lg="viewMode === 'list' ? 24 : 6"
|
||
:xl="viewMode === 'list' ? 24 : 6"
|
||
>
|
||
<div
|
||
role="button"
|
||
tabindex="0"
|
||
:style="{
|
||
borderRadius: `${token.borderRadiusLG}px`,
|
||
border: `1px dashed ${token.colorBorder}`,
|
||
padding: `${token.padding}px`,
|
||
cursor: 'pointer',
|
||
width: '100%',
|
||
aspectRatio: viewMode === 'list' ? undefined : '1 / 1',
|
||
minHeight: viewMode === 'list' ? '88px' : undefined,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: `${token.marginXS}px`,
|
||
background: token.colorBgContainer,
|
||
boxSizing: 'border-box',
|
||
}"
|
||
@click="openEditor()"
|
||
@keydown.enter="openEditor()"
|
||
>
|
||
<PlusOutlined :style="{ color: token.colorTextSecondary }" />
|
||
<a-typography-text type="secondary">یادداشت جدید</a-typography-text>
|
||
</div>
|
||
</a-col>
|
||
</a-row>
|
||
</CrmSkeletonCard>
|
||
</CrmSkeleton>
|
||
|
||
<a-button
|
||
type="primary"
|
||
shape="circle"
|
||
aria-label="یادداشت جدید"
|
||
:style="{
|
||
position: 'fixed',
|
||
insetInlineEnd: '16px',
|
||
bottom: '24px',
|
||
zIndex: 40,
|
||
width: '48px',
|
||
height: '48px',
|
||
}"
|
||
@click="openEditor()"
|
||
>
|
||
<template #icon><PlusOutlined /></template>
|
||
</a-button>
|
||
|
||
<div ref="imageUploadEl" :style="{ display: 'none' }">
|
||
<a-upload
|
||
:show-upload-list="false"
|
||
accept="image/*"
|
||
:multiple="true"
|
||
:before-upload="beforeImageUpload"
|
||
>
|
||
<span />
|
||
</a-upload>
|
||
</div>
|
||
<div ref="fileUploadEl" :style="{ display: 'none' }">
|
||
<a-upload
|
||
:show-upload-list="false"
|
||
:multiple="true"
|
||
:before-upload="beforeFileUpload"
|
||
>
|
||
<span />
|
||
</a-upload>
|
||
</div>
|
||
<div ref="audioUploadEl" :style="{ display: 'none' }">
|
||
<a-upload
|
||
:show-upload-list="false"
|
||
accept="audio/*"
|
||
:multiple="true"
|
||
:before-upload="beforeAudioUpload"
|
||
>
|
||
<span />
|
||
</a-upload>
|
||
</div>
|
||
|
||
<a-drawer
|
||
v-model:open="editorOpen"
|
||
:title="editorTitle"
|
||
placement="left"
|
||
:width="560"
|
||
destroy-on-close
|
||
:body-style="{ paddingBottom: '80px' }"
|
||
@close="closeEditor"
|
||
>
|
||
<template #extra>
|
||
<a-space :size="8">
|
||
<a-button
|
||
v-if="editingId && historyNoteRevisionCount > 0"
|
||
@click="
|
||
openHistory({
|
||
id: editingId,
|
||
revisionCount: historyNoteRevisionCount,
|
||
} as NoteDto)
|
||
"
|
||
>
|
||
<template #icon><HistoryOutlined /></template>
|
||
تاریخچه
|
||
</a-button>
|
||
<a-button type="primary" :loading="saving" :disabled="saving" @click="saveNote">
|
||
ذخیره
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
|
||
<a-form layout="vertical">
|
||
<a-form-item>
|
||
<a-input v-model:value="draftTitle" placeholder="عنوان (اختیاری)" />
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<a-textarea
|
||
ref="bodyTextarea"
|
||
v-model:value="draftBody"
|
||
:rows="8"
|
||
placeholder="یادداشت خود را بنویسید…"
|
||
:style="{ background: draftColorCss, width: '100%' }"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||
<a-space :size="8" wrap>
|
||
<a-button
|
||
v-for="sw in colorSwatches"
|
||
:key="sw.value"
|
||
shape="circle"
|
||
:title="sw.label"
|
||
:aria-label="sw.label"
|
||
:type="draftColor === sw.value ? 'primary' : 'default'"
|
||
:style="{
|
||
backgroundColor: NOTE_COLOR_HEX[sw.value],
|
||
borderColor:
|
||
draftColor === sw.value
|
||
? 'var(--ant-color-primary)'
|
||
: 'var(--ant-color-border)',
|
||
}"
|
||
@click="draftColor = sw.value"
|
||
>
|
||
<template v-if="draftColor === sw.value" #icon><CheckOutlined /></template>
|
||
</a-button>
|
||
</a-space>
|
||
<a-button :type="draftPinned ? 'primary' : 'default'" @click="onPinClick">
|
||
<template #icon><PushpinOutlined /></template>
|
||
سنجاق
|
||
</a-button>
|
||
</a-flex>
|
||
|
||
<a-divider orientation="right">پیوست و ابزار</a-divider>
|
||
<a-space wrap :size="8">
|
||
<a-button @click="clickUpload(imageUploadEl)">
|
||
<template #icon><PictureOutlined /></template>
|
||
تصویر
|
||
</a-button>
|
||
<a-button @click="clickUpload(fileUploadEl)">
|
||
<template #icon><FileOutlined /></template>
|
||
فایل
|
||
</a-button>
|
||
<a-button @click="clickUpload(audioUploadEl)">
|
||
<template #icon><AudioOutlined /></template>
|
||
صدا
|
||
</a-button>
|
||
<a-button :type="showChecklistEditor ? 'primary' : 'default'" @click="toggleChecklistEditor">
|
||
<template #icon><CheckOutlined /></template>
|
||
چکلیست
|
||
</a-button>
|
||
<VoiceToTextButton
|
||
variant="toolbar"
|
||
hide-error
|
||
@transcript="(t) => (draftBody = appendSpokenText(draftBody, t))"
|
||
/>
|
||
<a-button @click="promptLocation">
|
||
<template #icon><EnvironmentOutlined /></template>
|
||
موقعیت
|
||
</a-button>
|
||
<a-button @click="openMoreSheet">
|
||
<template #icon><MoreOutlined /></template>
|
||
بیشتر
|
||
</a-button>
|
||
</a-space>
|
||
|
||
<a-flex v-if="showChecklistEditor" vertical :gap="8" :style="{ marginTop: '12px' }">
|
||
<a-flex
|
||
v-for="(item, idx) in draftChecklist"
|
||
:key="item.id"
|
||
:gap="8"
|
||
align="center"
|
||
>
|
||
<a-checkbox
|
||
:checked="item.done"
|
||
@change="toggleDraftCheck(item.id)"
|
||
/>
|
||
<a-input
|
||
v-model:value="draftChecklist[idx]!.text"
|
||
placeholder="مورد چکلیست"
|
||
style="width: 100%"
|
||
/>
|
||
<a-button @click="removeChecklistRow(item.id)">
|
||
<template #icon><CloseOutlined /></template>
|
||
</a-button>
|
||
</a-flex>
|
||
<a-button type="link" @click="addChecklistRow">+ مورد جدید</a-button>
|
||
</a-flex>
|
||
|
||
<a-space
|
||
v-if="draftFiles.length || existingAttachments.length"
|
||
wrap
|
||
:size="6"
|
||
:style="{ marginTop: '12px' }"
|
||
>
|
||
<a-tag
|
||
v-for="(f, i) in draftFiles"
|
||
:key="`df-${i}`"
|
||
closable
|
||
@close="removeDraftFile(i)"
|
||
>
|
||
{{ f.name }}
|
||
</a-tag>
|
||
<a-tag
|
||
v-for="att in existingAttachments"
|
||
:key="att.id"
|
||
closable
|
||
@close="removeExistingAttachment(att.id)"
|
||
>
|
||
{{ att.fileName }}
|
||
</a-tag>
|
||
</a-space>
|
||
|
||
<a-divider orientation="right">جزئیات</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="نوع یادداشت">
|
||
<a-select
|
||
v-model:value="draftType"
|
||
:options="noteTypeOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="سطح دسترسی">
|
||
<a-select
|
||
v-model:value="draftVisibility"
|
||
:options="visibilityOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="مرتبط با — مشتری">
|
||
<a-select
|
||
v-model:value="draftPersonId"
|
||
:options="personOptions"
|
||
show-search
|
||
option-filter-prop="label"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="معامله">
|
||
<a-select
|
||
v-model:value="draftDealId"
|
||
:options="dealOptions"
|
||
show-search
|
||
option-filter-prop="label"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="یادآوری">
|
||
<JalaliDateInput
|
||
v-model="draftReminder"
|
||
mode="datetime"
|
||
icon="bell"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="برچسبها">
|
||
<a-input
|
||
v-model:value="draftTags"
|
||
placeholder="مثلاً قیمت جلسه"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="24">
|
||
<a-form-item label="موقعیت">
|
||
<a-input
|
||
v-model:value="draftLocation"
|
||
placeholder="اختیاری"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="24">
|
||
<a-form-item>
|
||
<a-space :size="8" align="start">
|
||
<a-switch v-model:checked="draftAnalyze" />
|
||
<a-flex vertical :gap="0">
|
||
<a-typography-text strong>تحلیل با هوش مصنوعی</a-typography-text>
|
||
<a-typography-text type="secondary">
|
||
پس از ذخیره، پیشنهاد کار بعدی و حافظه مشتری استخراج میشود.
|
||
</a-typography-text>
|
||
</a-flex>
|
||
</a-space>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-space v-if="parseTags(draftTags).length || draftPersonId || draftDealId || draftLocation" wrap :size="6">
|
||
<a-tag v-for="t in parseTags(draftTags)" :key="t" color="cyan" :bordered="false">
|
||
#{{ t }}
|
||
</a-tag>
|
||
<a-tag v-if="draftPersonId" :bordered="false">{{ personName(draftPersonId) }}</a-tag>
|
||
<a-tag v-if="draftDealId" :bordered="false">معامله</a-tag>
|
||
<a-tag v-if="draftLocation" :bordered="false">{{ draftLocation }}</a-tag>
|
||
</a-space>
|
||
|
||
<NoteAiSuggestionPanel
|
||
v-if="editingId"
|
||
:note-id="editingId"
|
||
:has-person="Boolean(draftPersonId)"
|
||
@applied="load"
|
||
@dismissed="load"
|
||
/>
|
||
|
||
</a-form>
|
||
|
||
<template #footer>
|
||
<a-flex justify="flex-end" :gap="8">
|
||
<a-button @click="closeEditor">لغو</a-button>
|
||
<a-button type="primary" :loading="saving" :disabled="saving" @click="saveNote">
|
||
{{ saving ? 'در حال ذخیره…' : 'ذخیره یادداشت' }}
|
||
</a-button>
|
||
</a-flex>
|
||
</template>
|
||
</a-drawer>
|
||
|
||
<a-drawer
|
||
v-model:open="moreSheetOpen"
|
||
title="جزئیات بیشتر"
|
||
placement="bottom"
|
||
:height="480"
|
||
>
|
||
<a-form layout="vertical">
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="انتخاب مشتری">
|
||
<a-select
|
||
v-model:value="draftPersonId"
|
||
:options="personOptions"
|
||
show-search
|
||
option-filter-prop="label"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="معامله">
|
||
<a-select
|
||
v-model:value="draftDealId"
|
||
:options="dealOptions"
|
||
show-search
|
||
option-filter-prop="label"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="فعالیت">
|
||
<a-select
|
||
v-model:value="draftActivityId"
|
||
:options="activityOptions"
|
||
show-search
|
||
option-filter-prop="label"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="نوع یادداشت">
|
||
<a-select
|
||
v-model:value="draftType"
|
||
:options="noteTypeOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="سطح دسترسی">
|
||
<a-select
|
||
v-model:value="draftVisibility"
|
||
:options="visibilityOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="یادآوری">
|
||
<JalaliDateInput
|
||
v-model="draftReminder"
|
||
mode="datetime"
|
||
icon="bell"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="24">
|
||
<a-form-item label="برچسبها">
|
||
<a-input v-model:value="draftTags" placeholder="برچسبها" style="width: 100%" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="24">
|
||
<a-form-item>
|
||
<a-space :size="8" align="start">
|
||
<a-switch v-model:checked="draftAnalyze" />
|
||
<a-flex vertical :gap="0">
|
||
<a-typography-text strong>تحلیل با هوش مصنوعی</a-typography-text>
|
||
<a-typography-text type="secondary">پس از ذخیره اجرا میشود.</a-typography-text>
|
||
</a-flex>
|
||
</a-space>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-button type="primary" block @click="moreSheetOpen = false">تأیید</a-button>
|
||
</a-form>
|
||
</a-drawer>
|
||
|
||
<a-modal
|
||
v-model:open="historyOpen"
|
||
title="تاریخچه ویرایش"
|
||
:footer="null"
|
||
width="480px"
|
||
destroy-on-close
|
||
@cancel="closeHistory"
|
||
>
|
||
<a-spin :spinning="historyLoading">
|
||
<a-alert v-if="historyError" type="error" show-icon :message="historyError" />
|
||
<a-empty
|
||
v-else-if="!historyLoading && !historyItems.length"
|
||
description="هنوز نسخهای ذخیره نشده."
|
||
/>
|
||
<a-flex v-else vertical :gap="12">
|
||
<a-card v-for="rev in historyItems" :key="rev.id" :bordered="true">
|
||
<a-space wrap :size="6">
|
||
<a-typography-text strong>
|
||
{{ formatJalaliDay(new Date(rev.createdAt)) }}
|
||
·
|
||
{{ formatClockFa(new Date(rev.createdAt)) }}
|
||
</a-typography-text>
|
||
<a-typography-text v-if="rev.editor?.fullName" type="secondary">
|
||
{{ rev.editor.fullName }}
|
||
</a-typography-text>
|
||
<a-tag
|
||
:color="rev.visibility === 'personal' ? 'orange' : 'cyan'"
|
||
:bordered="false"
|
||
>
|
||
{{
|
||
rev.visibility === 'personal'
|
||
? NOTE_VISIBILITY_LABELS.personal
|
||
: NOTE_VISIBILITY_LABELS.team
|
||
}}
|
||
</a-tag>
|
||
</a-space>
|
||
<a-typography-title v-if="rev.title" :level="5">{{ rev.title }}</a-typography-title>
|
||
<a-typography-paragraph type="secondary">
|
||
{{ previewBody(rev.body) }}
|
||
</a-typography-paragraph>
|
||
<a-button @click="restoreRevision(rev.id)">بازگردانی این نسخه</a-button>
|
||
</a-card>
|
||
</a-flex>
|
||
</a-spin>
|
||
</a-modal>
|
||
|
||
<a-modal
|
||
v-model:open="locationModalOpen"
|
||
title="موقعیت مکانی"
|
||
ok-text="تأیید"
|
||
cancel-text="انصراف"
|
||
@ok="confirmLocation"
|
||
>
|
||
<a-input v-model:value="locationDraft" placeholder="موقعیت" style="width: 100%" />
|
||
</a-modal>
|
||
</a-flex>
|
||
</template>
|