From 3b128bd9809cbea3903eb3bb5eafe8e8d6eae547 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 3 Oct 2026 13:16:13 +0330 Subject: [PATCH] Selection of multiple officials and role sub-categories --- .../migration.sql | 17 + .../migration.sql | 35 ++ apps/api/prisma/schema.prisma | 9 + .../activities/activities-excel.service.ts | 19 +- apps/api/src/activities/activities.service.ts | 138 ++++++-- apps/api/src/activities/dto.ts | 10 + apps/api/src/common/record-scope.ts | 15 +- apps/api/src/org-roles/dto.ts | 11 + apps/api/src/org-roles/org-roles.service.ts | 184 +++++++++- apps/web/src/views/ActivitiesView.vue | 6 +- apps/web/src/views/ActivityDetailView.vue | 51 ++- apps/web/src/views/ActivityFormView.vue | 170 +++++++++- apps/web/src/views/TeamSettingsView.vue | 315 ++++++++++++++++-- packages/shared/src/index.ts | 19 ++ 14 files changed, 904 insertions(+), 95 deletions(-) create mode 100644 apps/api/prisma/migrations/20261003140000_org_role_parent/migration.sql create mode 100644 apps/api/prisma/migrations/20261003150000_activity_assignees/migration.sql diff --git a/apps/api/prisma/migrations/20261003140000_org_role_parent/migration.sql b/apps/api/prisma/migrations/20261003140000_org_role_parent/migration.sql new file mode 100644 index 0000000..09a63d2 --- /dev/null +++ b/apps/api/prisma/migrations/20261003140000_org_role_parent/migration.sql @@ -0,0 +1,17 @@ +-- OrgRole hierarchy (parent / child sub-roles) +ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "parentId" TEXT; + +CREATE INDEX IF NOT EXISTS "OrgRole_parentId_idx" ON "OrgRole"("parentId"); +CREATE INDEX IF NOT EXISTS "OrgRole_organizationId_parentId_idx" ON "OrgRole"("organizationId", "parentId"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'OrgRole_parentId_fkey' + ) THEN + ALTER TABLE "OrgRole" + ADD CONSTRAINT "OrgRole_parentId_fkey" + FOREIGN KEY ("parentId") REFERENCES "OrgRole"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/api/prisma/migrations/20261003150000_activity_assignees/migration.sql b/apps/api/prisma/migrations/20261003150000_activity_assignees/migration.sql new file mode 100644 index 0000000..11c790b --- /dev/null +++ b/apps/api/prisma/migrations/20261003150000_activity_assignees/migration.sql @@ -0,0 +1,35 @@ +-- Multi-assignee support for activities (implicit many-to-many) +CREATE TABLE IF NOT EXISTS "_ActivityAssignees" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "_ActivityAssignees_AB_unique" ON "_ActivityAssignees"("A", "B"); +CREATE INDEX IF NOT EXISTS "_ActivityAssignees_B_index" ON "_ActivityAssignees"("B"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_A_fkey' + ) THEN + ALTER TABLE "_ActivityAssignees" + ADD CONSTRAINT "_ActivityAssignees_A_fkey" + FOREIGN KEY ("A") REFERENCES "Activity"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_B_fkey' + ) THEN + ALTER TABLE "_ActivityAssignees" + ADD CONSTRAINT "_ActivityAssignees_B_fkey" + FOREIGN KEY ("B") REFERENCES "User"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Backfill from legacy single assigneeId +INSERT INTO "_ActivityAssignees" ("A", "B") +SELECT a."id", a."assigneeId" +FROM "Activity" a +WHERE a."assigneeId" IS NOT NULL +ON CONFLICT DO NOTHING; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ef01db9..ec4b0e0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -246,6 +246,7 @@ model User { ownedCompanies Company[] @relation("CompanyOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") activities Activity[] @relation("ActivityAssignee") + assignedActivities Activity[] @relation("ActivityAssignees") createdActivities Activity[] @relation("ActivityCreator") notes Note[] @relation("NoteAuthor") noteRevisions NoteRevision[] @relation("NoteRevisionEditor") @@ -288,6 +289,10 @@ model OrgRole { permissions String[] @default([]) /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند systemKey String? + /// نقش والد برای سلسله‌مراتب زیرنقش‌ها + parentId String? + parent OrgRole? @relation("OrgRoleTree", fields: [parentId], references: [id], onDelete: Restrict) + children OrgRole[] @relation("OrgRoleTree") users User[] memberships Membership[] createdAt DateTime @default(now()) @@ -295,6 +300,8 @@ model OrgRole { @@unique([organizationId, name]) @@index([organizationId]) + @@index([parentId]) + @@index([organizationId, parentId]) } model Membership { @@ -579,6 +586,8 @@ model Activity { deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) assigneeId String? assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + /// مسئولان مشترک (علاوه بر assigneeId به‌عنوان مسئول اصلی) + assignees User[] @relation("ActivityAssignees") createdById String? createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) notes Note[] diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts index e9629b1..9440d17 100644 --- a/apps/api/src/activities/activities-excel.service.ts +++ b/apps/api/src/activities/activities-excel.service.ts @@ -109,6 +109,7 @@ export class ActivitiesExcelService { company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, assignee: { select: { fullName: true } }, + assignees: { select: { fullName: true } }, createdBy: { select: { fullName: true } }, }, orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }], @@ -124,7 +125,9 @@ export class ActivitiesExcelService { title: row.title, body: row.description ?? '', result: '', - assignee: row.assignee?.fullName ?? '', + assignee: row.assignees?.length + ? row.assignees.map((a) => a.fullName).join('، ') + : (row.assignee?.fullName ?? ''), creator: row.createdBy?.fullName ?? '', dueDate: formatJalaliDate(row.dueAt), dueTime: formatClock(row.dueAt), @@ -182,8 +185,17 @@ export class ActivitiesExcelService { const personId = await this.findPersonId(actor.organizationId, row); const companyId = await this.findCompanyId(actor.organizationId, row.companyName); const dealId = await this.findDealId(actor.organizationId, row.dealTitle); - const assigneeId = - (await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId; + const assigneeNames = String(row.assignee ?? '') + .split(/[,،]/) + .map((s) => s.trim()) + .filter(Boolean); + const assigneeIds: string[] = []; + for (const name of assigneeNames) { + const uid = await this.findUserId(actor.organizationId, name); + if (uid && !assigneeIds.includes(uid)) assigneeIds.push(uid); + } + if (!assigneeIds.length) assigneeIds.push(actor.userId); + const assigneeId = assigneeIds[0]!; const creatorId = (await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId; @@ -218,6 +230,7 @@ export class ActivitiesExcelService { companyId, dealId, assigneeId, + assignees: { connect: assigneeIds.map((id) => ({ id })) }, createdById: creatorId, }, }); diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 9abb672..f5779b6 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -41,6 +41,7 @@ type ActivityRow = { company: { id: string; name: string } | null; deal: { id: string; title: string } | null; assignee: { id: string; fullName: string } | null; + assignees: Array<{ id: string; fullName: string }>; createdBy: { id: string; fullName: string } | null; }; @@ -49,6 +50,7 @@ const include = { company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, assignee: { select: { id: true, fullName: true } }, + assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } }, createdBy: { select: { id: true, fullName: true } }, } satisfies Prisma.ActivityInclude; @@ -186,8 +188,15 @@ export class ActivitiesService { ): Promise { const linkActor = actor ?? { userId, role: 'owner' }; await this.assertLinks(organizationId, linkActor, dto); - const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId; - if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); + const assigneeIds = await this.normalizeAssigneeIds( + organizationId, + dto.assigneeIds?.length + ? dto.assigneeIds + : dto.assigneeId + ? [dto.assigneeId] + : [userId], + ); + const assigneeId = assigneeIds[0] ?? userId; const row = await this.prisma.activity.create({ data: { @@ -206,15 +215,18 @@ export class ActivitiesService { companyId: emptyToNull(dto.companyId ?? null), dealId: emptyToNull(dto.dealId ?? null), assigneeId, + assignees: assigneeIds.length + ? { connect: assigneeIds.map((id) => ({ id })) } + : undefined, createdById: userId, }, include, }); - if (assigneeId) { + for (const uid of assigneeIds) { await this.notifications.create({ organizationId, - userId: assigneeId, + userId: uid, type: 'activity_assigned', title: 'فعالیت جدید برای شما', body: row.title, @@ -262,10 +274,23 @@ export class ActivitiesService { if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); - if ('assigneeId' in dto) { + let assigneeConnect: Array<{ id: string }> | undefined; + if (dto.assigneeIds !== undefined) { + const assigneeIds = await this.normalizeAssigneeIds( + organizationId, + dto.assigneeIds, + ); + if (!assigneeIds.length) { + throw new BadRequestException('حداقل یک مسئول لازم است'); + } + data.assigneeId = assigneeIds[0]!; + assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); + } else if ('assigneeId' in dto) { const assigneeId = emptyToNull(dto.assigneeId ?? null); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); data.assigneeId = assigneeId; + if (assigneeId) assigneeConnect = [{ id: assigneeId }]; + else assigneeConnect = []; } if (dto.status !== undefined) { data.status = dto.status; @@ -279,7 +304,12 @@ export class ActivitiesService { }); const row = await this.prisma.activity.update({ where: { id }, - data, + data: { + ...data, + ...(assigneeConnect + ? { assignees: { set: assigneeConnect } } + : {}), + }, include, }); if (dto.status === 'done' && existing?.status !== 'done') { @@ -340,37 +370,55 @@ export class ActivitiesService { }, ): Prisma.ActivityWhereInput { const { start, end } = dayBounds(); - const scope = - desk === 'mine' ? { assigneeId: actor.userId } : assigneeWhere(actor); - const facetWhere: Prisma.ActivityWhereInput = { - ...(facets?.type ? { type: facets.type } : {}), - ...(facets?.status ? { status: facets.status } : {}), - ...(facets?.assigneeId?.trim() ? { assigneeId: facets.assigneeId.trim() } : {}), - }; - const base: Prisma.ActivityWhereInput = { - organizationId, - ...scope, - ...facetWhere, - ...(entity?.personId ? { personId: entity.personId } : {}), - ...(entity?.companyId ? { companyId: entity.companyId } : {}), - ...(entity?.dealId ? { dealId: entity.dealId } : {}), - ...(q?.trim() + const scope: Prisma.ActivityWhereInput = + desk === 'mine' ? { OR: [ - { title: { contains: q.trim(), mode: 'insensitive' } }, - { description: { contains: q.trim(), mode: 'insensitive' } }, - { - person: { - OR: [ - { firstName: { contains: q.trim(), mode: 'insensitive' } }, - { lastName: { contains: q.trim(), mode: 'insensitive' } }, - ], - }, - }, - { deal: { title: { contains: q.trim(), mode: 'insensitive' } } }, + { assigneeId: actor.userId }, + { assignees: { some: { id: actor.userId } } }, ], } - : {}), + : assigneeWhere(actor); + const facetAssignee = facets?.assigneeId?.trim(); + const andParts: Prisma.ActivityWhereInput[] = [ + scope, + ...(facets?.type ? [{ type: facets.type }] : []), + ...(facets?.status ? [{ status: facets.status }] : []), + ...(facetAssignee + ? [ + { + OR: [ + { assigneeId: facetAssignee }, + { assignees: { some: { id: facetAssignee } } }, + ], + }, + ] + : []), + ...(entity?.personId ? [{ personId: entity.personId }] : []), + ...(entity?.companyId ? [{ companyId: entity.companyId }] : []), + ...(entity?.dealId ? [{ dealId: entity.dealId }] : []), + ]; + if (q?.trim()) { + const needle = q.trim(); + andParts.push({ + OR: [ + { title: { contains: needle, mode: 'insensitive' } }, + { description: { contains: needle, mode: 'insensitive' } }, + { + person: { + OR: [ + { firstName: { contains: needle, mode: 'insensitive' } }, + { lastName: { contains: needle, mode: 'insensitive' } }, + ], + }, + }, + { deal: { title: { contains: needle, mode: 'insensitive' } } }, + ], + }); + } + const base: Prisma.ActivityWhereInput = { + organizationId, + AND: andParts, }; const fromDate = range?.from ? new Date(range.from) : null; @@ -517,11 +565,35 @@ export class ActivitiesService { company: row.company, deal: row.deal, assignee: row.assignee, + assignees: (() => { + const list = [...(row.assignees ?? [])]; + if (row.assignee && !list.some((a) => a.id === row.assignee!.id)) { + list.unshift(row.assignee); + } + return list; + })(), createdBy: row.createdBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } + + 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; + } } function emptyToNull(value?: string | null): string | null { diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 0076529..47f66c1 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -148,6 +148,11 @@ export class CreateActivityDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() assigneeId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + assigneeIds?: string[]; } export class UpdateActivityDto { @@ -223,4 +228,9 @@ export class UpdateActivityDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() assigneeId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + assigneeIds?: string[]; } diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index b6a0475..ae6b987 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -76,17 +76,18 @@ export function projectCardAccessWhere( }; } -/** فعالیت‌ها بر اساس مسئول */ +/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */ export function assigneeWhere( actor: Pick, -): - | { assigneeId: string } - | { assigneeId: { in: string[] } } - | Record { +): Prisma.ActivityWhereInput { const ids = scopedOwnerIds(actor); if (!ids) return {}; - if (ids.length === 1) return { assigneeId: ids[0]! }; - return { assigneeId: { in: ids } }; + return { + OR: [ + { assigneeId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }; } /** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */ diff --git a/apps/api/src/org-roles/dto.ts b/apps/api/src/org-roles/dto.ts index adc41e8..2561f38 100644 --- a/apps/api/src/org-roles/dto.ts +++ b/apps/api/src/org-roles/dto.ts @@ -7,6 +7,7 @@ import { IsString, MaxLength, MinLength, + ValidateIf, ValidateNested, } from 'class-validator'; import { ROLE_PERMISSION_ITEMS, type RolePermissionKey } from '@mento/shared'; @@ -34,6 +35,11 @@ export class CreateOrgRoleDto { @ArrayUnique() @IsIn(PERMISSION_KEYS, { each: true }) permissions?: RolePermissionKey[]; + + @IsOptional() + @ValidateIf((_, v) => v !== null) + @IsString() + parentId?: string | null; } export class UpdateOrgRoleDto { @@ -57,6 +63,11 @@ export class UpdateOrgRoleDto { @ArrayUnique() @IsIn(PERMISSION_KEYS, { each: true }) permissions?: RolePermissionKey[]; + + @IsOptional() + @ValidateIf((_, v) => v !== null) + @IsString() + parentId?: string | null; } export class RolePermissionPatchItemDto { diff --git a/apps/api/src/org-roles/org-roles.service.ts b/apps/api/src/org-roles/org-roles.service.ts index 3aa108b..27482b3 100644 --- a/apps/api/src/org-roles/org-roles.service.ts +++ b/apps/api/src/org-roles/org-roles.service.ts @@ -16,6 +16,9 @@ import { systemKeyToUserRole, } from './role-defaults'; +/** حداکثر عمق درخت نقش (۰ = ریشه) */ +const MAX_ROLE_DEPTH = 4; + type RoleRow = { id: string; name: string; @@ -23,6 +26,8 @@ type RoleRow = { color: string; permissions: string[]; systemKey: string | null; + parentId: string | null; + createdAt: Date; memberships: Array<{ user: { id: string; @@ -30,6 +35,7 @@ type RoleRow = { avatarStorageKey: string | null; }; }>; + _count?: { children: number }; }; const ROLE_MEMBERS_INCLUDE = { @@ -39,6 +45,7 @@ const ROLE_MEMBERS_INCLUDE = { }, orderBy: { createdAt: 'asc' as const }, }, + _count: { select: { children: true } }, }; @Injectable() @@ -52,7 +59,7 @@ export class OrgRolesService { include: ROLE_MEMBERS_INCLUDE, orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }], }); - return rows.map((row) => this.toDto(row)); + return this.toOrderedDtos(rows); } async create( @@ -63,6 +70,7 @@ export class OrgRolesService { description?: string; color?: string; permissions?: RolePermissionKey[]; + parentId?: string | null; }, ): Promise { this.assertManager(actorRole); @@ -70,18 +78,33 @@ export class OrgRolesService { if (name.length < 2) { throw new BadRequestException('نام نقش باید حداقل دو حرف باشد'); } + + const parentId = dto.parentId?.trim() || null; + let parent: { id: string; color: string; permissions: string[] } | null = + null; + if (parentId) { + parent = await this.assertValidParent(organizationId, parentId, null); + } + + const permissions = dto.permissions?.length + ? sanitizePermissions(dto.permissions) + : parent + ? sanitizePermissions(parent.permissions) + : [...ALL_ROLE_PERMISSIONS]; + try { const row = await this.prisma.orgRole.create({ data: { organizationId, name, description: (dto.description ?? '').trim(), - color: dto.color ?? 'teal', - permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS], + color: dto.color ?? parent?.color ?? 'teal', + permissions, + parentId, }, include: ROLE_MEMBERS_INCLUDE, }); - return this.toDto(row); + return (await this.list(organizationId)).find((r) => r.id === row.id)!; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('نقشی با این نام از قبل وجود دارد'); @@ -99,6 +122,7 @@ export class OrgRolesService { description?: string; color?: string; permissions?: RolePermissionKey[]; + parentId?: string | null; }, ): Promise { this.assertManager(actorRole); @@ -117,15 +141,38 @@ export class OrgRolesService { } if (dto.description !== undefined) data.description = dto.description.trim(); if (dto.color !== undefined) data.color = dto.color; - if (dto.permissions !== undefined) data.permissions = sanitizePermissions(dto.permissions); + if (dto.permissions !== undefined) { + data.permissions = sanitizePermissions(dto.permissions); + } + + if (dto.parentId !== undefined) { + const nextParentId = + dto.parentId === null || dto.parentId === '' + ? null + : String(dto.parentId).trim(); + if (existing.systemKey && nextParentId) { + throw new BadRequestException('نقش سیستمی نمی‌تواند زیرمجموعهٔ نقش دیگری باشد'); + } + if (nextParentId === existing.id) { + throw new BadRequestException('نقش نمی‌تواند والد خودش باشد'); + } + if (nextParentId) { + await this.assertValidParent(organizationId, nextParentId, existing.id); + data.parent = { connect: { id: nextParentId } }; + } else if (!existing.systemKey) { + data.parent = { disconnect: true }; + } + } try { - const row = await this.prisma.orgRole.update({ + await this.prisma.orgRole.update({ where: { id: existing.id }, data, - include: ROLE_MEMBERS_INCLUDE, }); - return this.toDto(row); + const listed = await this.list(organizationId); + const found = listed.find((r) => r.id === id); + if (!found) throw new NotFoundException('نقش پیدا نشد'); + return found; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('نقشی با این نام از قبل وجود دارد'); @@ -156,11 +203,17 @@ export class OrgRolesService { this.assertManager(actorRole); const existing = await this.prisma.orgRole.findFirst({ where: { id, organizationId }, + include: { _count: { select: { children: true } } }, }); if (!existing) throw new NotFoundException('نقش پیدا نشد'); if (existing.systemKey) { throw new BadRequestException('نقش‌های سیستمی قابل حذف نیستند'); } + if (existing._count.children > 0) { + throw new BadRequestException( + 'ابتدا زیرنقش‌های این نقش را منتقل یا حذف کنید', + ); + } await this.prisma.orgRole.delete({ where: { id: existing.id } }); return { ok: true }; } @@ -180,6 +233,7 @@ export class OrgRolesService { color: seed.color, permissions: seed.permissions, systemKey: seed.systemKey, + parentId: null, }, }); byKey.set(seed.systemKey, created); @@ -234,7 +288,115 @@ export class OrgRolesService { } } - private toDto(row: RoleRow): OrgRoleDto { + /** + * Validates parent exists in org, depth limit, and no cycles when moving `movingId`. + */ + private async assertValidParent( + organizationId: string, + parentId: string, + movingId: string | null, + ): Promise<{ id: string; color: string; permissions: string[] }> { + const parent = await this.prisma.orgRole.findFirst({ + where: { id: parentId, organizationId }, + select: { id: true, color: true, permissions: true, parentId: true }, + }); + if (!parent) { + throw new BadRequestException('نقش والد پیدا نشد'); + } + + const all = await this.prisma.orgRole.findMany({ + where: { organizationId }, + select: { id: true, parentId: true }, + }); + const parentById = new Map(all.map((r) => [r.id, r.parentId])); + + // Depth of parent + 1 for the new/moved node + let depth = 0; + let cursor: string | null = parent.id; + const seen = new Set(); + while (cursor) { + if (seen.has(cursor)) { + throw new BadRequestException('ساختار نقش‌ها حلقه دارد'); + } + seen.add(cursor); + depth += 1; + cursor = parentById.get(cursor) ?? null; + if (depth > MAX_ROLE_DEPTH + 1) break; + } + if (depth > MAX_ROLE_DEPTH) { + throw new BadRequestException( + `حداکثر عمق زیرنقش ${MAX_ROLE_DEPTH + 1} سطح است`, + ); + } + + if (movingId) { + // Cannot set parent to a descendant of movingId + const descendants = new Set(); + const queue = [movingId]; + while (queue.length) { + const current = queue.shift()!; + for (const row of all) { + if (row.parentId === current && !descendants.has(row.id)) { + descendants.add(row.id); + queue.push(row.id); + } + } + } + if (descendants.has(parentId)) { + throw new BadRequestException( + 'نمی‌توان نقش را زیرمجموعهٔ زیرنقش خودش قرار داد', + ); + } + } + + return parent; + } + + private toOrderedDtos(rows: RoleRow[]): OrgRoleDto[] { + const byParent = new Map(); + for (const row of rows) { + const key = row.parentId ?? null; + const list = byParent.get(key) ?? []; + list.push(row); + byParent.set(key, list); + } + for (const list of byParent.values()) { + list.sort((a, b) => { + const ak = a.systemKey ? `0-${a.systemKey}` : `1-${a.name}`; + const bk = b.systemKey ? `0-${b.systemKey}` : `1-${b.name}`; + const byKey = ak.localeCompare(bk, 'fa'); + if (byKey !== 0) return byKey; + return a.createdAt.getTime() - b.createdAt.getTime(); + }); + } + + const nameById = new Map(rows.map((r) => [r.id, r.name])); + const out: OrgRoleDto[] = []; + + const walk = (parentId: string | null, depth: number) => { + const children = byParent.get(parentId) ?? []; + for (const row of children) { + out.push(this.toDto(row, depth, nameById)); + walk(row.id, depth + 1); + } + }; + walk(null, 0); + + // Orphans (broken parent pointer) — append at end + const included = new Set(out.map((r) => r.id)); + for (const row of rows) { + if (!included.has(row.id)) { + out.push(this.toDto(row, 0, nameById)); + } + } + return out; + } + + private toDto( + row: RoleRow, + depth: number, + nameById: Map, + ): OrgRoleDto { const systemKey = row.systemKey === 'owner' || row.systemKey === 'admin' || @@ -249,6 +411,10 @@ export class OrgRolesService { color: row.color, permissions: sanitizePermissions(row.permissions), systemKey, + parentId: row.parentId, + parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null, + depth, + childCount: row._count?.children ?? 0, userCount: row.memberships.length, members: row.memberships.map((membership) => ({ id: membership.user.id, diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 706c7f8..0f3ba43 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -717,7 +717,11 @@ onUnmounted(() => { {{ dayText(item) }}
- {{ item.assignee?.fullName || '—' }} + {{ + item.assignees?.length + ? item.assignees.map((a) => a.fullName).join('، ') + : item.assignee?.fullName || '—' + }} {{ item.company?.name || item.person?.fullName }} diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 5e7c082..f733110 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -30,7 +30,18 @@ const commentOpen = ref(false); const postponeOpen = ref(false); const noteOpen = ref(true); const users = ref([]); -const assignUserId = ref(''); +const assignUserIds = ref([]); + +const assigneeNames = computed(() => { + const a = activity.value; + if (!a) return '—'; + const list = a.assignees?.length + ? a.assignees.map((u) => u.fullName) + : a.assignee + ? [a.assignee.fullName] + : []; + return list.length ? list.join('، ') : '—'; +}); const typeLabel: Record = { call: 'تماس', @@ -95,7 +106,11 @@ async function load() { try { const { data } = await api.get(`/activities/${id.value}`); activity.value = data; - assignUserId.value = data.assignee?.id ?? ''; + assignUserIds.value = data.assignees?.length + ? data.assignees.map((u) => u.id) + : data.assignee?.id + ? [data.assignee.id] + : []; } catch { error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.'; activity.value = null; @@ -144,11 +159,16 @@ async function reopen() { async function reassign() { if (!id.value || !canReassign.value) return; + if (!assignUserIds.value.length) { + error.value = 'حداقل یک مسئول لازم است.'; + return; + } saving.value = true; error.value = ''; try { await api.patch(`/activities/${id.value}`, { - assigneeId: assignUserId.value || null, + assigneeIds: [...assignUserIds.value], + assigneeId: assignUserIds.value[0] || null, }); await load(); } catch { @@ -271,25 +291,25 @@ onMounted(() => {
- مسئول + مسئولان
@@ -586,6 +606,7 @@ dd { .person { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.45rem; } @@ -600,6 +621,18 @@ dd { background: #fff; } +.assign-select.multi { + min-height: 5.5rem; + padding: 0.35rem; +} + +.assign-hint { + display: block; + margin-top: 0.3rem; + color: #94a3b8; + font-size: 0.75rem; +} + .flag.low, .priority.low { color: #64748b; diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index eea5649..1a5c672 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -77,7 +77,7 @@ const dueTime = ref('10:30'); const durationMinutes = ref(30); const personId = ref(''); const dealId = ref(''); -const assigneeId = ref(''); +const assigneeIds = ref([]); const isDone = ref(false); const needsNote = ref(false); const reminderAt = ref(''); @@ -109,7 +109,17 @@ const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: str const typeMeta = computed(() => typeOptions.find((o) => o.value === type.value)); const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null); -const selectedAssignee = computed(() => users.value.find((u) => u.id === assigneeId.value) ?? null); +const selectedAssignees = computed(() => + assigneeIds.value + .map((id) => users.value.find((u) => u.id === id)) + .filter((u): u is (typeof users.value)[number] => Boolean(u)), +); +const assigneePickerLabel = computed(() => { + const list = selectedAssignees.value; + if (!list.length) return 'انتخاب مسئول'; + if (list.length === 1) return list[0]!.fullName; + return `${list[0]!.fullName} و ${toFaDigits(list.length - 1)} نفر دیگر`; +}); const selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null); const personDeals = computed(() => deals.value.filter((d) => !personId.value || d.person?.id === personId.value), @@ -161,7 +171,7 @@ function resetForm() { durationMinutes.value = 30; personId.value = ''; dealId.value = ''; - assigneeId.value = auth.user?.id ?? ''; + assigneeIds.value = auth.user?.id ? [auth.user.id] : []; isDone.value = false; needsNote.value = false; reminderAt.value = ''; @@ -213,7 +223,13 @@ async function load() { reminderNotifySms.value = data.reminderNotifySms ?? false; personId.value = data.person?.id ?? ''; dealId.value = data.deal?.id ?? ''; - assigneeId.value = data.assignee?.id ?? auth.user?.id ?? ''; + assigneeIds.value = data.assignees?.length + ? data.assignees.map((a) => a.id) + : data.assignee?.id + ? [data.assignee.id] + : auth.user?.id + ? [auth.user.id] + : []; isDone.value = data.status === 'done'; } else { resetForm(); @@ -272,15 +288,26 @@ function payload(): CreateActivityRequest { personId: personId.value || null, companyId: selectedPerson.value?.company?.id ?? null, dealId: dealId.value || null, - assigneeId: assigneeId.value || null, + assigneeIds: [...assigneeIds.value], + assigneeId: assigneeIds.value[0] || null, }; } +function toggleAssignee(userId: string) { + const idx = assigneeIds.value.indexOf(userId); + if (idx >= 0) { + if (assigneeIds.value.length <= 1) return; + assigneeIds.value = assigneeIds.value.filter((id) => id !== userId); + return; + } + assigneeIds.value = [...assigneeIds.value, userId]; +} + function validate() { if (!type.value) return 'نوع فعالیت را انتخاب کنید.'; if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.'; if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.'; - if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.'; + if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.'; if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.'; if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.'; if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) { @@ -480,22 +507,56 @@ watch([id, isNew], () => void load(), { immediate: true });
- + -
@@ -622,8 +683,14 @@ watch([id, isNew], () => void load(), { immediate: true });
{{ toFaDigits(durationMinutes) }} دقیقه
-
مسئول
-
{{ selectedAssignee?.fullName || '—' }}
+
مسئولان
+
+ {{ + selectedAssignees.length + ? selectedAssignees.map((u) => u.fullName).join('، ') + : '—' + }} +
مشتری مرتبط
@@ -825,6 +892,81 @@ watch([id, isNew], () => void load(), { immediate: true }); transform: rotate(90deg); } +.assignee-avatars { + display: flex; + align-items: center; +} + +.assignee-avatars :deep(.avatar), +.assignee-avatars > * { + margin-inline-end: -0.35rem; + box-shadow: 0 0 0 2px #fff; +} + +.hint { + display: block; + margin-top: 0.35rem; + color: #94a3b8; + font-size: 0.75rem; +} + +.assignee-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.5rem; +} + +.assignee-chips .chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: #f0fdfa; + color: #0f766e; + border-radius: 999px; + padding: 0.2rem 0.55rem; + font-size: 0.78rem; + font-weight: 600; +} + +.assignee-chips .chip button { + border: 0; + background: transparent; + color: #64748b; + cursor: pointer; + font: inherit; + line-height: 1; + padding: 0 0.1rem; +} + +.multi-menu .multi-option { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; +} + +.multi-menu .check { + width: 18px; + height: 18px; + border: 1.5px solid #cbd5e1; + border-radius: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #fff; +} + +.multi-menu .check.on { + background: #14b8a6; + border-color: #14b8a6; +} + +.multi-menu .multi-option.on { + background: #f0fdfa; +} + .type-ico { width: 32px; height: 32px; diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index b1cbac1..f731c66 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -77,6 +77,7 @@ const roleFormId = ref(null); const roleFormName = ref(''); const roleFormDesc = ref(''); const roleFormColor = ref('teal'); +const roleFormParentId = ref(''); const roleFormPerms = ref(ROLE_PERMISSION_ITEMS.map((p) => p.key)); const deleteOpen = ref(false); @@ -148,10 +149,42 @@ const visibleMembers = computed(() => { return showAllMembers.value ? role.members : role.members.slice(0, 3); }); +const selectedChildRoles = computed(() => { + const id = selectedRole.value?.id; + if (!id) return [] as OrgRoleDto[]; + return roles.value.filter((r) => r.parentId === id); +}); + +/** گزینه‌های والد در فرم نقش — خود نقش و زیرنقش‌هایش حذف می‌شوند */ +const parentRoleOptions = computed(() => { + const editingId = roleFormId.value; + const blocked = new Set(); + if (editingId) { + blocked.add(editingId); + const queue = [editingId]; + while (queue.length) { + const current = queue.shift()!; + for (const role of roles.value) { + if (role.parentId === current && !blocked.has(role.id)) { + blocked.add(role.id); + queue.push(role.id); + } + } + } + } + return roles.value.filter((r) => !blocked.has(r.id) && (r.depth ?? 0) < 4); +}); + const inviteableRoles = computed(() => roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'), ); +function roleTreeLabel(role: OrgRoleDto) { + const depth = role.depth ?? 0; + const prefix = depth > 0 ? `${'— '.repeat(depth)}` : ''; + return `${prefix}${role.name}`; +} + function tabFromHash(hash: string): TabKey { if (hash.includes('settings-org') || hash.includes('access')) return 'access'; if (hash.includes('roles')) return 'roles'; @@ -344,15 +377,41 @@ async function toggleStatus(user: OrgUserDto) { } } -function openCreateRole() { +async function changeCommission(user: OrgUserDto, raw: string) { + if (!canInvite.value) return; + const next = Number(raw); + if (!Number.isFinite(next) || next === Number(user.commissionPercent ?? 0)) return; + error.value = ''; + ok.value = ''; + try { + await api.patch(`/users/${user.id}/commission`, { commissionPercent: next }); + ok.value = `پورسانت «${user.fullName}» ذخیره شد.`; + await load(); + } catch (e) { + error.value = apiError(e, 'ذخیره پورسانت ناموفق بود'); + } +} + +function openCreateRole(parentId?: string | null) { + const parent = parentId ? roles.value.find((r) => r.id === parentId) : null; roleFormId.value = null; roleFormName.value = ''; roleFormDesc.value = ''; - roleFormColor.value = 'teal'; - roleFormPerms.value = ROLE_PERMISSION_ITEMS.map((p) => p.key); + roleFormColor.value = (parent && ROLE_COLOR_OPTIONS.some((c) => c.value === parent.color) + ? parent.color + : 'teal') as RoleColor; + roleFormParentId.value = parent?.id ?? ''; + roleFormPerms.value = parent + ? [...parent.permissions] + : ROLE_PERMISSION_ITEMS.map((p) => p.key); roleFormOpen.value = true; } +function openCreateChildRole() { + if (!selectedRole.value) return; + openCreateRole(selectedRole.value.id); +} + function openEditRole() { const role = selectedRole.value; if (!role) return; @@ -362,6 +421,7 @@ function openEditRole() { roleFormColor.value = (ROLE_COLOR_OPTIONS.some((c) => c.value === role.color) ? role.color : 'teal') as RoleColor; + roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? ''); roleFormPerms.value = [...role.permissions]; roleFormOpen.value = true; } @@ -384,12 +444,24 @@ async function submitRoleForm() { roleFormSaving.value = true; error.value = ''; try { - const body = { + const editing = roleFormId.value + ? roles.value.find((r) => r.id === roleFormId.value) + : null; + const body: { + name: string; + description: string; + color: RoleColor; + permissions: RolePermissionKey[]; + parentId?: string | null; + } = { name: roleFormName.value.trim(), description: roleFormDesc.value.trim(), color: roleFormColor.value, permissions: roleFormPerms.value, }; + if (!editing?.systemKey) { + body.parentId = roleFormParentId.value || null; + } if (roleFormId.value) { const { data } = await api.patch(`/org-roles/${roleFormId.value}`, body); selectedRoleId.value = data.id; @@ -564,7 +636,7 @@ onUnmounted(() => { :aria-selected="accessFocusRole?.id === role.id" @click="accessFocusId = role.id" > - {{ role.name }} + {{ roleTreeLabel(role) }} {{ permCountLabel(role) }}
@@ -590,7 +662,7 @@ onUnmounted(() => { مجوز - {{ role.name }} + {{ roleTreeLabel(role) }} @@ -638,9 +710,42 @@ onUnmounted(() => {

{{ selectedRole.name }}

{{ selectedRole.description || '—' }}

+

+ زیرمجموعهٔ + +

+
+
+

زیرنقش‌ها

+ +
+
    +
  • + +
  • +
+

هنوز زیرنقشی تعریف نشده.

+
+

مجوزهای دسترسی

    @@ -677,6 +782,14 @@ onUnmounted(() => {