2026-08-29 21:06:30 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-05 19:13:01 +03:30
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
2026-10-07 15:23:14 +03:30
|
|
|
|
import { useEscClose } from '@/lib/modal-stack';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
import { useRoute } from 'vue-router';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
2026-09-01 14:29:34 +03:30
|
|
|
|
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
2026-09-03 14:20:55 +03:30
|
|
|
|
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
|
|
|
|
|
import { appendSpokenText } from '@/lib/spoken-text';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
import {
|
|
|
|
|
|
calendarDaysBetween,
|
|
|
|
|
|
formatClockFa,
|
|
|
|
|
|
formatJalaliDay,
|
|
|
|
|
|
toFaDigits,
|
|
|
|
|
|
} from '@/lib/jalali';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import type {
|
2026-09-05 19:13:01 +03:30
|
|
|
|
ActivityDto,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
DealDto,
|
|
|
|
|
|
NoteAttachmentDto,
|
2026-09-05 19:13:01 +03:30
|
|
|
|
NoteAiSuggestionDto,
|
|
|
|
|
|
NoteChecklistItemDto,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
NoteDto,
|
2026-09-27 13:19:08 +03:30
|
|
|
|
NoteRevisionDto,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
NoteType,
|
2026-09-27 13:19:08 +03:30
|
|
|
|
NoteVisibility,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
PaginatedResponse,
|
|
|
|
|
|
PersonDto,
|
|
|
|
|
|
PipelineBoardDto,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-09-27 13:19:08 +03:30
|
|
|
|
import { NOTE_VISIBILITY_LABELS } from '@mento/shared';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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,
|
2026-11-05 14:46:35 +03:30
|
|
|
|
} from '@/icons/ant-bridge';
|
|
|
|
|
|
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
|
|
|
|
|
|
const ACheckableTag = ATag.CheckableTag;
|
|
|
|
|
|
|
|
|
|
|
|
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
|
|
|
|
|
Typography;
|
|
|
|
|
|
const { token } = theme.useToken();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
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';
|
|
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
const route = useRoute();
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const q = ref('');
|
|
|
|
|
|
const typeFilter = ref<NoteType | 'all'>('all');
|
2026-09-27 13:19:08 +03:30
|
|
|
|
const visibilityFilter = ref<NoteVisibility | 'all'>('all');
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const sortKey = ref<SortKey>('updatedAt');
|
|
|
|
|
|
const viewMode = ref<ViewMode>('grid');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const notes = ref<NoteDto[]>([]);
|
|
|
|
|
|
const people = ref<PersonDto[]>([]);
|
|
|
|
|
|
const deals = ref<DealDto[]>([]);
|
2026-09-05 19:13:01 +03:30
|
|
|
|
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);
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const locationModalOpen = ref(false);
|
|
|
|
|
|
const locationDraft = ref('');
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const showChecklistEditor = ref(false);
|
|
|
|
|
|
const editingId = ref<string | null>(null);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
|
|
const draftTitle = ref('');
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const draftBody = ref('');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const draftType = ref<NoteType>('general');
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const draftColor = ref<ColorKey>('sand');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const draftTags = ref('');
|
|
|
|
|
|
const draftPersonId = ref('');
|
|
|
|
|
|
const draftDealId = ref('');
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const draftActivityId = ref('');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const draftReminder = ref('');
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const draftLocation = ref('');
|
|
|
|
|
|
const draftAnalyze = ref(true);
|
2026-09-05 19:41:39 +03:30
|
|
|
|
const draftPinned = ref(false);
|
2026-09-27 13:19:08 +03:30
|
|
|
|
const draftVisibility = ref<NoteVisibility>('team');
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const draftChecklist = ref<NoteChecklistItemDto[]>([]);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const draftFiles = ref<File[]>([]);
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const existingAttachments = ref<NoteAttachmentDto[]>([]);
|
|
|
|
|
|
|
2026-09-27 13:19:08 +03:30
|
|
|
|
const historyOpen = ref(false);
|
|
|
|
|
|
const historyLoading = ref(false);
|
|
|
|
|
|
const historyError = ref('');
|
|
|
|
|
|
const historyNoteId = ref<string | null>(null);
|
|
|
|
|
|
const historyItems = ref<NoteRevisionDto[]>([]);
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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);
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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: 'شخصی' },
|
2026-09-05 19:13:01 +03:30
|
|
|
|
];
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
|
|
const noteTypeOptions: Array<{ value: NoteType; label: string }> = [
|
|
|
|
|
|
{ value: 'general', label: 'معمولی' },
|
2026-09-05 19:13:01 +03:30
|
|
|
|
{ value: 'follow_up', label: 'پیگیری' },
|
|
|
|
|
|
{ value: 'idea', label: 'ایده' },
|
|
|
|
|
|
{ value: 'suggestion', label: 'پیشنهاد' },
|
2026-08-29 21:06:30 +03:30
|
|
|
|
{ value: 'meeting', label: 'جلسه' },
|
2026-09-05 19:13:01 +03:30
|
|
|
|
{ value: 'personal', label: 'شخصی' },
|
|
|
|
|
|
{ value: 'call_result', label: 'نتیجه تماس' },
|
2026-08-29 21:06:30 +03:30
|
|
|
|
{ value: 'important', label: 'نکته مهم' },
|
|
|
|
|
|
{ value: 'warning', label: 'هشدار' },
|
|
|
|
|
|
{ value: 'next_action', label: 'کار بعدی' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
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: 'سفید' },
|
|
|
|
|
|
];
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const typeLabelMap: Record<string, string> = {
|
|
|
|
|
|
general: 'معمولی',
|
|
|
|
|
|
follow_up: 'پیگیری',
|
|
|
|
|
|
idea: 'ایده',
|
|
|
|
|
|
suggestion: 'پیشنهاد',
|
|
|
|
|
|
meeting: 'جلسه',
|
|
|
|
|
|
personal: 'شخصی',
|
|
|
|
|
|
call_result: 'نتیجه تماس',
|
|
|
|
|
|
important: 'نکته مهم',
|
|
|
|
|
|
warning: 'هشدار',
|
|
|
|
|
|
next_action: 'کار بعدی',
|
|
|
|
|
|
};
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const editorTitle = computed(() => (editingId.value ? 'ویرایش' : 'یادداشت جدید'));
|
|
|
|
|
|
const draftColorCss = computed(() => colorToCss(draftColor.value));
|
2026-09-27 13:19:08 +03:30
|
|
|
|
const historyNoteRevisionCount = computed(() => {
|
|
|
|
|
|
if (!editingId.value) return 0;
|
|
|
|
|
|
return notes.value.find((n) => n.id === editingId.value)?.revisionCount ?? 0;
|
|
|
|
|
|
});
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const personName = (id: string) =>
|
|
|
|
|
|
people.value.find((p) => p.id === id)?.fullName ?? '';
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
/** Note color palette (data colors, not chrome). */
|
2026-09-05 19:41:39 +03:30
|
|
|
|
const NOTE_COLOR_HEX: Record<ColorKey, string> = {
|
|
|
|
|
|
sand: '#fff8e1',
|
|
|
|
|
|
rose: '#fce4ec',
|
|
|
|
|
|
sky: '#e3f2fd',
|
|
|
|
|
|
mint: '#e0f2f1',
|
|
|
|
|
|
lavender: '#f3e5f5',
|
|
|
|
|
|
default: '#ffffff',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
function colorToCss(raw: string | null | undefined): string {
|
2026-09-05 19:41:39 +03:30
|
|
|
|
return NOTE_COLOR_HEX[normalizeColor(raw)];
|
2026-09-05 19:13:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-27 13:19:08 +03:30
|
|
|
|
visibility:
|
|
|
|
|
|
visibilityFilter.value === 'all'
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: visibilityFilter.value,
|
2026-09-05 19:13:01 +03:30
|
|
|
|
sort: sortKey.value,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
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);
|
2026-09-05 19:13:01 +03:30
|
|
|
|
void prefetchCardMedia(notes.value);
|
|
|
|
|
|
void refreshAiBadges(notes.value);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && !e.response) {
|
|
|
|
|
|
error.value = 'اتصال به API برقرار نشد.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'بارگذاری یادداشتها ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
async function loadActivities() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
|
|
|
|
|
params: { limit: 50 },
|
|
|
|
|
|
});
|
|
|
|
|
|
activities.value = data.items;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
activities.value = [];
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetDraft() {
|
|
|
|
|
|
draftTitle.value = '';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftBody.value = '';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
draftType.value = 'general';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftColor.value = 'sand';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
draftTags.value = '';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftPersonId.value = typeof route.query.personId === 'string' ? route.query.personId : '';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
draftDealId.value = '';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftActivityId.value = '';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
draftReminder.value = '';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftLocation.value = '';
|
|
|
|
|
|
draftAnalyze.value = true;
|
2026-09-05 19:41:39 +03:30
|
|
|
|
draftPinned.value = false;
|
2026-09-27 13:19:08 +03:30
|
|
|
|
draftVisibility.value = 'team';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftChecklist.value = [];
|
2026-08-29 21:06:30 +03:30
|
|
|
|
draftFiles.value = [];
|
2026-09-05 19:13:01 +03:30
|
|
|
|
existingAttachments.value = [];
|
|
|
|
|
|
showChecklistEditor.value = false;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
editingId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function clickUpload(el: HTMLElement | null) {
|
|
|
|
|
|
el?.querySelector<HTMLInputElement>('input[type="file"]')?.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
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;
|
2026-09-05 19:41:39 +03:30
|
|
|
|
draftPinned.value = note.pinned;
|
2026-09-27 13:19:08 +03:30
|
|
|
|
draftVisibility.value =
|
|
|
|
|
|
note.visibility === 'personal' ? 'personal' : 'team';
|
2026-09-05 19:13:01 +03:30
|
|
|
|
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;
|
2026-11-03 16:51:51 +03:30
|
|
|
|
pendingPick.value = mode;
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
|
|
|
|
|
void nextTick(() => {
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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;
|
2026-09-05 19:13:01 +03:30
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeEditor() {
|
|
|
|
|
|
editorOpen.value = false;
|
|
|
|
|
|
moreSheetOpen.value = false;
|
|
|
|
|
|
resetDraft();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function appendPickedFiles(files: File[], kind: 'image' | 'file' | 'audio') {
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const filtered =
|
|
|
|
|
|
kind === 'image'
|
2026-11-03 16:51:51 +03:30
|
|
|
|
? files.filter((f) => f.type.startsWith('image/'))
|
2026-09-05 19:13:01 +03:30
|
|
|
|
: kind === 'audio'
|
2026-11-03 16:51:51 +03:30
|
|
|
|
? files.filter((f) => f.type.startsWith('audio/') || !f.type)
|
|
|
|
|
|
: files;
|
|
|
|
|
|
if (!filtered.length) return;
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftFiles.value = [...draftFiles.value, ...filtered].slice(0, 8);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
function removeDraftFile(index: number) {
|
|
|
|
|
|
draftFiles.value = draftFiles.value.filter((_, i) => i !== index);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
async function removeExistingAttachment(attachmentId: string) {
|
|
|
|
|
|
if (!editingId.value) return;
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
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,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
async function toggleCardChecklist(note: NoteDto, itemId: string) {
|
|
|
|
|
|
const checklist = (note.checklist ?? []).map((c) =>
|
|
|
|
|
|
c.id === itemId ? { ...c, done: !c.done } : c,
|
|
|
|
|
|
);
|
|
|
|
|
|
note.checklist = checklist;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
try {
|
2026-09-05 19:13:01 +03:30
|
|
|
|
await api.patch(`/notes/${note.id}`, { checklist });
|
|
|
|
|
|
} catch {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveNote() {
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const body = draftBody.value.trim();
|
|
|
|
|
|
const checklist = draftChecklist.value.filter((c) => c.text.trim());
|
|
|
|
|
|
if (!body && !checklist.length) {
|
|
|
|
|
|
error.value = 'متن یادداشت یا چکلیست لازم است.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
saving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = {
|
2026-09-05 19:13:01 +03:30
|
|
|
|
title: draftTitle.value.trim() || null,
|
|
|
|
|
|
body: body || (checklist.length ? 'چکلیست' : ''),
|
2026-08-29 21:06:30 +03:30
|
|
|
|
noteType: draftType.value,
|
2026-09-27 13:19:08 +03:30
|
|
|
|
visibility: draftVisibility.value,
|
2026-09-05 19:13:01 +03:30
|
|
|
|
color: draftColor.value === 'default' ? 'default' : draftColor.value,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
tags: parseTags(draftTags.value),
|
|
|
|
|
|
personId: draftPersonId.value || null,
|
|
|
|
|
|
dealId: draftDealId.value || null,
|
2026-09-05 19:13:01 +03:30
|
|
|
|
activityId: draftActivityId.value || null,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
reminderAt: draftReminder.value
|
|
|
|
|
|
? new Date(draftReminder.value).toISOString()
|
|
|
|
|
|
: null,
|
2026-09-05 19:13:01 +03:30
|
|
|
|
locationText: draftLocation.value.trim() || null,
|
|
|
|
|
|
analyzeEnabled: draftAnalyze.value,
|
2026-09-05 19:41:39 +03:30
|
|
|
|
pinned: draftPinned.value,
|
2026-09-05 19:13:01 +03:30
|
|
|
|
checklist,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
};
|
|
|
|
|
|
const pending = [...draftFiles.value];
|
|
|
|
|
|
let noteId = editingId.value;
|
|
|
|
|
|
if (editingId.value) {
|
|
|
|
|
|
await api.patch(`/notes/${editingId.value}`, payload);
|
|
|
|
|
|
} else {
|
2026-09-05 19:13:01 +03:30
|
|
|
|
const { data } = await api.post<NoteDto>('/notes', {
|
|
|
|
|
|
...payload,
|
|
|
|
|
|
title: draftTitle.value.trim() || undefined,
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
noteId = data.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (noteId && pending.length) {
|
|
|
|
|
|
await uploadFiles(noteId, pending);
|
2026-09-05 19:13:01 +03:30
|
|
|
|
draftFiles.value = [];
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 19:13:01 +03:30
|
|
|
|
if (noteId && draftAnalyze.value && body.length >= 8) {
|
|
|
|
|
|
void api.post(`/notes/${noteId}/analyze`).catch(() => undefined);
|
2026-09-01 14:29:34 +03:30
|
|
|
|
}
|
2026-09-05 19:13:01 +03:30
|
|
|
|
closeEditor();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 13:19:08 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 15:23:14 +03:30
|
|
|
|
useEscClose(() => editorOpen.value, () => closeEditor());
|
2026-11-03 16:51:51 +03:30
|
|
|
|
useEscClose(() => moreSheetOpen.value, () => {
|
|
|
|
|
|
moreSheetOpen.value = false;
|
|
|
|
|
|
});
|
2026-10-07 15:23:14 +03:30
|
|
|
|
useEscClose(() => historyOpen.value, () => closeHistory());
|
|
|
|
|
|
|
2026-09-27 13:19:08 +03:30
|
|
|
|
function closeHistory() {
|
|
|
|
|
|
historyOpen.value = false;
|
|
|
|
|
|
historyNoteId.value = null;
|
|
|
|
|
|
historyItems.value = [];
|
|
|
|
|
|
historyError.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function restoreRevision(revisionId: string) {
|
|
|
|
|
|
if (!historyNoteId.value) return;
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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 ??
|
|
|
|
|
|
'بازگردانی ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'بازگردانی ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-09-27 13:19:08 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
async function togglePin(id: string) {
|
|
|
|
|
|
await api.patch(`/notes/${id}/pin`);
|
|
|
|
|
|
await load();
|
2026-09-05 19:41:39 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
function promptLocation() {
|
2026-11-03 16:51:51 +03:30
|
|
|
|
locationDraft.value = draftLocation.value;
|
|
|
|
|
|
locationModalOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function confirmLocation() {
|
|
|
|
|
|
draftLocation.value = locationDraft.value.trim();
|
|
|
|
|
|
locationModalOpen.value = false;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
function openMoreSheet() {
|
|
|
|
|
|
moreSheetOpen.value = true;
|
|
|
|
|
|
if (!activities.value.length) void loadActivities();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function toggleChecklistEditor() {
|
|
|
|
|
|
showChecklistEditor.value = !showChecklistEditor.value;
|
|
|
|
|
|
if (showChecklistEditor.value && !draftChecklist.value.length) {
|
|
|
|
|
|
draftChecklist.value = [newChecklistItem()];
|
|
|
|
|
|
}
|
2026-09-05 19:13:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function setTypeFilter(value: NoteType | 'all', checked: boolean) {
|
|
|
|
|
|
if (checked) typeFilter.value = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setVisibilityFilter(value: NoteVisibility | 'all', checked: boolean) {
|
|
|
|
|
|
if (checked) visibilityFilter.value = value;
|
2026-09-05 19:13:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(typeFilter, () => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
});
|
2026-09-27 13:19:08 +03:30
|
|
|
|
watch(visibilityFilter, () => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
});
|
2026-09-05 19:13:01 +03:30
|
|
|
|
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;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 19:13:01 +03:30
|
|
|
|
void load();
|
|
|
|
|
|
void loadActivities();
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
revokeAllBlobs();
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-flex vertical :gap="16">
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-typography-title :level="3" :style="{ margin: 0 }">یادداشتها</a-typography-title>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
2026-09-03 14:20:55 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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)"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
{{ 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'"
|
2026-09-27 13:19:08 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-05 14:46:35 +03:30
|
|
|
|
<CrmSkeleton :loading="loading">
|
|
|
|
|
|
<CrmSkeletonCard :rows="6">
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-empty
|
|
|
|
|
|
v-if="!loading && !notes.length"
|
|
|
|
|
|
description="هنوز یادداشتی نیست — یکی اضافه کنید."
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-button type="primary" @click="openEditor()">
|
|
|
|
|
|
<template #icon><PlusOutlined /></template>
|
|
|
|
|
|
یادداشت جدید
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-empty>
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-row v-else :gutter="[12, 12]" align="stretch">
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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`,
|
2026-11-04 10:01:11 +03:30
|
|
|
|
width: '100%',
|
|
|
|
|
|
aspectRatio: viewMode === 'list' ? undefined : '1 / 1',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
boxSizing: 'border-box',
|
2026-11-03 16:51:51 +03:30
|
|
|
|
}"
|
|
|
|
|
|
@click="openEditor('default', note)"
|
|
|
|
|
|
@keydown.enter="openEditor('default', note)"
|
|
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-flex
|
|
|
|
|
|
justify="space-between"
|
|
|
|
|
|
align="flex-start"
|
|
|
|
|
|
:gap="token.marginXXS"
|
|
|
|
|
|
:style="{ flexShrink: 0 }"
|
|
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-typography-text
|
|
|
|
|
|
v-if="note.title"
|
|
|
|
|
|
strong
|
2026-11-04 10:01:11 +03:30
|
|
|
|
:ellipsis="viewMode === 'grid'"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
:style="{
|
|
|
|
|
|
flex: 1,
|
2026-11-04 10:01:11 +03:30
|
|
|
|
minWidth: 0,
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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
|
2026-11-04 10:01:11 +03:30
|
|
|
|
:style="{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minHeight: 0,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
gap: `${token.marginXXS}px`,
|
|
|
|
|
|
}"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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,
|
|
|
|
|
|
}"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
/>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</div>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-flex
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
(note.tags && note.tags.length) ||
|
|
|
|
|
|
note.noteType !== 'general' ||
|
|
|
|
|
|
note.visibility === 'personal' ||
|
|
|
|
|
|
pendingAi[note.id]
|
|
|
|
|
|
"
|
|
|
|
|
|
wrap="wrap"
|
|
|
|
|
|
:gap="token.marginXXS"
|
2026-11-04 10:01:11 +03:30
|
|
|
|
:style="{ flexShrink: 0, overflow: 'hidden', maxHeight: '48px' }"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-typography-text
|
|
|
|
|
|
type="secondary"
|
2026-11-04 10:01:11 +03:30
|
|
|
|
:ellipsis="true"
|
|
|
|
|
|
:style="{ fontSize: `${token.fontSizeSM}px`, flexShrink: 0 }"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
>
|
|
|
|
|
|
{{ formatNoteTime(note.updatedAt) }}
|
|
|
|
|
|
<template v-if="note.person"> · {{ note.person.fullName }}</template>
|
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-col>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<div
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
tabindex="0"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
borderRadius: `${token.borderRadiusLG}px`,
|
|
|
|
|
|
border: `1px dashed ${token.colorBorder}`,
|
|
|
|
|
|
padding: `${token.padding}px`,
|
|
|
|
|
|
cursor: 'pointer',
|
2026-11-04 10:01:11 +03:30
|
|
|
|
width: '100%',
|
|
|
|
|
|
aspectRatio: viewMode === 'list' ? undefined : '1 / 1',
|
|
|
|
|
|
minHeight: viewMode === 'list' ? '88px' : undefined,
|
2026-11-03 16:51:51 +03:30
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
gap: `${token.marginXS}px`,
|
|
|
|
|
|
background: token.colorBgContainer,
|
2026-11-04 10:01:11 +03:30
|
|
|
|
boxSizing: 'border-box',
|
2026-11-03 16:51:51 +03:30
|
|
|
|
}"
|
|
|
|
|
|
@click="openEditor()"
|
|
|
|
|
|
@keydown.enter="openEditor()"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<PlusOutlined :style="{ color: token.colorTextSecondary }" />
|
|
|
|
|
|
<a-typography-text type="secondary">یادداشت جدید</a-typography-text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
</a-row>
|
2026-11-05 14:46:35 +03:30
|
|
|
|
</CrmSkeletonCard>
|
|
|
|
|
|
</CrmSkeleton>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
shape="circle"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
aria-label="یادداشت جدید"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
:style="{
|
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
|
insetInlineEnd: '16px',
|
|
|
|
|
|
bottom: '24px',
|
|
|
|
|
|
zIndex: 40,
|
|
|
|
|
|
width: '48px',
|
|
|
|
|
|
height: '48px',
|
|
|
|
|
|
}"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
@click="openEditor()"
|
|
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<template #icon><PlusOutlined /></template>
|
|
|
|
|
|
</a-button>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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' }"
|
2026-09-05 19:41:39 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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%"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
/>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</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>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-09-05 19:13:01 +03:30
|
|
|
|
>
|
2026-09-27 13:19:08 +03:30
|
|
|
|
{{
|
2026-11-03 16:51:51 +03:30
|
|
|
|
rev.visibility === 'personal'
|
2026-09-27 13:19:08 +03:30
|
|
|
|
? NOTE_VISIBILITY_LABELS.personal
|
|
|
|
|
|
: NOTE_VISIBILITY_LABELS.team
|
|
|
|
|
|
}}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</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>
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</template>
|