From e61fad95c642c266a1cbc9a4373b60a4358b2b26 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 15 Sep 2026 19:13:30 +0330 Subject: [PATCH] Editing of the project's Kariz page --- apps/api/src/common/excel.ts | 10 + apps/api/src/pipelines/deals-excel.service.ts | 319 ++++++ apps/api/src/pipelines/deals.controller.ts | 55 +- apps/api/src/pipelines/pipelines.module.ts | 3 +- .../src/products/products-excel.service.ts | 172 ++++ apps/api/src/products/products.controller.ts | 45 +- apps/api/src/products/products.module.ts | 3 +- apps/api/src/project-boards/dto.ts | 22 + .../project-boards.controller.ts | 53 + .../project-boards/project-boards.service.ts | 108 ++ .../project-cards-excel.service.ts | 16 +- .../src/components/pipeline/AddDealButton.vue | 14 +- apps/web/src/components/pipeline/DealCard.vue | 37 +- .../components/pipeline/DealCardOverlay.vue | 21 +- .../src/components/pipeline/PipelineBoard.vue | 20 +- .../components/pipeline/PipelineColumn.vue | 33 +- .../src/components/pipeline/PipelineList.vue | 29 +- .../components/pipeline/PipelineToolbar.vue | 64 +- apps/web/src/components/ui/UiFilterBar.vue | 5 +- apps/web/src/lib/pipeline-board.ts | 87 +- apps/web/src/views/PipelineBoardView.vue | 88 +- apps/web/src/views/ProductsSettingsView.vue | 88 +- apps/web/src/views/ProjectBoardView.vue | 935 +++++++++--------- apps/web/src/views/ProjectCardFormView.vue | 89 +- packages/shared/src/index.ts | 2 + 25 files changed, 1751 insertions(+), 567 deletions(-) create mode 100644 apps/api/src/pipelines/deals-excel.service.ts create mode 100644 apps/api/src/products/products-excel.service.ts diff --git a/apps/api/src/common/excel.ts b/apps/api/src/common/excel.ts index 96044a8..ba4a9c5 100644 --- a/apps/api/src/common/excel.ts +++ b/apps/api/src/common/excel.ts @@ -144,6 +144,16 @@ export function formatClock(d: Date | string | null | undefined) { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } +export function parseAmount(raw?: string): number | null { + const text = toAsciiDigits(raw ?? '') + .replace(/[,،]/g, '') + .replace(/\s+/g, '') + .trim(); + if (!text) return null; + const n = Number(text.replace(/[^\d.-]/g, '')); + return Number.isFinite(n) ? n : null; +} + export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null { const dateText = toAsciiDigits(dateRaw ?? '').trim(); if (!dateText) return null; diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts new file mode 100644 index 0000000..85d5fc6 --- /dev/null +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -0,0 +1,319 @@ +import { Injectable } from '@nestjs/common'; +import type { DealStatus, ImportContactsResult } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { Actor } from '../common/record-scope'; +import { ownerWhere, resolveOwnerId } from '../common/record-scope'; +import { toJsonCustomFields } from '../common/custom-fields'; +import { normalizePhone } from '../contacts/normalize'; +import { + formatJalaliDate, + parseAmount, + parseJalaliDateTime, + readMappedRows, + writeXlsx, +} from '../common/excel'; +import { PipelinesService } from './pipelines.service'; + +const DEAL_COLUMNS = [ + { header: '_type', key: '_type', width: 12 }, + { header: 'کد دیدار معامله', key: 'didarId', width: 16 }, + { header: 'عنوان معامله', key: 'title', width: 28 }, + { header: 'مبلغ معامله', key: 'amount', width: 16 }, + { header: 'ارز معامله', key: 'currency', width: 12 }, + { header: 'وضعیت معامله', key: 'status', width: 14 }, + { header: 'مسئول معامله', key: 'ownerName', width: 18 }, + { header: 'مجوز مشاهده معامله', key: 'visibility', width: 18 }, + { header: 'تاریخ ثبت معامله', key: 'createdAt', width: 16 }, + { header: 'نام خانوادگی شخص معامله', key: 'personName', width: 22 }, + { header: 'کد دیدار شخص', key: 'personCode', width: 14 }, + { header: 'موبایل شخص معامله', key: 'personMobile', width: 16 }, + { header: 'نام شرکت معامله', key: 'companyName', width: 22 }, + { header: 'کاریز معامله', key: 'pipelineName', width: 20 }, + { header: 'مرحله کاریز معامله', key: 'stageName', width: 18 }, + { header: 'تاریخ انجام معامله', key: 'expectedCloseAt', width: 16 }, + { header: 'منبع معامله', key: 'source', width: 16 }, + { header: 'توضیحات معامله', key: 'notes', width: 32 }, + { header: 'برچسب', key: 'tags', width: 16 }, +]; + +const DEAL_ALIASES: Record = { + _type: '_type', + type: '_type', + کددیدارمعامله: 'didarId', + عنوانمعامله: 'title', + عنوان: 'title', + مبلغمعامله: 'amount', + مبلغ: 'amount', + ارزمعامله: 'currency', + ارز: 'currency', + وضعیتمعامله: 'status', + مسئولمعامله: 'ownerName', + مجوزمشاهدهمعامله: 'visibility', + تاریخثبتمعامله: 'createdAt', + نامخانوادگیشخصمعامله: 'personName', + کددیدارشخص: 'personCode', + موبایلشخصمعامله: 'personMobile', + نامشرکتمعامله: 'companyName', + شرکت: 'companyName', + کاریزمعامله: 'pipelineName', + کاریز: 'pipelineName', + مرحلهکاریزمعامله: 'stageName', + مرحله: 'stageName', + تاریخانجاممعامله: 'expectedCloseAt', + منبعمعامله: 'source', + منبع: 'source', + توضیحاتمعامله: 'notes', + توضیحات: 'notes', + برچسب: 'tags', +}; + +function statusLabel(status: DealStatus) { + if (status === 'won') return 'برنده'; + if (status === 'lost') return 'از دست‌رفته'; + return 'باز'; +} + +function parseStatus(raw?: string): DealStatus { + const v = (raw ?? '').trim(); + if (/برنده|won|موفق/i.test(v)) return 'won'; + if (/از\s*دست|باخت|lost|ناموفق/i.test(v)) return 'lost'; + return 'open'; +} + +function parseTags(raw?: string) { + return (raw ?? '') + .split(/[،,|#]+/) + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 20); +} + +@Injectable() +export class DealsExcelService { + constructor( + private readonly prisma: PrismaService, + private readonly pipelines: PipelinesService, + ) {} + + async export(actor: Actor): Promise { + const rows = await this.prisma.deal.findMany({ + where: { organizationId: actor.organizationId, ...ownerWhere(actor) }, + include: { + person: { + select: { id: true, firstName: true, lastName: true, mobile: true }, + }, + company: { select: { name: true } }, + owner: { select: { fullName: true } }, + pipeline: { select: { name: true } }, + stage: { select: { name: true } }, + }, + orderBy: [{ updatedAt: 'desc' }], + take: 10000, + }); + + return writeXlsx( + 'Deal', + DEAL_COLUMNS, + rows.map((row) => { + const extra = (row.customFields ?? {}) as Record; + return { + _type: 'Deal', + didarId: extra.didarId ?? row.id, + title: row.title, + amount: row.amount == null ? '' : Number(row.amount), + currency: row.currency, + status: statusLabel(row.status), + ownerName: row.owner?.fullName ?? '', + visibility: 'همه افراد شرکت', + createdAt: formatJalaliDate(row.createdAt), + personName: row.person + ? `${row.person.firstName} ${row.person.lastName}`.trim() + : '', + personCode: row.person?.id ?? '', + personMobile: row.person?.mobile ?? '', + companyName: row.company?.name ?? '', + pipelineName: row.pipeline.name, + stageName: row.stage.name, + expectedCloseAt: formatJalaliDate(row.expectedCloseAt), + source: row.source ?? '', + notes: row.notes ?? '', + tags: (row.tags ?? []).join('، '), + }; + }), + ); + } + + async import(actor: Actor, file: Express.Multer.File): Promise { + const rows = await readMappedRows(file, DEAL_ALIASES); + const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; + const affected = new Map(); + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + const rowNum = i + 2; + if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'deal') { + continue; + } + const title = (row.title || '').trim(); + if (title.length < 2) { + result.errors.push({ row: rowNum, message: 'عنوان معامله الزامی است' }); + continue; + } + + try { + const status = parseStatus(row.status); + const { pipelineId, stageId, pipelineName } = await this.resolvePipelineAndStage( + actor.organizationId, + row.pipelineName, + row.stageName, + status, + ); + affected.set(pipelineId, pipelineName); + + const duplicate = await this.prisma.deal.findFirst({ + where: { organizationId: actor.organizationId, pipelineId, title }, + select: { id: true }, + }); + if (duplicate) { + result.skipped += 1; + continue; + } + + const personId = await this.findPersonId(actor.organizationId, row); + const companyId = await this.findCompanyId(actor.organizationId, row.companyName); + const ownerId = + (await this.findUserId(actor.organizationId, row.ownerName)) ?? + resolveOwnerId(actor, null); + const amount = parseAmount(row.amount); + const expectedCloseAt = parseJalaliDateTime(row.expectedCloseAt); + const customFields: Record = {}; + if (row.didarId?.trim()) customFields.didarId = row.didarId.trim(); + + await this.prisma.deal.create({ + data: { + organizationId: actor.organizationId, + pipelineId, + stageId, + title, + amount: amount == null ? null : new Prisma.Decimal(amount), + currency: (row.currency || 'IRR').trim() || 'IRR', + status, + source: row.source?.trim() || null, + notes: row.notes?.trim() || null, + tags: parseTags(row.tags), + customFields: toJsonCustomFields(customFields), + expectedCloseAt, + personId, + companyId, + ownerId, + }, + }); + result.created += 1; + } catch (e) { + result.errors.push({ + row: rowNum, + message: e instanceof Error ? e.message : 'خطای ناشناخته', + }); + } + } + + const entries = [...affected.entries()]; + if (entries[0]) { + result.boardId = entries[0][0]; + result.boards = entries.map(([, n]) => n); + } + return result; + } + + private async resolvePipelineAndStage( + organizationId: string, + pipelineName?: string, + stageName?: string, + status: DealStatus = 'open', + ) { + const name = pipelineName?.trim() || 'کاریز فروش'; + const wanted = stageName?.trim(); + let pipeline = await this.prisma.pipeline.findFirst({ + where: { organizationId, name: { equals: name, mode: 'insensitive' } }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + if (!pipeline) { + const created = await this.pipelines.create(organizationId, name); + pipeline = await this.prisma.pipeline.findFirstOrThrow({ + where: { id: created.id }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + } + + if (wanted) { + const existing = pipeline.stages.find( + (s) => s.name.trim().toLowerCase() === wanted.toLowerCase(), + ); + if (existing) { + return { pipelineId: pipeline.id, stageId: existing.id, pipelineName: pipeline.name }; + } + const added = await this.pipelines.createStage(organizationId, pipeline.id, wanted); + const stage = added.stages.find((s) => s.name === wanted) ?? added.stages.at(-1); + if (!stage) throw new Error('کاریز مرحله‌ای ندارد'); + return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name }; + } + + const byStatus = + status === 'won' + ? pipeline.stages.find((s) => s.isWon) + : status === 'lost' + ? pipeline.stages.find((s) => s.isLost) + : pipeline.stages.find((s) => !s.isWon && !s.isLost); + const stage = byStatus ?? pipeline.stages[0]; + if (!stage) throw new Error('کاریز مرحله‌ای ندارد'); + return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name }; + } + + private async findPersonId(organizationId: string, row: Record) { + const mobile = normalizePhone(row.personMobile); + if (mobile) { + const needle = mobile.replace(/^\+98|^98|^0/, ''); + const byMobile = await this.prisma.person.findFirst({ + where: { organizationId, mobile: { contains: needle } }, + select: { id: true }, + }); + if (byMobile) return byMobile.id; + } + const name = (row.personName ?? '').trim(); + if (name) { + const byName = await this.prisma.person.findFirst({ + where: { + organizationId, + OR: [ + { lastName: { contains: name, mode: 'insensitive' } }, + { firstName: { contains: name.split(/\s+/)[0]!, mode: 'insensitive' } }, + ], + }, + select: { id: true }, + }); + if (byName) return byName.id; + } + return null; + } + + private async findCompanyId(organizationId: string, name?: string) { + const n = name?.trim(); + if (!n) return null; + const row = await this.prisma.company.findFirst({ + where: { organizationId, name: { equals: n, mode: 'insensitive' } }, + select: { id: true }, + }); + return row?.id ?? null; + } + + private async findUserId(organizationId: string, name?: string) { + const n = name?.trim(); + if (!n) return null; + const row = await this.prisma.user.findFirst({ + where: { organizationId, fullName: { contains: n, mode: 'insensitive' } }, + select: { id: true }, + }); + return row?.id ?? null; + } +} diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts index 168786c..73c785a 100644 --- a/apps/api/src/pipelines/deals.controller.ts +++ b/apps/api/src/pipelines/deals.controller.ts @@ -6,33 +6,72 @@ import { Param, Patch, Post, + Res, + StreamableFile, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto'; import { DealsService } from './deals.service'; - -type AuthUser = { organizationId: string; userId: string; role: string }; +import { DealsExcelService } from './deals-excel.service'; +import { brand } from '@mento/shared'; @Controller('deals') @UseGuards(JwtAuthGuard) export class DealsController { - constructor(private readonly dealsService: DealsService) {} + constructor( + private readonly dealsService: DealsService, + private readonly excelService: DealsExcelService, + ) {} + + @Get('export') + async export( + @CurrentUser() user: Actor, + @Res({ passthrough: true }) res: Response, + ) { + const buffer = await this.excelService.export(user); + res.set({ + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${brand.filePrefix}-deals.xlsx"`, + }); + return new StreamableFile(buffer); + } + + @Post('import') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15_000_000 }, + }), + ) + import( + @CurrentUser() user: Actor, + @UploadedFile() file: Express.Multer.File, + ) { + return this.excelService.import(user, file); + } @Get(':id') - get(@CurrentUser() user: AuthUser, @Param('id') id: string) { + get(@CurrentUser() user: Actor, @Param('id') id: string) { return this.dealsService.get(user.organizationId, id, user); } @Post() - create(@CurrentUser() user: AuthUser, @Body() dto: CreateDealDto) { + create(@CurrentUser() user: Actor, @Body() dto: CreateDealDto) { return this.dealsService.create(user.organizationId, user, dto); } @Patch(':id') update( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: UpdateDealDto, ) { @@ -41,7 +80,7 @@ export class DealsController { @Patch(':id/move') move( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: MoveDealDto, ) { @@ -49,7 +88,7 @@ export class DealsController { } @Delete(':id') - async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + async remove(@CurrentUser() user: Actor, @Param('id') id: string) { await this.dealsService.remove(user.organizationId, id, user); return { ok: true }; } diff --git a/apps/api/src/pipelines/pipelines.module.ts b/apps/api/src/pipelines/pipelines.module.ts index 734d2cb..49f6c53 100644 --- a/apps/api/src/pipelines/pipelines.module.ts +++ b/apps/api/src/pipelines/pipelines.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { DealsController } from './deals.controller'; import { DealsService } from './deals.service'; +import { DealsExcelService } from './deals-excel.service'; import { PipelinesController } from './pipelines.controller'; import { PipelinesService } from './pipelines.service'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -16,7 +17,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat MessageAutomationsModule, ], controllers: [PipelinesController, DealsController], - providers: [PipelinesService, DealsService], + providers: [PipelinesService, DealsService, DealsExcelService], exports: [PipelinesService, DealsService], }) export class PipelinesModule {} diff --git a/apps/api/src/products/products-excel.service.ts b/apps/api/src/products/products-excel.service.ts new file mode 100644 index 0000000..f4f4a22 --- /dev/null +++ b/apps/api/src/products/products-excel.service.ts @@ -0,0 +1,172 @@ +import { Injectable } from '@nestjs/common'; +import type { ImportContactsResult } from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; +import { + parseAmount, + readMappedRows, + writeXlsx, +} from '../common/excel'; +import { ProductsService } from './products.service'; + +const PRODUCT_COLUMNS = [ + { header: '_type', key: '_type', width: 12 }, + { header: 'نوع', key: 'kind', width: 12 }, + { header: 'نام محصول', key: 'name', width: 28 }, + { header: 'کد کالا', key: 'sku', width: 16 }, + { header: 'دسته‌بندی', key: 'category', width: 16 }, + { header: 'واحد', key: 'unit', width: 10 }, + { header: 'موجودی', key: 'stockQty', width: 12 }, + { header: 'برند', key: 'brand', width: 14 }, + { header: 'مدل', key: 'model', width: 14 }, + { header: 'کشور', key: 'country', width: 12 }, + { header: 'قیمت واحد', key: 'unitPrice', width: 14 }, + { header: 'ارز', key: 'currency', width: 10 }, + { header: 'وضعیت', key: 'active', width: 12 }, + { header: 'برچسب', key: 'tags', width: 16 }, + { header: 'توضیحات', key: 'description', width: 32 }, +]; + +const PRODUCT_ALIASES: Record = { + _type: '_type', + type: '_type', + نوع: 'kind', + ناممحصول: 'name', + نام: 'name', + نامکالا: 'name', + کدکالا: 'sku', + sku: 'sku', + کد: 'sku', + دستهبندی: 'category', + دسته: 'category', + واحد: 'unit', + موجودی: 'stockQty', + برند: 'brand', + مدل: 'model', + کشور: 'country', + قیمتواحد: 'unitPrice', + قیمت: 'unitPrice', + ارز: 'currency', + وضعیت: 'active', + فعال: 'active', + برچسب: 'tags', + توضیحات: 'description', +}; + +function kindLabel(kind: string) { + return kind === 'service' ? 'خدمت' : 'کالا'; +} + +function parseKind(raw?: string): 'product' | 'service' { + return /خدمت|service/i.test(raw ?? '') ? 'service' : 'product'; +} + +function parseActive(raw?: string) { + const v = (raw ?? '').trim(); + if (!v) return true; + return !/غیرفعال|inactive|خیر|no|0/i.test(v); +} + +function parseTags(raw?: string) { + return (raw ?? '') + .split(/[،,|#]+/) + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 20); +} + +@Injectable() +export class ProductsExcelService { + constructor( + private readonly prisma: PrismaService, + private readonly products: ProductsService, + ) {} + + async export(organizationId: string, q?: string): Promise { + const items = await this.products.list(organizationId, { q }); + return writeXlsx( + 'Product', + PRODUCT_COLUMNS, + items.map((p) => ({ + _type: 'Product', + kind: kindLabel(p.kind), + name: p.name, + sku: p.sku ?? '', + category: p.category ?? '', + unit: p.unit ?? '', + stockQty: p.stockQty ?? '', + brand: p.brand ?? '', + model: p.model ?? '', + country: p.country ?? '', + unitPrice: p.unitPrice ?? '', + currency: p.currency, + active: p.isActive ? 'فعال' : 'غیرفعال', + tags: (p.tags ?? []).join('، '), + description: p.description ?? '', + })), + ); + } + + async import( + organizationId: string, + file: Express.Multer.File, + ): Promise { + const rows = await readMappedRows(file, PRODUCT_ALIASES); + const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + const rowNum = i + 2; + if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'product') { + continue; + } + const name = (row.name || '').trim(); + if (name.length < 1) { + result.errors.push({ row: rowNum, message: 'نام محصول الزامی است' }); + continue; + } + + const sku = row.sku?.trim() || undefined; + const duplicate = await this.prisma.product.findFirst({ + where: { + organizationId, + OR: [ + ...(sku ? [{ sku: { equals: sku, mode: 'insensitive' as const } }] : []), + { name: { equals: name, mode: 'insensitive' } }, + ], + }, + select: { id: true }, + }); + if (duplicate) { + result.skipped += 1; + continue; + } + + try { + await this.products.create(organizationId, { + name, + kind: parseKind(row.kind), + sku, + category: row.category || undefined, + unit: row.unit || undefined, + stockQty: parseAmount(row.stockQty), + brand: row.brand || undefined, + model: row.model || undefined, + country: row.country || undefined, + tags: parseTags(row.tags), + unitPrice: parseAmount(row.unitPrice), + currency: row.currency || undefined, + isActive: parseActive(row.active), + description: row.description || undefined, + }); + result.created += 1; + } catch (e) { + result.errors.push({ + row: rowNum, + message: e instanceof Error ? e.message : 'خطای ناشناخته', + }); + } + } + + return result; + } +} diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts index 24a91d4..8a40462 100644 --- a/apps/api/src/products/products.controller.ts +++ b/apps/api/src/products/products.controller.ts @@ -7,19 +7,32 @@ import { Patch, Post, Query, + Res, + StreamableFile, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; +import type { Actor } from '../common/record-scope'; import { CreateProductDto, ListProductsQueryDto, UpdateProductDto } from './dto'; import { ProductsService } from './products.service'; +import { ProductsExcelService } from './products-excel.service'; +import { brand } from '@mento/shared'; @Controller('products') @UseGuards(JwtAuthGuard, RolesGuard) export class ProductsController { - constructor(private readonly productsService: ProductsService) {} + constructor( + private readonly productsService: ProductsService, + private readonly excelService: ProductsExcelService, + ) {} @Get() list( @@ -34,6 +47,36 @@ export class ProductsController { }); } + @Get('export') + async export( + @CurrentUser() user: Actor, + @Query('q') q: string | undefined, + @Res({ passthrough: true }) res: Response, + ) { + const buffer = await this.excelService.export(user.organizationId, q); + res.set({ + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${brand.filePrefix}-products.xlsx"`, + }); + return new StreamableFile(buffer); + } + + @Post('import') + @Roles('owner', 'admin') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15_000_000 }, + }), + ) + import( + @CurrentUser() user: Actor, + @UploadedFile() file: Express.Multer.File, + ) { + return this.excelService.import(user.organizationId, file); + } + @Get(':id') getOne( @CurrentUser() user: { organizationId: string }, diff --git a/apps/api/src/products/products.module.ts b/apps/api/src/products/products.module.ts index dc5a015..d877025 100644 --- a/apps/api/src/products/products.module.ts +++ b/apps/api/src/products/products.module.ts @@ -3,11 +3,12 @@ import { AuthModule } from '../auth/auth.module'; import { InventoryModule } from '../inventory/inventory.module'; import { ProductsController } from './products.controller'; import { ProductsService } from './products.service'; +import { ProductsExcelService } from './products-excel.service'; @Module({ imports: [AuthModule, InventoryModule], controllers: [ProductsController], - providers: [ProductsService], + providers: [ProductsService, ProductsExcelService], exports: [ProductsService], }) export class ProductsModule {} diff --git a/apps/api/src/project-boards/dto.ts b/apps/api/src/project-boards/dto.ts index d1c2d43..4783932 100644 --- a/apps/api/src/project-boards/dto.ts +++ b/apps/api/src/project-boards/dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; import { + IsArray, IsNumber, IsOptional, IsString, @@ -16,6 +17,27 @@ export class CreateProjectBoardDto { name!: string; } +export class CreateProjectBoardStageDto { + @IsString() + @MinLength(1) + @MaxLength(80) + name!: string; +} + +export class UpdateProjectBoardStageDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(80) + name?: string; +} + +export class ReorderProjectBoardStagesDto { + @IsArray() + @IsString({ each: true }) + stageIds!: string[]; +} + export class CreateProjectCardDto { @IsString() @MinLength(2) diff --git a/apps/api/src/project-boards/project-boards.controller.ts b/apps/api/src/project-boards/project-boards.controller.ts index aa5ef25..0e57516 100644 --- a/apps/api/src/project-boards/project-boards.controller.ts +++ b/apps/api/src/project-boards/project-boards.controller.ts @@ -23,8 +23,11 @@ import { RolesGuard } from '../auth/roles.guard'; import type { Actor } from '../common/record-scope'; import { CreateProjectBoardDto, + CreateProjectBoardStageDto, CreateProjectCardDto, MoveProjectCardDto, + ReorderProjectBoardStagesDto, + UpdateProjectBoardStageDto, UpdateProjectCardDto, } from './dto'; import { ProjectBoardsService } from './project-boards.service'; @@ -61,6 +64,56 @@ export class ProjectBoardsController { return this.boards.create(user.organizationId, dto.name); } + @Post('project-boards/:boardId/stages') + @Roles('owner', 'admin') + createStage( + @CurrentUser() user: Actor, + @Param('boardId') boardId: string, + @Body() dto: CreateProjectBoardStageDto, + ) { + return this.boards.createStage(user.organizationId, boardId, dto.name); + } + + @Patch('project-boards/:boardId/stages/order') + @Roles('owner', 'admin') + reorderStages( + @CurrentUser() user: Actor, + @Param('boardId') boardId: string, + @Body() dto: ReorderProjectBoardStagesDto, + ) { + return this.boards.reorderStages( + user.organizationId, + boardId, + dto.stageIds, + ); + } + + @Patch('project-boards/:boardId/stages/:stageId') + @Roles('owner', 'admin') + updateStage( + @CurrentUser() user: Actor, + @Param('boardId') boardId: string, + @Param('stageId') stageId: string, + @Body() dto: UpdateProjectBoardStageDto, + ) { + return this.boards.updateStage( + user.organizationId, + boardId, + stageId, + dto.name, + ); + } + + @Delete('project-boards/:boardId/stages/:stageId') + @Roles('owner', 'admin') + deleteStage( + @CurrentUser() user: Actor, + @Param('boardId') boardId: string, + @Param('stageId') stageId: string, + ) { + return this.boards.deleteStage(user.organizationId, boardId, stageId); + } + @Get('project-cards/export') async exportCards( @CurrentUser() user: Actor, diff --git a/apps/api/src/project-boards/project-boards.service.ts b/apps/api/src/project-boards/project-boards.service.ts index 2880148..cc49849 100644 --- a/apps/api/src/project-boards/project-boards.service.ts +++ b/apps/api/src/project-boards/project-boards.service.ts @@ -299,6 +299,114 @@ export class ProjectBoardsService { await this.prisma.projectCard.delete({ where: { id } }); } + async createStage( + organizationId: string, + boardId: string, + name: string, + ): Promise { + const board = await this.getOwnedBoard(organizationId, boardId); + const trimmed = name.trim(); + if (!trimmed) throw new BadRequestException('نام ستون لازم است'); + await this.prisma.projectBoardStage.create({ + data: { + boardId: board.id, + name: trimmed, + position: board.stages.length, + }, + }); + return this.getOwnedBoardDto(organizationId, board.id); + } + + async updateStage( + organizationId: string, + boardId: string, + stageId: string, + name?: string, + ): Promise { + const board = await this.getOwnedBoard(organizationId, boardId); + const stage = board.stages.find((s) => s.id === stageId); + if (!stage) throw new NotFoundException('ستون پیدا نشد'); + const trimmed = name?.trim() ?? ''; + if (!trimmed) throw new BadRequestException('نام ستون لازم است'); + await this.prisma.projectBoardStage.update({ + where: { id: stage.id }, + data: { name: trimmed }, + }); + return this.getOwnedBoardDto(organizationId, board.id); + } + + async deleteStage( + organizationId: string, + boardId: string, + stageId: string, + ): Promise { + const board = await this.getOwnedBoard(organizationId, boardId); + const stage = board.stages.find((s) => s.id === stageId); + if (!stage) throw new NotFoundException('ستون پیدا نشد'); + if (board.stages.length <= 1) { + throw new BadRequestException('حداقل یک ستون باید باقی بماند'); + } + const cardCount = await this.prisma.projectCard.count({ + where: { organizationId, stageId: stage.id }, + }); + if (cardCount > 0) { + throw new BadRequestException( + 'ابتدا کارت‌های این ستون را به ستون دیگری منتقل کنید', + ); + } + const remaining = board.stages.filter((s) => s.id !== stage.id); + await this.prisma.$transaction([ + this.prisma.projectBoardStage.delete({ where: { id: stage.id } }), + ...remaining.map((s, index) => + this.prisma.projectBoardStage.update({ + where: { id: s.id }, + data: { position: index }, + }), + ), + ]); + return this.getOwnedBoardDto(organizationId, board.id); + } + + async reorderStages( + organizationId: string, + boardId: string, + stageIds: string[], + ): Promise { + const board = await this.getOwnedBoard(organizationId, boardId); + const currentIds = board.stages.map((s) => s.id); + const unique = new Set(stageIds); + if ( + stageIds.length !== currentIds.length || + unique.size !== currentIds.length || + currentIds.some((id) => !unique.has(id)) + ) { + throw new BadRequestException('ترتیب ستون‌ها نامعتبر است'); + } + await this.prisma.$transaction( + stageIds.map((id, index) => + this.prisma.projectBoardStage.update({ + where: { id }, + data: { position: index }, + }), + ), + ); + return this.getOwnedBoardDto(organizationId, board.id); + } + + private async getOwnedBoard(organizationId: string, boardId: string) { + const board = await this.prisma.projectBoard.findFirst({ + where: { id: boardId, organizationId }, + include: { stages: { orderBy: { position: 'asc' as const } } }, + }); + if (!board) throw new NotFoundException('برد پیدا نشد'); + return board; + } + + private async getOwnedBoardDto(organizationId: string, boardId: string) { + const board = await this.getOwnedBoard(organizationId, boardId); + return this.toBoardDto(board); + } + toCardDto(row: { id: string; title: string; 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 4235508..7866c1b 100644 --- a/apps/api/src/project-boards/project-cards-excel.service.ts +++ b/apps/api/src/project-boards/project-cards-excel.service.ts @@ -108,6 +108,7 @@ export class ProjectCardsExcelService { async import(actor: Actor, file: Express.Multer.File): Promise { const rows = await readMappedRows(file, CASE_ALIASES); const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; + const affected = new Map(); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; @@ -122,12 +123,13 @@ export class ProjectCardsExcelService { } try { - const { boardId, stageId } = await this.resolveBoardAndStage( + const { boardId, stageId, boardName } = await this.resolveBoardAndStage( actor.organizationId, row.boardName, row.stageName, isClosedStatus(row.status), ); + affected.set(boardId, boardName); const duplicate = await this.prisma.projectCard.findFirst({ where: { organizationId: actor.organizationId, boardId, title }, select: { id: true }, @@ -165,6 +167,12 @@ export class ProjectCardsExcelService { } } + const entries = [...affected.entries()]; + if (entries[0]) { + result.boardId = entries[0][0]; + result.boards = entries.map(([, n]) => n); + } + return result; } @@ -204,19 +212,19 @@ export class ProjectCardsExcelService { const existing = board.stages.find( (s) => s.name.trim().toLowerCase() === wanted.toLowerCase(), ); - if (existing) return { boardId: board.id, stageId: existing.id }; + if (existing) return { boardId: board.id, stageId: existing.id, boardName: board.name }; const maxPos = board.stages.reduce((m, s) => Math.max(m, s.position), -1); const stage = await this.prisma.projectBoardStage.create({ data: { boardId: board.id, name: wanted, position: maxPos + 1 }, }); - return { boardId: board.id, stageId: stage.id }; + return { boardId: board.id, stageId: stage.id, boardName: board.name }; } const stage = closed ? board.stages[board.stages.length - 1] : board.stages[0]; if (!stage) throw new Error('برد مرحله‌ای ندارد'); - return { boardId: board.id, stageId: stage.id }; + return { boardId: board.id, stageId: stage.id, boardName: board.name }; } private async findPersonId(organizationId: string, row: Record) { diff --git a/apps/web/src/components/pipeline/AddDealButton.vue b/apps/web/src/components/pipeline/AddDealButton.vue index 2059594..cc0a102 100644 --- a/apps/web/src/components/pipeline/AddDealButton.vue +++ b/apps/web/src/components/pipeline/AddDealButton.vue @@ -4,9 +4,13 @@ import UiIcon from '@/components/UiIcon.vue'; import type { PipelineAccent } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; -defineProps<{ - accent: PipelineAccent; -}>(); +withDefaults( + defineProps<{ + accent: PipelineAccent; + label?: string; + }>(), + { label: 'افزودن معامله' }, +); const emit = defineEmits<{ add: []; @@ -19,10 +23,10 @@ const emit = defineEmits<{ block class="add-deal" :style="{ color: PIPELINE_ACCENT[accent].text }" - aria-label="افزودن معامله" + :aria-label="label" @click="emit('add')" > - افزودن معامله + {{ label }} diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 79cd301..a28b913 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -1,14 +1,22 @@ @@ -26,7 +37,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]); diff --git a/apps/web/src/components/pipeline/PipelineList.vue b/apps/web/src/components/pipeline/PipelineList.vue index b12b62c..a7b4535 100644 --- a/apps/web/src/components/pipeline/PipelineList.vue +++ b/apps/web/src/components/pipeline/PipelineList.vue @@ -1,15 +1,20 @@ - {{ opt.name }} - + - - - +
+ +
+ + + @@ -127,6 +145,25 @@ h1 { min-width: 8.5rem; } +.excel-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + flex: 0 0 auto; +} + +.excel-actions :deep(.btn-ghost-sm) { + border: 1px solid #e2e8f0; + background: #fff; + color: #0f766e; + border-radius: 10px; + padding: 0.45rem 0.75rem; + font-weight: 700; + font-size: 0.85rem; + cursor: pointer; + font: inherit; +} + .filters { flex: 1 1 12rem; min-width: 0; @@ -153,6 +190,7 @@ h1 { } .pipeline-pick, + .excel-actions, .filters { flex: 1 1 100%; } diff --git a/apps/web/src/components/ui/UiFilterBar.vue b/apps/web/src/components/ui/UiFilterBar.vue index 35c7223..acb7a68 100644 --- a/apps/web/src/components/ui/UiFilterBar.vue +++ b/apps/web/src/components/ui/UiFilterBar.vue @@ -7,8 +7,9 @@ import UiIcon from '@/components/UiIcon.vue'; withDefaults( defineProps<{ placeholder?: string; + showFilters?: boolean; }>(), - { placeholder: 'جستجو...' }, + { placeholder: 'جستجو...', showFilters: true }, ); const search = defineModel('search', { default: '' }); @@ -20,7 +21,7 @@ const open = ref(false);
- + فیلترها diff --git a/apps/web/src/lib/pipeline-board.ts b/apps/web/src/lib/pipeline-board.ts index 15fe2ec..32a8625 100644 --- a/apps/web/src/lib/pipeline-board.ts +++ b/apps/web/src/lib/pipeline-board.ts @@ -4,7 +4,7 @@ export type PipelineDeal = { id: string; title: string; description: string; - amount: number; + amount: number | null; date: string; initials: string; /** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */ @@ -12,6 +12,35 @@ export type PipelineDeal = { currencyUnit?: string; }; +export type PipelineUiCopy = { + itemNoun: string; + addItem: string; + empty: string; + addItemAria: string; + itemMenuAria: string; + listColTitle: string; + hideAmount?: boolean; +}; + +export const DEAL_BOARD_COPY: PipelineUiCopy = { + itemNoun: 'معامله', + addItem: 'افزودن معامله', + empty: 'معامله‌ای نیست', + addItemAria: 'افزودن معامله به این مرحله', + itemMenuAria: 'گزینه‌های معامله', + listColTitle: 'معامله', +}; + +export const PROJECT_BOARD_COPY: PipelineUiCopy = { + itemNoun: 'کارت', + addItem: 'افزودن کارت', + empty: 'کارتی نیست', + addItemAria: 'افزودن کارت به این مرحله', + itemMenuAria: 'گزینه‌های کارت', + listColTitle: 'کارت', + hideAmount: true, +}; + export type PipelineStage = { id: string; title: string; @@ -152,6 +181,48 @@ export function mapBoardToStages( }); } +export function mapProjectBoardToStages( + board: { + stages: Array<{ + id: string; + name: string; + cards: Array<{ + id: string; + title: string; + notes: string | null; + stageId: string; + createdAt: string; + person: { fullName: string } | null; + company: { name: string } | null; + deal: { title: string } | null; + }>; + }>; + }, + formatDate: (iso: string) => string, +): PipelineStage[] { + return board.stages.map((stage, index) => ({ + id: stage.id, + title: stage.name, + color: ACCENT_CYCLE[index % ACCENT_CYCLE.length]!, + deals: stage.cards.map((card) => ({ + id: card.id, + title: card.title, + description: + card.company?.name || + card.person?.fullName || + card.deal?.title || + card.notes?.trim() || + '', + amount: null, + date: formatDate(card.createdAt), + initials: initialsFromTitle( + card.company?.name || card.person?.fullName || card.title, + ), + stageId: card.stageId, + })), + })); +} + /** * جابه‌جایی معامله بین مراحل یا تغییر ترتیب داخل یک مرحله. @@ -241,15 +312,23 @@ export function filterStages( ) { return false; } - if (min != null && !Number.isNaN(min) && d.amount < min) return false; - if (max != null && !Number.isNaN(max) && d.amount > max) return false; + if (min != null && !Number.isNaN(min) && (d.amount == null || d.amount < min)) { + return false; + } + if (max != null && !Number.isNaN(max) && (d.amount == null || d.amount > max)) { + return false; + } return true; }), })); } +export function toFaItemCount(n: number, noun = 'معامله'): string { + return `${new Intl.NumberFormat('fa-IR').format(n)} ${noun}`; +} + export function toFaDealCount(n: number): string { - return `${new Intl.NumberFormat('fa-IR').format(n)} معامله`; + return toFaItemCount(n, 'معامله'); } /** جابه‌جایی یک ستون به ایندکس جدید */ diff --git a/apps/web/src/views/PipelineBoardView.vue b/apps/web/src/views/PipelineBoardView.vue index 556ffaa..74e186d 100644 --- a/apps/web/src/views/PipelineBoardView.vue +++ b/apps/web/src/views/PipelineBoardView.vue @@ -20,7 +20,8 @@ import UiButton from '@/components/ui/UiButton.vue'; import UiInput from '@/components/ui/UiInput.vue'; import UiModal from '@/components/ui/UiModal.vue'; import DealFormView from '@/views/DealFormView.vue'; -import type { PipelineBoardDto, PipelineDto } from '@mento/shared'; +import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared'; +import { brand } from '@mento/shared'; import { useAuthStore } from '@/stores/auth'; import { canManageSettings } from '@/lib/roles'; @@ -44,6 +45,9 @@ const newName = ref(''); const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null); 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); @@ -367,6 +371,64 @@ function onOpenDeal(dealId: string) { void router.push({ name: 'deal-detail', params: { id: dealId } }); } +async function exportExcel() { + busy.value = true; + importMsg.value = ''; + error.value = ''; + try { + const { data } = await api.get('/deals/export', { responseType: 'blob' }); + const url = URL.createObjectURL(data); + const a = document.createElement('a'); + a.href = url; + a.download = `${brand.filePrefix}-deals.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } catch { + error.value = 'خروجی اکسل ناموفق بود.'; + } finally { + busy.value = false; + } +} + +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; + 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; + } +} + function onMenuDeal(dealId: string) { menuOpenId.value = menuOpenId.value === dealId ? null : dealId; } @@ -405,9 +467,26 @@ defineExpose({ updateDealStage: applyUpdateDealStage }); v-model:amount-max="amountMax" :can-create="canCreatePipeline" @create="showCreate = true" - /> + > + +

{{ error }}

+

{{ importMsg }}

در حال بارگذاری کاریز…

(null); const sortOpen = ref(false); 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(); @@ -161,6 +165,70 @@ async function load() { } } +async function exportExcel() { + closeMenus(); + busy.value = true; + importMsg.value = ''; + error.value = ''; + try { + const { data } = await api.get('/products/export', { + params: { q: q.value || undefined }, + responseType: 'blob', + }); + const url = URL.createObjectURL(data); + const a = document.createElement('a'); + a.href = url; + a.download = `${brand.filePrefix}-products.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } catch { + error.value = 'خروجی اکسل ناموفق بود.'; + } finally { + busy.value = false; + } +} + +function pickImportFile() { + 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 toggleActive(product: ProductDto) { closeMenus(); await api.patch(`/products/${product.id}`, { isActive: !product.isActive }); @@ -208,6 +276,19 @@ onMounted(load);
+ + + محصول جدید @@ -269,6 +350,7 @@ onMounted(load);

{{ error }}

+

{{ importMsg }}

در حال بارگذاری…