From 4776d1a3c36ec30fdc84b54080668f5629889917 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 19 Sep 2026 15:30:31 +0330 Subject: [PATCH] Edit import excel --- .../activities/activities-excel.service.ts | 16 +- .../src/activities/activities.controller.ts | 14 +- apps/api/src/common/excel.ts | 117 ++++++++- apps/api/src/contacts/companies.controller.ts | 9 +- .../src/contacts/contacts-excel.service.ts | 55 ++++- apps/api/src/contacts/dto.ts | 10 +- apps/api/src/contacts/people.controller.ts | 11 +- apps/api/src/contacts/people.service.ts | 4 +- apps/api/src/pipelines/deals-excel.service.ts | 16 +- apps/api/src/pipelines/deals.controller.ts | 16 +- .../src/products/products-excel.service.ts | 11 +- apps/api/src/products/products.controller.ts | 15 +- .../project-boards.controller.ts | 14 +- .../project-cards-excel.service.ts | 16 +- apps/web/src/components/ExcelImportFlow.vue | 224 ++++++++++++++++++ apps/web/src/views/ActivitiesView.vue | 82 +++---- apps/web/src/views/ContactsView.vue | 89 ++++--- apps/web/src/views/PersonFormView.vue | 8 +- apps/web/src/views/PipelineBoardView.vue | 83 +++---- apps/web/src/views/ProductsSettingsView.vue | 78 +++--- apps/web/src/views/ProjectBoardView.vue | 85 +++---- packages/shared/src/index.ts | 19 ++ 22 files changed, 731 insertions(+), 261 deletions(-) create mode 100644 apps/web/src/components/ExcelImportFlow.vue diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts index c236d25..e9629b1 100644 --- a/apps/api/src/activities/activities-excel.service.ts +++ b/apps/api/src/activities/activities-excel.service.ts @@ -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 { - const rows = await readMappedRows(file, ACTIVITY_ALIASES); + preview(file: Express.Multer.File): Promise { + return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title'])); + } + + async import( + actor: Actor, + file: Express.Multer.File, + mappingRaw?: string, + ): Promise { + 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++) { diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 35436a6..5093296 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -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') diff --git a/apps/api/src/common/excel.ts b/apps/api/src/common/excel.ts index ba4a9c5..baff4bb 100644 --- a/apps/api/src/common/excel.ts +++ b/apps/api/src/common/excel.ts @@ -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; + needsMapping: boolean; +}; + +export function excelFieldsFromColumns( + columns: Array<{ header: string; key: string }>, + requiredKeys: string[] = [], +): ExcelFieldOption[] { + const seen = new Set(); + 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 | 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 = {}; + for (const [header, field] of Object.entries(parsed as Record)) { + if (typeof field === 'string' && field.trim()) mapping[header] = field.trim(); + } + return mapping; + } catch { + throw new BadRequestException('نگاشت ستون‌ها نامعتبر است'); + } +} + +function resolveFieldKey( + raw: string, + aliases: Record, + mapping?: Record, +) { + 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, + fields: ExcelFieldOption[], +): Promise { + const sheet = await workbookFromUpload(file); + const headerRow = sheet.getRow(1); + const columns: ExcelColumnPreview[] = []; + const suggested: Record = {}; + const used = new Set(); + + 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, + mapping?: Record, ): Promise[]> { 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( - 'ستون‌های فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.', + 'ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.', ); } diff --git a/apps/api/src/contacts/companies.controller.ts b/apps/api/src/contacts/companies.controller.ts index 81601db..c35251a 100644 --- a/apps/api/src/contacts/companies.controller.ts +++ b/apps/api/src/contacts/companies.controller.ts @@ -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') diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index 00f83fd..106f5aa 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -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 = { ستاره: '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 = { name: 'name', نام: 'name', @@ -126,6 +142,16 @@ const COMPANY_ALIASES: Record = { یادداشت: '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 { + return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS); + } + + previewCompanies(file: Express.Multer.File): Promise { + return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS); + } + async importPeople( actor: Actor, file: Express.Multer.File, + mappingRaw?: string, ): Promise { - 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 { - 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++) { diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index 7725719..3070c35 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -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; diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index 0a145cf..b8a43f8 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -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') diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index d0a3044..81f7d6d 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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), diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index 0b78551..f442f6c 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -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 { - const rows = await readMappedRows(file, DEAL_ALIASES); + preview(file: Express.Multer.File): Promise { + return previewExcel(file, DEAL_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title'])); + } + + async import( + actor: Actor, + file: Express.Multer.File, + mappingRaw?: string, + ): Promise { + const rows = await readMappedRows(file, DEAL_ALIASES, parseExcelMapping(mappingRaw)); const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; const affected = new Map(); diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts index 73c785a..bc843b9 100644 --- a/apps/api/src/pipelines/deals.controller.ts +++ b/apps/api/src/pipelines/deals.controller.ts @@ -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') diff --git a/apps/api/src/products/products-excel.service.ts b/apps/api/src/products/products-excel.service.ts index f4f4a22..ee19ff0 100644 --- a/apps/api/src/products/products-excel.service.ts +++ b/apps/api/src/products/products-excel.service.ts @@ -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 { + return previewExcel(file, PRODUCT_ALIASES, excelFieldsFromColumns(PRODUCT_COLUMNS, ['name'])); + } + async import( organizationId: string, file: Express.Multer.File, + mappingRaw?: string, ): Promise { - 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++) { diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts index 8a40462..3f7caf8 100644 --- a/apps/api/src/products/products.controller.ts +++ b/apps/api/src/products/products.controller.ts @@ -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') diff --git a/apps/api/src/project-boards/project-boards.controller.ts b/apps/api/src/project-boards/project-boards.controller.ts index 0e57516..97d7b34 100644 --- a/apps/api/src/project-boards/project-boards.controller.ts +++ b/apps/api/src/project-boards/project-boards.controller.ts @@ -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') diff --git a/apps/api/src/project-boards/project-cards-excel.service.ts b/apps/api/src/project-boards/project-cards-excel.service.ts index ed1ca1c..d9ba0ce 100644 --- a/apps/api/src/project-boards/project-cards-excel.service.ts +++ b/apps/api/src/project-boards/project-cards-excel.service.ts @@ -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 { - const rows = await readMappedRows(file, CASE_ALIASES); + preview(file: Express.Multer.File): Promise { + return previewExcel(file, CASE_ALIASES, excelFieldsFromColumns(CASE_COLUMNS, ['title'])); + } + + async import( + actor: Actor, + file: Express.Multer.File, + mappingRaw?: string, + ): Promise { + const rows = await readMappedRows(file, CASE_ALIASES, parseExcelMapping(mappingRaw)); const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; const affected = new Map(); diff --git a/apps/web/src/components/ExcelImportFlow.vue b/apps/web/src/components/ExcelImportFlow.vue new file mode 100644 index 0000000..3544443 --- /dev/null +++ b/apps/web/src/components/ExcelImportFlow.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index be7664c..0e176c6 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -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(null); const busy = ref(false); const importMsg = ref(''); -const fileInput = ref(null); const counts = ref>({ 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('/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(() => { خروجی اکسل خروجی - - ورود از اکسل - ورود - - + + + ورود از اکسل + ورود + + فعالیت جدید diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index 4cce79e..77ee2e6 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -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 | null>(null); const companies = ref | null>(null); -const fileInput = ref(null); const menuId = ref(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(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); خروجی اکسل خروجی - - ورود از اکسل - ورود - - + + + ورود از اکسل + ورود + +
- - + + - - + + diff --git a/apps/web/src/views/PipelineBoardView.vue b/apps/web/src/views/PipelineBoardView.vue index 97a0752..b565f85 100644 --- a/apps/web/src/views/PipelineBoardView.vue +++ b/apps/web/src/views/PipelineBoardView.vue @@ -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(null); const stageBusy = ref(false); const busy = ref(false); const importMsg = ref(''); -const fileInput = ref(null); const draggingId = ref(null); const draggingColumnId = ref(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('/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 }); - - + + + diff --git a/apps/web/src/views/ProductsSettingsView.vue b/apps/web/src/views/ProductsSettingsView.vue index 0ed7f31..8eeded2 100644 --- a/apps/web/src/views/ProductsSettingsView.vue +++ b/apps/web/src/views/ProductsSettingsView.vue @@ -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(null); const categories = computed(() => { const set = new Set(); @@ -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('/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); خروجی اکسل - - ورود از اکسل - - + + ورود از اکسل + محصول جدید diff --git a/apps/web/src/views/ProjectBoardView.vue b/apps/web/src/views/ProjectBoardView.vue index e482403..bb7b1e4 100644 --- a/apps/web/src/views/ProjectBoardView.vue +++ b/apps/web/src/views/ProjectBoardView.vue @@ -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(null); const stageBusy = ref(false); const busy = ref(false); const importMsg = ref(''); -const fileInput = ref(null); const draggingId = ref(null); const draggingColumnId = ref(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( - '/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(() => { - - + + + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c2d6f5c..0893249 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; + needsMapping: boolean; +} + export interface ProductDto { id: string; kind: "product" | "service";