diff --git a/apps/api/prisma/migrations/20260927150000_deal_assignees/migration.sql b/apps/api/prisma/migrations/20260927150000_deal_assignees/migration.sql new file mode 100644 index 0000000..955dd21 --- /dev/null +++ b/apps/api/prisma/migrations/20260927150000_deal_assignees/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "_DealAssignees" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_DealAssignees_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_DealAssignees_B_index" ON "_DealAssignees"("B"); + +-- AddForeignKey +ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 766bad8..1be3989 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -224,6 +224,7 @@ model User { organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) ownedDeals Deal[] @relation("DealOwner") + assignedDeals Deal[] @relation("DealAssignees") ownedQuotes Quote[] @relation("QuoteOwner") ownedInvoices Invoice[] @relation("InvoiceOwner") ownedPeople Person[] @relation("PersonOwner") @@ -509,6 +510,7 @@ model Deal { company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) ownerId String? owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull) + assignees User[] @relation("DealAssignees") position Int @default(0) activities Activity[] smartNotes Note[] diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index c664e15..d5f603a 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -16,7 +16,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; -import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope'; +import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope'; import { normalizeTags } from '../common/tags'; type ActivityActor = Pick; @@ -460,7 +460,7 @@ export class ActivitiesService { } if (dealId) { const deal = await this.prisma.deal.findFirst({ - where: { id: dealId, organizationId, ...scope }, + where: { id: dealId, organizationId, ...dealAccessWhere(actor) }, select: { id: true }, }); if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست'); diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 9af0d11..fed59dd 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -48,6 +48,20 @@ export function ownerWhere( return { ownerId: { in: ids } }; } +/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */ +export function dealAccessWhere( + actor: Pick, +): Prisma.DealWhereInput { + const ids = scopedOwnerIds(actor); + if (!ids) return {}; + return { + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }; +} + /** فعالیت‌ها بر اساس مسئول */ export function assigneeWhere( actor: Pick, @@ -80,7 +94,14 @@ export function noteAccessWhere( { OR: [ { authorId: { in: ids } }, - { deal: { ownerId: { in: ids } } }, + { + deal: { + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }, + }, { person: { ownerId: { in: ids } } }, { company: { ownerId: { in: ids } } }, ], diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts index 1d31262..9e91668 100644 --- a/apps/api/src/notes/notes.service.ts +++ b/apps/api/src/notes/notes.service.ts @@ -22,6 +22,7 @@ import { type Actor, noteAccessWhere, ownerWhere, + dealAccessWhere, } from '../common/record-scope'; type NoteActor = Pick< @@ -579,7 +580,7 @@ export class NotesService { } if (dealId) { const deal = await this.prisma.deal.findFirst({ - where: { id: dealId, organizationId, ...scope }, + where: { id: dealId, organizationId, ...dealAccessWhere(actor) }, select: { id: true }, }); if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست'); diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index f442f6c..41bb106 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -3,7 +3,7 @@ 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 { dealAccessWhere, resolveOwnerId } from '../common/record-scope'; import { toJsonCustomFields } from '../common/custom-fields'; import { normalizePhone } from '../contacts/normalize'; import { @@ -102,7 +102,7 @@ export class DealsExcelService { async export(actor: Actor): Promise { const rows = await this.prisma.deal.findMany({ - where: { organizationId: actor.organizationId, ...ownerWhere(actor) }, + where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) }, include: { person: { select: { id: true, firstName: true, lastName: true, mobile: true }, diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 4468303..5ac88b8 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -21,6 +21,7 @@ import { WebhooksService } from '../public-api/webhooks.service'; import { type Actor, canReassignOwner, + dealAccessWhere, ownerWhere, resolveOwnerId, } from '../common/record-scope'; @@ -29,10 +30,14 @@ const dealInclude = { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, owner: { select: { id: true, fullName: true } }, + assignees: { select: { id: true, fullName: true } }, lineItems: { orderBy: { position: 'asc' as const } }, } satisfies Prisma.DealInclude; -type DealActor = Pick; +type DealActor = Pick< + Actor, + 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' +>; @Injectable() export class DealsService { @@ -93,6 +98,10 @@ export class DealsService { if (dto.lineItems?.length) { await this.assertProducts(organizationId, dto.lineItems); } + const assigneeIds = await this.normalizeAssigneeIds( + organizationId, + dto.assigneeIds, + ); const maxPos = await this.prisma.deal.aggregate({ where: { organizationId, stageId: stage.id }, @@ -133,6 +142,9 @@ export class DealsService { companyId: emptyToNull(dto.companyId ?? null), ownerId: resolveOwnerId(actor, dto.ownerId), position: (maxPos._max.position ?? -1) + 1, + ...(assigneeIds.length + ? { assignees: { connect: assigneeIds.map((id) => ({ id })) } } + : {}), ...(dto.lineItems?.length ? { lineItems: { @@ -219,6 +231,15 @@ export class DealsService { data.ownerId = resolveOwnerId(actor, dto.ownerId); } + let assigneeConnect: { id: string }[] | undefined; + if (dto.assigneeIds !== undefined) { + const assigneeIds = await this.normalizeAssigneeIds( + organizationId, + dto.assigneeIds, + ); + assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); + } + if (dto.lineItems !== undefined) { const total = sumLineItems(dto.lineItems); data.amount = @@ -248,7 +269,15 @@ export class DealsService { }); } } - await tx.deal.update({ where: { id }, data }); + await tx.deal.update({ + where: { id }, + data: { + ...data, + ...(assigneeConnect + ? { assignees: { set: assigneeConnect } } + : {}), + }, + }); }); const updated = await this.get(organizationId, id, actor); @@ -271,7 +300,7 @@ export class DealsService { dto: MoveDealRequest, ): Promise { const deal = await this.prisma.deal.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...dealAccessWhere(actor) }, select: { id: true, title: true, @@ -377,7 +406,7 @@ export class DealsService { actor: DealActor, ) { const found = await this.prisma.deal.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...dealAccessWhere(actor) }, select: { id: true }, }); if (!found) throw new NotFoundException('معامله پیدا نشد'); @@ -389,13 +418,30 @@ export class DealsService { actor: DealActor, ) { const row = await this.prisma.deal.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...dealAccessWhere(actor) }, include: dealInclude, }); if (!row) throw new NotFoundException('معامله پیدا نشد'); return row; } + private async normalizeAssigneeIds( + organizationId: string, + raw?: string[] | null, + ): Promise { + const ids = [ + ...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)), + ]; + if (!ids.length) return []; + const count = await this.prisma.user.count({ + where: { organizationId, id: { in: ids }, isActive: true }, + }); + if (count !== ids.length) { + throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست'); + } + return ids; + } + private async assertProducts( organizationId: string, items: DealLineItemInput[], diff --git a/apps/api/src/pipelines/dto.ts b/apps/api/src/pipelines/dto.ts index 3b73fe8..575d4e7 100644 --- a/apps/api/src/pipelines/dto.ts +++ b/apps/api/src/pipelines/dto.ts @@ -138,6 +138,11 @@ export class CreateDealDto { @IsString() ownerId?: string | null; + @IsOptional() + @IsArray() + @IsString({ each: true }) + assigneeIds?: string[]; + @IsOptional() @IsObject() customFields?: Record; @@ -216,6 +221,11 @@ export class UpdateDealDto { @IsString() ownerId?: string | null; + @IsOptional() + @IsArray() + @IsString({ each: true }) + assigneeIds?: string[]; + @IsOptional() @IsObject() customFields?: Record; diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts index 8096c78..d5fb47a 100644 --- a/apps/api/src/pipelines/pipelines.controller.ts +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -13,6 +13,7 @@ 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 { CreatePipelineDto, CreatePipelineStageDto, @@ -33,13 +34,10 @@ export class PipelinesController { @Get('board') board( - @CurrentUser() user: { organizationId: string; userId: string; role: string }, + @CurrentUser() user: Actor, @Query('pipelineId') pipelineId?: string, ) { - return this.pipelinesService.getBoard(user.organizationId, pipelineId, { - userId: user.userId, - role: user.role, - }); + return this.pipelinesService.getBoard(user.organizationId, pipelineId, user); } @Post() diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index 8275eb1..ca6b036 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -12,7 +12,7 @@ import type { import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { asCustomFields } from '../common/custom-fields'; -import { type Actor, ownerWhere } from '../common/record-scope'; +import { type Actor, dealAccessWhere } from '../common/record-scope'; import { decimalToBase, loadOrgRates } from '../common/currency'; const DEFAULT_STAGES = [ @@ -46,6 +46,7 @@ type DealRow = { person: { id: string; firstName: string; lastName: string } | null; company: { id: string; name: string } | null; owner: { id: string; fullName: string } | null; + assignees?: Array<{ id: string; fullName: string }>; lineItems?: Array<{ id: string; productId: string | null; @@ -236,7 +237,10 @@ export class PipelinesService { async getBoard( organizationId: string, pipelineId?: string, - actor?: Pick, + actor?: Pick< + Actor, + 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' + >, ): Promise { const pipeline = pipelineId ? await this.prisma.pipeline.findFirst({ @@ -261,12 +265,13 @@ export class PipelinesService { where: { organizationId, pipelineId: resolved.id, - ...(actor ? ownerWhere(actor) : {}), + ...(actor ? dealAccessWhere(actor) : {}), }, include: { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, owner: { select: { id: true, fullName: true } }, + assignees: { select: { id: true, fullName: true } }, }, orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }], }); @@ -332,6 +337,7 @@ export class PipelinesService { : null, company: row.company, owner: row.owner, + assignees: row.assignees ?? [], lineItems: (row.lineItems ?? []).map((item) => { const quantity = Number(item.quantity); const unitPrice = Number(item.unitPrice); diff --git a/apps/api/src/project-boards/project-boards.service.ts b/apps/api/src/project-boards/project-boards.service.ts index 63d54e3..2e5114b 100644 --- a/apps/api/src/project-boards/project-boards.service.ts +++ b/apps/api/src/project-boards/project-boards.service.ts @@ -16,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { type Actor, canReassignOwner, + dealAccessWhere, ownerWhere, resolveOwnerId, } from '../common/record-scope'; @@ -506,7 +507,7 @@ export class ProjectBoardsService { } if (dealId) { const d = await this.prisma.deal.findFirst({ - where: { id: dealId, organizationId, ...ownerWhere(actor) }, + where: { id: dealId, organizationId, ...dealAccessWhere(actor) }, select: { id: true }, }); if (!d) throw new BadRequestException('معامله معتبر نیست'); diff --git a/apps/web/src/components/SavedFiltersBar.vue b/apps/web/src/components/SavedFiltersBar.vue new file mode 100644 index 0000000..0c1ab85 --- /dev/null +++ b/apps/web/src/components/SavedFiltersBar.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 98120d9..2cc912f 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -32,6 +32,12 @@ const money = computed(() => formatCurrencyParts(props.deal.amount ?? 0, props.deal.currencyUnit ?? 'تومان'), ); const colors = computed(() => PIPELINE_ACCENT[props.accent]); +const assigneeTitle = computed(() => + (props.deal.assigneeNames ?? []).filter(Boolean).join('، ') || 'مسئول', +); +const extraAssignees = computed(() => + Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1), +); + +

