Import large activity Excel in the browser via JSON chunks to avoid 502.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 18:28:47 +03:30
co-authored by Cursor
parent ac57b8ac33
commit cbfe470f33
11 changed files with 6451 additions and 31 deletions
+48
View File
@@ -0,0 +1,48 @@
import type { ExcelFieldOption } from '@mento/shared';
/** هم‌تراز با aliases سمت API برای ایمپورت فعالیت */
export const ACTIVITY_EXCEL_ALIASES: Record<string, string> = {
_type: '_type',
type: '_type',
نوعفعالیت: 'kind',
عنوانفعالیت: 'title',
عنوان: 'title',
متنفعالیت: 'body',
نتیجهفعالیت: 'result',
مسئولاجرافعالیت: 'assignee',
ثبتکنندهفعالیت: 'creator',
تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate',
زمانبرنامهریزیشدهاجرافعالیت: 'dueTime',
وضعیتاجرایفعالیت: 'status',
تاریخانجامشدنفعالیت: 'doneDate',
زمانانجامشدنفعالیت: 'doneTime',
کدمعاملهفعالیت: 'dealCode',
کدکارتفعالیت: 'cardCode',
اشخاصفعالیت: 'personName',
کداشخاصفعالیت: 'personCode',
موبایلاشخاصفعالیت: 'personMobile',
شرکتهایفعالیت: 'companyName',
کدشرکتهایفعالیت: 'companyCode',
معاملهفعالیت1: 'dealTitle',
کارتفعالیت1: 'cardTitle',
هشتگ: 'hashtag',
};
export const ACTIVITY_EXCEL_FIELDS: ExcelFieldOption[] = [
{ key: 'title', label: 'عنوان فعالیت', required: true },
{ key: 'kind', label: 'نوع فعالیت' },
{ key: 'body', label: 'متن فعالیت' },
{ key: 'result', label: 'نتیجه فعالیت' },
{ key: 'assignee', label: 'مسئول اجرا فعالیت' },
{ key: 'creator', label: 'ثبت کننده فعالیت' },
{ key: 'dueDate', label: 'تاریخ برنامه ریزی شده اجرا فعالیت' },
{ key: 'dueTime', label: 'زمان برنامه ریزی شده اجرا فعالیت' },
{ key: 'status', label: 'وضعیت اجرای فعالیت' },
{ key: 'doneDate', label: 'تاریخ انجام شدن فعالیت' },
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
{ key: 'personName', label: 'اشخاص فعالیت' },
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
{ key: 'companyName', label: 'شرکت های فعالیت' },
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
{ key: 'hashtag', label: 'هشتگ' },
];
+138
View File
@@ -0,0 +1,138 @@
import * as XLSX from 'xlsx';
import type { ExcelFieldOption, ExcelPreviewResult } from '@mento/shared';
function normalizeHeader(raw: string) {
return raw
.trim()
.toLowerCase()
.replace(/[_-]+/g, '')
.replace(/‌/g, '')
.replace(/\s+/g, '')
.replace(/[()]/g, '');
}
function cellToString(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
}
return String(value).trim();
}
function resolveFieldKey(
raw: string,
aliases: Record<string, string>,
mapping?: Record<string, string>,
) {
if (mapping) {
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
}
const compact = normalizeHeader(raw);
return aliases[compact] ?? aliases[raw.trim()] ?? '';
}
/** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */
export async function previewExcelClient(
file: File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
): Promise<ExcelPreviewResult> {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
const sheet = wb.Sheets[sheetName]!;
const matrix = XLSX.utils.sheet_to_json<(string | number | boolean | Date | null)[]>(
sheet,
{ header: 1, defval: '', raw: false },
) as unknown[][];
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const columns: ExcelPreviewResult['columns'] = [];
const suggested: Record<string, string> = {};
const used = new Set<string>();
headerRow.forEach((header, idx) => {
if (!header) return;
const samples: string[] = [];
for (let r = 1; r < Math.min(matrix.length, 6); r += 1) {
const value = cellToString(matrix[r]?.[idx]);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: idx + 1, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
if (!columns.length) throw new Error('ستون عنوان در ردیف اول فایل پیدا نشد');
let rowCount = 0;
for (let r = 1; r < matrix.length; r += 1) {
const row = matrix[r] ?? [];
if (row.some((c) => cellToString(c))) rowCount += 1;
}
const required = fields.filter((f) => f.required).map((f) => f.key);
const mapped = new Set(Object.values(suggested));
const missingRequired = required.some((key) => !mapped.has(key));
const unrecognized = columns.some((col) => !suggested[col.header]);
return {
columns,
fields,
suggested,
needsMapping: unrecognized || missingRequired || !mapped.size,
rowCount,
largeFile: rowCount >= 80,
};
}
export async function readMappedRowsClient(
file: File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
): Promise<Record<string, string>[]> {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
const sheet = wb.Sheets[sheetName]!;
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: '',
raw: false,
}) as unknown[][];
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const colMap = new Map<number, string>();
headerRow.forEach((header, idx) => {
if (!header) return;
const key = resolveFieldKey(header, aliases, mapping);
if (key) colMap.set(idx, key);
});
if (!colMap.size) {
throw new Error('ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.');
}
const rows: Record<string, string>[] = [];
for (let r = 1; r < matrix.length; r += 1) {
const line = matrix[r] ?? [];
const item: Record<string, string> = {};
let any = false;
for (const [idx, key] of colMap) {
const value = cellToString(line[idx]);
if (value) any = true;
item[key] = value;
}
if (any) rows.push(item);
}
if (!rows.length) throw new Error('هیچ ردیف داده‌ای در فایل نیست');
return rows;
}