2669 lines
71 KiB
Vue
2669 lines
71 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { useEscClose } from '@/lib/modal-stack';
|
||
import { useRoute } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
|
||
import NoteCustomFields from '@/components/notes/NoteCustomFields.vue';
|
||
import NoteCategoriesManager from '@/components/notes/NoteCategoriesManager.vue';
|
||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||
import UiIcon from '@/components/UiIcon.vue';
|
||
import { UiDropdownMenu } from '@/components/ui';
|
||
import { appendSpokenText } from '@/lib/spoken-text';
|
||
import { canManageSettings } from '@/lib/roles';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import {
|
||
calendarDaysBetween,
|
||
formatClockFa,
|
||
formatJalaliDay,
|
||
toFaDigits,
|
||
} from '@/lib/jalali';
|
||
import type {
|
||
ActivityDto,
|
||
DealDto,
|
||
NoteAttachmentDto,
|
||
NoteAiSuggestionDto,
|
||
NoteChecklistItemDto,
|
||
NoteDto,
|
||
NoteCategoryDto,
|
||
NoteRevisionDto,
|
||
NoteVisibility,
|
||
PaginatedResponse,
|
||
PersonDto,
|
||
PipelineBoardDto,
|
||
CustomFieldDefinitionDto,
|
||
} from '@mento/shared';
|
||
import { NOTE_VISIBILITY_LABELS } from '@mento/shared';
|
||
|
||
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 auth = useAuthStore();
|
||
const canManageNoteFields = computed(() => canManageSettings(auth.user?.role));
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const error = ref('');
|
||
const q = ref('');
|
||
const typeFilter = ref<string>('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 menuOpenId = ref<string | null>(null);
|
||
const blobCache = ref<Record<string, string>>({});
|
||
const audioDurations = ref<Record<string, string>>({});
|
||
|
||
const editorOpen = ref(false);
|
||
const moreSheetOpen = ref(false);
|
||
const showChecklistEditor = ref(false);
|
||
const editingId = ref<string | null>(null);
|
||
|
||
const draftTitle = ref('');
|
||
const draftBody = ref('');
|
||
const draftType = ref('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 draftCustomFields = ref<Record<string, unknown>>({});
|
||
const draftFiles = ref<File[]>([]);
|
||
const existingAttachments = ref<NoteAttachmentDto[]>([]);
|
||
const noteFieldDefs = ref<CustomFieldDefinitionDto[]>([]);
|
||
|
||
const historyOpen = ref(false);
|
||
const historyLoading = ref(false);
|
||
const historyError = ref('');
|
||
const historyNoteId = ref<string | null>(null);
|
||
const historyItems = ref<NoteRevisionDto[]>([]);
|
||
|
||
const imageInput = ref<HTMLInputElement | null>(null);
|
||
const fileInput = ref<HTMLInputElement | null>(null);
|
||
const audioInput = ref<HTMLInputElement | null>(null);
|
||
const bodyTextarea = ref<HTMLTextAreaElement | null>(null);
|
||
|
||
const MOBILE_MQ = '(max-width: 860px)';
|
||
const isMobile = ref(false);
|
||
let mq: MediaQueryList | null = null;
|
||
|
||
const noteCategories = ref<NoteCategoryDto[]>([
|
||
{ id: 'general', key: 'general', label: 'معمولی', tone: 'general', position: 0, isActive: true, showInBar: true, isSystem: true },
|
||
{ id: 'follow_up', key: 'follow_up', label: 'پیگیری', tone: 'follow_up', position: 1, isActive: true, showInBar: true, isSystem: true },
|
||
{ id: 'idea', key: 'idea', label: 'ایده', tone: 'idea', position: 2, isActive: true, showInBar: true, isSystem: true },
|
||
{ id: 'suggestion', key: 'suggestion', label: 'پیشنهاد', tone: 'suggestion', position: 3, isActive: true, showInBar: true, isSystem: true },
|
||
{ id: 'meeting', key: 'meeting', label: 'جلسه', tone: 'meeting', position: 4, isActive: true, showInBar: true, isSystem: true },
|
||
{ id: 'personal', key: 'personal', label: 'شخصی', tone: 'personal', position: 5, isActive: true, showInBar: true, isSystem: true },
|
||
]);
|
||
const categoriesOpen = ref(false);
|
||
|
||
const categoryChips = computed(() => [
|
||
{ value: 'all', label: 'همه', tone: 'all' },
|
||
...noteCategories.value
|
||
.filter((item) => item.isActive && item.showInBar)
|
||
.map((item) => ({ value: item.key, label: item.label, tone: item.tone || 'general' })),
|
||
]);
|
||
|
||
const noteTypeOptions = computed(() =>
|
||
noteCategories.value
|
||
.filter((item) => item.isActive)
|
||
.map((item) => ({ value: item.key, label: item.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 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 ?? '';
|
||
|
||
/** Hex only — panel is Teleported outside `.notes-page`, so CSS vars would not inherit. */
|
||
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 noteCategories.value.find((item) => item.key === t)?.label ?? typeLabelMap[t] ?? t;
|
||
}
|
||
|
||
function typeTone(t: string) {
|
||
const tone = noteCategories.value.find((item) => item.key === t)?.tone;
|
||
if (tone) return tone;
|
||
if (t === 'call_result' || t === 'next_action' || t === 'warning') return 'follow_up';
|
||
if (t === 'important') return 'suggestion';
|
||
if (
|
||
t === 'general' ||
|
||
t === 'follow_up' ||
|
||
t === 'idea' ||
|
||
t === 'suggestion' ||
|
||
t === 'meeting' ||
|
||
t === 'personal'
|
||
) {
|
||
return t;
|
||
}
|
||
return 'general';
|
||
}
|
||
|
||
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 customFieldLines(note: NoteDto) {
|
||
const values = note.customFields ?? {};
|
||
return noteFieldDefs.value.flatMap((field) => {
|
||
const value = values[field.key];
|
||
if (value == null || value === '') return [];
|
||
const text = field.fieldType === 'boolean' ? (value ? 'بله' : 'خیر') : String(value);
|
||
return [{ key: field.key, label: field.label, text }];
|
||
});
|
||
}
|
||
|
||
async function loadNoteCategories() {
|
||
try {
|
||
noteCategories.value = (await api.get<NoteCategoryDto[]>('/notes/categories')).data;
|
||
const visible = new Set(
|
||
noteCategories.value.filter((item) => item.isActive && item.showInBar).map((item) => item.key),
|
||
);
|
||
if (typeFilter.value !== 'all' && !visible.has(typeFilter.value)) {
|
||
typeFilter.value = 'all';
|
||
}
|
||
} catch {
|
||
/* نوار پیشفرض میماند */
|
||
}
|
||
}
|
||
|
||
async function loadNoteFields() {
|
||
try {
|
||
noteFieldDefs.value = (
|
||
await api.get<CustomFieldDefinitionDto[]>('/custom-fields', {
|
||
params: { entityType: 'note', activeOnly: true },
|
||
})
|
||
).data;
|
||
} catch {
|
||
noteFieldDefs.value = [];
|
||
}
|
||
}
|
||
|
||
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 = [];
|
||
draftCustomFields.value = {};
|
||
draftFiles.value = [];
|
||
existingAttachments.value = [];
|
||
showChecklistEditor.value = false;
|
||
editingId.value = null;
|
||
}
|
||
|
||
function openEditor(mode: OpenMode = 'default', note?: NoteDto) {
|
||
menuOpenId.value = null;
|
||
if (note) {
|
||
editingId.value = note.id;
|
||
draftTitle.value = note.title ?? '';
|
||
draftBody.value = note.body;
|
||
draftType.value = note.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 ?? [])];
|
||
draftCustomFields.value = { ...(note.customFields ?? {}) };
|
||
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;
|
||
|
||
void nextTick(() => {
|
||
bodyTextarea.value?.focus();
|
||
if (mode === 'image') imageInput.value?.click();
|
||
if (mode === 'attach') fileInput.value?.click();
|
||
if (mode === 'audio') audioInput.value?.click();
|
||
});
|
||
}
|
||
|
||
function closeEditor() {
|
||
editorOpen.value = false;
|
||
moreSheetOpen.value = false;
|
||
resetDraft();
|
||
}
|
||
|
||
function onFilesSelected(event: Event, kind: 'image' | 'file' | 'audio') {
|
||
const input = event.target as HTMLInputElement;
|
||
const picked = Array.from(input.files ?? []);
|
||
input.value = '';
|
||
if (!picked.length) return;
|
||
const filtered =
|
||
kind === 'image'
|
||
? picked.filter((f) => f.type.startsWith('image/'))
|
||
: kind === 'audio'
|
||
? picked.filter((f) => f.type.startsWith('audio/') || !f.type)
|
||
: picked;
|
||
draftFiles.value = [...draftFiles.value, ...filtered].slice(0, 8);
|
||
}
|
||
|
||
function removeDraftFile(index: number) {
|
||
draftFiles.value = draftFiles.value.filter((_, i) => i !== index);
|
||
}
|
||
|
||
async function removeExistingAttachment(attachmentId: string) {
|
||
if (!editingId.value) return;
|
||
if (!confirm('این پیوست حذف شود؟')) return;
|
||
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;
|
||
}
|
||
const missingField = noteFieldDefs.value.find((field) => {
|
||
if (!field.required) return false;
|
||
const value = draftCustomFields.value[field.key];
|
||
if (field.fieldType === 'boolean') return value !== true && value !== false;
|
||
return value == null || String(value).trim() === '';
|
||
});
|
||
if (missingField) {
|
||
error.value = `فیلد «${missingField.label}» لازم است.`;
|
||
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,
|
||
customFields: draftCustomFields.value,
|
||
};
|
||
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) {
|
||
menuOpenId.value = null;
|
||
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(() => categoriesOpen.value, () => {
|
||
categoriesOpen.value = false;
|
||
});
|
||
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;
|
||
if (!confirm('این نسخه جایگزین متن فعلی شود؟')) return;
|
||
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) {
|
||
menuOpenId.value = null;
|
||
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;
|
||
}
|
||
}
|
||
|
||
async function removeNote(id: string) {
|
||
menuOpenId.value = null;
|
||
if (!confirm('این یادداشت حذف شود؟')) return;
|
||
await api.delete(`/notes/${id}`);
|
||
if (editingId.value === id) closeEditor();
|
||
await load();
|
||
}
|
||
|
||
function promptLocation() {
|
||
const next = window.prompt('موقعیت مکانی', draftLocation.value || '');
|
||
if (next == null) return;
|
||
draftLocation.value = next.trim();
|
||
}
|
||
|
||
function openMoreSheet() {
|
||
moreSheetOpen.value = true;
|
||
if (!activities.value.length) void loadActivities();
|
||
}
|
||
|
||
function syncMobileFlag() {
|
||
isMobile.value = mq?.matches ?? false;
|
||
}
|
||
|
||
function onDocClick(ev: MouseEvent) {
|
||
const t = ev.target as HTMLElement | null;
|
||
if (!t?.closest?.('.card-menu, .ui-dd-panel')) menuOpenId.value = null;
|
||
}
|
||
|
||
watch(typeFilter, () => {
|
||
void load();
|
||
});
|
||
watch(visibilityFilter, () => {
|
||
void load();
|
||
});
|
||
watch(sortKey, () => {
|
||
void load();
|
||
});
|
||
|
||
onMounted(() => {
|
||
mq = window.matchMedia(MOBILE_MQ);
|
||
syncMobileFlag();
|
||
mq.addEventListener('change', syncMobileFlag);
|
||
document.addEventListener('click', onDocClick);
|
||
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();
|
||
void loadNoteFields();
|
||
void loadNoteCategories();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
mq?.removeEventListener('change', syncMobileFlag);
|
||
document.removeEventListener('click', onDocClick);
|
||
revokeAllBlobs();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="notes-page">
|
||
<header class="page-head">
|
||
<div>
|
||
<h1>یادداشتها</h1>
|
||
<p class="lead">فکر کنید، یادداشت کنید، هیچ چیز را فراموش نکنید.</p>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="error" class="banner-error">{{ error }}</p>
|
||
|
||
<!-- Desktop toolbar -->
|
||
<div class="toolbar desktop-only">
|
||
<div class="chips">
|
||
<button
|
||
v-for="chip in categoryChips"
|
||
:key="chip.value"
|
||
type="button"
|
||
class="chip"
|
||
:class="[`tone-${chip.tone}`, { active: typeFilter === chip.value }]"
|
||
@click="typeFilter = chip.value"
|
||
>
|
||
<span v-if="chip.value !== 'all'" class="dot" />
|
||
{{ chip.label }}
|
||
</button>
|
||
<button
|
||
v-if="canManageNoteFields"
|
||
type="button"
|
||
class="chip chip-manage"
|
||
@click="categoriesOpen = true"
|
||
>
|
||
دستهها
|
||
</button>
|
||
<span class="chip-sep" aria-hidden="true" />
|
||
<button
|
||
type="button"
|
||
class="chip"
|
||
:class="{ active: visibilityFilter === 'all' }"
|
||
@click="visibilityFilter = 'all'"
|
||
>
|
||
همه دسترسیها
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="chip tone-team"
|
||
:class="{ active: visibilityFilter === 'team' }"
|
||
@click="visibilityFilter = 'team'"
|
||
>
|
||
تیمی
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="chip tone-private"
|
||
:class="{ active: visibilityFilter === 'personal' }"
|
||
@click="visibilityFilter = 'personal'"
|
||
>
|
||
فقط من
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-end">
|
||
<div class="view-toggle" role="group" aria-label="نمای لیست">
|
||
<button
|
||
type="button"
|
||
:class="{ active: viewMode === 'grid' }"
|
||
title="شبکه"
|
||
@click="viewMode = 'grid'"
|
||
>
|
||
<UiIcon name="grid" :size="18" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:class="{ active: viewMode === 'list' }"
|
||
title="لیست"
|
||
@click="viewMode = 'list'"
|
||
>
|
||
<UiIcon name="list" :size="18" />
|
||
</button>
|
||
</div>
|
||
<select v-model="sortKey" class="sort-select" aria-label="مرتبسازی">
|
||
<option value="updatedAt">جدیدترین</option>
|
||
<option value="createdAt">تاریخ ایجاد</option>
|
||
<option value="reminderAt">یادآوری</option>
|
||
</select>
|
||
<button type="button" class="icon-btn" title="فیلتر" @click="q = q; load()">
|
||
<UiIcon name="filter" :size="18" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Desktop quick-add -->
|
||
<div class="quick-add desktop-only" @click="openEditor()">
|
||
<input
|
||
class="quick-input"
|
||
type="text"
|
||
readonly
|
||
placeholder="یادداشتی بنویسید..."
|
||
@focus.prevent="openEditor()"
|
||
/>
|
||
<div class="quick-actions" @click.stop>
|
||
<button type="button" class="icon-btn" title="تصویر" @click="openEditor('image')">
|
||
<UiIcon name="layers" :size="18" />
|
||
</button>
|
||
<button type="button" class="icon-btn" title="صدا" @click="openEditor('mic')">
|
||
<UiIcon name="mic" :size="18" />
|
||
</button>
|
||
<button type="button" class="icon-btn" title="پیوست" @click="openEditor('attach')">
|
||
<UiIcon name="document" :size="18" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mobile quick bar -->
|
||
<div class="mobile-quick mobile-only">
|
||
<button type="button" class="plus-teal" @click="openEditor()">
|
||
<UiIcon name="plus" :size="20" />
|
||
</button>
|
||
<input
|
||
class="quick-input"
|
||
type="text"
|
||
readonly
|
||
placeholder="یادداشتی بنویسید..."
|
||
@focus.prevent="openEditor()"
|
||
/>
|
||
<button type="button" class="icon-btn" @click="openEditor('image')">
|
||
<UiIcon name="layers" :size="18" />
|
||
</button>
|
||
<button type="button" class="icon-btn" @click="openEditor('mic')">
|
||
<UiIcon name="mic" :size="18" />
|
||
</button>
|
||
<button type="button" class="icon-btn" @click="openEditor('attach')">
|
||
<UiIcon name="document" :size="18" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Mobile chips -->
|
||
<div class="chips chips-scroll mobile-only">
|
||
<button
|
||
v-for="chip in categoryChips"
|
||
:key="`m-${chip.value}`"
|
||
type="button"
|
||
class="chip"
|
||
:class="[`tone-${chip.tone}`, { active: typeFilter === chip.value }]"
|
||
@click="typeFilter = chip.value"
|
||
>
|
||
<span v-if="chip.value !== 'all'" class="dot" />
|
||
{{ chip.label }}
|
||
</button>
|
||
<button
|
||
v-if="canManageNoteFields"
|
||
type="button"
|
||
class="chip chip-manage"
|
||
@click="categoriesOpen = true"
|
||
>
|
||
دستهها
|
||
</button>
|
||
<span class="chip-sep" aria-hidden="true" />
|
||
<button
|
||
type="button"
|
||
class="chip"
|
||
:class="{ active: visibilityFilter === 'all' }"
|
||
@click="visibilityFilter = 'all'"
|
||
>
|
||
همه دسترسیها
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="chip tone-team"
|
||
:class="{ active: visibilityFilter === 'team' }"
|
||
@click="visibilityFilter = 'team'"
|
||
>
|
||
تیمی
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="chip tone-private"
|
||
:class="{ active: visibilityFilter === 'personal' }"
|
||
@click="visibilityFilter = 'personal'"
|
||
>
|
||
فقط من
|
||
</button>
|
||
</div>
|
||
|
||
<p v-if="loading" class="state-msg">در حال بارگذاری…</p>
|
||
<p v-else-if="!notes.length" class="state-msg">هنوز یادداشتی نیست — یکی اضافه کنید.</p>
|
||
|
||
<div
|
||
v-else
|
||
class="notes-board"
|
||
:class="viewMode === 'list' ? 'is-list' : 'is-grid'"
|
||
>
|
||
<article
|
||
v-for="note in notes"
|
||
:key="note.id"
|
||
class="note-card"
|
||
:style="{ background: colorToCss(note.color) }"
|
||
@click="openEditor('default', note)"
|
||
>
|
||
<div class="card-top">
|
||
<span v-if="note.pinned" class="pin" title="سنجاقشده" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||
<path
|
||
d="M14.5 3.5 16 5l-1.2 1.2 3.7 3.7 1.3-1.3 1.4 1.4-7.8 7.8-1.4-1.4 1.3-1.3-3.7-3.7L8 13.5 6.5 12l8-8.5ZM7 18l-3 3"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
<div class="card-menu" @click.stop>
|
||
<UiDropdownMenu
|
||
:open="menuOpenId === note.id"
|
||
align="end"
|
||
@update:open="(v) => (menuOpenId = v ? note.id : null)"
|
||
>
|
||
<template #trigger="{ toggle }">
|
||
<button
|
||
type="button"
|
||
class="icon-btn menu-btn"
|
||
aria-label="عملیات"
|
||
@click.stop="toggle"
|
||
>
|
||
<UiIcon name="dots-v" :size="18" />
|
||
</button>
|
||
</template>
|
||
<button type="button" @click.stop="openEditor('default', note)">ویرایش</button>
|
||
<button type="button" @click.stop="togglePin(note.id)">
|
||
{{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }}
|
||
</button>
|
||
<button
|
||
v-if="(note.revisionCount ?? 0) > 0"
|
||
type="button"
|
||
@click.stop="openHistory(note)"
|
||
>
|
||
تاریخچه ویرایش
|
||
</button>
|
||
<button type="button" class="danger" @click.stop="removeNote(note.id)">حذف</button>
|
||
</UiDropdownMenu>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 v-if="note.title" class="card-title">{{ note.title }}</h3>
|
||
|
||
<ul v-if="note.checklist?.length" class="card-check" @click.stop>
|
||
<li v-for="item in note.checklist" :key="item.id">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
:checked="item.done"
|
||
@change="toggleCardChecklist(note, item.id)"
|
||
/>
|
||
<span :class="{ done: item.done }">{{ item.text }}</span>
|
||
</label>
|
||
</li>
|
||
</ul>
|
||
<p v-else-if="note.body" class="card-body">{{ previewBody(note.body) }}</p>
|
||
<ul v-if="customFieldLines(note).length" class="card-fields">
|
||
<li v-for="line in customFieldLines(note)" :key="line.key">
|
||
<b>{{ line.label }}:</b> {{ line.text }}
|
||
</li>
|
||
</ul>
|
||
|
||
<div v-if="firstImage(note)" class="card-thumb">
|
||
<img
|
||
v-if="blobCache[blobKey(note.id, firstImage(note)!.id)]"
|
||
:src="blobCache[blobKey(note.id, firstImage(note)!.id)]"
|
||
:alt="firstImage(note)!.fileName"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="firstPdf(note)" class="att-chip pdf">
|
||
<UiIcon name="document" :size="14" />
|
||
<span>{{ firstPdf(note)!.fileName }}</span>
|
||
</div>
|
||
|
||
<div
|
||
v-if="firstAudio(note) && blobCache[blobKey(note.id, firstAudio(note)!.id)]"
|
||
class="audio-row"
|
||
@click.stop
|
||
>
|
||
<audio
|
||
controls
|
||
preload="metadata"
|
||
:src="blobCache[blobKey(note.id, firstAudio(note)!.id)]"
|
||
/>
|
||
<span
|
||
v-if="audioDurations[blobKey(note.id, firstAudio(note)!.id)]"
|
||
class="dur"
|
||
>
|
||
{{ audioDurations[blobKey(note.id, firstAudio(note)!.id)] }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="card-meta">
|
||
<span class="type-badge" :class="`tone-${typeTone(String(note.noteType))}`">
|
||
{{ typeLabel(String(note.noteType)) }}
|
||
</span>
|
||
<span
|
||
class="vis-badge"
|
||
:class="note.visibility === 'personal' ? 'private' : 'team'"
|
||
>
|
||
{{
|
||
note.visibility === 'personal'
|
||
? NOTE_VISIBILITY_LABELS.personal
|
||
: NOTE_VISIBILITY_LABELS.team
|
||
}}
|
||
</span>
|
||
<span v-if="pendingAi[note.id]" class="ai-badge" title="پیشنهاد AI">
|
||
<UiIcon name="sparkle" :size="12" />
|
||
AI
|
||
</span>
|
||
</div>
|
||
|
||
<footer class="card-foot">
|
||
<time>{{ formatNoteTime(note.updatedAt) }}</time>
|
||
<span v-if="note.person" class="person">{{ note.person.fullName }}</span>
|
||
</footer>
|
||
</article>
|
||
|
||
<button
|
||
type="button"
|
||
class="note-card add-card"
|
||
@click="openEditor()"
|
||
>
|
||
<UiIcon name="plus" :size="22" />
|
||
<span>یادداشت جدید</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Mobile FAB -->
|
||
<button
|
||
v-if="!editorOpen"
|
||
type="button"
|
||
class="fab mobile-only"
|
||
aria-label="یادداشت جدید"
|
||
@click="openEditor()"
|
||
>
|
||
<UiIcon name="plus" :size="24" />
|
||
</button>
|
||
|
||
<!-- Hidden file inputs -->
|
||
<input
|
||
ref="imageInput"
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
hidden
|
||
@change="onFilesSelected($event, 'image')"
|
||
/>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
@change="onFilesSelected($event, 'file')"
|
||
/>
|
||
<input
|
||
ref="audioInput"
|
||
type="file"
|
||
accept="audio/*"
|
||
multiple
|
||
hidden
|
||
@change="onFilesSelected($event, 'audio')"
|
||
/>
|
||
|
||
<!-- Unified create/edit modal -->
|
||
<Teleport to="body">
|
||
<div v-if="editorOpen" class="panel-root">
|
||
<div class="panel-backdrop" aria-hidden="true" />
|
||
<aside
|
||
class="editor-panel"
|
||
:style="{
|
||
'--canvas': draftColorCss,
|
||
background: draftColorCss,
|
||
}"
|
||
>
|
||
<header class="panel-head">
|
||
<h2>{{ editorTitle }}</h2>
|
||
<div class="panel-head-actions">
|
||
<button
|
||
v-if="editingId && historyNoteRevisionCount > 0"
|
||
type="button"
|
||
class="btn-ghost history-btn"
|
||
@click="
|
||
openHistory({
|
||
id: editingId,
|
||
revisionCount: historyNoteRevisionCount,
|
||
} as NoteDto)
|
||
"
|
||
>
|
||
تاریخچه
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-save mobile-only-flex"
|
||
:disabled="saving"
|
||
@click="saveNote"
|
||
>
|
||
{{ saving ? '…' : 'ذخیره' }}
|
||
</button>
|
||
<button type="button" class="icon-btn" @click="closeEditor">
|
||
<UiIcon name="x" :size="20" />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="panel-scroll">
|
||
<p v-if="error" class="banner-error">{{ error }}</p>
|
||
<input
|
||
v-model="draftTitle"
|
||
class="title-input"
|
||
type="text"
|
||
placeholder="عنوان (اختیاری)"
|
||
/>
|
||
<textarea
|
||
ref="bodyTextarea"
|
||
v-model="draftBody"
|
||
class="body-canvas"
|
||
rows="8"
|
||
placeholder="یادداشت خود را بنویسید…"
|
||
/>
|
||
|
||
<div class="palette-row">
|
||
<div class="swatches" role="listbox" aria-label="رنگ یادداشت">
|
||
<button
|
||
v-for="sw in colorSwatches"
|
||
:key="sw.value"
|
||
type="button"
|
||
class="swatch"
|
||
:class="[`tone-${sw.value}`, { selected: draftColor === sw.value }]"
|
||
:style="{ backgroundColor: NOTE_COLOR_HEX[sw.value] }"
|
||
:title="sw.label"
|
||
:aria-label="sw.label"
|
||
:aria-selected="draftColor === sw.value"
|
||
@click="draftColor = sw.value"
|
||
>
|
||
<UiIcon v-if="draftColor === sw.value" name="check-plain" :size="14" />
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="media-btn pin-btn"
|
||
:class="{ on: draftPinned }"
|
||
title="سنجاق"
|
||
@click="onPinClick"
|
||
>
|
||
سنجاق
|
||
</button>
|
||
</div>
|
||
|
||
<div class="media-row desktop-only-flex">
|
||
<button type="button" class="media-btn" @click="imageInput?.click()">
|
||
<UiIcon name="layers" :size="18" /> تصویر
|
||
</button>
|
||
<button type="button" class="media-btn" @click="fileInput?.click()">
|
||
<UiIcon name="document" :size="18" /> فایل
|
||
</button>
|
||
<button type="button" class="media-btn" @click="audioInput?.click()">
|
||
<UiIcon name="mic" :size="18" /> صدا
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="media-btn"
|
||
:class="{ on: showChecklistEditor }"
|
||
@click="
|
||
showChecklistEditor = !showChecklistEditor;
|
||
if (showChecklistEditor && !draftChecklist.length)
|
||
draftChecklist = [newChecklistItem()];
|
||
"
|
||
>
|
||
<UiIcon name="check" :size="18" /> چکلیست
|
||
</button>
|
||
<VoiceToTextButton
|
||
variant="toolbar"
|
||
hide-error
|
||
@transcript="(t) => (draftBody = appendSpokenText(draftBody, t))"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="showChecklistEditor" class="checklist-ed">
|
||
<div
|
||
v-for="(item, idx) in draftChecklist"
|
||
:key="item.id"
|
||
class="check-row"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
:checked="item.done"
|
||
@change="toggleDraftCheck(item.id)"
|
||
/>
|
||
<input
|
||
v-model="draftChecklist[idx]!.text"
|
||
type="text"
|
||
placeholder="مورد چکلیست"
|
||
/>
|
||
<button type="button" class="icon-btn" @click="removeChecklistRow(item.id)">
|
||
<UiIcon name="x" :size="16" />
|
||
</button>
|
||
</div>
|
||
<button type="button" class="linkish" @click="addChecklistRow">+ مورد جدید</button>
|
||
</div>
|
||
|
||
<div v-if="draftFiles.length || existingAttachments.length" class="pending-files">
|
||
<div v-for="(f, i) in draftFiles" :key="`df-${i}`" class="file-chip">
|
||
<span>{{ f.name }}</span>
|
||
<button type="button" @click="removeDraftFile(i)">×</button>
|
||
</div>
|
||
<div
|
||
v-for="att in existingAttachments"
|
||
:key="att.id"
|
||
class="file-chip"
|
||
>
|
||
<span>{{ att.fileName }}</span>
|
||
<button type="button" @click="removeExistingAttachment(att.id)">×</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="desktop-fields">
|
||
<label class="field">
|
||
<span>نوع یادداشت</span>
|
||
<select v-model="draftType">
|
||
<option
|
||
v-for="opt in noteTypeOptions"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
>
|
||
{{ opt.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>سطح دسترسی</span>
|
||
<select v-model="draftVisibility">
|
||
<option value="team">{{ NOTE_VISIBILITY_LABELS.team }}</option>
|
||
<option value="personal">
|
||
{{ NOTE_VISIBILITY_LABELS.personal }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<div class="field-row">
|
||
<label class="field">
|
||
<span>مرتبط با — مشتری</span>
|
||
<select v-model="draftPersonId">
|
||
<option value="">—</option>
|
||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||
{{ p.fullName }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>معامله</span>
|
||
<select v-model="draftDealId">
|
||
<option value="">—</option>
|
||
<option v-for="d in deals" :key="d.id" :value="d.id">
|
||
{{ d.title }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label class="field">
|
||
<span>یادآوری</span>
|
||
<JalaliDateInput v-model="draftReminder" mode="datetime" icon="bell" />
|
||
</label>
|
||
<label class="field">
|
||
<span>برچسبها</span>
|
||
<input v-model="draftTags" type="text" placeholder="مثلاً قیمت جلسه" />
|
||
</label>
|
||
<label class="field">
|
||
<span>موقعیت</span>
|
||
<input v-model="draftLocation" type="text" placeholder="اختیاری" />
|
||
</label>
|
||
<label class="ai-toggle">
|
||
<input v-model="draftAnalyze" type="checkbox" />
|
||
<div>
|
||
<strong>تحلیل با هوش مصنوعی</strong>
|
||
<p>پس از ذخیره، پیشنهاد کار بعدی و حافظه مشتری استخراج میشود.</p>
|
||
</div>
|
||
</label>
|
||
<NoteCustomFields v-model="draftCustomFields" :fields="noteFieldDefs" />
|
||
</div>
|
||
|
||
<div class="chip-summary mobile-only-flex">
|
||
<span v-for="(f, i) in draftFiles" :key="`mf-${i}`" class="tag-chip">{{ f.name }}</span>
|
||
<span v-for="t in parseTags(draftTags)" :key="t" class="tag-chip">#{{ t }}</span>
|
||
<span v-if="draftPersonId" class="tag-chip">{{ personName(draftPersonId) }}</span>
|
||
<span v-if="draftDealId" class="tag-chip">معامله</span>
|
||
<span v-if="draftLocation" class="tag-chip">{{ draftLocation }}</span>
|
||
<span class="tag-chip">
|
||
{{
|
||
draftVisibility === 'personal'
|
||
? NOTE_VISIBILITY_LABELS.personal
|
||
: NOTE_VISIBILITY_LABELS.team
|
||
}}
|
||
</span>
|
||
</div>
|
||
|
||
<NoteAiSuggestionPanel
|
||
v-if="editingId"
|
||
:note-id="editingId"
|
||
:has-person="Boolean(draftPersonId)"
|
||
@applied="load"
|
||
@dismissed="load"
|
||
/>
|
||
</div>
|
||
|
||
<footer class="panel-foot desktop-only-flex">
|
||
<button
|
||
type="button"
|
||
class="btn-primary"
|
||
:disabled="saving"
|
||
@click="saveNote"
|
||
>
|
||
{{ saving ? 'در حال ذخیره…' : 'ذخیره یادداشت' }}
|
||
</button>
|
||
<button type="button" class="btn-ghost" @click="closeEditor">لغو</button>
|
||
</footer>
|
||
|
||
<nav class="mob-toolbar mobile-only-flex">
|
||
<button
|
||
type="button"
|
||
@click="
|
||
showChecklistEditor = !showChecklistEditor;
|
||
if (showChecklistEditor && !draftChecklist.length)
|
||
draftChecklist = [newChecklistItem()];
|
||
"
|
||
>
|
||
<UiIcon name="check" :size="20" />
|
||
<span>چکلیست</span>
|
||
</button>
|
||
<div class="voice-slot">
|
||
<VoiceToTextButton
|
||
variant="toolbar"
|
||
hide-error
|
||
@transcript="(t) => (draftBody = appendSpokenText(draftBody, t))"
|
||
/>
|
||
<button type="button" class="tiny" @click="audioInput?.click()">فایل صدا</button>
|
||
</div>
|
||
<button type="button" @click="imageInput?.click()">
|
||
<UiIcon name="layers" :size="20" />
|
||
<span>عکس</span>
|
||
</button>
|
||
<button type="button" @click="fileInput?.click()">
|
||
<UiIcon name="document" :size="20" />
|
||
<span>فایل</span>
|
||
</button>
|
||
<button type="button" @click="promptLocation">
|
||
<UiIcon name="flag" :size="20" />
|
||
<span>موقعیت</span>
|
||
</button>
|
||
<button type="button" @click="openMoreSheet">
|
||
<UiIcon name="dots" :size="20" />
|
||
<span>بیشتر</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<div v-if="moreSheetOpen" class="sheet-root">
|
||
<div class="sheet-backdrop" aria-hidden="true" />
|
||
<div class="sheet">
|
||
<header>
|
||
<h3>جزئیات بیشتر</h3>
|
||
<button type="button" class="icon-btn" @click="moreSheetOpen = false">
|
||
<UiIcon name="x" :size="18" />
|
||
</button>
|
||
</header>
|
||
<label class="field">
|
||
<span>انتخاب مشتری</span>
|
||
<select v-model="draftPersonId">
|
||
<option value="">—</option>
|
||
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>معامله</span>
|
||
<select v-model="draftDealId">
|
||
<option value="">—</option>
|
||
<option v-for="d in deals" :key="d.id" :value="d.id">{{ d.title }}</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>فعالیت</span>
|
||
<select v-model="draftActivityId">
|
||
<option value="">—</option>
|
||
<option v-for="a in activities" :key="a.id" :value="a.id">{{ a.title }}</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>نوع یادداشت</span>
|
||
<select v-model="draftType">
|
||
<option
|
||
v-for="opt in noteTypeOptions"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
>
|
||
{{ opt.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>سطح دسترسی</span>
|
||
<select v-model="draftVisibility">
|
||
<option value="team">{{ NOTE_VISIBILITY_LABELS.team }}</option>
|
||
<option value="personal">
|
||
{{ NOTE_VISIBILITY_LABELS.personal }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>یادآوری</span>
|
||
<JalaliDateInput v-model="draftReminder" mode="datetime" icon="bell" />
|
||
</label>
|
||
<label class="field">
|
||
<span>برچسبها</span>
|
||
<input v-model="draftTags" type="text" placeholder="برچسبها" />
|
||
</label>
|
||
<label class="ai-toggle">
|
||
<input v-model="draftAnalyze" type="checkbox" />
|
||
<div>
|
||
<strong>تحلیل با هوش مصنوعی</strong>
|
||
<p>پس از ذخیره اجرا میشود.</p>
|
||
</div>
|
||
</label>
|
||
<NoteCustomFields v-model="draftCustomFields" :fields="noteFieldDefs" />
|
||
<button type="button" class="btn-primary sheet-done" @click="moreSheetOpen = false">
|
||
تأیید
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<NoteCategoriesManager
|
||
v-if="categoriesOpen"
|
||
@close="categoriesOpen = false"
|
||
@saved="loadNoteCategories"
|
||
/>
|
||
|
||
<Teleport to="body">
|
||
<div v-if="historyOpen" class="history-root">
|
||
<div class="history-backdrop" aria-hidden="true" />
|
||
<aside class="history-panel" role="dialog" aria-label="تاریخچه ویرایش">
|
||
<header class="history-head">
|
||
<h2>تاریخچه ویرایش</h2>
|
||
<button type="button" class="icon-btn" @click="closeHistory">
|
||
<UiIcon name="x" :size="20" />
|
||
</button>
|
||
</header>
|
||
<p v-if="historyLoading" class="state-msg">در حال بارگذاری…</p>
|
||
<p v-else-if="historyError" class="banner-error">{{ historyError }}</p>
|
||
<p v-else-if="!historyItems.length" class="state-msg">
|
||
هنوز نسخهای ذخیره نشده.
|
||
</p>
|
||
<ul v-else class="history-list">
|
||
<li v-for="rev in historyItems" :key="rev.id" class="history-item">
|
||
<div class="history-meta">
|
||
<strong>
|
||
{{ formatJalaliDay(new Date(rev.createdAt)) }}
|
||
·
|
||
{{ formatClockFa(new Date(rev.createdAt)) }}
|
||
</strong>
|
||
<span v-if="rev.editor?.fullName" class="muted">
|
||
{{ rev.editor.fullName }}
|
||
</span>
|
||
<span
|
||
class="vis-badge"
|
||
:class="rev.visibility === 'personal' ? 'private' : 'team'"
|
||
>
|
||
{{
|
||
rev.visibility === 'personal'
|
||
? NOTE_VISIBILITY_LABELS.personal
|
||
: NOTE_VISIBILITY_LABELS.team
|
||
}}
|
||
</span>
|
||
</div>
|
||
<h4 v-if="rev.title">{{ rev.title }}</h4>
|
||
<p class="history-body">{{ previewBody(rev.body) }}</p>
|
||
<button
|
||
type="button"
|
||
class="btn-ghost"
|
||
@click="restoreRevision(rev.id)"
|
||
>
|
||
بازگردانی این نسخه
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</aside>
|
||
</div>
|
||
</Teleport>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.notes-page {
|
||
--note-sand: #fff8e1;
|
||
--note-rose: #fce4ec;
|
||
--note-sky: #e3f2fd;
|
||
--note-mint: #e0f2f1;
|
||
--note-lavender: #f3e5f5;
|
||
--note-default: #ffffff;
|
||
--teal: #0d9488;
|
||
--teal-soft: #ccfbf1;
|
||
--radius: 10px;
|
||
--shadow: 0 4px 18px rgba(15, 23, 42, 0.07);
|
||
width: 100%;
|
||
max-width: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
}
|
||
.page-head h1 {
|
||
margin: 0;
|
||
font-size: 1.55rem;
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
}
|
||
.lead {
|
||
margin: 0.35rem 0 0;
|
||
color: #64748b;
|
||
font-size: 0.95rem;
|
||
}
|
||
.card-fields {
|
||
margin: 0.4rem 0 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
font-size: 0.82rem;
|
||
color: #334155;
|
||
}
|
||
.card-fields b {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.banner-error {
|
||
margin: 0.75rem 0 0;
|
||
padding: 0.65rem 0.85rem;
|
||
border-radius: var(--radius);
|
||
background: #fef2f2;
|
||
color: #b91c1c;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 1.1rem;
|
||
}
|
||
.toolbar-end {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
}
|
||
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
}
|
||
.chips-scroll {
|
||
flex-wrap: nowrap;
|
||
overflow-x: auto;
|
||
margin: 0.85rem -0.25rem 0;
|
||
padding: 0 0.25rem 0.35rem;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
border: 1px solid #e2e8f0;
|
||
background: #fff;
|
||
color: #475569;
|
||
border-radius: 999px;
|
||
padding: 0.35rem 0.75rem;
|
||
font-size: 0.82rem;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
}
|
||
.chip .dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
.chip.tone-all.active {
|
||
background: var(--teal);
|
||
border-color: var(--teal);
|
||
color: #fff;
|
||
}
|
||
.chip.tone-general {
|
||
color: #64748b;
|
||
}
|
||
.chip.tone-general.active {
|
||
background: #ecfdf5;
|
||
border-color: #6ee7b7;
|
||
color: #047857;
|
||
}
|
||
.chip.tone-follow_up {
|
||
color: #dc2626;
|
||
}
|
||
.chip.tone-follow_up.active {
|
||
background: #fef2f2;
|
||
border-color: #fca5a5;
|
||
}
|
||
.chip.tone-idea {
|
||
color: #db2777;
|
||
}
|
||
.chip.tone-idea.active {
|
||
background: #fdf2f8;
|
||
border-color: #f9a8d4;
|
||
}
|
||
.chip.tone-suggestion {
|
||
color: #d97706;
|
||
}
|
||
.chip.tone-suggestion.active {
|
||
background: #fffbeb;
|
||
border-color: #fcd34d;
|
||
}
|
||
.chip.tone-meeting {
|
||
color: #2563eb;
|
||
}
|
||
.chip.tone-meeting.active {
|
||
background: #eff6ff;
|
||
border-color: #93c5fd;
|
||
}
|
||
.chip.tone-personal {
|
||
color: #7c3aed;
|
||
}
|
||
.chip.tone-personal.active {
|
||
background: #f5f3ff;
|
||
border-color: #c4b5fd;
|
||
}
|
||
.chip-sep {
|
||
width: 1px;
|
||
align-self: stretch;
|
||
background: #e2e8f0;
|
||
margin-inline: 0.15rem;
|
||
}
|
||
.chip-manage {
|
||
border-style: dashed;
|
||
}
|
||
.chip.tone-team {
|
||
color: #0f766e;
|
||
}
|
||
.chip.tone-team.active {
|
||
background: #ccfbf1;
|
||
border-color: #5eead4;
|
||
color: #0f766e;
|
||
}
|
||
.chip.tone-private {
|
||
color: #9a3412;
|
||
}
|
||
.chip.tone-private.active {
|
||
background: #ffedd5;
|
||
border-color: #fdba74;
|
||
color: #9a3412;
|
||
}
|
||
.chip:not([class*='tone-']).active {
|
||
background: var(--teal);
|
||
border-color: var(--teal);
|
||
color: #fff;
|
||
}
|
||
|
||
.view-toggle {
|
||
display: inline-flex;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
.view-toggle button {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 0.35rem 0.45rem;
|
||
color: #64748b;
|
||
cursor: pointer;
|
||
}
|
||
.view-toggle button.active {
|
||
background: var(--teal-soft);
|
||
color: var(--teal);
|
||
}
|
||
.sort-select {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: var(--radius);
|
||
padding: 0.35rem 0.55rem;
|
||
background: #fff;
|
||
color: #334155;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.icon-btn {
|
||
border: 0;
|
||
background: transparent;
|
||
color: #64748b;
|
||
border-radius: var(--radius);
|
||
padding: 0.35rem;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.icon-btn:hover {
|
||
background: #f1f5f9;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.quick-add {
|
||
margin-top: 0.95rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 0.45rem 0.55rem 0.45rem 0.85rem;
|
||
cursor: text;
|
||
}
|
||
.quick-input {
|
||
flex: 1;
|
||
border: 0;
|
||
outline: none;
|
||
background: transparent;
|
||
font-size: 0.95rem;
|
||
color: #0f172a;
|
||
cursor: pointer;
|
||
}
|
||
.quick-actions {
|
||
display: flex;
|
||
gap: 0.15rem;
|
||
}
|
||
|
||
.mobile-quick {
|
||
margin-top: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 0.4rem;
|
||
}
|
||
.plus-teal {
|
||
width: 38px;
|
||
height: 38px;
|
||
border: 0;
|
||
border-radius: var(--radius);
|
||
background: var(--teal);
|
||
color: #fff;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.state-msg {
|
||
margin: 1.5rem 0;
|
||
color: #64748b;
|
||
text-align: center;
|
||
}
|
||
|
||
.notes-board.is-grid {
|
||
margin-top: 1.1rem;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||
gap: 0.85rem;
|
||
align-items: stretch;
|
||
}
|
||
.notes-board.is-list {
|
||
margin-top: 1.1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
.notes-board.is-list .note-card {
|
||
aspect-ratio: auto;
|
||
min-height: 120px;
|
||
height: auto;
|
||
width: 100%;
|
||
}
|
||
|
||
.note-card {
|
||
aspect-ratio: 1 / 1;
|
||
width: 100%;
|
||
min-height: 0;
|
||
margin: 0;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
border: 1px solid rgba(15, 23, 42, 0.05);
|
||
padding: 0.75rem 0.8rem 0.65rem;
|
||
cursor: pointer;
|
||
position: relative;
|
||
text-align: start;
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.note-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||
}
|
||
|
||
.add-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.4rem;
|
||
background: transparent;
|
||
border: 1.5px dashed #cbd5e1;
|
||
color: #64748b;
|
||
box-shadow: none;
|
||
font-size: 0.92rem;
|
||
}
|
||
.add-card:hover {
|
||
border-color: var(--teal);
|
||
color: var(--teal);
|
||
background: #f0fdfa;
|
||
}
|
||
|
||
.card-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
min-height: 1.25rem;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.pin {
|
||
color: #ef4444;
|
||
display: inline-flex;
|
||
}
|
||
.card-menu {
|
||
position: relative;
|
||
margin-inline-start: auto;
|
||
}
|
||
|
||
.card-title {
|
||
margin: 0 0 0.35rem;
|
||
font-size: 0.95rem;
|
||
font-weight: 700;
|
||
line-height: 1.35;
|
||
color: #0f172a;
|
||
flex-shrink: 0;
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.card-body {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
color: #334155;
|
||
font-size: 0.82rem;
|
||
line-height: 1.45;
|
||
flex: 1 1 2.9em;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 6;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
.card-check {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
}
|
||
.card-check label {
|
||
display: flex;
|
||
gap: 0.45rem;
|
||
align-items: flex-start;
|
||
font-size: 0.88rem;
|
||
color: #334155;
|
||
cursor: pointer;
|
||
}
|
||
.card-check .done {
|
||
text-decoration: line-through;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.card-thumb {
|
||
margin-top: 0.45rem;
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
flex: 0 1 72px;
|
||
min-height: 0;
|
||
max-height: 72px;
|
||
}
|
||
.card-thumb img {
|
||
display: block;
|
||
width: 100%;
|
||
height: 72px;
|
||
max-height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.att-chip {
|
||
margin-top: 0.55rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: var(--radius);
|
||
background: rgba(15, 23, 42, 0.06);
|
||
font-size: 0.75rem;
|
||
color: #475569;
|
||
max-width: 100%;
|
||
}
|
||
.att-chip span {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.audio-row {
|
||
margin-top: 0.55rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.audio-row audio {
|
||
width: 100%;
|
||
height: 32px;
|
||
}
|
||
.audio-row .dur {
|
||
font-size: 0.75rem;
|
||
color: #64748b;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem;
|
||
align-items: center;
|
||
margin-top: 0.35rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.type-badge {
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
padding: 0.18rem 0.5rem;
|
||
border-radius: 999px;
|
||
background: #f1f5f9;
|
||
color: #475569;
|
||
}
|
||
.type-badge.tone-general {
|
||
background: #ecfdf5;
|
||
color: #047857;
|
||
}
|
||
.type-badge.tone-follow_up {
|
||
background: #fef2f2;
|
||
color: #dc2626;
|
||
}
|
||
.type-badge.tone-idea {
|
||
background: #fdf2f8;
|
||
color: #db2777;
|
||
}
|
||
.type-badge.tone-suggestion {
|
||
background: #fffbeb;
|
||
color: #d97706;
|
||
}
|
||
.type-badge.tone-meeting {
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
}
|
||
.type-badge.tone-personal {
|
||
background: #f5f3ff;
|
||
color: #7c3aed;
|
||
}
|
||
.vis-badge {
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
padding: 0.15rem 0.45rem;
|
||
border-radius: 999px;
|
||
background: #ecfdf5;
|
||
color: #0f766e;
|
||
}
|
||
.vis-badge.private {
|
||
background: #ffedd5;
|
||
color: #9a3412;
|
||
}
|
||
.vis-badge.team {
|
||
background: #ecfdf5;
|
||
color: #0f766e;
|
||
}
|
||
.ai-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
color: var(--teal);
|
||
background: var(--teal-soft);
|
||
border-radius: 999px;
|
||
padding: 0.15rem 0.4rem;
|
||
}
|
||
|
||
.card-foot {
|
||
margin-top: auto;
|
||
padding-top: 0.45rem;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 0.5rem;
|
||
font-size: 0.72rem;
|
||
color: #94a3b8;
|
||
flex-shrink: 0;
|
||
}
|
||
.card-foot .person {
|
||
color: #64748b;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.fab {
|
||
position: fixed;
|
||
inset-inline-end: 1.1rem;
|
||
bottom: calc(1.25rem + var(--mobile-tabbar-h));
|
||
z-index: 40;
|
||
width: 54px;
|
||
height: 54px;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
background: var(--teal);
|
||
color: #fff;
|
||
box-shadow: 0 10px 28px rgba(13, 148, 136, 0.4);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.panel-root {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 80;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 1rem;
|
||
--note-sand: #fff8e1;
|
||
--note-rose: #fce4ec;
|
||
--note-sky: #e3f2fd;
|
||
--note-mint: #e0f2f1;
|
||
--note-lavender: #f3e5f5;
|
||
--note-default: #ffffff;
|
||
--teal: #0d9488;
|
||
--teal-soft: #ccfbf1;
|
||
}
|
||
.panel-backdrop {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
}
|
||
.editor-panel {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: min(520px, 100%);
|
||
max-height: min(92vh, 840px);
|
||
border-radius: var(--radius, 10px);
|
||
background: #fff;
|
||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
animation: modal-in 0.2s ease;
|
||
}
|
||
@keyframes modal-in {
|
||
from {
|
||
transform: translateY(10px) scale(0.98);
|
||
opacity: 0.55;
|
||
}
|
||
to {
|
||
transform: none;
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.panel-head,
|
||
.mob-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
.panel-head h2 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: var(--ink);
|
||
line-height: 1.4;
|
||
}
|
||
.panel-scroll {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.panel-foot {
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
border-top: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.title-input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
border: 0;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
padding: 0.35rem 0;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
color: #0f172a;
|
||
outline: none;
|
||
background: transparent;
|
||
}
|
||
.body-canvas {
|
||
width: 100%;
|
||
border: 0;
|
||
border-radius: var(--radius);
|
||
padding: 0.85rem;
|
||
resize: vertical;
|
||
min-height: 160px;
|
||
background: var(--canvas, var(--note-sand));
|
||
color: #0f172a;
|
||
font-size: 0.98rem;
|
||
line-height: 1.6;
|
||
outline: none;
|
||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.swatches {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.palette-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.65rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.swatch {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
border: 1.5px solid rgba(15, 23, 42, 0.18);
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #0f766e;
|
||
padding: 0;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||
}
|
||
.swatch.tone-default {
|
||
border-color: #94a3b8;
|
||
background-image: linear-gradient(
|
||
45deg,
|
||
#f1f5f9 25%,
|
||
transparent 25%,
|
||
transparent 75%,
|
||
#f1f5f9 75%
|
||
);
|
||
background-size: 8px 8px;
|
||
}
|
||
.swatch.selected {
|
||
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--teal, #0d9488);
|
||
}
|
||
.pin-btn.on {
|
||
border-color: #f59e0b;
|
||
color: #b45309;
|
||
background: #fffbeb;
|
||
}
|
||
|
||
.panel-head-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.desktop-only-flex {
|
||
display: flex !important;
|
||
}
|
||
.mobile-only-flex {
|
||
display: none !important;
|
||
}
|
||
.desktop-fields {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.media-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
align-items: center;
|
||
}
|
||
.media-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
border: 1px solid #e2e8f0;
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
padding: 0.35rem 0.55rem;
|
||
font-size: 0.8rem;
|
||
color: #475569;
|
||
cursor: pointer;
|
||
}
|
||
.media-btn.on {
|
||
border-color: var(--teal);
|
||
color: var(--teal);
|
||
background: var(--teal-soft);
|
||
}
|
||
|
||
.field,
|
||
.field-row .field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
font-size: 0.82rem;
|
||
color: #64748b;
|
||
}
|
||
.field select,
|
||
.field input[type='text'] {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: var(--radius);
|
||
padding: 0.45rem 0.55rem;
|
||
font-size: 0.9rem;
|
||
color: #0f172a;
|
||
background: #fff;
|
||
}
|
||
.field-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.55rem;
|
||
}
|
||
|
||
.ai-toggle {
|
||
display: flex;
|
||
gap: 0.65rem;
|
||
align-items: flex-start;
|
||
padding: 0.7rem 0.75rem;
|
||
border-radius: var(--radius);
|
||
background: #f0fdfa;
|
||
border: 1px solid #99f6e4;
|
||
cursor: pointer;
|
||
}
|
||
.ai-toggle strong {
|
||
display: block;
|
||
color: #0f766e;
|
||
font-size: 0.9rem;
|
||
}
|
||
.ai-toggle p {
|
||
margin: 0.2rem 0 0;
|
||
font-size: 0.78rem;
|
||
color: #64748b;
|
||
}
|
||
|
||
.checklist-ed {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.check-row {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
align-items: center;
|
||
}
|
||
.check-row input[type='text'] {
|
||
flex: 1;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: var(--radius);
|
||
padding: 0.35rem 0.5rem;
|
||
}
|
||
.linkish {
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--teal);
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
text-align: start;
|
||
padding: 0.2rem 0;
|
||
}
|
||
|
||
.pending-files {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem;
|
||
}
|
||
.file-chip,
|
||
.tag-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
background: #f1f5f9;
|
||
border-radius: var(--radius);
|
||
padding: 0.25rem 0.45rem;
|
||
font-size: 0.75rem;
|
||
color: #475569;
|
||
}
|
||
.file-chip button {
|
||
border: 0;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.btn-primary,
|
||
.btn-save {
|
||
border: 0;
|
||
background: var(--teal);
|
||
color: #fff;
|
||
border-radius: var(--radius, 10px);
|
||
padding: 0.55rem 1rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.btn-primary:disabled,
|
||
.btn-save:disabled {
|
||
opacity: 0.65;
|
||
cursor: wait;
|
||
}
|
||
.btn-ghost {
|
||
border: 1px solid #e2e8f0;
|
||
background: #fff;
|
||
color: #475569;
|
||
border-radius: var(--radius, 10px);
|
||
padding: 0.55rem 1rem;
|
||
cursor: pointer;
|
||
}
|
||
.btn-primary {
|
||
flex: 1;
|
||
}
|
||
|
||
.mobile-editor {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 90;
|
||
background: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.mob-canvas {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 0.85rem 1rem 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
.mob-canvas .body-canvas {
|
||
min-height: 42vh;
|
||
flex: 1;
|
||
}
|
||
.chip-summary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem;
|
||
}
|
||
.mob-toolbar {
|
||
display: flex;
|
||
gap: 0.15rem;
|
||
padding: 0.45rem 0.35rem calc(0.55rem + env(safe-area-inset-bottom));
|
||
border-top: 1px solid #e2e8f0;
|
||
background: #fff;
|
||
overflow-x: auto;
|
||
}
|
||
.mob-toolbar > button,
|
||
.voice-slot {
|
||
flex: 1 0 auto;
|
||
min-width: 3.4rem;
|
||
border: 0;
|
||
background: transparent;
|
||
color: #475569;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
font-size: 0.65rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem;
|
||
}
|
||
.voice-slot .tiny {
|
||
border: 0;
|
||
background: transparent;
|
||
color: #94a3b8;
|
||
font-size: 0.6rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.sheet-root {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 5;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: flex-end;
|
||
}
|
||
.sheet-backdrop {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.4);
|
||
}
|
||
.sheet {
|
||
position: relative;
|
||
background: #fff;
|
||
border-radius: var(--radius) var(--radius) 0 0;
|
||
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||
max-height: 85vh;
|
||
overflow: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.7rem;
|
||
animation: sheet-up 0.2s ease;
|
||
}
|
||
@keyframes sheet-up {
|
||
from {
|
||
transform: translateY(24px);
|
||
}
|
||
to {
|
||
transform: none;
|
||
}
|
||
}
|
||
.sheet header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.sheet h3 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
}
|
||
.sheet-done {
|
||
width: 100%;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.mobile-only {
|
||
display: none !important;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.notes-board.is-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 860px) {
|
||
.notes-page {
|
||
padding-bottom: 5rem;
|
||
}
|
||
|
||
.desktop-only {
|
||
display: none !important;
|
||
}
|
||
.desktop-only-flex,
|
||
.desktop-fields {
|
||
display: none !important;
|
||
}
|
||
.mobile-only-flex {
|
||
display: flex !important;
|
||
}
|
||
.chip-summary.mobile-only-flex {
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem;
|
||
}
|
||
.mobile-quick.mobile-only,
|
||
.chips.mobile-only {
|
||
display: flex !important;
|
||
}
|
||
.fab.mobile-only {
|
||
display: inline-flex !important;
|
||
}
|
||
.notes-board.is-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0.65rem;
|
||
}
|
||
.note-card {
|
||
padding: 0.65rem;
|
||
}
|
||
.editor-panel {
|
||
width: min(100%, 520px);
|
||
max-height: 94vh;
|
||
border-radius: var(--radius);
|
||
}
|
||
.panel-root {
|
||
padding: 0.5rem;
|
||
place-items: center;
|
||
}
|
||
.btn-save.mobile-only-flex {
|
||
padding: 0.4rem 0.85rem;
|
||
font-size: 0.88rem;
|
||
}
|
||
}
|
||
|
||
.history-btn {
|
||
padding: 0.35rem 0.65rem;
|
||
font-size: 0.82rem;
|
||
}
|
||
.history-root {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 120;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 1rem;
|
||
}
|
||
.history-backdrop {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
}
|
||
.history-panel {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: min(100%, 480px);
|
||
max-height: min(88vh, 640px);
|
||
overflow: auto;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
|
||
padding: 1rem 1.1rem 1.25rem;
|
||
}
|
||
.history-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.history-head h2 {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
font-weight: 650;
|
||
color: #0f172a;
|
||
}
|
||
.history-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.85rem;
|
||
}
|
||
.history-item {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
padding: 0.75rem 0.85rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.history-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem 0.55rem;
|
||
align-items: center;
|
||
font-size: 0.82rem;
|
||
color: #334155;
|
||
}
|
||
.history-meta .muted {
|
||
color: #94a3b8;
|
||
}
|
||
.history-item h4 {
|
||
margin: 0;
|
||
font-size: 0.92rem;
|
||
color: #0f172a;
|
||
}
|
||
.history-body {
|
||
margin: 0;
|
||
font-size: 0.88rem;
|
||
color: #475569;
|
||
white-space: pre-wrap;
|
||
line-height: 1.55;
|
||
}
|
||
.history-item .btn-ghost {
|
||
align-self: flex-start;
|
||
padding: 0.35rem 0.7rem;
|
||
font-size: 0.8rem;
|
||
}
|
||
</style>
|