{{ error }}

{{ importMsg }}

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

diff --git a/docs/وضعیت-پیشرفت.md b/docs/وضعیت-پیشرفت.md index 1096734..7c0c01f 100644 --- a/docs/وضعیت-پیشرفت.md +++ b/docs/وضعیت-پیشرفت.md @@ -14,6 +14,7 @@ | تاریخ | مورد | |---|---| +| ۱۴۰۵/۷/۵ | چند مسئول روی معاملهٔ کاریز + ذخیره فیلتر در اشخاص و کاریز | | ۱۴۰۵/۷/۵ | صیقل Smart Notes: یادداشت شخصی/تیمی + تاریخچه ویرایش و بازگردانی | | ۱۴۰۵/۶/۲۰ | پورسانت کارشناس (درصد پیش‌فرض + تغییر روی فاکتور) و دفتر چک وصل به پرداخت | | ۱۴۰۵/۶/۱۹ | انبار داخلی + مانده طرف‌حساب وقتی ته‌حساب وصل نیست؛ خروج کالا با صدور فاکتور | diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 63b7279..bd2814e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -774,6 +774,8 @@ export interface DealDto { person: PersonSummaryDto | null; company: CompanySummaryDto | null; owner: UserSummaryDto | null; + /** مسئولان مشترک معامله (علاوه بر مالک اصلی) */ + assignees: UserSummaryDto[]; lineItems: DealLineItemDto[]; createdAt: string; updatedAt: string; @@ -820,6 +822,7 @@ export interface CreateDealRequest { personId?: string | null; companyId?: string | null; ownerId?: string | null; + assigneeIds?: string[]; customFields?: Record; lineItems?: DealLineItemInput[]; } @@ -838,10 +841,27 @@ export interface UpdateDealRequest { personId?: string | null; companyId?: string | null; ownerId?: string | null; + assigneeIds?: string[]; customFields?: Record; lineItems?: DealLineItemInput[]; } +/** فیلتر ذخیره‌شدهٔ لیست اشخاص */ +export interface PeopleViewFilters { + q?: string; + sort?: string; + mood?: string; + page?: number; +} + +/** فیلتر ذخیره‌شدهٔ کاریز / معاملات */ +export interface DealsViewFilters { + q?: string; + amountMin?: string; + amountMax?: string; + pipelineId?: string; + viewMode?: string; +} export interface MoveDealRequest { stageId: string; position?: number;