feat(roles): let each section decide who may assign work to others
Section access now only creates a record for yourself. Assigning it to a colleague requires a separate permission on that section. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { assertAssigneeSet } from '../common/assign-access';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { tehranDayKey } from '../routines/routine-dates';
|
||||
@@ -566,6 +567,12 @@ export class ActivitiesService {
|
||||
? [dto.assigneeId]
|
||||
: [userId],
|
||||
);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId, role: linkActor.role, organizationId },
|
||||
'activities',
|
||||
assigneeIds,
|
||||
);
|
||||
const assigneeId = assigneeIds[0] ?? userId;
|
||||
const initialStageId = emptyToNull(dto.stageId ?? null);
|
||||
const initialStage = initialStageId
|
||||
@@ -587,6 +594,12 @@ export class ActivitiesService {
|
||||
resolved.fields,
|
||||
dto.fields,
|
||||
);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId, role: linkActor.role, organizationId },
|
||||
'activities',
|
||||
participantIds,
|
||||
);
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
@@ -723,6 +736,13 @@ export class ActivitiesService {
|
||||
data.typeDefId = resolved.typeDefId;
|
||||
data.fields = fields as Prisma.InputJsonValue;
|
||||
data.participantIds = participantIds;
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'activities',
|
||||
participantIds,
|
||||
existing?.participantIds ?? [],
|
||||
);
|
||||
const before = new Set(existing?.participantIds ?? []);
|
||||
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
|
||||
}
|
||||
@@ -748,22 +768,47 @@ export class ActivitiesService {
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
if (!assigneeIds.length) {
|
||||
throw new BadRequestException('حداقل یک مسئول لازم است');
|
||||
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
||||
const current = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { assigneeId: true, assignees: { select: { id: true } } },
|
||||
});
|
||||
const previous = current?.assignees.length
|
||||
? current.assignees.map((item) => item.id)
|
||||
: current?.assigneeId
|
||||
? [current.assigneeId]
|
||||
: [];
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
if (!assigneeIds.length) {
|
||||
throw new BadRequestException('حداقل یک مسئول لازم است');
|
||||
}
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'activities',
|
||||
assigneeIds,
|
||||
previous,
|
||||
);
|
||||
data.assigneeId = assigneeIds[0]!;
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
} else {
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'activities',
|
||||
[assigneeId],
|
||||
previous,
|
||||
);
|
||||
data.assigneeId = assigneeId;
|
||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||
else assigneeConnect = [];
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
ASSIGN_SECTION_PERMISSION,
|
||||
type AssignSection,
|
||||
} from '@mento/shared';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { elevated } from './record-scope';
|
||||
|
||||
const ASSIGN_NOUN: Record<AssignSection, string> = {
|
||||
customers: 'مشتری',
|
||||
leads: 'لید',
|
||||
sales: 'معامله',
|
||||
activities: 'فعالیت',
|
||||
routines: 'کار روتین',
|
||||
projects: 'کارت پروژه',
|
||||
tickets: 'تیکت',
|
||||
};
|
||||
|
||||
type AssignActor = { userId: string; role: string; organizationId: string };
|
||||
|
||||
export function assignOthersDeniedMessage(section: AssignSection): string {
|
||||
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط میتوانید برای خودتان ثبت کنید.`;
|
||||
}
|
||||
|
||||
export async function actorCanAssignToOthers(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
): Promise<boolean> {
|
||||
if (elevated(actor.role)) return true;
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: actor.userId,
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = membership?.orgRole?.permissions;
|
||||
const key = ASSIGN_SECTION_PERMISSION[section];
|
||||
return Array.isArray(permissions) && permissions.includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
|
||||
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
|
||||
*/
|
||||
export async function assertAssigneeSet(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
nextIds: Array<string | null | undefined>,
|
||||
previousIds: Array<string | null | undefined> = [],
|
||||
): Promise<void> {
|
||||
if (await actorCanAssignToOthers(prisma, actor, section)) return;
|
||||
const prev = new Set(
|
||||
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
|
||||
);
|
||||
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
|
||||
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
|
||||
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
|
||||
}
|
||||
@@ -225,8 +225,10 @@ export function noteAccessWhere(
|
||||
export function resolveOwnerId(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
requested?: string | null,
|
||||
allowOthers?: boolean,
|
||||
): string {
|
||||
if (elevated(actor.role) && requested?.trim()) {
|
||||
const allowed = allowOthers ?? elevated(actor.role);
|
||||
if (allowed && requested?.trim()) {
|
||||
return requested.trim();
|
||||
}
|
||||
return actor.userId;
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
import { customersCompanyWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
elevated,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||
@@ -201,7 +201,11 @@ export class CompaniesService {
|
||||
notes: emptyToNull(dto.notes),
|
||||
registeredAt: parseDateOrNull(dto.registeredAt),
|
||||
tags: normalizeTags(dto.tags),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId: resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||
),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
};
|
||||
await this.assertNoDuplicate(actor.organizationId, data);
|
||||
@@ -266,8 +270,14 @@ export class CompaniesService {
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
if (dto.ownerId !== undefined) {
|
||||
const current = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
|
||||
@@ -38,9 +38,9 @@ import {
|
||||
import { customersPersonWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
@@ -453,7 +453,11 @@ export class PeopleService implements OnModuleInit {
|
||||
mood: dto.mood ?? 'newcomer',
|
||||
tags: normalizeTags(dto.tags),
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId: resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||
),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
||||
? { createdAt: registeredAt }
|
||||
@@ -574,8 +578,14 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
data.companyId = nextCompanyId;
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
if (dto.ownerId !== undefined) {
|
||||
const current = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
|
||||
@@ -302,8 +302,6 @@ export class LeadsController {
|
||||
}
|
||||
|
||||
@Post('assign')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
|
||||
return this.leads
|
||||
.assign(user, dto.leadIds, dto.assigneeId)
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
elevated,
|
||||
forScope,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import {
|
||||
asAddresses,
|
||||
collectAddresses,
|
||||
@@ -379,10 +380,11 @@ export class LeadsService {
|
||||
const stageId = dto.stageId || stages[0]?.id;
|
||||
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
||||
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'leads');
|
||||
let assigneeId: string | null = null;
|
||||
if (elevated(actor.role) && dto.assigneeId) {
|
||||
if (allowOthers && dto.assigneeId) {
|
||||
assigneeId = dto.assigneeId;
|
||||
} else if (!elevated(actor.role)) {
|
||||
} else if (!allowOthers) {
|
||||
assigneeId = actor.userId;
|
||||
}
|
||||
|
||||
@@ -570,8 +572,9 @@ export class LeadsService {
|
||||
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
|
||||
data.stage = { connect: { id: dto.stageId } };
|
||||
}
|
||||
if (elevated(actor.role) && dto.assigneeId !== undefined) {
|
||||
if (dto.assigneeId !== undefined) {
|
||||
const aid = dto.assigneeId ? String(dto.assigneeId) : null;
|
||||
await assertAssigneeSet(this.prisma, actor, 'leads', [aid], [existing.assigneeId]);
|
||||
data.assignee = aid
|
||||
? { connect: { id: aid } }
|
||||
: { disconnect: true };
|
||||
@@ -641,9 +644,7 @@ export class LeadsService {
|
||||
leadIds: string[],
|
||||
assigneeId: string,
|
||||
): Promise<number> {
|
||||
if (!elevated(actor.role)) {
|
||||
throw new BadRequestException('فقط مدیر میتواند تخصیص دهد');
|
||||
}
|
||||
await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]);
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: assigneeId,
|
||||
|
||||
@@ -41,7 +41,9 @@ export const SYSTEM_ROLE_SEEDS: Array<{
|
||||
name: 'کارشناس فروش',
|
||||
description: 'امکان دسترسی به بخشهای فروش و مشتریان برای انجام فعالیتهای روزمره.',
|
||||
color: 'teal',
|
||||
permissions: ALL_ROLE_PERMISSIONS.filter((key) => key !== 'audit'),
|
||||
permissions: ALL_ROLE_PERMISSIONS.filter(
|
||||
(key) => key !== 'audit' && !key.startsWith('assign_'),
|
||||
),
|
||||
},
|
||||
{
|
||||
systemKey: 'support',
|
||||
|
||||
@@ -22,11 +22,11 @@ import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
|
||||
const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
@@ -106,6 +106,10 @@ export class DealsService {
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, assignActor, 'sales');
|
||||
const ownerId = resolveOwnerId(actor, dto.ownerId, allowOthers);
|
||||
await assertAssigneeSet(this.prisma, assignActor, 'sales', [ownerId, ...assigneeIds]);
|
||||
|
||||
const maxPos = await this.prisma.deal.aggregate({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
@@ -156,7 +160,7 @@ export class DealsService {
|
||||
createdById: actor.userId,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
@@ -259,17 +263,38 @@ export class DealsService {
|
||||
}
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
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.ownerId !== undefined || dto.assigneeIds !== undefined) {
|
||||
const current = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { ownerId: true, assignees: { select: { id: true } } },
|
||||
});
|
||||
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
|
||||
if (dto.ownerId !== undefined) {
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
assignActor,
|
||||
'sales',
|
||||
[nextOwner],
|
||||
[current?.ownerId],
|
||||
);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
assignActor,
|
||||
'sales',
|
||||
assigneeIds,
|
||||
current?.assignees.map((item) => item.id) ?? [],
|
||||
);
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
}
|
||||
}
|
||||
|
||||
if (pricedFromLines && dto.lineItems) {
|
||||
|
||||
@@ -15,12 +15,12 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
projectCardAccessWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجامشده'] as const;
|
||||
@@ -206,6 +206,13 @@ export class ProjectBoardsService {
|
||||
});
|
||||
|
||||
const assigneeIds = [...new Set((dto.assigneeIds ?? []).map((id) => id.trim()).filter(Boolean))];
|
||||
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
|
||||
const ownerId = resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, assignActor, 'projects'),
|
||||
);
|
||||
await assertAssigneeSet(this.prisma, assignActor, 'projects', [ownerId, ...assigneeIds]);
|
||||
const created = await this.prisma.projectCard.create({
|
||||
data: {
|
||||
organizationId,
|
||||
@@ -217,7 +224,7 @@ export class ProjectBoardsService {
|
||||
personId: emptyToNull(dto.personId),
|
||||
companyId: emptyToNull(dto.companyId),
|
||||
dealId: emptyToNull(dto.dealId),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
@@ -257,24 +264,40 @@ export class ProjectBoardsService {
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('ownerId' in dto) {
|
||||
if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) {
|
||||
throw new BadRequestException('تغییر مسئول مجاز نیست');
|
||||
}
|
||||
data.ownerId = emptyToNull(dto.ownerId ?? null);
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'projects',
|
||||
[nextOwner],
|
||||
[existing.ownerId],
|
||||
);
|
||||
data.ownerId = nextOwner;
|
||||
}
|
||||
|
||||
const nextAssigneeIds = dto.assigneeIds
|
||||
? [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))]
|
||||
: undefined;
|
||||
if (nextAssigneeIds) {
|
||||
const currentAssignees = await this.prisma.projectCard.findFirst({
|
||||
where: { id },
|
||||
select: { assignees: { select: { id: true } } },
|
||||
});
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'projects',
|
||||
nextAssigneeIds,
|
||||
currentAssignees?.assignees.map((item) => item.id) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
const row = await this.prisma.projectCard.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(dto.assigneeIds
|
||||
? {
|
||||
assignees: {
|
||||
set: [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))].map(
|
||||
(uid) => ({ id: uid }),
|
||||
),
|
||||
},
|
||||
}
|
||||
...(nextAssigneeIds
|
||||
? { assignees: { set: nextAssigneeIds.map((uid) => ({ id: uid })) } }
|
||||
: {}),
|
||||
},
|
||||
include: cardInclude,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { elevated } from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assignOthersDeniedMessage } from '../common/assign-access';
|
||||
import {
|
||||
ChangeRoutineAssigneeDto,
|
||||
ListRoutinesQueryDto,
|
||||
@@ -360,6 +361,11 @@ export class RoutinesService {
|
||||
assigneeId?: string | null,
|
||||
teamId?: string | null,
|
||||
): Promise<{ assigneeId: string | null; teamId: string | null }> {
|
||||
if ((assigneeId && assigneeId !== actor.userId) || teamId) {
|
||||
if (!(await actorCanAssignToOthers(this.prisma, actor, 'routines'))) {
|
||||
throw new ForbiddenException(assignOthersDeniedMessage('routines'));
|
||||
}
|
||||
}
|
||||
if (assigneeId) {
|
||||
if (!canManage(scope, assigneeId)) {
|
||||
throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید');
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
elevated,
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import { assertAssigneeSet } from '../common/assign-access';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
@@ -136,9 +137,7 @@ export class TicketsService {
|
||||
if (assigneeId) {
|
||||
await this.assertUser(actor.organizationId, assigneeId);
|
||||
}
|
||||
if (!elevated(actor.role) && assigneeId && assigneeId !== actor.userId) {
|
||||
assigneeId = actor.userId;
|
||||
}
|
||||
await assertAssigneeSet(this.prisma, actor, 'tickets', [assigneeId]);
|
||||
|
||||
const number = await this.nextNumber(actor.organizationId);
|
||||
const status = (dto.status as never) || 'open';
|
||||
@@ -270,9 +269,7 @@ export class TicketsService {
|
||||
if ('assigneeId' in dto) {
|
||||
const next = emptyToNull(dto.assigneeId ?? null);
|
||||
if (next) await this.assertUser(actor.organizationId, next);
|
||||
if (!elevated(actor.role) && next && next !== actor.userId) {
|
||||
throw new BadRequestException('فقط میتوانید تیکت را به خودتان ارجاع دهید');
|
||||
}
|
||||
await assertAssigneeSet(this.prisma, actor, 'tickets', [next], [existing.assigneeId]);
|
||||
data.assigneeId = next;
|
||||
if (next && next !== existing.assigneeId && next !== actor.userId) {
|
||||
await this.notifications.create({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityFieldDef,
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
PersonDto,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
||||
@@ -22,6 +24,8 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const model = defineModel<ActivityFieldValues>({ required: true });
|
||||
const auth = useAuthStore();
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
|
||||
|
||||
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
|
||||
const v = model.value[key];
|
||||
@@ -304,6 +308,7 @@ watch(
|
||||
<UserMultiSelect
|
||||
:model-value="participants(f.key).userIds"
|
||||
:users="users"
|
||||
:disabled="!canAssignOthers"
|
||||
add-label="افزودن همکار"
|
||||
empty-label="همکاری انتخاب نشده"
|
||||
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
todayKey,
|
||||
} from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -117,7 +118,7 @@ watch(
|
||||
|
||||
const assignees = computed(() => {
|
||||
const c = ctx.value;
|
||||
if (!c?.isManager) return [];
|
||||
if (!c?.isManager || !canAssignToOthers(auth.user, 'routines')) return [];
|
||||
return c.users;
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatJalaliDayClock } from '@/lib/jalali';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
@@ -103,7 +105,9 @@ const customerTask = computed(() => {
|
||||
return (t.source !== 'routine' && t.source !== 'manual') || t.entities.length > 0;
|
||||
});
|
||||
const done = computed(() => task.value?.status === 'completed');
|
||||
const auth = useAuthStore();
|
||||
const canManage = computed(() => !!shell?.context.value?.isManager);
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
|
||||
function startEdit() {
|
||||
@@ -276,7 +280,7 @@ function eventText(ev: RoutineTaskEventDto): string {
|
||||
<JalaliDateInput v-model="edit.time" mode="time" />
|
||||
</UiField>
|
||||
</template>
|
||||
<UiField v-if="canManage" label="مسئول" class="span-2">
|
||||
<UiField v-if="canManage && canAssignOthers" label="مسئول" class="span-2">
|
||||
<UiSelect v-model="edit.assigneeId">
|
||||
<option v-for="u in shell?.context.value?.users ?? []" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
ASSIGN_SECTION_PERMISSION,
|
||||
type AssignSection,
|
||||
type RolePermissionKey,
|
||||
} from '@mento/shared';
|
||||
|
||||
export function canAssignToOthers(
|
||||
user:
|
||||
| {
|
||||
role?: string | null;
|
||||
permissions?: Array<RolePermissionKey | string> | null;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
section: AssignSection,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (user.role === 'owner' || user.role === 'admin') return true;
|
||||
return (user.permissions ?? []).includes(ASSIGN_SECTION_PERMISSION[section]);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
OrgUserDto,
|
||||
} from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canReassignRecords } from '@/lib/roles';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@@ -51,7 +51,7 @@ function goBack() {
|
||||
}
|
||||
const auth = useAuthStore();
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||
const canReassign = computed(() => canReassignRecords(auth.user?.role));
|
||||
const canReassign = computed(() => canAssignToOthers(auth.user, 'activities'));
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import {
|
||||
formatJalaliDayClock,
|
||||
formatJalaliNumeric,
|
||||
@@ -175,7 +176,10 @@ async function ensurePersonLoaded(pid: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
|
||||
|
||||
function toggleAssigneeMenu() {
|
||||
if (!canAssignOthers.value) return;
|
||||
assigneeOpen.value = !assigneeOpen.value;
|
||||
if (assigneeOpen.value) {
|
||||
typeOpen.value = false;
|
||||
@@ -1263,9 +1267,11 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
<small class="hint">
|
||||
{{
|
||||
teamGroups.length
|
||||
? 'یک تیم یا نقش را انتخاب کنید تا همهٔ اعضایش مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
|
||||
: 'یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
|
||||
canAssignOthers
|
||||
? teamGroups.length
|
||||
? 'یک تیم یا نقش را انتخاب کنید تا همهٔ اعضایش مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
|
||||
: 'یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
|
||||
: 'بدون دسترسی «تخصیص فعالیت به دیگران» این فعالیت فقط برای خودتان ثبت میشود.'
|
||||
}}
|
||||
</small>
|
||||
<div v-if="selectedRoles.length || selectedTeams.length || extraAssignees.length" class="assignee-chips">
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { canUseBehavior } from '@/lib/behavior-access';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { currencySuffix, formatMoney } from '@/lib/money';
|
||||
@@ -180,6 +181,7 @@ const personSelectOptions = computed(() =>
|
||||
const companySelectOptions = computed(() =>
|
||||
companies.value.map((c) => ({ value: c.id, label: c.name })),
|
||||
);
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'sales'));
|
||||
const ownerSelectOptions = computed(() =>
|
||||
users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
);
|
||||
@@ -772,13 +774,14 @@ onMounted(load);
|
||||
v-model:value="ownerId"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:disabled="!canAssignOthers"
|
||||
:options="ownerSelectOptions"
|
||||
placeholder="انتخاب مسئول"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-col v-if="canAssignOthers" :xs="24" :md="12">
|
||||
<a-form-item label="مسئولان مشترک">
|
||||
<UserMultiSelect v-model="selectedAssigneeIds" :users="users" />
|
||||
<a-typography-text type="secondary">علاوه بر مسئول اصلی.</a-typography-text>
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import SmsComposeModal from '@/components/SmsComposeModal.vue';
|
||||
@@ -82,7 +82,7 @@ type LeadDetail = LeadDto & { events: LeadEventDto[] };
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => canManageSettings(auth.user?.role));
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
|
||||
const canBehavior = computed(() => canUseBehavior(auth.user));
|
||||
const copilotOpen = ref(false);
|
||||
|
||||
@@ -941,7 +941,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
اعمال وضعیت
|
||||
</a-button>
|
||||
|
||||
<template v-if="isAdmin">
|
||||
<template v-if="canAssignOthers">
|
||||
<a-divider orientation="right">تخصیص کارشناس</a-divider>
|
||||
<a-form-item label="کارشناس">
|
||||
<a-select
|
||||
@@ -1085,7 +1085,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
</a-form-item>
|
||||
<a-button block :disabled="busy" @click="changeStage">اعمال وضعیت</a-button>
|
||||
|
||||
<template v-if="isAdmin">
|
||||
<template v-if="canAssignOthers">
|
||||
<a-divider orientation="right">تخصیص کارشناس</a-divider>
|
||||
<a-form-item label="کارشناس">
|
||||
<a-select
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
} from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
@@ -147,6 +148,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => canManageSettings(auth.user?.role));
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
|
||||
|
||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||
const loading = ref(false);
|
||||
@@ -1355,7 +1357,7 @@ onBeforeUnmount(() => {
|
||||
<a-typography-text>
|
||||
{{ toFaDigits(selectedIds.length) }} مورد انتخابشده
|
||||
</a-typography-text>
|
||||
<template v-if="isAdmin">
|
||||
<template v-if="canAssignOthers">
|
||||
<a-select
|
||||
v-model:value="assignUserId"
|
||||
placeholder="تخصیص به کارشناس"
|
||||
@@ -1364,6 +1366,8 @@ onBeforeUnmount(() => {
|
||||
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
|
||||
/>
|
||||
<a-button :disabled="!assignUserId || busy" @click="assignSelected">اعمال تخصیص</a-button>
|
||||
</template>
|
||||
<template v-if="isAdmin">
|
||||
<a-select
|
||||
v-model:value="bulkStageId"
|
||||
placeholder="تغییر وضعیت"
|
||||
@@ -1755,7 +1759,8 @@ onBeforeUnmount(() => {
|
||||
<a-select
|
||||
v-model:value="createForm.assigneeId"
|
||||
allow-clear
|
||||
placeholder="بدون مسئول"
|
||||
:disabled="!canAssignOthers"
|
||||
:placeholder="canAssignOthers ? 'بدون مسئول' : 'خودتان'"
|
||||
style="width: 100%"
|
||||
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
@@ -21,6 +23,8 @@ const emit = defineEmits<{
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'projects'));
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => {
|
||||
@@ -231,10 +235,11 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-assignees">مسئولان کارت</label>
|
||||
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi">
|
||||
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi" :disabled="!canAssignOthers">
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</select>
|
||||
<small class="hint">با نگه داشتن Ctrl میتوانید چند نفر را انتخاب کنید.</small>
|
||||
<small v-if="canAssignOthers" class="hint">با نگه داشتن Ctrl میتوانید چند نفر را انتخاب کنید.</small>
|
||||
<small v-else class="hint">بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت میشود.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-notes">یادداشت</label>
|
||||
|
||||
@@ -56,11 +56,18 @@ const router = useRouter();
|
||||
/** مجوزی که ماژولش در سوپرادمین خاموش است در نقشها نشان داده نمیشود. */
|
||||
const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
|
||||
customers: ['customers'],
|
||||
assign_customers: ['customers'],
|
||||
leads: ['leads'],
|
||||
assign_leads: ['leads'],
|
||||
sales: ['pipeline'],
|
||||
assign_sales: ['pipeline'],
|
||||
billing: ['invoices', 'quotes'],
|
||||
products: ['products'],
|
||||
activities: ['activities'],
|
||||
assign_activities: ['activities'],
|
||||
assign_routines: ['routines'],
|
||||
assign_projects: ['project_boards'],
|
||||
assign_tickets: ['tickets'],
|
||||
notes: ['notes'],
|
||||
reports: ['reports'],
|
||||
audit: ['audit_logs'],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDateTime } from '@/lib/jalali';
|
||||
import { canViewAllRecords } from '@/lib/roles';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
UiBadge,
|
||||
@@ -61,7 +61,7 @@ const faqHints = ref<FaqArticleDto[]>([]);
|
||||
const csatScore = ref<number | null>(null);
|
||||
const csatComment = ref('');
|
||||
|
||||
const canAssignOthers = computed(() => canViewAllRecords(auth.user?.role));
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'tickets'));
|
||||
|
||||
const showCsat = computed(() => {
|
||||
if (!ticket.value || isNew.value) return false;
|
||||
|
||||
@@ -723,11 +723,18 @@ export function migrateVisibilityToCustomersAccess(
|
||||
|
||||
export type RolePermissionKey =
|
||||
| "customers"
|
||||
| "assign_customers"
|
||||
| "leads"
|
||||
| "assign_leads"
|
||||
| "sales"
|
||||
| "assign_sales"
|
||||
| "billing"
|
||||
| "products"
|
||||
| "activities"
|
||||
| "assign_activities"
|
||||
| "assign_routines"
|
||||
| "assign_projects"
|
||||
| "assign_tickets"
|
||||
| "notes"
|
||||
| "reports"
|
||||
| "audit"
|
||||
@@ -737,16 +744,43 @@ export type RolePermissionKey =
|
||||
| "treasury_payroll"
|
||||
| "behavior";
|
||||
|
||||
/** بخشی که مسئولش را میشود به کاربر دیگری داد */
|
||||
export type AssignSection =
|
||||
| "customers"
|
||||
| "leads"
|
||||
| "sales"
|
||||
| "activities"
|
||||
| "routines"
|
||||
| "projects"
|
||||
| "tickets";
|
||||
|
||||
export const ASSIGN_SECTION_PERMISSION: Record<AssignSection, RolePermissionKey> = {
|
||||
customers: "assign_customers",
|
||||
leads: "assign_leads",
|
||||
sales: "assign_sales",
|
||||
activities: "assign_activities",
|
||||
routines: "assign_routines",
|
||||
projects: "assign_projects",
|
||||
tickets: "assign_tickets",
|
||||
};
|
||||
|
||||
export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
key: RolePermissionKey;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "customers", label: "مشتریان" },
|
||||
{ key: "assign_customers", label: "تخصیص مشتری به دیگران" },
|
||||
{ key: "leads", label: "لیدها" },
|
||||
{ key: "assign_leads", label: "تخصیص لید به دیگران" },
|
||||
{ key: "sales", label: "فروش و معاملات" },
|
||||
{ key: "assign_sales", label: "تخصیص معامله به دیگران" },
|
||||
{ key: "billing", label: "پیشفاکتورها و فاکتورها" },
|
||||
{ key: "products", label: "محصولات و خدمات" },
|
||||
{ key: "activities", label: "فعالیتها" },
|
||||
{ key: "assign_activities", label: "تخصیص فعالیت به دیگران" },
|
||||
{ key: "assign_routines", label: "تخصیص کار روتین به دیگران" },
|
||||
{ key: "assign_projects", label: "تخصیص کارت پروژه به دیگران" },
|
||||
{ key: "assign_tickets", label: "تخصیص تیکت به دیگران" },
|
||||
{ key: "notes", label: "یادداشتها" },
|
||||
{ key: "reports", label: "گزارشها" },
|
||||
{ key: "audit", label: "لاگ فعالیت کاربران" },
|
||||
|
||||
Reference in New Issue
Block a user