Files
CRM_Montzar/apps/web/src/lib/activity-thread.ts
T
MahdiandCursor f13a1249af 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 <cursoragent@cursor.com>
2026-11-10 21:31:21 +03:30

162 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { jalaliToDate } from '@/lib/jalali';
export type ActivityThreadEntry = {
at: Date | null;
dateLabel: string;
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 {
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 trimmed = line.trim();
const stamp = trimmed.match(STAMP);
if (stamp) {
sawStamp = true;
if (current) entries.push(trimEntry(current));
current = {
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);
}
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,
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<T extends { id: string; createdAt: string }>(
entries: ActivityThreadEntry[],
images: T[],
windowMs = COMMENT_MEDIA_WINDOW_MS,
): { blocks: Array<ActivityThreadEntry & { media: T[] }>; 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<string>();
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(),
attachmentIds: [...new Set((entry.attachmentIds ?? []).filter(Boolean))],
};
}
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;
}