Edit import excel
This commit is contained in:
@@ -5,12 +5,16 @@ import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
|
||||
const ACTIVITY_COLUMNS = [
|
||||
@@ -143,8 +147,16 @@ export class ActivitiesExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
|
||||
}
|
||||
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
|
||||
@@ -79,6 +79,17 @@ export class ActivitiesController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
importPreview(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
@@ -89,8 +100,9 @@ export class ActivitiesController {
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
return this.excelService.import(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -188,9 +188,121 @@ export async function workbookFromUpload(file: Express.Multer.File | undefined)
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export type ExcelFieldOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type ExcelColumnPreview = {
|
||||
header: string;
|
||||
index: number;
|
||||
samples: string[];
|
||||
};
|
||||
|
||||
export type ExcelPreviewResult = {
|
||||
columns: ExcelColumnPreview[];
|
||||
fields: ExcelFieldOption[];
|
||||
suggested: Record<string, string>;
|
||||
needsMapping: boolean;
|
||||
};
|
||||
|
||||
export function excelFieldsFromColumns(
|
||||
columns: Array<{ header: string; key: string }>,
|
||||
requiredKeys: string[] = [],
|
||||
): ExcelFieldOption[] {
|
||||
const seen = new Set<string>();
|
||||
const fields: ExcelFieldOption[] = [];
|
||||
for (const col of columns) {
|
||||
if (!col.key || col.key === '_type' || seen.has(col.key)) continue;
|
||||
seen.add(col.key);
|
||||
fields.push({
|
||||
key: col.key,
|
||||
label: col.header,
|
||||
required: requiredKeys.includes(col.key),
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseExcelMapping(raw?: string): Record<string, string> | undefined {
|
||||
if (raw == null || !String(raw).trim()) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw)) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const [header, field] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof field === 'string' && field.trim()) mapping[header] = field.trim();
|
||||
}
|
||||
return mapping;
|
||||
} catch {
|
||||
throw new BadRequestException('نگاشت ستونها نامعتبر است');
|
||||
}
|
||||
}
|
||||
|
||||
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()] ?? aliases[normalizeHeader(raw)] ?? '';
|
||||
}
|
||||
|
||||
export async function previewExcel(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
fields: ExcelFieldOption[],
|
||||
): Promise<ExcelPreviewResult> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
const columns: ExcelColumnPreview[] = [];
|
||||
const suggested: Record<string, string> = {};
|
||||
const used = new Set<string>();
|
||||
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const header = cellStr(cell.value);
|
||||
if (!header) return;
|
||||
const samples: string[] = [];
|
||||
const last = Math.min(sheet.rowCount, 6);
|
||||
for (let r = 2; r <= last; r += 1) {
|
||||
const value = cellStr(sheet.getRow(r).getCell(col).value);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header, index: col, samples });
|
||||
const key = resolveFieldKey(header, aliases);
|
||||
if (key && !used.has(key)) {
|
||||
suggested[header] = key;
|
||||
used.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (!columns.length) {
|
||||
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMappedRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
@@ -198,13 +310,12 @@ export async function readMappedRows(
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const compact = normalizeHeader(raw);
|
||||
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
|
||||
const key = resolveFieldKey(raw, aliases, mapping);
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
|
||||
'ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,20 @@ export class CompaniesController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
previewImport(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.previewCompanies(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.importCompanies(user, file);
|
||||
return this.excelService.importCompanies(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -6,8 +6,11 @@ import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import {
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleService } from './people.service';
|
||||
@@ -107,6 +110,19 @@ const PERSON_ALIASES: Record<string, string> = {
|
||||
ستاره: 'star',
|
||||
};
|
||||
|
||||
const PERSON_FIELDS = [
|
||||
{ key: 'firstName', label: 'نام' },
|
||||
{ key: 'lastName', label: 'نام خانوادگی' },
|
||||
{ key: 'title', label: 'نام کامل / عنوان' },
|
||||
{ key: 'mobile', label: 'موبایل' },
|
||||
{ key: 'phone', label: 'تلفن' },
|
||||
{ key: 'email', label: 'ایمیل' },
|
||||
{ key: 'position', label: 'سمت' },
|
||||
{ key: 'companyName', label: 'شرکت' },
|
||||
{ key: 'notes', label: 'یادداشت' },
|
||||
{ key: 'mood', label: 'وضعیت مشتری' },
|
||||
];
|
||||
|
||||
const COMPANY_ALIASES: Record<string, string> = {
|
||||
name: 'name',
|
||||
نام: 'name',
|
||||
@@ -126,6 +142,16 @@ const COMPANY_ALIASES: Record<string, string> = {
|
||||
یادداشت: 'notes',
|
||||
};
|
||||
|
||||
const COMPANY_FIELDS = [
|
||||
{ key: 'name', label: 'نام شرکت', required: true },
|
||||
{ key: 'nationalId', label: 'شناسه ملی' },
|
||||
{ key: 'phone', label: 'تلفن' },
|
||||
{ key: 'email', label: 'ایمیل' },
|
||||
{ key: 'website', label: 'وبسایت' },
|
||||
{ key: 'address', label: 'آدرس' },
|
||||
{ key: 'notes', label: 'یادداشت' },
|
||||
];
|
||||
|
||||
const CONTACT_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
|
||||
@@ -294,11 +320,20 @@ export class ContactsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
previewPeople(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS);
|
||||
}
|
||||
|
||||
previewCompanies(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS);
|
||||
}
|
||||
|
||||
async importPeople(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES);
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
@@ -307,9 +342,18 @@ export class ContactsExcelService {
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
||||
continue;
|
||||
}
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
|
||||
if (!names) {
|
||||
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
|
||||
firstName: (row.firstName ?? '').trim().slice(0, 80),
|
||||
lastName: (row.lastName ?? '').trim().slice(0, 80),
|
||||
};
|
||||
if (
|
||||
!names.firstName &&
|
||||
!names.lastName &&
|
||||
!row.mobile?.trim() &&
|
||||
!row.email?.trim() &&
|
||||
!row.phone?.trim()
|
||||
) {
|
||||
result.errors.push({ row: rowNum, message: 'حداقل نام، موبایل، تلفن یا ایمیل لازم است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -357,8 +401,9 @@ export class ContactsExcelService {
|
||||
async importCompanies(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, COMPANY_ALIASES);
|
||||
const rows = await readMappedRows(file, COMPANY_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
|
||||
@@ -135,15 +135,15 @@ export class UpdateCompanyDto {
|
||||
}
|
||||
|
||||
export class CreatePersonDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
firstName!: string;
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
lastName!: string;
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@@ -192,13 +192,11 @@ export class CreatePersonDto {
|
||||
export class UpdatePersonDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
|
||||
@@ -57,13 +57,22 @@ export class PeopleController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
previewImport(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.previewPeople(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.importPeople(user, file);
|
||||
return this.excelService.importPeople(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -134,8 +134,8 @@ export class PeopleService {
|
||||
|
||||
const data: Prisma.PersonUncheckedCreateInput = {
|
||||
organizationId: actor.organizationId,
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
firstName: (dto.firstName ?? '').trim(),
|
||||
lastName: (dto.lastName ?? '').trim(),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
|
||||
@@ -7,11 +7,15 @@ import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { toJsonCustomFields } from '../common/custom-fields';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
parseAmount,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
@@ -144,8 +148,16 @@ export class DealsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, DEAL_ALIASES);
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, DEAL_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title']));
|
||||
}
|
||||
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, DEAL_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const affected = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -45,6 +45,19 @@ export class DealsController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
importPreview(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
@@ -55,8 +68,9 @@ export class DealsController {
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
return this.excelService.import(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -2,9 +2,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import type { ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
parseAmount,
|
||||
parseExcelMapping,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@@ -106,11 +110,16 @@ export class ProductsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, PRODUCT_ALIASES, excelFieldsFromColumns(PRODUCT_COLUMNS, ['name']));
|
||||
}
|
||||
|
||||
async import(
|
||||
organizationId: string,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, PRODUCT_ALIASES);
|
||||
const rows = await readMappedRows(file, PRODUCT_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
|
||||
@@ -62,6 +62,18 @@ export class ProductsController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@Roles('owner', 'admin')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
importPreview(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@Roles('owner', 'admin')
|
||||
@UseInterceptors(
|
||||
@@ -73,8 +85,9 @@ export class ProductsController {
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user.organizationId, file);
|
||||
return this.excelService.import(user.organizationId, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -128,6 +128,17 @@ export class ProjectBoardsController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('project-cards/import/preview')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
previewCards(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('project-cards/import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
@@ -138,8 +149,9 @@ export class ProjectBoardsController {
|
||||
importCards(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
return this.excelService.import(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get('project-cards/:id')
|
||||
|
||||
@@ -5,11 +5,15 @@ import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { ProjectBoardsService } from './project-boards.service';
|
||||
|
||||
@@ -105,8 +109,16 @@ export class ProjectCardsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, CASE_ALIASES);
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, CASE_ALIASES, excelFieldsFromColumns(CASE_COLUMNS, ['title']));
|
||||
}
|
||||
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, CASE_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const affected = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ExcelPreviewResult, ImportContactsResult } from '@mento/shared';
|
||||
import { UiButton, UiModal, UiSelect } from '@/components/ui';
|
||||
|
||||
const props = defineProps<{
|
||||
previewPath: string;
|
||||
importPath: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
start: [];
|
||||
done: [ImportContactsResult];
|
||||
fail: [string];
|
||||
settled: [];
|
||||
}>();
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const pending = ref<File | null>(null);
|
||||
const preview = ref<ExcelPreviewResult | null>(null);
|
||||
const mapping = reactive<Record<string, string>>({});
|
||||
const submitting = ref(false);
|
||||
|
||||
const requiredKeys = computed(
|
||||
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
|
||||
);
|
||||
|
||||
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
|
||||
|
||||
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
|
||||
|
||||
function pick() {
|
||||
if (props.disabled || submitting.value) return;
|
||||
input.value?.click();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
pending.value = null;
|
||||
preview.value = null;
|
||||
submitting.value = false;
|
||||
for (const key of Object.keys(mapping)) delete mapping[key];
|
||||
}
|
||||
|
||||
function closeMapper() {
|
||||
reset();
|
||||
emit('settled');
|
||||
}
|
||||
|
||||
function setColumnField(header: string, field: string) {
|
||||
if (field) {
|
||||
for (const [col, current] of Object.entries(mapping)) {
|
||||
if (col !== header && current === field) mapping[col] = '';
|
||||
}
|
||||
}
|
||||
mapping[header] = field;
|
||||
}
|
||||
|
||||
async function postImport(file: File, map?: Record<string, string>) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (map && Object.values(map).some(Boolean)) {
|
||||
form.append('mapping', JSON.stringify(map));
|
||||
}
|
||||
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function onFile(ev: Event) {
|
||||
const el = ev.target as HTMLInputElement;
|
||||
const file = el.files?.[0];
|
||||
el.value = '';
|
||||
if (!file) return;
|
||||
emit('start');
|
||||
submitting.value = true;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ExcelPreviewResult>(props.previewPath, form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
if (!data.needsMapping) {
|
||||
emit('done', await postImport(file));
|
||||
reset();
|
||||
emit('settled');
|
||||
return;
|
||||
}
|
||||
pending.value = file;
|
||||
preview.value = data;
|
||||
for (const col of data.columns) {
|
||||
mapping[col.header] = data.suggested[col.header] ?? '';
|
||||
}
|
||||
} catch (e) {
|
||||
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
|
||||
reset();
|
||||
emit('settled');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMapping() {
|
||||
if (!pending.value || !canImport.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
emit('done', await postImport(pending.value, { ...mapping }));
|
||||
reset();
|
||||
emit('settled');
|
||||
} catch (e) {
|
||||
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="excel-import" @click.stop="pick">
|
||||
<slot />
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onFile"
|
||||
@click.stop
|
||||
/>
|
||||
</span>
|
||||
|
||||
<UiModal :open="!!preview" title="تطبیق ستونهای اکسل" @close="closeMapper">
|
||||
<p class="lead">
|
||||
ستونهای این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
|
||||
</p>
|
||||
<div class="rows">
|
||||
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
|
||||
<div class="source">
|
||||
<strong>{{ col.header }}</strong>
|
||||
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
|
||||
</div>
|
||||
<UiSelect
|
||||
:model-value="mapping[col.header] ?? ''"
|
||||
@update:model-value="setColumnField(col.header, $event)"
|
||||
>
|
||||
<option value="">نادیده بگیر</option>
|
||||
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
|
||||
{{ field.label }}{{ field.required ? ' *' : '' }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!canImport" class="hint">فیلدهای ستارهدار را حتماً به یک ستون وصل کنید.</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
|
||||
<UiButton :disabled="submitting || !canImport" @click="confirmMapping">
|
||||
{{ submitting ? 'در حال ورود…' : 'ورود فایل' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.excel-import {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.source small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
@@ -78,7 +79,6 @@ const activityModal = ref<{
|
||||
const menuId = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const counts = ref<Record<ActivityFilter, number>>({
|
||||
today: 0,
|
||||
overdue: 0,
|
||||
@@ -286,44 +286,31 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/activities/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await Promise.all([load(), loadCounts()]);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
|
||||
} else {
|
||||
error.value = 'ایمپورت ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await Promise.all([load(), loadCounts()]);
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -357,17 +344,20 @@ onMounted(() => {
|
||||
<span class="btn-full">خروجی اکسل</span>
|
||||
<span class="btn-short">خروجی</span>
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="pickImportFile">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/activities/import/preview"
|
||||
import-path="/activities/import"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
</ExcelImportFlow>
|
||||
<UiButton variant="primary" @click="activityModal = {}">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
فعالیت جدید
|
||||
|
||||
@@ -5,6 +5,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiModal,
|
||||
@@ -52,7 +53,6 @@ const error = ref('');
|
||||
const importMsg = ref('');
|
||||
const people = ref<PaginatedResponse<PersonDto> | null>(null);
|
||||
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const menuId = ref<string | null>(null);
|
||||
const sortOpen = ref(false);
|
||||
const filterOpen = ref(false);
|
||||
@@ -187,46 +187,38 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
const importPreviewPath = computed(() =>
|
||||
tab.value === 'people' ? '/people/import/preview' : '/companies/import/preview',
|
||||
);
|
||||
const importPath = computed(() =>
|
||||
tab.value === 'people' ? '/people/import' : '/companies/import',
|
||||
);
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const path = tab.value === 'people' ? '/people/import' : '/companies/import';
|
||||
const { data } = await api.post<ImportContactsResult>(path, form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
const sample = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
error.value = sample;
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
|
||||
} else {
|
||||
error.value = 'ایمپورت ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -251,17 +243,20 @@ onMounted(load);
|
||||
<span class="btn-full">خروجی اکسل</span>
|
||||
<span class="btn-short">خروجی</span>
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="pickImportFile">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
:preview-path="importPreviewPath"
|
||||
:import-path="importPath"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<UiButton variant="ghost" size="sm" :disabled="busy">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
</ExcelImportFlow>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
|
||||
|
||||
@@ -196,12 +196,12 @@ onMounted(load);
|
||||
<form v-else class="crm-form" @submit.prevent="submit">
|
||||
<UiCard>
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام" required for-id="firstName">
|
||||
<UiInput id="firstName" v-model="firstName" required />
|
||||
<UiField label="نام" for-id="firstName">
|
||||
<UiInput id="firstName" v-model="firstName" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="نام خانوادگی" required for-id="lastName">
|
||||
<UiInput id="lastName" v-model="lastName" required />
|
||||
<UiField label="نام خانوادگی" for-id="lastName">
|
||||
<UiInput id="lastName" v-model="lastName" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="موبایل" for-id="mobile">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
moveStageToIndex,
|
||||
updateDealStage,
|
||||
} from '@/lib/pipeline-board';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
@@ -47,7 +48,6 @@ const deleteStageId = ref<string | null>(null);
|
||||
const stageBusy = ref(false);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const draggingId = ref<string | null>(null);
|
||||
const draggingColumnId = ref<string | null>(null);
|
||||
@@ -390,43 +390,35 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/deals/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedPipelineId.value) {
|
||||
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ایمپورت ناموفق بود');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedPipelineId.value) {
|
||||
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
function onMenuDeal(dealId: string) {
|
||||
@@ -472,16 +464,19 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/deals/import/preview"
|
||||
import-path="/deals/import"
|
||||
:disabled="loading || busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
</ExcelImportFlow>
|
||||
</template>
|
||||
</PipelineToolbar>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UiPagination,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ProductFormView from '@/views/ProductFormView.vue';
|
||||
import type { ImportContactsResult, ProductDto } from '@mento/shared';
|
||||
@@ -41,7 +42,6 @@ const moreOpen = ref(false);
|
||||
const productModal = ref(false);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const categories = computed(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -187,45 +187,32 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
function onImportStart() {
|
||||
closeMenus();
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/products/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string | string[] })?.message ??
|
||||
'ایمپورت ناموفق بود',
|
||||
)
|
||||
: 'ایمپورت ناموفق بود';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function toggleActive(product: ProductDto) {
|
||||
@@ -270,16 +257,17 @@ onMounted(load);
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/products/import/preview"
|
||||
import-path="/products/import"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy">ورود از اکسل</UiButton>
|
||||
</ExcelImportFlow>
|
||||
<UiButton variant="primary" @click="productModal = true">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
محصول جدید
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
moveStageToIndex,
|
||||
updateDealStage,
|
||||
} from '@/lib/pipeline-board';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
@@ -50,7 +51,6 @@ const deleteStageId = ref<string | null>(null);
|
||||
const stageBusy = ref(false);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const draggingId = ref<string | null>(null);
|
||||
const draggingColumnId = ref<string | null>(null);
|
||||
@@ -387,45 +387,35 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>(
|
||||
'/project-cards/import',
|
||||
form,
|
||||
{ headers: { 'Content-Type': undefined } },
|
||||
);
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedBoardId.value) {
|
||||
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ایمپورت ناموفق بود');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedBoardId.value) {
|
||||
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
function onMenuCard(cardId: string) {
|
||||
@@ -470,16 +460,19 @@ onBeforeUnmount(() => {
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/project-cards/import/preview"
|
||||
import-path="/project-cards/import"
|
||||
:disabled="loading || busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
</ExcelImportFlow>
|
||||
</template>
|
||||
</PipelineToolbar>
|
||||
|
||||
|
||||
@@ -850,6 +850,25 @@ export interface ImportContactsResult {
|
||||
boards?: string[];
|
||||
}
|
||||
|
||||
export interface ExcelFieldOption {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface ExcelColumnPreview {
|
||||
header: string;
|
||||
index: number;
|
||||
samples: string[];
|
||||
}
|
||||
|
||||
export interface ExcelPreviewResult {
|
||||
columns: ExcelColumnPreview[];
|
||||
fields: ExcelFieldOption[];
|
||||
suggested: Record<string, string>;
|
||||
needsMapping: boolean;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
id: string;
|
||||
kind: "product" | "service";
|
||||
|
||||
Reference in New Issue
Block a user