diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index cbeb57f..3b2c253 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -449,6 +449,10 @@ function activityQuery(id: string) { } function openActivity(id: string) { + if (quickId.value === id) { + closeQuick(); + return; + } window.clearTimeout(peekLinger); quickId.value = id; peekId.value = id; @@ -482,12 +486,14 @@ function openFullActivity(id: string) { :select-mode="selectMode" :selected-ids="selectedIds" :peek-id="peekId" + :preview-id="quickId" @drag-start="onDragStart" @drag-end="onDragEnd" @drag-over="onDragOver" @drop="onDrop" @add-deal="emit('add', $event)" @open-deal="openActivity" + @detail-deal="openFullActivity" @menu-deal="onMenuActivity" @toggle-select="emit('toggleSelect', $event)" @bulk-delete="emit('bulkDelete')" diff --git a/apps/web/src/components/activities/ActivityQuickView.vue b/apps/web/src/components/activities/ActivityQuickView.vue index 39b0a0c..1840011 100644 --- a/apps/web/src/components/activities/ActivityQuickView.vue +++ b/apps/web/src/components/activities/ActivityQuickView.vue @@ -10,13 +10,15 @@ import { Flex as AFlex, Row as ARow, Select as ASelect, + Image as AImage, Spin as ASpin, Typography, theme, } from 'ant-design-vue'; import type { ActivityDto } from '@mento/shared'; import { api } from '@/lib/api'; -import { parseActivityThread } from '@/lib/activity-thread'; +import { bindThreadMedia, parseActivityThread } from '@/lib/activity-thread'; +import { noteChips } from '@/lib/lead-form-notes'; import { formatClockFa, formatJalaliDay, @@ -40,7 +42,7 @@ const emit = defineEmits<{ full: [id: string]; }>(); -const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography; +const { Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = Typography; const { token } = theme.useToken(); const auth = useAuthStore(); @@ -145,7 +147,7 @@ function onPointerDown(event: PointerEvent) { if (!(target instanceof Node)) return; if (panelEl.value?.contains(target)) return; if (commentOpen.value) return; - if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer')) return; + if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer, .ant-image-preview-root, .ant-image-preview-wrap')) return; const card = cardEl(props.activityId); if (card?.contains(target)) return; emit('close'); @@ -170,6 +172,7 @@ onBeforeUnmount(() => { document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener('keydown', onKey); window.clearTimeout(hideTimer); + clearNoteMedia(); }); const activity = ref(null); @@ -180,6 +183,20 @@ const commentOpen = ref(false); const postponeKey = ref('h1'); const customDue = ref(''); +type NoteMedia = { + id: string; + name: string; + url: string; + kind: 'image' | 'audio' | 'file'; + createdAt: string; +}; +const noteMedia = ref([]); + +function clearNoteMedia() { + for (const item of noteMedia.value) URL.revokeObjectURL(item.url); + noteMedia.value = []; +} + const isClosed = computed( () => activity.value?.status === 'done' || activity.value?.status === 'canceled', ); @@ -205,6 +222,14 @@ const createdLabel = computed(() => { const thread = computed(() => parseActivityThread(activity.value?.description)); const plainNote = computed(() => (thread.value ? '' : activity.value?.description?.trim() || '')); +const threadBlocks = computed(() => { + const entries = thread.value; + if (!entries) return null; + return bindThreadMedia(entries, noteMedia.value); +}); +const looseImages = computed(() => + (threadBlocks.value?.loose ?? noteMedia.value).filter((item) => item.kind === 'image'), +); const noteScrollStyle = computed(() => ({ maxHeight: '220px', @@ -279,6 +304,41 @@ function apiMessage(e: unknown, fallback: string) { return fallback; } +async function loadNoteMedia(row: ActivityDto) { + clearNoteMedia(); + const next: NoteMedia[] = []; + for (const image of row.images ?? []) { + const mime = image.mimeType.toLowerCase(); + const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file'; + try { + const { data } = await api.get(`/activities/${row.id}/images/${image.id}`, { + responseType: 'blob', + }); + const url = URL.createObjectURL(data); + if (props.activityId !== row.id) { + URL.revokeObjectURL(url); + continue; + } + next.push({ + id: image.id, + name: image.fileName, + url, + kind, + createdAt: image.createdAt, + }); + } catch { + /* فایل در دسترس نیست */ + } + } + if (props.activityId !== row.id) { + for (const item of next) URL.revokeObjectURL(item.url); + return; + } + noteMedia.value = next; + await nextTick(); + measure(); +} + async function load(id: string) { loading.value = true; error.value = ''; @@ -287,6 +347,7 @@ async function load(id: string) { if (props.activityId !== id) return; activity.value = data; if (!customDue.value) customDue.value = formatLocalDateTimeInput(addMinutes(60)); + void loadNoteMedia(data); } catch (e) { if (props.activityId === id) { activity.value = null; @@ -309,6 +370,7 @@ watch( activity.value = null; error.value = ''; resetDrafts(); + clearNoteMedia(); placed.value = false; window.clearTimeout(hideTimer); hideTimer = window.setTimeout(() => { @@ -320,6 +382,7 @@ watch( if (prev && prev !== id) { resetDrafts(); activity.value = null; + clearNoteMedia(); } if (prev && prev !== id && rendered.value) placed.value = false; rendered.value = true; @@ -466,10 +529,10 @@ function openFull() { -
- +
+
@@ -482,14 +545,83 @@ function openFull() { - {{ entry.body }} + + + + +
+
+
-
- - {{ plainNote }} +
+ + + + +
یادداشتی ثبت نشده.
@@ -536,3 +668,15 @@ function openFull() {
+ + diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 0a35e49..a4bd9d9 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -4,7 +4,6 @@ import { CalendarOutlined, CheckOutlined, DeleteOutlined, - EyeOutlined, FlagFilled, MoreOutlined, } from '@ant-design/icons-vue'; @@ -33,6 +32,8 @@ const props = withDefaults( selectMode?: boolean; selected?: boolean; peeking?: boolean; + /** پنل کناری همین کارت باز است؛ فلش را برمی‌گرداند. جدا از هایلایتِ بعد از بستن. */ + previewing?: boolean; }>(), { copy: () => DEAL_BOARD_COPY }, ); @@ -41,6 +42,7 @@ const emit = defineEmits<{ dragStart: [deal: PipelineDeal, event: DragEvent]; dragEnd: []; open: [dealId: string]; + detail: [dealId: string]; menu: [dealId: string]; toggleSelect: [dealId: string]; bulkDelete: []; @@ -76,6 +78,25 @@ const priorityFlag = computed(() => props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null, ); +function onTitleClick() { + if (props.selectMode) { + emit('toggleSelect', props.deal.id); + return; + } + if (props.copy.quickView) emit('detail', props.deal.id); + else emit('open', props.deal.id); +} + +const chevronStyle = computed(() => ({ + display: 'inline-block', + width: '0.42rem', + height: '0.42rem', + borderRight: '1.6px solid currentColor', + borderBottom: '1.6px solid currentColor', + transform: props.previewing ? 'rotate(-45deg)' : 'rotate(135deg)', + transition: 'transform 0.15s ease', +})); + const cardStyle = computed(() => ({ cursor: props.selectMode ? 'pointer' : 'grab', opacity: props.dragging ? 0.4 : 1, @@ -149,7 +170,8 @@ const cardStyle = computed(() => ({ fontWeight: 600, }" :title="deal.title" - @click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)" + @click.stop="onTitleClick" + @dblclick.stop > {{ deal.title }} @@ -227,10 +249,12 @@ const cardStyle = computed(() => ({ - + diff --git a/apps/web/src/components/pipeline/PipelineBoard.vue b/apps/web/src/components/pipeline/PipelineBoard.vue index bf97879..d9e14b5 100644 --- a/apps/web/src/components/pipeline/PipelineBoard.vue +++ b/apps/web/src/components/pipeline/PipelineBoard.vue @@ -22,8 +22,9 @@ withDefaults( selectMode?: boolean; selectedIds?: Set; peekId?: string | null; + previewId?: string | null; }>(), - { copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null }, + { copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null }, ); const emit = defineEmits<{ @@ -33,6 +34,7 @@ const emit = defineEmits<{ drop: [stageId: string, event: DragEvent, index?: number]; addDeal: [stageId: string]; openDeal: [dealId: string]; + detailDeal: [dealId: string]; menuDeal: [dealId: string]; toggleSelect: [dealId: string]; bulkDelete: []; @@ -111,6 +113,7 @@ function submitAdd() { :select-mode="selectMode" :selected-ids="selectedIds" :peek-id="peekId" + :preview-id="previewId" :is-first="index === 0" :is-last="index === stages.length - 1" @drag-start="(d, e) => emit('dragStart', d, e)" @@ -119,6 +122,7 @@ function submitAdd() { @drop="(id, e, index) => emit('drop', id, e, index)" @add-deal="emit('addDeal', $event)" @open-deal="emit('openDeal', $event)" + @detail-deal="emit('detailDeal', $event)" @menu-deal="emit('menuDeal', $event)" @toggle-select="emit('toggleSelect', $event)" @bulk-delete="emit('bulkDelete')" diff --git a/apps/web/src/components/pipeline/PipelineColumn.vue b/apps/web/src/components/pipeline/PipelineColumn.vue index 3736e65..b164c8a 100644 --- a/apps/web/src/components/pipeline/PipelineColumn.vue +++ b/apps/web/src/components/pipeline/PipelineColumn.vue @@ -27,8 +27,9 @@ const props = withDefaults( selectMode?: boolean; selectedIds?: Set; peekId?: string | null; + previewId?: string | null; }>(), - { copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null }, + { copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null }, ); const emit = defineEmits<{ @@ -38,6 +39,7 @@ const emit = defineEmits<{ drop: [stageId: string, event: DragEvent, index?: number]; addDeal: [stageId: string]; openDeal: [dealId: string]; + detailDeal: [dealId: string]; menuDeal: [dealId: string]; toggleSelect: [dealId: string]; bulkDelete: []; @@ -316,9 +318,11 @@ function onMenuClick({ key }: { key: string | number }) { :select-mode="selectMode" :selected="selectedIds.has(deal.id)" :peeking="peekId === deal.id" + :previewing="previewId === deal.id" @drag-start="(d, e) => emit('dragStart', d, e)" @drag-end="clearDragUi(); emit('dragEnd')" @open="emit('openDeal', $event)" + @detail="emit('detailDeal', $event)" @menu="emit('menuDeal', $event)" @toggle-select="emit('toggleSelect', $event)" @bulk-delete="emit('bulkDelete')" diff --git a/apps/web/src/lib/activity-thread.ts b/apps/web/src/lib/activity-thread.ts index d2ebd56..451e0b2 100644 --- a/apps/web/src/lib/activity-thread.ts +++ b/apps/web/src/lib/activity-thread.ts @@ -6,9 +6,13 @@ export type ActivityThreadEntry = { timeLabel: string; author: string; body: string; + /** شناسهٔ فایل‌های چسبیده به این یادداشت (عکس/صوت/فایل) */ + attachmentIds?: string[]; }; const STAMP = /^\[(.+?) ساعت (\S+?)(?: — (.+))?\]$/; +const ATTACHMENTS = /^\[\[attachments:([^\]]+)\]\]$/i; +const COMMENT_MEDIA_WINDOW_MS = 3 * 60 * 1000; /** یادداشت فعالیت را به نظرهای جدا با مهر تاریخ/ساعت/نویسنده تبدیل می‌کند. */ export function parseActivityThread(raw: string | null | undefined): ActivityThreadEntry[] | null { @@ -21,19 +25,29 @@ export function parseActivityThread(raw: string | null | undefined): ActivityThr let sawStamp = false; for (const line of text.split('\n')) { - const match = line.trim().match(STAMP); - if (match) { + const trimmed = line.trim(); + const stamp = trimmed.match(STAMP); + if (stamp) { sawStamp = true; if (current) entries.push(trimEntry(current)); current = { - at: stampToDate(match[1] ?? '', match[2] ?? ''), - dateLabel: (match[1] ?? '').trim(), - timeLabel: (match[2] ?? '').trim(), - author: (match[3] ?? '').trim(), + at: stampToDate(stamp[1] ?? '', stamp[2] ?? ''), + dateLabel: (stamp[1] ?? '').trim(), + timeLabel: (stamp[2] ?? '').trim(), + author: (stamp[3] ?? '').trim(), body: '', + attachmentIds: [], }; continue; } + const attach = trimmed.match(ATTACHMENTS); + if (attach && current) { + current.attachmentIds = attach[1]! + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + continue; + } if (current) current.body += `${current.body ? '\n' : ''}${line}`; else preamble.push(line); } @@ -42,13 +56,89 @@ export function parseActivityThread(raw: string | null | undefined): ActivityThr const head = preamble.join('\n').trim(); if (head) { - entries.unshift({ at: null, dateLabel: '', timeLabel: '', author: '', body: head }); + entries.unshift({ + at: null, + dateLabel: '', + timeLabel: '', + author: '', + body: head, + attachmentIds: [], + }); } return entries; } +/** برگرداندن متن ذخیره‌شونده از نظرهای جدا. */ +export function serializeActivityThread(entries: ActivityThreadEntry[]): string { + return entries + .map((entry) => { + const ids = (entry.attachmentIds ?? []).filter(Boolean); + const attachLine = ids.length ? `\n[[attachments:${ids.join(',')}]]` : ''; + if (!entry.dateLabel) return `${entry.body}${attachLine}`.trim(); + const stamp = `[${entry.dateLabel} ساعت ${entry.timeLabel}${entry.author ? ` — ${entry.author}` : ''}]`; + return entry.body ? `${stamp}\n${entry.body}${attachLine}` : `${stamp}${attachLine}`; + }) + .filter((chunk) => chunk.trim()) + .join('\n\n'); +} + +/** فایل‌ها را به یادداشت‌ها می‌چسباند: اول با شناسهٔ صریح، بعد با نزدیکی زمان. */ +export function bindThreadMedia( + entries: ActivityThreadEntry[], + images: T[], + windowMs = COMMENT_MEDIA_WINDOW_MS, +): { blocks: Array; loose: T[] } { + const blocks = entries.map((entry) => ({ ...entry, media: [] as T[] })); + const byId = new Map(images.map((image) => [image.id, image])); + const used = new Set(); + + blocks.forEach((block) => { + for (const id of block.attachmentIds ?? []) { + const image = byId.get(id); + if (!image || used.has(id)) continue; + block.media.push(image); + used.add(id); + } + }); + + for (const image of images) { + if (used.has(image.id)) continue; + const at = new Date(image.createdAt).getTime(); + if (Number.isNaN(at)) continue; + let best = -1; + let bestDelta = Number.POSITIVE_INFINITY; + blocks.forEach((block, index) => { + if (!block.at) return; + const delta = block.at.getTime() - at; + if (delta >= -60_000 && delta < bestDelta) { + best = index; + bestDelta = delta; + } + }); + if (best >= 0 && bestDelta <= windowMs) { + blocks[best]!.media.push(image); + used.add(image.id); + } + } + + const firstStamp = blocks.find((block) => block.at)?.at?.getTime() ?? Number.POSITIVE_INFINITY; + const preamble = blocks.find((block) => !block.at); + const loose: T[] = []; + for (const image of images) { + if (used.has(image.id)) continue; + const at = new Date(image.createdAt).getTime(); + if (preamble && !Number.isNaN(at) && at < firstStamp) preamble.media.push(image); + else loose.push(image); + } + return { blocks, loose }; +} + function trimEntry(entry: ActivityThreadEntry): ActivityThreadEntry { - return { ...entry, body: entry.body.trim() }; + return { + ...entry, + body: entry.body.trim(), + attachmentIds: [...new Set((entry.attachmentIds ?? []).filter(Boolean))], + }; } function latinDigits(value: string): string { diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 21eb772..0586c0a 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -1,23 +1,26 @@