Selection of multiple officials and role sub-categories
This commit is contained in:
@@ -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 $$;
|
||||||
@@ -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;
|
||||||
@@ -246,6 +246,7 @@ model User {
|
|||||||
ownedCompanies Company[] @relation("CompanyOwner")
|
ownedCompanies Company[] @relation("CompanyOwner")
|
||||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||||
activities Activity[] @relation("ActivityAssignee")
|
activities Activity[] @relation("ActivityAssignee")
|
||||||
|
assignedActivities Activity[] @relation("ActivityAssignees")
|
||||||
createdActivities Activity[] @relation("ActivityCreator")
|
createdActivities Activity[] @relation("ActivityCreator")
|
||||||
notes Note[] @relation("NoteAuthor")
|
notes Note[] @relation("NoteAuthor")
|
||||||
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
||||||
@@ -288,6 +289,10 @@ model OrgRole {
|
|||||||
permissions String[] @default([])
|
permissions String[] @default([])
|
||||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||||
systemKey String?
|
systemKey String?
|
||||||
|
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||||
|
parentId String?
|
||||||
|
parent OrgRole? @relation("OrgRoleTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||||
|
children OrgRole[] @relation("OrgRoleTree")
|
||||||
users User[]
|
users User[]
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -295,6 +300,8 @@ model OrgRole {
|
|||||||
|
|
||||||
@@unique([organizationId, name])
|
@@unique([organizationId, name])
|
||||||
@@index([organizationId])
|
@@index([organizationId])
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([organizationId, parentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Membership {
|
model Membership {
|
||||||
@@ -579,6 +586,8 @@ model Activity {
|
|||||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||||
assigneeId String?
|
assigneeId String?
|
||||||
assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||||
|
/// مسئولان مشترک (علاوه بر assigneeId بهعنوان مسئول اصلی)
|
||||||
|
assignees User[] @relation("ActivityAssignees")
|
||||||
createdById String?
|
createdById String?
|
||||||
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
notes Note[]
|
notes Note[]
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export class ActivitiesExcelService {
|
|||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
deal: { select: { id: true, title: true } },
|
deal: { select: { id: true, title: true } },
|
||||||
assignee: { select: { fullName: true } },
|
assignee: { select: { fullName: true } },
|
||||||
|
assignees: { select: { fullName: true } },
|
||||||
createdBy: { select: { fullName: true } },
|
createdBy: { select: { fullName: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
||||||
@@ -124,7 +125,9 @@ export class ActivitiesExcelService {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
body: row.description ?? '',
|
body: row.description ?? '',
|
||||||
result: '',
|
result: '',
|
||||||
assignee: row.assignee?.fullName ?? '',
|
assignee: row.assignees?.length
|
||||||
|
? row.assignees.map((a) => a.fullName).join('، ')
|
||||||
|
: (row.assignee?.fullName ?? ''),
|
||||||
creator: row.createdBy?.fullName ?? '',
|
creator: row.createdBy?.fullName ?? '',
|
||||||
dueDate: formatJalaliDate(row.dueAt),
|
dueDate: formatJalaliDate(row.dueAt),
|
||||||
dueTime: formatClock(row.dueAt),
|
dueTime: formatClock(row.dueAt),
|
||||||
@@ -182,8 +185,17 @@ export class ActivitiesExcelService {
|
|||||||
const personId = await this.findPersonId(actor.organizationId, row);
|
const personId = await this.findPersonId(actor.organizationId, row);
|
||||||
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
||||||
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
|
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
|
||||||
const assigneeId =
|
const assigneeNames = String(row.assignee ?? '')
|
||||||
(await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId;
|
.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 =
|
const creatorId =
|
||||||
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
|
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
|
||||||
|
|
||||||
@@ -218,6 +230,7 @@ export class ActivitiesExcelService {
|
|||||||
companyId,
|
companyId,
|
||||||
dealId,
|
dealId,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
|
assignees: { connect: assigneeIds.map((id) => ({ id })) },
|
||||||
createdById: creatorId,
|
createdById: creatorId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type ActivityRow = {
|
|||||||
company: { id: string; name: string } | null;
|
company: { id: string; name: string } | null;
|
||||||
deal: { id: string; title: string } | null;
|
deal: { id: string; title: string } | null;
|
||||||
assignee: { id: string; fullName: string } | null;
|
assignee: { id: string; fullName: string } | null;
|
||||||
|
assignees: Array<{ id: string; fullName: string }>;
|
||||||
createdBy: { id: string; fullName: string } | null;
|
createdBy: { id: string; fullName: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ const include = {
|
|||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
deal: { select: { id: true, title: true } },
|
deal: { select: { id: true, title: true } },
|
||||||
assignee: { select: { id: true, fullName: true } },
|
assignee: { select: { id: true, fullName: true } },
|
||||||
|
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
|
||||||
createdBy: { select: { id: true, fullName: true } },
|
createdBy: { select: { id: true, fullName: true } },
|
||||||
} satisfies Prisma.ActivityInclude;
|
} satisfies Prisma.ActivityInclude;
|
||||||
|
|
||||||
@@ -186,8 +188,15 @@ export class ActivitiesService {
|
|||||||
): Promise<ActivityDto> {
|
): Promise<ActivityDto> {
|
||||||
const linkActor = actor ?? { userId, role: 'owner' };
|
const linkActor = actor ?? { userId, role: 'owner' };
|
||||||
await this.assertLinks(organizationId, linkActor, dto);
|
await this.assertLinks(organizationId, linkActor, dto);
|
||||||
const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId;
|
const assigneeIds = await this.normalizeAssigneeIds(
|
||||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
organizationId,
|
||||||
|
dto.assigneeIds?.length
|
||||||
|
? dto.assigneeIds
|
||||||
|
: dto.assigneeId
|
||||||
|
? [dto.assigneeId]
|
||||||
|
: [userId],
|
||||||
|
);
|
||||||
|
const assigneeId = assigneeIds[0] ?? userId;
|
||||||
|
|
||||||
const row = await this.prisma.activity.create({
|
const row = await this.prisma.activity.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -206,15 +215,18 @@ export class ActivitiesService {
|
|||||||
companyId: emptyToNull(dto.companyId ?? null),
|
companyId: emptyToNull(dto.companyId ?? null),
|
||||||
dealId: emptyToNull(dto.dealId ?? null),
|
dealId: emptyToNull(dto.dealId ?? null),
|
||||||
assigneeId,
|
assigneeId,
|
||||||
|
assignees: assigneeIds.length
|
||||||
|
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (assigneeId) {
|
for (const uid of assigneeIds) {
|
||||||
await this.notifications.create({
|
await this.notifications.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
userId: assigneeId,
|
userId: uid,
|
||||||
type: 'activity_assigned',
|
type: 'activity_assigned',
|
||||||
title: 'فعالیت جدید برای شما',
|
title: 'فعالیت جدید برای شما',
|
||||||
body: row.title,
|
body: row.title,
|
||||||
@@ -262,10 +274,23 @@ export class ActivitiesService {
|
|||||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? 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);
|
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||||
data.assigneeId = assigneeId;
|
data.assigneeId = assigneeId;
|
||||||
|
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||||
|
else assigneeConnect = [];
|
||||||
}
|
}
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
data.status = dto.status;
|
data.status = dto.status;
|
||||||
@@ -279,7 +304,12 @@ export class ActivitiesService {
|
|||||||
});
|
});
|
||||||
const row = await this.prisma.activity.update({
|
const row = await this.prisma.activity.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
...data,
|
||||||
|
...(assigneeConnect
|
||||||
|
? { assignees: { set: assigneeConnect } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||||
@@ -340,37 +370,55 @@ export class ActivitiesService {
|
|||||||
},
|
},
|
||||||
): Prisma.ActivityWhereInput {
|
): Prisma.ActivityWhereInput {
|
||||||
const { start, end } = dayBounds();
|
const { start, end } = dayBounds();
|
||||||
const scope =
|
const scope: Prisma.ActivityWhereInput =
|
||||||
desk === 'mine' ? { assigneeId: actor.userId } : assigneeWhere(actor);
|
desk === 'mine'
|
||||||
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()
|
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: q.trim(), mode: 'insensitive' } },
|
{ assigneeId: actor.userId },
|
||||||
{ description: { contains: q.trim(), mode: 'insensitive' } },
|
{ assignees: { some: { id: actor.userId } } },
|
||||||
{
|
|
||||||
person: {
|
|
||||||
OR: [
|
|
||||||
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
|
|
||||||
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ deal: { title: { contains: q.trim(), mode: 'insensitive' } } },
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: 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;
|
const fromDate = range?.from ? new Date(range.from) : null;
|
||||||
@@ -517,11 +565,35 @@ export class ActivitiesService {
|
|||||||
company: row.company,
|
company: row.company,
|
||||||
deal: row.deal,
|
deal: row.deal,
|
||||||
assignee: row.assignee,
|
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,
|
createdBy: row.createdBy,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async normalizeAssigneeIds(
|
||||||
|
organizationId: string,
|
||||||
|
raw?: string[] | null,
|
||||||
|
): Promise<string[]> {
|
||||||
|
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 {
|
function emptyToNull(value?: string | null): string | null {
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ export class CreateActivityDto {
|
|||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateActivityDto {
|
export class UpdateActivityDto {
|
||||||
@@ -223,4 +228,9 @@ export class UpdateActivityDto {
|
|||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,17 +76,18 @@ export function projectCardAccessWhere(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** فعالیتها بر اساس مسئول */
|
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||||
export function assigneeWhere(
|
export function assigneeWhere(
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||||
):
|
): Prisma.ActivityWhereInput {
|
||||||
| { assigneeId: string }
|
|
||||||
| { assigneeId: { in: string[] } }
|
|
||||||
| Record<string, never> {
|
|
||||||
const ids = scopedOwnerIds(actor);
|
const ids = scopedOwnerIds(actor);
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
if (ids.length === 1) return { assigneeId: ids[0]! };
|
return {
|
||||||
return { assigneeId: { in: ids } };
|
OR: [
|
||||||
|
{ assigneeId: { in: ids } },
|
||||||
|
{ assignees: { some: { id: { in: ids } } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ROLE_PERMISSION_ITEMS, type RolePermissionKey } from '@mento/shared';
|
import { ROLE_PERMISSION_ITEMS, type RolePermissionKey } from '@mento/shared';
|
||||||
@@ -34,6 +35,11 @@ export class CreateOrgRoleDto {
|
|||||||
@ArrayUnique()
|
@ArrayUnique()
|
||||||
@IsIn(PERMISSION_KEYS, { each: true })
|
@IsIn(PERMISSION_KEYS, { each: true })
|
||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== null)
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateOrgRoleDto {
|
export class UpdateOrgRoleDto {
|
||||||
@@ -57,6 +63,11 @@ export class UpdateOrgRoleDto {
|
|||||||
@ArrayUnique()
|
@ArrayUnique()
|
||||||
@IsIn(PERMISSION_KEYS, { each: true })
|
@IsIn(PERMISSION_KEYS, { each: true })
|
||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== null)
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RolePermissionPatchItemDto {
|
export class RolePermissionPatchItemDto {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
systemKeyToUserRole,
|
systemKeyToUserRole,
|
||||||
} from './role-defaults';
|
} from './role-defaults';
|
||||||
|
|
||||||
|
/** حداکثر عمق درخت نقش (۰ = ریشه) */
|
||||||
|
const MAX_ROLE_DEPTH = 4;
|
||||||
|
|
||||||
type RoleRow = {
|
type RoleRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +26,8 @@ type RoleRow = {
|
|||||||
color: string;
|
color: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
systemKey: string | null;
|
systemKey: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
memberships: Array<{
|
memberships: Array<{
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +35,7 @@ type RoleRow = {
|
|||||||
avatarStorageKey: string | null;
|
avatarStorageKey: string | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
_count?: { children: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_MEMBERS_INCLUDE = {
|
const ROLE_MEMBERS_INCLUDE = {
|
||||||
@@ -39,6 +45,7 @@ const ROLE_MEMBERS_INCLUDE = {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' as const },
|
orderBy: { createdAt: 'asc' as const },
|
||||||
},
|
},
|
||||||
|
_count: { select: { children: true } },
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -52,7 +59,7 @@ export class OrgRolesService {
|
|||||||
include: ROLE_MEMBERS_INCLUDE,
|
include: ROLE_MEMBERS_INCLUDE,
|
||||||
orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }],
|
||||||
});
|
});
|
||||||
return rows.map((row) => this.toDto(row));
|
return this.toOrderedDtos(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@@ -63,6 +70,7 @@ export class OrgRolesService {
|
|||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
|
parentId?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<OrgRoleDto> {
|
): Promise<OrgRoleDto> {
|
||||||
this.assertManager(actorRole);
|
this.assertManager(actorRole);
|
||||||
@@ -70,18 +78,33 @@ export class OrgRolesService {
|
|||||||
if (name.length < 2) {
|
if (name.length < 2) {
|
||||||
throw new BadRequestException('نام نقش باید حداقل دو حرف باشد');
|
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 {
|
try {
|
||||||
const row = await this.prisma.orgRole.create({
|
const row = await this.prisma.orgRole.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
name,
|
name,
|
||||||
description: (dto.description ?? '').trim(),
|
description: (dto.description ?? '').trim(),
|
||||||
color: dto.color ?? 'teal',
|
color: dto.color ?? parent?.color ?? 'teal',
|
||||||
permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS],
|
permissions,
|
||||||
|
parentId,
|
||||||
},
|
},
|
||||||
include: ROLE_MEMBERS_INCLUDE,
|
include: ROLE_MEMBERS_INCLUDE,
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return (await this.list(organizationId)).find((r) => r.id === row.id)!;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
throw new ConflictException('نقشی با این نام از قبل وجود دارد');
|
throw new ConflictException('نقشی با این نام از قبل وجود دارد');
|
||||||
@@ -99,6 +122,7 @@ export class OrgRolesService {
|
|||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
|
parentId?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<OrgRoleDto> {
|
): Promise<OrgRoleDto> {
|
||||||
this.assertManager(actorRole);
|
this.assertManager(actorRole);
|
||||||
@@ -117,15 +141,38 @@ export class OrgRolesService {
|
|||||||
}
|
}
|
||||||
if (dto.description !== undefined) data.description = dto.description.trim();
|
if (dto.description !== undefined) data.description = dto.description.trim();
|
||||||
if (dto.color !== undefined) data.color = dto.color;
|
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 {
|
try {
|
||||||
const row = await this.prisma.orgRole.update({
|
await this.prisma.orgRole.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data,
|
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) {
|
} catch (e) {
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
throw new ConflictException('نقشی با این نام از قبل وجود دارد');
|
throw new ConflictException('نقشی با این نام از قبل وجود دارد');
|
||||||
@@ -156,11 +203,17 @@ export class OrgRolesService {
|
|||||||
this.assertManager(actorRole);
|
this.assertManager(actorRole);
|
||||||
const existing = await this.prisma.orgRole.findFirst({
|
const existing = await this.prisma.orgRole.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
|
include: { _count: { select: { children: true } } },
|
||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundException('نقش پیدا نشد');
|
if (!existing) throw new NotFoundException('نقش پیدا نشد');
|
||||||
if (existing.systemKey) {
|
if (existing.systemKey) {
|
||||||
throw new BadRequestException('نقشهای سیستمی قابل حذف نیستند');
|
throw new BadRequestException('نقشهای سیستمی قابل حذف نیستند');
|
||||||
}
|
}
|
||||||
|
if (existing._count.children > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'ابتدا زیرنقشهای این نقش را منتقل یا حذف کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.prisma.orgRole.delete({ where: { id: existing.id } });
|
await this.prisma.orgRole.delete({ where: { id: existing.id } });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -180,6 +233,7 @@ export class OrgRolesService {
|
|||||||
color: seed.color,
|
color: seed.color,
|
||||||
permissions: seed.permissions,
|
permissions: seed.permissions,
|
||||||
systemKey: seed.systemKey,
|
systemKey: seed.systemKey,
|
||||||
|
parentId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
byKey.set(seed.systemKey, created);
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string | null, RoleRow[]>();
|
||||||
|
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<string, string>,
|
||||||
|
): OrgRoleDto {
|
||||||
const systemKey =
|
const systemKey =
|
||||||
row.systemKey === 'owner' ||
|
row.systemKey === 'owner' ||
|
||||||
row.systemKey === 'admin' ||
|
row.systemKey === 'admin' ||
|
||||||
@@ -249,6 +411,10 @@ export class OrgRolesService {
|
|||||||
color: row.color,
|
color: row.color,
|
||||||
permissions: sanitizePermissions(row.permissions),
|
permissions: sanitizePermissions(row.permissions),
|
||||||
systemKey,
|
systemKey,
|
||||||
|
parentId: row.parentId,
|
||||||
|
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
|
||||||
|
depth,
|
||||||
|
childCount: row._count?.children ?? 0,
|
||||||
userCount: row.memberships.length,
|
userCount: row.memberships.length,
|
||||||
members: row.memberships.map((membership) => ({
|
members: row.memberships.map((membership) => ({
|
||||||
id: membership.user.id,
|
id: membership.user.id,
|
||||||
|
|||||||
@@ -717,7 +717,11 @@ onUnmounted(() => {
|
|||||||
<span>{{ dayText(item) }}</span>
|
<span>{{ dayText(item) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="who">
|
<div class="who">
|
||||||
<strong>{{ item.assignee?.fullName || '—' }}</strong>
|
<strong>{{
|
||||||
|
item.assignees?.length
|
||||||
|
? item.assignees.map((a) => a.fullName).join('، ')
|
||||||
|
: item.assignee?.fullName || '—'
|
||||||
|
}}</strong>
|
||||||
<span v-if="item.company || item.person">
|
<span v-if="item.company || item.person">
|
||||||
<UiIcon name="building" :size="12" />
|
<UiIcon name="building" :size="12" />
|
||||||
{{ item.company?.name || item.person?.fullName }}
|
{{ item.company?.name || item.person?.fullName }}
|
||||||
|
|||||||
@@ -30,7 +30,18 @@ const commentOpen = ref(false);
|
|||||||
const postponeOpen = ref(false);
|
const postponeOpen = ref(false);
|
||||||
const noteOpen = ref(true);
|
const noteOpen = ref(true);
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
const assignUserId = ref('');
|
const assignUserIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
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<ActivityType, string> = {
|
const typeLabel: Record<ActivityType, string> = {
|
||||||
call: 'تماس',
|
call: 'تماس',
|
||||||
@@ -95,7 +106,11 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
||||||
activity.value = data;
|
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 {
|
} catch {
|
||||||
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
|
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
|
||||||
activity.value = null;
|
activity.value = null;
|
||||||
@@ -144,11 +159,16 @@ async function reopen() {
|
|||||||
|
|
||||||
async function reassign() {
|
async function reassign() {
|
||||||
if (!id.value || !canReassign.value) return;
|
if (!id.value || !canReassign.value) return;
|
||||||
|
if (!assignUserIds.value.length) {
|
||||||
|
error.value = 'حداقل یک مسئول لازم است.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
await api.patch(`/activities/${id.value}`, {
|
await api.patch(`/activities/${id.value}`, {
|
||||||
assigneeId: assignUserId.value || null,
|
assigneeIds: [...assignUserIds.value],
|
||||||
|
assigneeId: assignUserIds.value[0] || null,
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -271,25 +291,25 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<dt>
|
<dt>
|
||||||
<UiIcon name="user" :size="15" />
|
<UiIcon name="user" :size="15" />
|
||||||
مسئول
|
مسئولان
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="person">
|
<dd class="person">
|
||||||
<template v-if="canReassign">
|
<template v-if="canReassign">
|
||||||
<select
|
<select
|
||||||
v-model="assignUserId"
|
v-model="assignUserIds"
|
||||||
class="assign-select"
|
class="assign-select multi"
|
||||||
|
multiple
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
@change="reassign"
|
@change="reassign"
|
||||||
>
|
>
|
||||||
<option value="">بدون مسئول</option>
|
|
||||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||||
{{ u.fullName }}
|
{{ u.fullName }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<small class="assign-hint">با Ctrl چند نفر را انتخاب کنید</small>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<UserAvatar v-if="activity.assignee" :name="activity.assignee.fullName" :size="28" />
|
{{ assigneeNames }}
|
||||||
{{ activity.assignee?.fullName || '—' }}
|
|
||||||
</template>
|
</template>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -586,6 +606,7 @@ dd {
|
|||||||
.person {
|
.person {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +621,18 @@ dd {
|
|||||||
background: #fff;
|
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,
|
.flag.low,
|
||||||
.priority.low {
|
.priority.low {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const dueTime = ref('10:30');
|
|||||||
const durationMinutes = ref(30);
|
const durationMinutes = ref(30);
|
||||||
const personId = ref('');
|
const personId = ref('');
|
||||||
const dealId = ref('');
|
const dealId = ref('');
|
||||||
const assigneeId = ref('');
|
const assigneeIds = ref<string[]>([]);
|
||||||
const isDone = ref(false);
|
const isDone = ref(false);
|
||||||
const needsNote = ref(false);
|
const needsNote = ref(false);
|
||||||
const reminderAt = ref('');
|
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 typeMeta = computed(() => typeOptions.find((o) => o.value === type.value));
|
||||||
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
|
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 selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null);
|
||||||
const personDeals = computed(() =>
|
const personDeals = computed(() =>
|
||||||
deals.value.filter((d) => !personId.value || d.person?.id === personId.value),
|
deals.value.filter((d) => !personId.value || d.person?.id === personId.value),
|
||||||
@@ -161,7 +171,7 @@ function resetForm() {
|
|||||||
durationMinutes.value = 30;
|
durationMinutes.value = 30;
|
||||||
personId.value = '';
|
personId.value = '';
|
||||||
dealId.value = '';
|
dealId.value = '';
|
||||||
assigneeId.value = auth.user?.id ?? '';
|
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||||
isDone.value = false;
|
isDone.value = false;
|
||||||
needsNote.value = false;
|
needsNote.value = false;
|
||||||
reminderAt.value = '';
|
reminderAt.value = '';
|
||||||
@@ -213,7 +223,13 @@ async function load() {
|
|||||||
reminderNotifySms.value = data.reminderNotifySms ?? false;
|
reminderNotifySms.value = data.reminderNotifySms ?? false;
|
||||||
personId.value = data.person?.id ?? '';
|
personId.value = data.person?.id ?? '';
|
||||||
dealId.value = data.deal?.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';
|
isDone.value = data.status === 'done';
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -272,15 +288,26 @@ function payload(): CreateActivityRequest {
|
|||||||
personId: personId.value || null,
|
personId: personId.value || null,
|
||||||
companyId: selectedPerson.value?.company?.id ?? null,
|
companyId: selectedPerson.value?.company?.id ?? null,
|
||||||
dealId: dealId.value || 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() {
|
function validate() {
|
||||||
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
|
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
|
||||||
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
|
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
|
||||||
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
|
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
|
||||||
if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.';
|
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
|
||||||
if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.';
|
if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.';
|
||||||
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
|
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
|
||||||
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
|
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
|
||||||
@@ -480,22 +507,56 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>مسئول انجام این فعالیت <i>*</i></label>
|
<label>مسئولان انجام این فعالیت <i>*</i></label>
|
||||||
<button class="picker person-pick" type="button" @click.stop="assigneeOpen = !assigneeOpen">
|
<button class="picker person-pick" type="button" @click.stop="assigneeOpen = !assigneeOpen">
|
||||||
<UserAvatar :name="selectedAssignee?.fullName || 'مسئول'" :size="32" />
|
<div class="assignee-avatars">
|
||||||
<span :class="{ ghost: !selectedAssignee }">
|
<UserAvatar
|
||||||
{{ selectedAssignee?.fullName || 'انتخاب مسئول' }}
|
v-for="user in selectedAssignees.slice(0, 3)"
|
||||||
|
:key="user.id"
|
||||||
|
:name="user.fullName"
|
||||||
|
:size="28"
|
||||||
|
/>
|
||||||
|
<UserAvatar
|
||||||
|
v-if="!selectedAssignees.length"
|
||||||
|
name="مسئول"
|
||||||
|
:size="28"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span :class="{ ghost: !selectedAssignees.length }">
|
||||||
|
{{ assigneePickerLabel }}
|
||||||
</span>
|
</span>
|
||||||
<UiIcon name="chevron" :size="16" />
|
<UiIcon name="chevron" :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<ul v-if="assigneeOpen" class="menu">
|
<ul v-if="assigneeOpen" class="menu multi-menu" @click.stop>
|
||||||
<li v-for="user in users" :key="user.id">
|
<li v-for="user in users" :key="user.id">
|
||||||
<button type="button" @click="assigneeId = user.id; assigneeOpen = false">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="multi-option"
|
||||||
|
:class="{ on: assigneeIds.includes(user.id) }"
|
||||||
|
@click="toggleAssignee(user.id)"
|
||||||
|
>
|
||||||
|
<span class="check" :class="{ on: assigneeIds.includes(user.id) }">
|
||||||
|
<UiIcon v-if="assigneeIds.includes(user.id)" name="check-plain" :size="12" />
|
||||||
|
</span>
|
||||||
<UserAvatar :name="user.fullName" :size="28" />
|
<UserAvatar :name="user.fullName" :size="28" />
|
||||||
{{ user.fullName }}
|
{{ user.fullName }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<small class="hint">میتوانید چند نفر را بهعنوان مسئول انتخاب کنید.</small>
|
||||||
|
<div v-if="selectedAssignees.length" class="assignee-chips">
|
||||||
|
<span v-for="user in selectedAssignees" :key="user.id" class="chip">
|
||||||
|
{{ user.fullName }}
|
||||||
|
<button
|
||||||
|
v-if="assigneeIds.length > 1"
|
||||||
|
type="button"
|
||||||
|
aria-label="حذف مسئول"
|
||||||
|
@click="toggleAssignee(user.id)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -622,8 +683,14 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
<dd>{{ toFaDigits(durationMinutes) }} دقیقه</dd>
|
<dd>{{ toFaDigits(durationMinutes) }} دقیقه</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>مسئول</dt>
|
<dt>مسئولان</dt>
|
||||||
<dd>{{ selectedAssignee?.fullName || '—' }}</dd>
|
<dd>
|
||||||
|
{{
|
||||||
|
selectedAssignees.length
|
||||||
|
? selectedAssignees.map((u) => u.fullName).join('، ')
|
||||||
|
: '—'
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>مشتری مرتبط</dt>
|
<dt>مشتری مرتبط</dt>
|
||||||
@@ -825,6 +892,81 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
transform: rotate(90deg);
|
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 {
|
.type-ico {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const roleFormId = ref<string | null>(null);
|
|||||||
const roleFormName = ref('');
|
const roleFormName = ref('');
|
||||||
const roleFormDesc = ref('');
|
const roleFormDesc = ref('');
|
||||||
const roleFormColor = ref<RoleColor>('teal');
|
const roleFormColor = ref<RoleColor>('teal');
|
||||||
|
const roleFormParentId = ref('');
|
||||||
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
|
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
|
||||||
|
|
||||||
const deleteOpen = ref(false);
|
const deleteOpen = ref(false);
|
||||||
@@ -148,10 +149,42 @@ const visibleMembers = computed(() => {
|
|||||||
return showAllMembers.value ? role.members : role.members.slice(0, 3);
|
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<string>();
|
||||||
|
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(() =>
|
const inviteableRoles = computed(() =>
|
||||||
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
|
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 {
|
function tabFromHash(hash: string): TabKey {
|
||||||
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
|
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
|
||||||
if (hash.includes('roles')) return 'roles';
|
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;
|
roleFormId.value = null;
|
||||||
roleFormName.value = '';
|
roleFormName.value = '';
|
||||||
roleFormDesc.value = '';
|
roleFormDesc.value = '';
|
||||||
roleFormColor.value = 'teal';
|
roleFormColor.value = (parent && ROLE_COLOR_OPTIONS.some((c) => c.value === parent.color)
|
||||||
roleFormPerms.value = ROLE_PERMISSION_ITEMS.map((p) => p.key);
|
? parent.color
|
||||||
|
: 'teal') as RoleColor;
|
||||||
|
roleFormParentId.value = parent?.id ?? '';
|
||||||
|
roleFormPerms.value = parent
|
||||||
|
? [...parent.permissions]
|
||||||
|
: ROLE_PERMISSION_ITEMS.map((p) => p.key);
|
||||||
roleFormOpen.value = true;
|
roleFormOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreateChildRole() {
|
||||||
|
if (!selectedRole.value) return;
|
||||||
|
openCreateRole(selectedRole.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
function openEditRole() {
|
function openEditRole() {
|
||||||
const role = selectedRole.value;
|
const role = selectedRole.value;
|
||||||
if (!role) return;
|
if (!role) return;
|
||||||
@@ -362,6 +421,7 @@ function openEditRole() {
|
|||||||
roleFormColor.value = (ROLE_COLOR_OPTIONS.some((c) => c.value === role.color)
|
roleFormColor.value = (ROLE_COLOR_OPTIONS.some((c) => c.value === role.color)
|
||||||
? role.color
|
? role.color
|
||||||
: 'teal') as RoleColor;
|
: 'teal') as RoleColor;
|
||||||
|
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
||||||
roleFormPerms.value = [...role.permissions];
|
roleFormPerms.value = [...role.permissions];
|
||||||
roleFormOpen.value = true;
|
roleFormOpen.value = true;
|
||||||
}
|
}
|
||||||
@@ -384,12 +444,24 @@ async function submitRoleForm() {
|
|||||||
roleFormSaving.value = true;
|
roleFormSaving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
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(),
|
name: roleFormName.value.trim(),
|
||||||
description: roleFormDesc.value.trim(),
|
description: roleFormDesc.value.trim(),
|
||||||
color: roleFormColor.value,
|
color: roleFormColor.value,
|
||||||
permissions: roleFormPerms.value,
|
permissions: roleFormPerms.value,
|
||||||
};
|
};
|
||||||
|
if (!editing?.systemKey) {
|
||||||
|
body.parentId = roleFormParentId.value || null;
|
||||||
|
}
|
||||||
if (roleFormId.value) {
|
if (roleFormId.value) {
|
||||||
const { data } = await api.patch<OrgRoleDto>(`/org-roles/${roleFormId.value}`, body);
|
const { data } = await api.patch<OrgRoleDto>(`/org-roles/${roleFormId.value}`, body);
|
||||||
selectedRoleId.value = data.id;
|
selectedRoleId.value = data.id;
|
||||||
@@ -564,7 +636,7 @@ onUnmounted(() => {
|
|||||||
:aria-selected="accessFocusRole?.id === role.id"
|
:aria-selected="accessFocusRole?.id === role.id"
|
||||||
@click="accessFocusId = role.id"
|
@click="accessFocusId = role.id"
|
||||||
>
|
>
|
||||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
<span class="pill" :data-color="role.color">{{ roleTreeLabel(role) }}</span>
|
||||||
<small>{{ permCountLabel(role) }}</small>
|
<small>{{ permCountLabel(role) }}</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -590,7 +662,7 @@ onUnmounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>مجوز</th>
|
<th>مجوز</th>
|
||||||
<th v-for="role in roles" :key="role.id">{{ role.name }}</th>
|
<th v-for="role in roles" :key="role.id">{{ roleTreeLabel(role) }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -638,9 +710,42 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h3>{{ selectedRole.name }}</h3>
|
<h3>{{ selectedRole.name }}</h3>
|
||||||
<p>{{ selectedRole.description || '—' }}</p>
|
<p>{{ selectedRole.description || '—' }}</p>
|
||||||
|
<p v-if="selectedRole.parentName" class="parent-line">
|
||||||
|
زیرمجموعهٔ
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="linkish"
|
||||||
|
@click="pickRole(selectedRole.parentId!)"
|
||||||
|
>
|
||||||
|
{{ selectedRole.parentName }}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="selectedChildRoles.length || canEditRoles" class="panel-block">
|
||||||
|
<div class="block-head">
|
||||||
|
<h4>زیرنقشها</h4>
|
||||||
|
<button
|
||||||
|
v-if="canEditRoles && (selectedRole.depth ?? 0) < 4"
|
||||||
|
type="button"
|
||||||
|
class="linkish"
|
||||||
|
@click="openCreateChildRole"
|
||||||
|
>
|
||||||
|
+ زیرنقش
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul v-if="selectedChildRoles.length" class="child-roles">
|
||||||
|
<li v-for="child in selectedChildRoles" :key="child.id">
|
||||||
|
<button type="button" class="child-role-btn" @click="pickRole(child.id)">
|
||||||
|
<span class="pill" :data-color="child.color">{{ child.name }}</span>
|
||||||
|
<small>{{ toFaDigits(child.userCount) }} کاربر</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="empty">هنوز زیرنقشی تعریف نشده.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel-block">
|
<section class="panel-block">
|
||||||
<h4>مجوزهای دسترسی</h4>
|
<h4>مجوزهای دسترسی</h4>
|
||||||
<ul class="perms">
|
<ul class="perms">
|
||||||
@@ -677,6 +782,14 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer v-if="canEditRoles" class="panel-actions">
|
<footer v-if="canEditRoles" class="panel-actions">
|
||||||
|
<button
|
||||||
|
v-if="(selectedRole.depth ?? 0) < 4"
|
||||||
|
type="button"
|
||||||
|
class="btn outline"
|
||||||
|
@click="openCreateChildRole"
|
||||||
|
>
|
||||||
|
+ زیرنقش
|
||||||
|
</button>
|
||||||
<button type="button" class="btn outline" @click="openEditRole">
|
<button type="button" class="btn outline" @click="openEditRole">
|
||||||
<UiIcon name="pencil" :size="16" />
|
<UiIcon name="pencil" :size="16" />
|
||||||
ویرایش نقش
|
ویرایش نقش
|
||||||
@@ -740,6 +853,7 @@ onUnmounted(() => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>نام و نام خانوادگی</th>
|
<th>نام و نام خانوادگی</th>
|
||||||
<th>نقش</th>
|
<th>نقش</th>
|
||||||
|
<th>پورسانت %</th>
|
||||||
<th>وضعیت</th>
|
<th>وضعیت</th>
|
||||||
<th>
|
<th>
|
||||||
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
|
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
|
||||||
@@ -763,6 +877,21 @@ onUnmounted(() => {
|
|||||||
{{ user.orgRoleName || '—' }}
|
{{ user.orgRoleName || '—' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td @click.stop>
|
||||||
|
<label class="comm-field">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
dir="ltr"
|
||||||
|
:value="user.commissionPercent ?? 0"
|
||||||
|
:disabled="!canInvite"
|
||||||
|
:aria-label="`پورسانت ${user.fullName}`"
|
||||||
|
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status" :class="{ off: user.isActive === false }">
|
<span class="status" :class="{ off: user.isActive === false }">
|
||||||
<i />
|
<i />
|
||||||
@@ -788,7 +917,7 @@ onUnmounted(() => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!loading && !pagedUsers.length">
|
<tr v-if="!loading && !pagedUsers.length">
|
||||||
<td colspan="5" class="empty">کاربری یافت نشد.</td>
|
<td colspan="6" class="empty">کاربری یافت نشد.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -805,26 +934,51 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="status" :class="{ off: user.isActive === false }">
|
<div class="card-meta" @click.stop>
|
||||||
<i />
|
<label class="comm-field">
|
||||||
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
<span>پورسانت %</span>
|
||||||
</span>
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
dir="ltr"
|
||||||
|
:value="user.commissionPercent ?? 0"
|
||||||
|
:disabled="!canInvite"
|
||||||
|
:aria-label="`پورسانت ${user.fullName}`"
|
||||||
|
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="status" :class="{ off: user.isActive === false }">
|
||||||
|
<i />
|
||||||
|
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
|
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tab === 'roles'" class="roles-grid">
|
<div v-if="tab === 'roles'" class="roles-tree">
|
||||||
<button
|
<button
|
||||||
v-for="role in roles"
|
v-for="role in roles"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="role-card"
|
class="role-card"
|
||||||
:class="{ on: selectedRole?.id === role.id }"
|
:class="{ on: selectedRole?.id === role.id, child: (role.depth ?? 0) > 0 }"
|
||||||
|
:style="{ '--role-depth': String(role.depth ?? 0) }"
|
||||||
@click="pickRole(role.id)"
|
@click="pickRole(role.id)"
|
||||||
>
|
>
|
||||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
<div class="role-card-top">
|
||||||
|
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
||||||
|
<span v-if="role.childCount" class="child-badge">
|
||||||
|
{{ toFaDigits(role.childCount) }} زیرنقش
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p>{{ role.description || 'بدون توضیح' }}</p>
|
<p>{{ role.description || 'بدون توضیح' }}</p>
|
||||||
<small>{{ toFaDigits(role.userCount) }} کاربر</small>
|
<small>
|
||||||
|
{{ toFaDigits(role.userCount) }} کاربر
|
||||||
|
<template v-if="role.parentName"> · زیرمجموعهٔ {{ role.parentName }}</template>
|
||||||
|
</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -859,7 +1013,9 @@ onUnmounted(() => {
|
|||||||
<label>
|
<label>
|
||||||
نقش
|
نقش
|
||||||
<select v-model="inviteRoleId">
|
<select v-model="inviteRoleId">
|
||||||
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">{{ role.name }}</option>
|
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">
|
||||||
|
{{ roleTreeLabel(role) }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
@@ -871,10 +1027,23 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</UiModal>
|
</UiModal>
|
||||||
|
|
||||||
<UiModal :open="roleFormOpen" :title="roleFormId ? 'ویرایش نقش' : 'نقش جدید'" @close="roleFormOpen = false">
|
<UiModal
|
||||||
|
:open="roleFormOpen"
|
||||||
|
:title="roleFormId ? 'ویرایش نقش' : roleFormParentId ? 'زیرنقش جدید' : 'نقش جدید'"
|
||||||
|
@close="roleFormOpen = false"
|
||||||
|
>
|
||||||
<form class="form" @submit.prevent="submitRoleForm">
|
<form class="form" @submit.prevent="submitRoleForm">
|
||||||
<label>نام نقش<input v-model="roleFormName" required minlength="2" /></label>
|
<label>نام نقش<input v-model="roleFormName" required minlength="2" /></label>
|
||||||
<label>توضیح<textarea v-model="roleFormDesc" rows="3" /></label>
|
<label>توضیح<textarea v-model="roleFormDesc" rows="3" /></label>
|
||||||
|
<label v-if="!(roleFormId && roles.find((r) => r.id === roleFormId)?.systemKey)">
|
||||||
|
نقش والد
|
||||||
|
<select v-model="roleFormParentId">
|
||||||
|
<option value="">بدون والد (نقش اصلی)</option>
|
||||||
|
<option v-for="role in parentRoleOptions" :key="role.id" :value="role.id">
|
||||||
|
{{ roleTreeLabel(role) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
رنگ
|
رنگ
|
||||||
<select v-model="roleFormColor">
|
<select v-model="roleFormColor">
|
||||||
@@ -1450,6 +1619,7 @@ tbody tr + tr td {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1457,12 +1627,46 @@ tbody tr + tr td {
|
|||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.roles-grid {
|
.card-meta {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-field input {
|
||||||
|
width: 4.8rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-field input:disabled {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-grid,
|
||||||
|
.roles-tree {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.roles-tree {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.role-card {
|
.role-card {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -1470,11 +1674,83 @@ tbody tr + tr td {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.roles-tree .role-card {
|
||||||
|
margin-inline-start: calc(var(--role-depth, 0) * 1.25rem);
|
||||||
|
border-inline-start: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-tree .role-card.child {
|
||||||
|
border-inline-start-color: #99f6e4;
|
||||||
|
background: #f8fffd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f766e;
|
||||||
|
background: #ccfbf1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.role-card.on {
|
.role-card.on {
|
||||||
border-color: #99f6e4;
|
border-color: #99f6e4;
|
||||||
box-shadow: 0 0 0 2px #ccfbf1;
|
box-shadow: 0 0 0 2px #ccfbf1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parent-line {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-roles {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-role-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-role-btn small {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.access-wrap,
|
.access-wrap,
|
||||||
.org-grid,
|
.org-grid,
|
||||||
.form {
|
.form {
|
||||||
@@ -1635,7 +1911,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.org-grid,
|
.org-grid,
|
||||||
.roles-grid {
|
.roles-grid,
|
||||||
|
.roles-tree {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,14 @@ export interface OrgRoleDto {
|
|||||||
color: string;
|
color: string;
|
||||||
permissions: RolePermissionKey[];
|
permissions: RolePermissionKey[];
|
||||||
systemKey: UserRole | null;
|
systemKey: UserRole | null;
|
||||||
|
/** نقش والد؛ null یعنی ریشه */
|
||||||
|
parentId: string | null;
|
||||||
|
/** نام نقش والد برای نمایش در UI */
|
||||||
|
parentName: string | null;
|
||||||
|
/** عمق در درخت (۰ = ریشه) */
|
||||||
|
depth: number;
|
||||||
|
/** تعداد زیرنقشهای مستقیم */
|
||||||
|
childCount: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
members: OrgRoleMemberDto[];
|
members: OrgRoleMemberDto[];
|
||||||
}
|
}
|
||||||
@@ -384,6 +392,8 @@ export interface CreateOrgRoleRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
|
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateOrgRoleRequest {
|
export interface UpdateOrgRoleRequest {
|
||||||
@@ -391,6 +401,8 @@ export interface UpdateOrgRoleRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
|
/** null یعنی جدا کردن از والد و تبدیل به ریشه */
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InviteUserRequest {
|
export interface InviteUserRequest {
|
||||||
@@ -947,7 +959,10 @@ export interface ActivityDto {
|
|||||||
person: PersonSummaryDto | null;
|
person: PersonSummaryDto | null;
|
||||||
company: CompanySummaryDto | null;
|
company: CompanySummaryDto | null;
|
||||||
deal: DealSummaryDto | null;
|
deal: DealSummaryDto | null;
|
||||||
|
/** مسئول اصلی (اولین مسئول) — سازگاری با فیلترها و یادآور */
|
||||||
assignee: UserSummaryDto | null;
|
assignee: UserSummaryDto | null;
|
||||||
|
/** همه مسئولان انجام فعالیت */
|
||||||
|
assignees: UserSummaryDto[];
|
||||||
createdBy: UserSummaryDto | null;
|
createdBy: UserSummaryDto | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -967,7 +982,10 @@ export interface CreateActivityRequest {
|
|||||||
personId?: string | null;
|
personId?: string | null;
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
|
/** سازگاری — اگر assigneeIds نباشد از این استفاده میشود */
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
|
/** چند مسئول؛ اولی بهعنوان assigneeId ذخیره میشود */
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateActivityRequest {
|
export interface UpdateActivityRequest {
|
||||||
@@ -986,6 +1004,7 @@ export interface UpdateActivityRequest {
|
|||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivitySummaryDto {
|
export interface ActivitySummaryDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user