From f13a1249af6b5f7d167c964fa75fca6a168cfd99 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 10 Nov 2026 21:31:21 +0330 Subject: [PATCH] feat(activities): edit detail in place and tighten the board peek Let people change activity fields and their own notes with photos from the detail page, open full detail from the card title, and toggle the side peek with a chevron. Co-authored-by: Cursor --- .../activities/ActivitiesKanban.vue | 6 + .../activities/ActivityQuickView.vue | 164 ++- apps/web/src/components/pipeline/DealCard.vue | 32 +- .../src/components/pipeline/PipelineBoard.vue | 6 +- .../components/pipeline/PipelineColumn.vue | 6 +- apps/web/src/lib/activity-thread.ts | 106 +- apps/web/src/views/ActivityDetailView.vue | 1143 +++++++++++++---- 7 files changed, 1163 insertions(+), 300 deletions(-) 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 @@