Add named teams and per-user data visibility (own/team/org) with settings UI and org module.

This commit is contained in:
2026-09-26 17:15:09 +03:30
parent 499d13d1df
commit 1b160c8cd3
17 changed files with 1097 additions and 28 deletions
@@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "dataVisibility" TEXT NOT NULL DEFAULT 'own';
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'member',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Team_organizationId_idx" ON "Team"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "Team_organizationId_name_key" ON "Team"("organizationId", "name");
-- CreateIndex
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
-- CreateIndex
CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+34
View File
@@ -155,6 +155,7 @@ model Organization {
users User[]
memberships Membership[]
orgRoles OrgRole[]
teams Team[]
companies Company[]
people Person[]
pipelines Pipeline[]
@@ -241,6 +242,7 @@ model User {
auditLogs AuditLog[] @relation("AuditLogActor")
savedViews SavedView[] @relation("SavedViewOwner")
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
teamMemberships TeamMember[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -281,6 +283,8 @@ model Membership {
orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull)
isActive Boolean @default(true)
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
/// own | team | org — سطح دید داده‌ها برای نقش‌های غیرمالک/مدیر
dataVisibility String @default("own")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -290,6 +294,36 @@ model Membership {
@@index([orgRoleId])
}
/// گروه کاری داخل سازمان برای دیدپذیری مشترک
model Team {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
description String @default("")
members TeamMember[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId])
}
model TeamMember {
id String @id @default(cuid())
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// member | manager
role String @default("member")
createdAt DateTime @default(now())
@@unique([teamId, userId])
@@index([userId])
@@index([teamId])
}
/// حساب سوپرادمین پلتفرم — جدا از User سازمانی
model PlatformAdmin {
id String @id @default(cuid())
+2
View File
@@ -38,6 +38,7 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module';
import { SavedViewsModule } from './saved-views/saved-views.module';
import { PublicApiModule } from './public-api/public-api.module';
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
@Module({
imports: [
@@ -83,6 +84,7 @@ import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
SavedViewsModule,
PublicApiModule,
SmsCampaignsModule,
TeamsModule,
],
})
export class AppModule {}
+46 -1
View File
@@ -2,8 +2,11 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { DataVisibility } from '@mento/shared';
import { BillingService } from '../billing/billing.service';
import { normalizeDataVisibility } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
import { canViewAllRecords } from '@mento/shared';
export type JwtPayload = {
sub: string;
@@ -50,16 +53,58 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
isActive: true,
organization: { isActive: true },
},
select: { organizationId: true, role: true },
select: { organizationId: true, role: true, dataVisibility: true },
});
if (!membership) {
throw new UnauthorizedException();
}
await this.billing.requireActiveForOrganization(membership.organizationId);
const dataVisibility = normalizeDataVisibility(membership.dataVisibility);
const visibleUserIds = await this.resolveVisibleUserIds(
membership.organizationId,
user.id,
membership.role,
dataVisibility,
);
return {
userId: user.id,
organizationId: membership.organizationId,
role: membership.role,
dataVisibility,
visibleUserIds,
};
}
private async resolveVisibleUserIds(
organizationId: string,
userId: string,
role: string,
dataVisibility: DataVisibility,
): Promise<string[] | null> {
if (canViewAllRecords(role) || dataVisibility === 'org') {
return null;
}
if (dataVisibility === 'own') {
return [userId];
}
const memberships = await this.prisma.teamMember.findMany({
where: {
userId,
team: { organizationId },
},
select: { teamId: true },
});
if (!memberships.length) return [userId];
const peers = await this.prisma.teamMember.findMany({
where: {
teamId: { in: memberships.map((m) => m.teamId) },
},
select: { userId: true },
});
return [...new Set([userId, ...peers.map((p) => p.userId)])];
}
}
+62 -20
View File
@@ -1,4 +1,4 @@
import type { UserRole } from '@mento/shared';
import type { DataVisibility, UserRole } from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
@@ -6,39 +6,73 @@ export type Actor = {
userId: string;
role: string;
organizationId: string;
/** own | team | org — از Membership */
dataVisibility?: DataVisibility;
/**
* شناسه کاربرانی که پرونده‌شان برای این Actor قابل‌مشاهده است.
* اگر undefined باشد فقط خود کاربر؛ اگر null یعنی همه (elevated/org).
*/
visibleUserIds?: string[] | null;
};
export function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
export function seesAllRecords(
actor: Pick<Actor, 'role' | 'dataVisibility' | 'visibleUserIds'>,
): boolean {
if (elevated(actor.role)) return true;
if (actor.dataVisibility === 'org') return true;
if (actor.visibleUserIds === null) return true;
return false;
}
function scopedOwnerIds(
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): string[] | null {
if (seesAllRecords(actor)) return null;
const ids = actor.visibleUserIds?.length
? actor.visibleUserIds
: [actor.userId];
return [...new Set(ids.filter(Boolean))];
}
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
export function ownerWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { ownerId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { ownerId: actor.userId };
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const ids = scopedOwnerIds(actor);
if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! };
return { ownerId: { in: ids } };
}
/** فعالیت‌ها بر اساس مسئول */
export function assigneeWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { assigneeId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { assigneeId: actor.userId };
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
):
| { assigneeId: string }
| { assigneeId: { in: string[] } }
| Record<string, never> {
const ids = scopedOwnerIds(actor);
if (!ids) return {};
if (ids.length === 1) return { assigneeId: ids[0]! };
return { assigneeId: { in: ids } };
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت */
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید */
export function noteAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): Prisma.NoteWhereInput {
if (elevated(actor.role)) return {};
const ids = scopedOwnerIds(actor);
if (!ids) return {};
return {
OR: [
{ authorId: actor.userId },
{ deal: { ownerId: actor.userId } },
{ person: { ownerId: actor.userId } },
{ company: { ownerId: actor.userId } },
{ authorId: { in: ids } },
{ deal: { ownerId: { in: ids } } },
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
};
}
@@ -53,13 +87,14 @@ export function resolveOwnerId(
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده */
/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */
export function ticketAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
const ids = scopedOwnerIds(actor);
if (!ids) return {};
return {
OR: [{ assigneeId: actor.userId }, { reporterId: actor.userId }],
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
};
}
@@ -68,3 +103,10 @@ export function canReassignOwner(role: string | null | undefined): boolean {
}
export type OrgRole = UserRole;
export function normalizeDataVisibility(
value: string | null | undefined,
): DataVisibility {
if (value === 'team' || value === 'org' || value === 'own') return value;
return 'own';
}
+5 -2
View File
@@ -403,13 +403,16 @@ export class SalesService {
actor: Actor,
filterUserId?: string,
): Prisma.SalesGoalWhereInput {
if (elevated(actor.role)) {
if (elevated(actor.role) || actor.dataVisibility === 'org' || actor.visibleUserIds === null) {
if (filterUserId === 'org') return { userId: null };
if (filterUserId) return { userId: filterUserId };
return {};
}
const ids = actor.visibleUserIds?.length
? actor.visibleUserIds
: [actor.userId];
return {
OR: [{ userId: actor.userId }, { userId: null }],
OR: [{ userId: { in: ids } }, { userId: null }],
};
}
+60
View File
@@ -0,0 +1,60 @@
import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateTeamDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
managerIds?: string[];
}
export class UpdateTeamDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
name?: string;
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
}
class TeamMemberInputDto {
@IsString()
userId!: string;
@IsIn(['member', 'manager'])
role!: 'member' | 'manager';
}
export class SetTeamMembersDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeamMemberInputDto)
members!: TeamMemberInputDto[];
}
+70
View File
@@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { CreateTeamDto, SetTeamMembersDto, UpdateTeamDto } from './dto';
import { TeamsService } from './teams.service';
@Controller('teams')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('teams')
export class TeamsController {
constructor(private readonly teams: TeamsService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.teams.list(user.organizationId);
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateTeamDto,
) {
return this.teams.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateTeamDto,
) {
return this.teams.update(user.organizationId, id, dto);
}
@Put(':id/members')
@Roles('owner', 'admin')
setMembers(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: SetTeamMembersDto,
) {
return this.teams.setMembers(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.teams.remove(user.organizationId, id);
return { ok: true };
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { TeamsController } from './teams.controller';
import { TeamsService } from './teams.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [TeamsController],
providers: [TeamsService],
exports: [TeamsService],
})
export class TeamsModule {}
+244
View File
@@ -0,0 +1,244 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
CreateTeamRequest,
SetTeamMembersRequest,
TeamDto,
TeamMemberDto,
TeamMemberRole,
UpdateTeamRequest,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TeamsService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<TeamDto[]> {
const rows = await this.prisma.team.findMany({
where: { organizationId },
include: {
members: {
include: {
user: {
select: {
id: true,
fullName: true,
avatarStorageKey: true,
},
},
},
orderBy: [{ role: 'asc' }, { user: { fullName: 'asc' } }],
},
},
orderBy: { name: 'asc' },
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateTeamRequest,
): Promise<TeamDto> {
const name = dto.name?.trim();
if (!name) throw new BadRequestException('نام تیم الزامی است');
const managerIds = new Set((dto.managerIds ?? []).filter(Boolean));
const memberIds = [
...new Set([
...(dto.memberIds ?? []).filter(Boolean),
...managerIds,
]),
];
if (memberIds.length) {
await this.assertMembersInOrg(organizationId, memberIds);
}
try {
const row = await this.prisma.team.create({
data: {
organizationId,
name,
description: dto.description?.trim() ?? '',
members: memberIds.length
? {
create: memberIds.map((userId) => ({
userId,
role: managerIds.has(userId) ? 'manager' : 'member',
})),
}
: undefined,
},
include: {
members: {
include: {
user: {
select: {
id: true,
fullName: true,
avatarStorageKey: true,
},
},
},
},
},
});
return this.toDto(row);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('تیمی با این نام از قبل وجود دارد');
}
throw e;
}
}
async update(
organizationId: string,
id: string,
dto: UpdateTeamRequest,
): Promise<TeamDto> {
await this.ensureTeam(organizationId, id);
const data: Prisma.TeamUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام تیم الزامی است');
data.name = name;
}
if (dto.description !== undefined) {
data.description = dto.description.trim();
}
try {
await this.prisma.team.update({ where: { id }, data });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('تیمی با این نام از قبل وجود دارد');
}
throw e;
}
return this.get(organizationId, id);
}
async remove(organizationId: string, id: string): Promise<void> {
await this.ensureTeam(organizationId, id);
await this.prisma.team.delete({ where: { id } });
}
async setMembers(
organizationId: string,
id: string,
dto: SetTeamMembersRequest,
): Promise<TeamDto> {
await this.ensureTeam(organizationId, id);
const members = dto.members ?? [];
const userIds = [...new Set(members.map((m) => m.userId).filter(Boolean))];
if (userIds.length) {
await this.assertMembersInOrg(organizationId, userIds);
}
const byUser = new Map<string, TeamMemberRole>();
for (const m of members) {
if (!m.userId) continue;
byUser.set(m.userId, m.role === 'manager' ? 'manager' : 'member');
}
await this.prisma.$transaction([
this.prisma.teamMember.deleteMany({ where: { teamId: id } }),
...(byUser.size
? [
this.prisma.teamMember.createMany({
data: [...byUser.entries()].map(([userId, role]) => ({
teamId: id,
userId,
role,
})),
}),
]
: []),
]);
return this.get(organizationId, id);
}
private async get(organizationId: string, id: string): Promise<TeamDto> {
const row = await this.prisma.team.findFirst({
where: { id, organizationId },
include: {
members: {
include: {
user: {
select: {
id: true,
fullName: true,
avatarStorageKey: true,
},
},
},
orderBy: [{ role: 'asc' }, { user: { fullName: 'asc' } }],
},
},
});
if (!row) throw new NotFoundException('تیم پیدا نشد');
return this.toDto(row);
}
private async ensureTeam(organizationId: string, id: string) {
const row = await this.prisma.team.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!row) throw new NotFoundException('تیم پیدا نشد');
}
private async assertMembersInOrg(
organizationId: string,
userIds: string[],
) {
const count = await this.prisma.membership.count({
where: {
organizationId,
userId: { in: userIds },
isActive: true,
},
});
if (count !== userIds.length) {
throw new BadRequestException('برخی کاربران عضو فعال این محیط نیستند');
}
}
private toDto(row: {
id: string;
name: string;
description: string;
createdAt: Date;
updatedAt: Date;
members: Array<{
role: string;
user: {
id: string;
fullName: string;
avatarStorageKey: string | null;
};
}>;
}): TeamDto {
const members: TeamMemberDto[] = row.members.map((m) => ({
userId: m.user.id,
fullName: m.user.fullName,
role: m.role === 'manager' ? 'manager' : 'member',
hasAvatar: Boolean(m.user.avatarStorageKey),
}));
return {
id: row.id,
name: row.name,
description: row.description,
memberCount: members.length,
members,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+5
View File
@@ -67,3 +67,8 @@ export class UpdateUserCommissionDto {
@Max(100)
commissionPercent!: number;
}
export class UpdateUserVisibilityDto {
@IsIn(['own', 'team', 'org'])
dataVisibility!: 'own' | 'team' | 'org';
}
+16
View File
@@ -18,6 +18,7 @@ import {
UpdateUserCommissionDto,
UpdateUserRoleDto,
UpdateUserStatusDto,
UpdateUserVisibilityDto,
} from './dto';
import { UsersService } from './users.service';
@@ -79,6 +80,21 @@ export class UsersController {
);
}
@Patch(':id/visibility')
@Roles('owner', 'admin')
updateVisibility(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserVisibilityDto,
) {
return this.usersService.updateVisibility(
user.organizationId,
user.role,
id,
dto.dataVisibility,
);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
+42 -1
View File
@@ -5,7 +5,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
import type {
DataVisibility,
InviteUserRequest,
OrgUserDto,
UserRole,
} from '@mento/shared';
import { DATA_VISIBILITIES } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
@@ -32,6 +38,7 @@ type MembershipRow = {
orgRoleId: string | null;
isActive: boolean;
commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
user: {
@@ -155,6 +162,34 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updateVisibility(
organizationId: string,
actorRole: UserRole,
targetId: string,
dataVisibility: DataVisibility,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر سطح دید را ندارید');
}
if (!DATA_VISIBILITIES.includes(dataVisibility)) {
throw new BadRequestException('سطح دید نامعتبر است');
}
const target = await this.memberships.findUserInOrgById(
organizationId,
targetId,
);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException(
'مالک و مدیر همیشه همه پرونده‌ها را می‌بینند',
);
}
await this.prisma.membership.update({
where: { id: target.id },
data: { dataVisibility },
});
return this.dtoOf(organizationId, targetId);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
@@ -326,6 +361,12 @@ export class UsersService {
lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null,
hasAvatar: Boolean(row.user.avatarStorageKey),
commissionPercent: Number(row.commissionPercent ?? 0),
dataVisibility:
row.role === 'owner' || row.role === 'admin'
? 'org'
: DATA_VISIBILITIES.includes(row.dataVisibility as DataVisibility)
? (row.dataVisibility as DataVisibility)
: 'own',
createdAt: row.createdAt.toISOString(),
};
}
@@ -0,0 +1,362 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
OrgUserDto,
TeamDto,
TeamMemberRole,
} from '@mento/shared';
import {
DATA_VISIBILITY_LABELS,
DATA_VISIBILITIES,
type DataVisibility,
} from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import { toFaDigits } from '@/lib/jalali';
const props = defineProps<{
users: OrgUserDto[];
canEdit: boolean;
}>();
const emit = defineEmits<{
refreshUsers: [];
}>();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const teams = ref<TeamDto[]>([]);
const newName = ref('');
const newDesc = ref('');
const editingId = ref<string | null>(null);
const memberDraft = ref<Record<string, TeamMemberRole>>({});
const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<TeamDto[]>('/teams');
teams.value = data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول تیم‌ها و دیدپذیری برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری تیم‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createTeam() {
if (!newName.value.trim()) {
error.value = 'نام تیم را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.post('/teams', {
name: newName.value.trim(),
description: newDesc.value.trim(),
});
newName.value = '';
newDesc.value = '';
ok.value = 'تیم ساخته شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ساخت تیم ناموفق بود.')
: 'ساخت تیم ناموفق بود.';
} finally {
saving.value = false;
}
}
function startEdit(team: TeamDto) {
editingId.value = team.id;
const draft: Record<string, TeamMemberRole> = {};
for (const m of team.members) {
draft[m.userId] = m.role;
}
memberDraft.value = draft;
}
function toggleMember(userId: string) {
if (memberDraft.value[userId]) {
const next = { ...memberDraft.value };
delete next[userId];
memberDraft.value = next;
} else {
memberDraft.value = { ...memberDraft.value, [userId]: 'member' };
}
}
function setRole(userId: string, role: TeamMemberRole) {
if (!memberDraft.value[userId]) return;
memberDraft.value = { ...memberDraft.value, [userId]: role };
}
async function saveMembers() {
if (!editingId.value) return;
saving.value = true;
error.value = '';
try {
await api.put(`/teams/${editingId.value}/members`, {
members: Object.entries(memberDraft.value).map(([userId, role]) => ({
userId,
role,
})),
});
ok.value = 'اعضای تیم ذخیره شد.';
editingId.value = null;
await load();
} catch {
error.value = 'ذخیره اعضا ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeTeam(id: string) {
if (!confirm('این تیم حذف شود؟')) return;
try {
await api.delete(`/teams/${id}`);
if (editingId.value === id) editingId.value = null;
await load();
} catch {
error.value = 'حذف تیم ناموفق بود.';
}
}
async function setVisibility(userId: string, dataVisibility: DataVisibility) {
try {
await api.patch(`/users/${userId}/visibility`, { dataVisibility });
ok.value = 'سطح دید به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.';
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'تغییر سطح دید ناموفق بود.')
: 'تغییر سطح دید ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="teams-panel">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<section v-if="canEdit" class="card">
<h3>تیم جدید</h3>
<div class="row">
<UiField label="نام تیم">
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="newDesc" placeholder="اختیاری" />
</UiField>
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
</div>
</section>
<section class="card">
<h3>تیم‌ها</h3>
<UiEmptyState
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
<div v-else class="team-list">
<article v-for="t in teams" :key="t.id" class="team">
<header>
<div>
<strong>{{ t.name }}</strong>
<p class="muted">
{{ t.description || '—' }}
· {{ toFaDigits(t.memberCount) }} عضو
</p>
</div>
<div v-if="canEdit" class="actions">
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
</div>
</header>
<p class="members">
{{
t.members.length
? t.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ')
: 'بدون عضو'
}}
</p>
<div v-if="editingId === t.id" class="editor">
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
<input
type="checkbox"
:checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)"
/>
<span>{{ u.fullName }}</span>
<select
v-if="memberDraft[u.id]"
:value="memberDraft[u.id]"
@change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
>
<option value="member">عضو</option>
<option value="manager">سرپرست</option>
</select>
</label>
<div class="actions">
<UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
<UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
</div>
</div>
</article>
</div>
</section>
<section class="card">
<h3>سطح دید داده‌ها</h3>
<p class="hint">
مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه: فقط خود، تیم‌های عضو، یا کل سازمان.
</p>
<UiEmptyState
v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی سطح دید جدا دارند."
/>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-row">
<span>{{ u.fullName }}</span>
<select
:value="u.dataVisibility ?? 'own'"
:disabled="!canEdit"
@change="
setVisibility(
u.id,
($event.target as HTMLSelectElement).value as DataVisibility,
)
"
>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
{{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.teams-panel {
display: grid;
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 1rem;
}
.card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
.team-list {
display: grid;
gap: 0.75rem;
}
.team {
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 0.75rem;
}
.team header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.muted,
.hint,
.members {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.editor {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.45rem;
}
.person {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
}
.vis-table {
display: grid;
gap: 0.5rem;
}
.vis-row {
display: grid;
grid-template-columns: 1fr 220px;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
border-bottom: 1px solid #f1f5f9;
}
select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.row,
.vis-row {
grid-template-columns: 1fr;
}
}
</style>
+9
View File
@@ -350,6 +350,15 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'violet',
module: 'team_settings',
},
{
to: '/settings/team#settings-teams',
title: 'تیم‌ها و دیدپذیری',
desc: 'گروه‌های کاری و سطح مشاهده پرونده‌ها',
icon: 'users',
tint: 'sky',
module: 'teams',
admin: true,
},
{
to: '/settings/audit-logs',
title: 'لاگ فعالیت کاربران',
+20 -3
View File
@@ -23,8 +23,9 @@ import {
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
type TabKey = 'users' | 'roles' | 'access';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
const PAGE_SIZE = 10;
@@ -154,12 +155,20 @@ const inviteableRoles = computed(() =>
function tabFromHash(hash: string): TabKey {
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
if (hash.includes('roles')) return 'roles';
if (hash.includes('teams') || hash.includes('settings-teams')) return 'teams';
return 'users';
}
function setTab(next: TabKey) {
tab.value = next;
const hash = next === 'access' ? 'settings-org' : next === 'roles' ? 'roles' : 'settings-team';
const hash =
next === 'access'
? 'settings-org'
: next === 'roles'
? 'roles'
: next === 'teams'
? 'settings-teams'
: 'settings-team';
if (route.hash.replace('#', '') !== hash) {
void router.replace({ hash: `#${hash}` });
}
@@ -501,12 +510,20 @@ onUnmounted(() => {
<nav class="tabs" aria-label="بخش‌های مدیریت کاربران">
<button type="button" :class="{ on: tab === 'users' }" @click="setTab('users')">لیست کاربران</button>
<button type="button" :class="{ on: tab === 'roles' }" @click="setTab('roles')">نقش‌ها</button>
<button type="button" :class="{ on: tab === 'teams' }" @click="setTab('teams')">تیم‌ها و دیدپذیری</button>
<button type="button" :class="{ on: tab === 'access' }" @click="setTab('access')">تنظیمات دسترسی</button>
</nav>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<TeamsSettingsPanel
v-if="tab === 'teams'"
:users="users"
:can-edit="canInvite"
@refresh-users="load"
/>
<div v-if="tab === 'access'" class="access-wrap">
<section class="card org-card">
<h2>سازمان</h2>
@@ -597,7 +614,7 @@ onUnmounted(() => {
</section>
</div>
<div v-else class="stage" :class="{ 'panel-open': Boolean(selectedRole) }">
<div v-else-if="tab === 'users' || tab === 'roles'" class="stage" :class="{ 'panel-open': Boolean(selectedRole) }">
<aside v-if="selectedRole" class="role-panel" :class="{ sheet: isNarrow }">
<div v-if="isNarrow" class="sheet-handle" />
<header class="panel-head">
+58 -1
View File
@@ -120,7 +120,8 @@ export type OrgModuleKey =
| "audit_logs"
| "saved_views"
| "public_api"
| "sms_campaigns";
| "sms_campaigns"
| "teams";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers",
@@ -151,6 +152,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"saved_views",
"public_api",
"sms_campaigns",
"teams",
];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
@@ -182,6 +184,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
saved_views: "فیلترهای ذخیره‌شده",
public_api: "API و وب‌هوک",
sms_campaigns: "پیامک گروهی",
teams: "تیم‌ها و دیدپذیری",
};
export const ORG_MODULE_GROUPS: Array<{
@@ -227,6 +230,7 @@ export const ORG_MODULE_GROUPS: Array<{
title: "تنظیمات",
keys: [
"team_settings",
"teams",
"custom_fields",
"currencies",
"tahesab",
@@ -293,9 +297,25 @@ export interface OrgUserDto {
lastLoginAt?: string | null;
hasAvatar?: boolean;
commissionPercent?: number;
/** own = فقط خود | team = هم‌تیمی‌ها | org = کل سازمان */
dataVisibility?: DataVisibility;
createdAt: string;
}
export type DataVisibility = "own" | "team" | "org";
export const DATA_VISIBILITIES: DataVisibility[] = ["own", "team", "org"];
export const DATA_VISIBILITY_LABELS: Record<DataVisibility, string> = {
own: "فقط پرونده‌های خود",
team: "تیم‌های عضو",
org: "کل سازمان",
};
export interface UpdateUserVisibilityRequest {
dataVisibility: DataVisibility;
}
export type RolePermissionKey =
| "customers"
| "sales"
@@ -3086,3 +3106,40 @@ export interface SmsCampaignListResponse {
items: SmsCampaignDto[];
total: number;
}
/* ——— تیم‌ها و دیدپذیری ——— */
export type TeamMemberRole = "member" | "manager";
export interface TeamMemberDto {
userId: string;
fullName: string;
role: TeamMemberRole;
hasAvatar?: boolean;
}
export interface TeamDto {
id: string;
name: string;
description: string;
memberCount: number;
members: TeamMemberDto[];
createdAt: string;
updatedAt: string;
}
export interface CreateTeamRequest {
name: string;
description?: string;
memberIds?: string[];
managerIds?: string[];
}
export interface UpdateTeamRequest {
name?: string;
description?: string;
}
export interface SetTeamMembersRequest {
members: Array<{ userId: string; role: TeamMemberRole }>;
}