fix(activities): show each note as its own dated message
The colon inside the clock was splitting the stamp from the comment, so date, author, text, and attachments now stay together. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
import { canAssignToOthers } from '@/lib/assign-access';
|
import { canAssignToOthers } from '@/lib/assign-access';
|
||||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
import { parseActivityThread, type ActivityThreadEntry } from '@/lib/activity-thread';
|
||||||
import {
|
import {
|
||||||
leadNoteFileIsImage,
|
leadNoteFileIsImage,
|
||||||
noteChips,
|
noteChips,
|
||||||
@@ -64,12 +65,22 @@ const loading = ref(true);
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const activity = ref<ActivityDto | null>(null);
|
const activity = ref<ActivityDto | null>(null);
|
||||||
const imageUrls = ref<Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }>>([]);
|
type NoteMedia = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
kind: 'image' | 'audio' | 'file';
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
const imageUrls = ref<NoteMedia[]>([]);
|
||||||
const moreOpen = ref(false);
|
const moreOpen = ref(false);
|
||||||
const commentOpen = ref(false);
|
const commentOpen = ref(false);
|
||||||
const postponeOpen = ref(false);
|
const postponeOpen = ref(false);
|
||||||
const noteOpen = ref(true);
|
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(() =>
|
const noteRows = computed(() =>
|
||||||
noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]),
|
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() === 'پاسخهایفرم:'),
|
(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<string>();
|
||||||
|
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<Record<string, string>>({});
|
const recoveredImages = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
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 {
|
function noteImageSrc(file: LeadNoteFile): string | null {
|
||||||
if (!leadNoteFileIsImage(file)) return null;
|
if (!leadNoteFileIsImage(file)) return null;
|
||||||
return file.dataUrl || recoveredImages.value[file.name] || null;
|
return file.dataUrl || recoveredImages.value[file.name] || null;
|
||||||
@@ -226,7 +282,7 @@ function clearImageUrls() {
|
|||||||
|
|
||||||
async function loadActivityImages(row: ActivityDto) {
|
async function loadActivityImages(row: ActivityDto) {
|
||||||
clearImageUrls();
|
clearImageUrls();
|
||||||
const next: Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }> = [];
|
const next: NoteMedia[] = [];
|
||||||
for (const image of row.images ?? []) {
|
for (const image of row.images ?? []) {
|
||||||
const mime = image.mimeType.toLowerCase();
|
const mime = image.mimeType.toLowerCase();
|
||||||
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
|
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
|
||||||
@@ -239,6 +295,7 @@ async function loadActivityImages(row: ActivityDto) {
|
|||||||
name: image.fileName,
|
name: image.fileName,
|
||||||
url: URL.createObjectURL(data),
|
url: URL.createObjectURL(data),
|
||||||
kind,
|
kind,
|
||||||
|
createdAt: image.createdAt,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* فایل در دسترس نیست */
|
/* فایل در دسترس نیست */
|
||||||
@@ -617,7 +674,76 @@ onMounted(() => {
|
|||||||
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
||||||
</button>
|
</button>
|
||||||
<div v-show="noteOpen">
|
<div v-show="noteOpen">
|
||||||
<dl v-if="noteRows.length" class="note-rows">
|
<div v-if="threadBlocks" class="note-thread">
|
||||||
|
<article
|
||||||
|
v-for="(entry, index) in threadBlocks.blocks"
|
||||||
|
:key="`${entry.dateLabel}-${entry.timeLabel}-${index}`"
|
||||||
|
class="note-bubble"
|
||||||
|
>
|
||||||
|
<header v-if="entry.dateLabel">
|
||||||
|
<time>{{ entry.dateLabel }} ساعت {{ entry.timeLabel }}</time>
|
||||||
|
<span v-if="entry.author">{{ entry.author }}</span>
|
||||||
|
</header>
|
||||||
|
<dl v-if="entryRows(entry).length" class="note-rows">
|
||||||
|
<div v-for="(row, rowIndex) in entryRows(entry)" :key="`${row.label}-${rowIndex}`">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
<template v-if="row.file && noteImageSrc(row.file)">
|
||||||
|
<a class="note-shot" :href="noteImageSrc(row.file)!" target="_blank" rel="noopener">
|
||||||
|
<img :src="noteImageSrc(row.file)!" :alt="row.file.name" />
|
||||||
|
</a>
|
||||||
|
<span>{{ row.file.name }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="row.file">{{ row.file.name }}</span>
|
||||||
|
<template v-else>
|
||||||
|
<template v-for="(chip, chipIndex) in noteChips(row.text)" :key="chipIndex">
|
||||||
|
<a v-if="chip.kind === 'link'" :href="chip.href" target="_blank" rel="noopener">{{ chip.label }}</a>
|
||||||
|
<a v-else-if="chip.kind === 'image'" class="note-shot" :href="chip.src" target="_blank" rel="noopener">
|
||||||
|
<img :src="chip.src" :alt="chip.name" />
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ chip.text }}</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p v-else-if="entry.body" class="note-bubble-body">
|
||||||
|
<template v-for="(chip, chipIndex) in noteChips(entry.body)" :key="chipIndex">
|
||||||
|
<a v-if="chip.kind === 'link'" :href="chip.href" target="_blank" rel="noopener">{{ chip.label }}</a>
|
||||||
|
<a v-else-if="chip.kind === 'image'" class="note-shot" :href="chip.src" target="_blank" rel="noopener">
|
||||||
|
<img :src="chip.src" :alt="chip.name" />
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ chip.text }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<div v-if="entry.media.some((image) => image.kind === 'image')" class="note-photos">
|
||||||
|
<a
|
||||||
|
v-for="image in entry.media.filter((item) => item.kind === 'image')"
|
||||||
|
:key="image.id"
|
||||||
|
:href="image.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<img :src="image.url" :alt="image.name" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.media.some((image) => image.kind === 'audio')" class="note-voices">
|
||||||
|
<audio
|
||||||
|
v-for="image in entry.media.filter((item) => item.kind === 'audio')"
|
||||||
|
:key="image.id"
|
||||||
|
:src="image.url"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul v-if="entry.media.some((image) => image.kind === 'file')" class="note-files">
|
||||||
|
<li v-for="image in entry.media.filter((item) => item.kind === 'file')" :key="image.id">
|
||||||
|
<a :href="image.url" :download="image.name">{{ image.name }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<dl v-else-if="noteRows.length" class="note-rows">
|
||||||
<div v-for="(row, index) in noteRows" :key="`${row.label}-${index}`">
|
<div v-for="(row, index) in noteRows" :key="`${row.label}-${index}`">
|
||||||
<dt>{{ row.label }}</dt>
|
<dt>{{ row.label }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -652,7 +778,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<p v-else-if="!noteExtra.length && !imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
|
<p v-else-if="!noteExtra.length && !imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
|
||||||
<p v-if="noteExtra.length" class="note-extra">
|
<p v-if="!threadBlocks && noteExtra.length" class="note-extra">
|
||||||
<template v-for="(chip, index) in noteExtra" :key="index">
|
<template v-for="(chip, index) in noteExtra" :key="index">
|
||||||
<a
|
<a
|
||||||
v-if="chip.kind === 'link'"
|
v-if="chip.kind === 'link'"
|
||||||
@@ -672,9 +798,9 @@ onMounted(() => {
|
|||||||
<span v-else>{{ chip.text }}</span>
|
<span v-else>{{ chip.text }}</span>
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
|
<div v-if="looseMedia.some((image) => image.kind === 'image')" class="note-photos">
|
||||||
<a
|
<a
|
||||||
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
|
v-for="image in looseMedia.filter((item) => item.kind === 'image')"
|
||||||
:key="image.id"
|
:key="image.id"
|
||||||
:href="image.url"
|
:href="image.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -683,17 +809,17 @@ onMounted(() => {
|
|||||||
<img :src="image.url" :alt="image.name" />
|
<img :src="image.url" :alt="image.name" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="imageUrls.some((image) => image.kind === 'audio')" class="note-voices">
|
<div v-if="looseMedia.some((image) => image.kind === 'audio')" class="note-voices">
|
||||||
<audio
|
<audio
|
||||||
v-for="image in imageUrls.filter((item) => item.kind === 'audio')"
|
v-for="image in looseMedia.filter((item) => item.kind === 'audio')"
|
||||||
:key="image.id"
|
:key="image.id"
|
||||||
:src="image.url"
|
:src="image.url"
|
||||||
controls
|
controls
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="imageUrls.some((image) => image.kind === 'file')" class="note-files">
|
<ul v-if="looseMedia.some((image) => image.kind === 'file')" class="note-files">
|
||||||
<li v-for="image in imageUrls.filter((item) => item.kind === 'file')" :key="image.id">
|
<li v-for="image in looseMedia.filter((item) => item.kind === 'file')" :key="image.id">
|
||||||
<a :href="image.url" :download="image.name">{{ image.name }}</a>
|
<a :href="image.url" :download="image.name">{{ image.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1098,6 +1224,46 @@ dd {
|
|||||||
color: #94a3b8;
|
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 {
|
.note-rows {
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user