Deals Excel import/export aligned with Didar: browser-side import, new deal fields (code, probability, closedAt, creator, lostNote, referrer), Didar custom fields

This commit is contained in:
2026-10-06 16:36:04 +03:30
parent e08f1452ef
commit 2a2728e5ee
11 changed files with 1032 additions and 210 deletions
+23 -6
View File
@@ -39,17 +39,24 @@ export type ClientExcelPositionalColumn = { key: string; label: string };
export type ClientExcelReadOptions = {
positionalColumns?: ClientExcelPositionalColumn[];
detectHeaderless?: (firstRowCells: string[]) => boolean;
/**
* مقدار خام سلول به‌جای متن قالب‌بندی‌شده؛ خروجی دیدار به کدهای عددی قالب تاریخ می‌دهد
* و بدون این گزینه «4231» به‌صورت «8/1/11» خوانده می‌شود.
*/
rawValues?: boolean;
/** ستون‌های ناشناخته با کلید `${prefix}${عنوان ستون}` ارسال می‌شوند (فیلد سفارشی) */
customFieldPrefix?: string;
};
function readMatrix(fileBuffer: ArrayBuffer) {
const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: true });
function readMatrix(fileBuffer: ArrayBuffer, rawValues = false) {
const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: !rawValues });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
const sheet = wb.Sheets[sheetName]!;
return XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: '',
raw: false,
raw: rawValues,
}) as unknown[][];
}
@@ -60,11 +67,13 @@ export async function previewExcelClient(
fields: ExcelFieldOption[],
options?: ClientExcelReadOptions,
): Promise<ExcelPreviewResult> {
const matrix = readMatrix(await file.arrayBuffer());
const matrix = readMatrix(await file.arrayBuffer(), options?.rawValues);
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const headerless =
Boolean(options?.positionalColumns?.length) &&
Boolean(options?.detectHeaderless?.(headerRow));
const prefix = options?.customFieldPrefix;
const allFields = [...fields];
const columns: ExcelPreviewResult['columns'] = [];
const suggested: Record<string, string> = {};
@@ -96,6 +105,11 @@ export async function previewExcelClient(
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
} else if (!key && prefix) {
const cfKey = `${prefix}${header}`;
allFields.push({ key: cfKey, label: `فیلد سفارشی: ${header}` });
suggested[header] = cfKey;
used.add(cfKey);
}
});
}
@@ -116,7 +130,7 @@ export async function previewExcelClient(
return {
columns,
fields,
fields: allFields,
suggested,
needsMapping: headerless
? false
@@ -132,7 +146,7 @@ export async function readMappedRowsClient(
mapping?: Record<string, string>,
options?: ClientExcelReadOptions,
): Promise<Record<string, string>[]> {
const matrix = readMatrix(await file.arrayBuffer());
const matrix = readMatrix(await file.arrayBuffer(), options?.rawValues);
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const headerless =
!mapping &&
@@ -152,6 +166,9 @@ export async function readMappedRowsClient(
if (!header) return;
const key = resolveFieldKey(header, aliases, mapping);
if (key) colMap.set(idx, key);
else if (!mapping && options?.customFieldPrefix) {
colMap.set(idx, `${options.customFieldPrefix}${header}`);
}
});
}
+52
View File
@@ -96,6 +96,14 @@ const pipelineId = ref('');
const ownerId = ref('');
const selectedAssigneeIds = ref<string[]>([]);
const lostReason = ref('');
const lostNote = ref('');
const dealCode = ref('');
const probability = ref('');
const sourceDetail = ref('');
const referrer = ref('');
const closedAt = ref('');
const createdByName = ref('');
const sourceRef = ref('');
const status = ref('');
const customFields = ref<Record<string, unknown>>({});
const lines = ref<LineDraft[]>([]);
@@ -251,6 +259,14 @@ async function load() {
ownerId.value = data.owner?.id ?? auth.user?.id ?? '';
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
lostReason.value = data.lostReason ?? '';
lostNote.value = data.lostNote ?? '';
dealCode.value = data.code ?? '';
probability.value = data.probability == null ? '' : String(data.probability);
sourceDetail.value = data.sourceDetail ?? '';
referrer.value = data.referrer ?? '';
closedAt.value = data.closedAt ? data.closedAt.slice(0, 10) : '';
createdByName.value = data.createdBy?.fullName ?? '';
sourceRef.value = data.importSource === 'didar' && data.sourceRef ? data.sourceRef : '';
status.value = data.status;
customFields.value = { ...(data.customFields ?? {}) };
lines.value = (data.lineItems ?? []).map((item) => ({
@@ -302,6 +318,10 @@ function payload(): CreateDealRequest {
? null
: Number(amount.value),
source: source.value || undefined,
sourceDetail: sourceDetail.value.trim() || null,
referrer: referrer.value.trim() || null,
code: dealCode.value.trim() || null,
probability: probability.value === '' ? null : Number(probability.value),
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
isStale: health.value === 'stale',
@@ -374,6 +394,8 @@ async function submit() {
await api.patch(`/deals/${id.value}`, {
...payload(),
lostReason: lostReason.value || null,
lostNote: lostNote.value.trim() || null,
closedAt: closedAt.value || null,
});
if (stageId.value) {
await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value });
@@ -679,13 +701,43 @@ onMounted(load);
<UiInput v-model="source" placeholder="مثال: وب‌سایت، معرفی، نمایشگاه…" />
</UiField>
<UiField label="شیوه آشنایی دلخواه">
<UiInput v-model="sourceDetail" placeholder="توضیح آزاد" />
</UiField>
<UiField label="معرف">
<UiInput v-model="referrer" placeholder="نام معرف" />
</UiField>
<UiField label="کد دستی معامله">
<UiInput v-model="dealCode" />
</UiField>
<UiField label="احتمال موفقیت (درصد)">
<UiInput v-model="probability" type="number" min="0" max="100" />
</UiField>
<UiField label="برچسب‌ها">
<UiInput v-model="tagsRaw" placeholder="سازمانی، فوری (با کاما جدا کنید)" />
</UiField>
<UiField v-if="!isNew" label="تاریخ انجام معامله">
<JalaliDateInput v-model="closedAt" placeholder="انتخاب تاریخ" />
</UiField>
<UiField v-if="!isNew" label="دلیل شکست (در صورت باخت معامله)" class="crm-col-span-full">
<UiInput id="lostReason" v-model="lostReason" placeholder="علت از دست رفتن معامله را بنویسید…" />
</UiField>
<UiField v-if="!isNew" label="یادداشت شکست" class="crm-col-span-full">
<UiInput v-model="lostNote" />
</UiField>
<p v-if="!isNew && (createdByName || sourceRef)" class="hint crm-col-span-full">
<span v-if="createdByName">ثبت‌کننده: {{ createdByName }}</span>
<span v-if="createdByName && sourceRef"> · </span>
<span v-if="sourceRef">کد دیدار: {{ toFaDigits(sourceRef) }}</span>
</p>
</div>
<!-- بخش محصولات / خدمات -->
+20 -1
View File
@@ -24,7 +24,22 @@ import UiModal from '@/components/ui/UiModal.vue';
import DealFormView from '@/views/DealFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import { brand } from '@mento/shared';
import {
DEAL_EXCEL_ALIASES,
DEAL_EXCEL_COLUMNS,
EXCEL_CUSTOM_FIELD_PREFIX,
brand,
} from '@mento/shared';
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
key: c.key,
label: c.header,
required: c.key === 'title',
}));
const dealClientReadOptions = {
rawValues: true,
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
};
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
@@ -506,6 +521,10 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"