diff --git a/apps/web/src/lib/activity-thread.ts b/apps/web/src/lib/activity-thread.ts new file mode 100644 index 0000000..d2ebd56 --- /dev/null +++ b/apps/web/src/lib/activity-thread.ts @@ -0,0 +1,71 @@ +import { jalaliToDate } from '@/lib/jalali'; + +export type ActivityThreadEntry = { + at: Date | null; + dateLabel: string; + timeLabel: string; + author: string; + body: string; +}; + +const STAMP = /^\[(.+?) ساعت (\S+?)(?: — (.+))?\]$/; + +/** یادداشت فعالیت را به نظرهای جدا با مهر تاریخ/ساعت/نویسنده تبدیل می‌کند. */ +export function parseActivityThread(raw: string | null | undefined): ActivityThreadEntry[] | null { + const text = (raw ?? '').replace(/\r\n/g, '\n').trim(); + if (!text) return null; + + const entries: ActivityThreadEntry[] = []; + const preamble: string[] = []; + let current: ActivityThreadEntry | null = null; + let sawStamp = false; + + for (const line of text.split('\n')) { + const match = line.trim().match(STAMP); + if (match) { + 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(), + body: '', + }; + continue; + } + if (current) current.body += `${current.body ? '\n' : ''}${line}`; + else preamble.push(line); + } + if (current) entries.push(trimEntry(current)); + if (!sawStamp) return null; + + const head = preamble.join('\n').trim(); + if (head) { + entries.unshift({ at: null, dateLabel: '', timeLabel: '', author: '', body: head }); + } + return entries; +} + +function trimEntry(entry: ActivityThreadEntry): ActivityThreadEntry { + return { ...entry, body: entry.body.trim() }; +} + +function latinDigits(value: string): string { + const fa = '۰۱۲۳۴۵۶۷۸۹'; + const ar = '٠١٢٣٤٥٦٧٨٩'; + return value.replace(/[۰-۹٠-٩]/g, (ch) => { + const faIndex = fa.indexOf(ch); + return String(faIndex >= 0 ? faIndex : ar.indexOf(ch)); + }); +} + +function stampToDate(dateLabel: string, timeLabel: string): Date | null { + const date = latinDigits(dateLabel).match(/(\d{4})\/(\d{1,2})\/(\d{1,2})/); + if (!date) return null; + const at = jalaliToDate(Number(date[1]), Number(date[2]), Number(date[3])); + if (Number.isNaN(at.getTime())) return null; + const time = latinDigits(timeLabel).match(/(\d{1,2}):(\d{2})/); + if (time) at.setHours(Number(time[1]), Number(time[2]), 0, 0); + return at; +} diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index b5101e1..880b958 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -25,6 +25,7 @@ import { useAuthStore } from '@/stores/auth'; import { canAssignToOthers } from '@/lib/assign-access'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { brand } from '@mento/shared'; +import { parseActivityThread, type ActivityThreadEntry } from '@/lib/activity-thread'; import { leadNoteFileIsImage, noteChips, @@ -64,12 +65,22 @@ const loading = ref(true); const saving = ref(false); const error = ref(''); const activity = ref(null); -const imageUrls = ref>([]); +type NoteMedia = { + id: string; + name: string; + url: string; + kind: 'image' | 'audio' | 'file'; + createdAt: string; +}; +const imageUrls = ref([]); const moreOpen = ref(false); const commentOpen = ref(false); const postponeOpen = ref(false); const noteOpen = ref(true); -const noteModel = computed(() => parseLeadFormNotes(activity.value?.description)); +const thread = computed(() => parseActivityThread(activity.value?.description)); +const noteModel = computed(() => + thread.value ? { mode: 'prose' as const, prose: '', rows: [] as LeadNoteRow[] } : parseLeadFormNotes(activity.value?.description), +); const noteRows = computed(() => noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]), ); @@ -80,8 +91,53 @@ const noteExtra = computed(() => (chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخ‌هایفرم:'), ), ); +const COMMENT_MEDIA_WINDOW_MS = 3 * 60 * 1000; + +const threadBlocks = computed(() => { + const entries = thread.value; + if (!entries) return null; + const blocks = entries.map((entry) => ({ ...entry, media: [] as NoteMedia[] })); + const used = new Set(); + for (const image of imageUrls.value) { + 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 <= COMMENT_MEDIA_WINDOW_MS) { + 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: NoteMedia[] = []; + for (const image of imageUrls.value) { + 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 }; +}); + +const looseMedia = computed(() => threadBlocks.value?.loose ?? imageUrls.value); + const recoveredImages = ref>({}); +function entryRows(entry: ActivityThreadEntry): LeadNoteRow[] { + if (entry.at) return []; + const parsed = parseLeadFormNotes(entry.body); + return parsed.mode === 'answers' ? parsed.rows : []; +} + function noteImageSrc(file: LeadNoteFile): string | null { if (!leadNoteFileIsImage(file)) return null; return file.dataUrl || recoveredImages.value[file.name] || null; @@ -226,7 +282,7 @@ function clearImageUrls() { async function loadActivityImages(row: ActivityDto) { clearImageUrls(); - const next: Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }> = []; + 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'; @@ -239,6 +295,7 @@ async function loadActivityImages(row: ActivityDto) { name: image.fileName, url: URL.createObjectURL(data), kind, + createdAt: image.createdAt, }); } catch { /* فایل در دسترس نیست */ @@ -617,7 +674,76 @@ onMounted(() => {
-
+
+
+
+ + {{ entry.author }} +
+
+
+
{{ row.label }}
+
+ + {{ row.file.name }} + +
+
+
+

+ +

+
+ + + +
+
+
+ +
+
+
{{ row.label }}
@@ -652,7 +778,7 @@ onMounted(() => {

یادداشتی ثبت نشده.

-

+

-
+ -
+
-
    -
  • + @@ -1098,6 +1224,46 @@ dd { color: #94a3b8; } +.note-thread { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.note-bubble { + border: 1px solid #e2e8f0; + border-radius: 0.85rem; + background: #f8fafc; + padding: 0.7rem 0.85rem; +} + +.note-bubble header { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.35rem; + color: #64748b; + font-size: 0.78rem; +} + +.note-bubble header span { + color: #0f766e; + font-weight: 700; +} + +.note .note-bubble-body { + margin: 0; + color: #334155; + line-height: 1.9; + white-space: pre-line; + overflow-wrap: anywhere; +} + +.note-bubble .note-rows { + margin-top: 0; +} + .note-rows { margin: 0.75rem 0 0; display: grid;