Files
CRM_Montzar/apps/web/src/views/NotesView.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
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>
2026-11-05 14:46:35 +03:30

1616 lines
51 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, 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>