feat(activities): let a group show and limit its members
Empty membership stays open to the whole workspace; picking people restricts who can see that activity group. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "ActivityBoardMember" (
|
||||||
|
"boardId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId");
|
||||||
|
|
||||||
|
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -311,6 +311,7 @@ model User {
|
|||||||
tablePreferences UserTablePreference[]
|
tablePreferences UserTablePreference[]
|
||||||
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
||||||
teamMemberships TeamMember[]
|
teamMemberships TeamMember[]
|
||||||
|
activityBoardMembers ActivityBoardMember[]
|
||||||
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
||||||
assignedLeads Lead[] @relation("LeadAssignee")
|
assignedLeads Lead[] @relation("LeadAssignee")
|
||||||
createdLeads Lead[] @relation("LeadCreator")
|
createdLeads Lead[] @relation("LeadCreator")
|
||||||
@@ -766,6 +767,7 @@ model ActivityBoard {
|
|||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
stages ActivityStage[]
|
stages ActivityStage[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
|
members ActivityBoardMember[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -773,6 +775,17 @@ model ActivityBoard {
|
|||||||
@@index([organizationId, position])
|
@@index([organizationId, position])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// عضو گروه فعالیت؛ اگر گروهی هیچ عضوی نداشته باشد برای همهٔ محیط باز است
|
||||||
|
model ActivityBoardMember {
|
||||||
|
boardId String
|
||||||
|
board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([boardId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
model ActivityStage {
|
model ActivityStage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|||||||
@@ -72,14 +72,14 @@ export class ActivitiesController {
|
|||||||
|
|
||||||
@Get('boards')
|
@Get('boards')
|
||||||
listBoards(@CurrentUser() user: Actor) {
|
listBoards(@CurrentUser() user: Actor) {
|
||||||
return this.stagesService.listBoards(user.organizationId);
|
return this.stagesService.listBoards(user.organizationId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('boards')
|
@Post('boards')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
||||||
return this.stagesService.createBoard(user.organizationId, dto.name);
|
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('boards/:id')
|
@Patch('boards/:id')
|
||||||
@@ -103,7 +103,7 @@ export class ActivitiesController {
|
|||||||
|
|
||||||
@Get('stages')
|
@Get('stages')
|
||||||
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
||||||
return this.stagesService.list(user.organizationId, query.boardId);
|
return this.stagesService.list(user.organizationId, query.boardId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stages')
|
@Post('stages')
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export class ActivitiesService {
|
|||||||
const boardCtx = await this.stages.boardContext(organizationId);
|
const boardCtx = await this.stages.boardContext(organizationId);
|
||||||
if (facets?.boardId) {
|
if (facets?.boardId) {
|
||||||
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
|
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
|
||||||
|
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
|
||||||
(where.AND as Prisma.ActivityWhereInput[]).push(
|
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||||
this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
|
this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
|
||||||
);
|
);
|
||||||
@@ -320,6 +321,7 @@ export class ActivitiesService {
|
|||||||
facets?: ListFacets,
|
facets?: ListFacets,
|
||||||
): Promise<ActivityStageCountsDto> {
|
): Promise<ActivityStageCountsDto> {
|
||||||
const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
|
const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
|
||||||
|
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
|
||||||
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
||||||
const rows = await this.stages.rows(organizationId, boardId);
|
const rows = await this.stages.rows(organizationId, boardId);
|
||||||
const defaults = this.stages.defaultsOf(rows);
|
const defaults = this.stages.defaultsOf(rows);
|
||||||
@@ -451,6 +453,7 @@ export class ActivitiesService {
|
|||||||
: requestedBoardId
|
: requestedBoardId
|
||||||
? await this.stages.resolveBoardId(organizationId, requestedBoardId)
|
? await this.stages.resolveBoardId(organizationId, requestedBoardId)
|
||||||
: null;
|
: null;
|
||||||
|
if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor);
|
||||||
const resolved = await this.types.resolve(organizationId, {
|
const resolved = await this.types.resolve(organizationId, {
|
||||||
typeDefId: dto.typeDefId,
|
typeDefId: dto.typeDefId,
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
@@ -571,6 +574,7 @@ export class ActivitiesService {
|
|||||||
select: { boardId: true },
|
select: { boardId: true },
|
||||||
});
|
});
|
||||||
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
||||||
|
await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor);
|
||||||
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
|
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
|
||||||
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هموضعیت گروه جدید میرود
|
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هموضعیت گروه جدید میرود
|
||||||
data.boardId = nextBoardId;
|
data.boardId = nextBoardId;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
ActivityBoardDto,
|
ActivityBoardDto,
|
||||||
ActivityStageColor,
|
ActivityStageColor,
|
||||||
@@ -34,8 +39,23 @@ type BoardRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
|
members: Array<{ userId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toBoardDto(row: BoardRow): ActivityBoardDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
isDefault: row.isDefault,
|
||||||
|
position: row.position,
|
||||||
|
memberIds: row.members.map((member) => member.userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managesAllBoards(role: string) {
|
||||||
|
return role === 'owner' || role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
const STAGE_SELECT = {
|
const STAGE_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
boardId: true,
|
boardId: true,
|
||||||
@@ -50,6 +70,7 @@ const BOARD_SELECT = {
|
|||||||
name: true,
|
name: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
position: true,
|
position: true,
|
||||||
|
members: { select: { userId: true } },
|
||||||
} satisfies Prisma.ActivityBoardSelect;
|
} satisfies Prisma.ActivityBoardSelect;
|
||||||
|
|
||||||
export type StageDefaults = Record<ActivityStatus, string | null>;
|
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||||
@@ -109,14 +130,36 @@ export class ActivityStagesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> {
|
async listBoards(
|
||||||
|
organizationId: string,
|
||||||
|
actor?: { userId: string; role: string },
|
||||||
|
): Promise<ActivityBoardDto[]> {
|
||||||
await this.ensureDefaultBoard(organizationId);
|
await this.ensureDefaultBoard(organizationId);
|
||||||
const rows = await this.prisma.activityBoard.findMany({
|
const rows = await this.prisma.activityBoard.findMany({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
||||||
select: BOARD_SELECT,
|
select: BOARD_SELECT,
|
||||||
});
|
});
|
||||||
return rows.map((b) => ({ ...b }));
|
const boards = rows.map(toBoardDto);
|
||||||
|
if (!actor || managesAllBoards(actor.role)) return boards;
|
||||||
|
return boards.filter(
|
||||||
|
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروهها را میبینند */
|
||||||
|
async assertCanAccessBoard(
|
||||||
|
organizationId: string,
|
||||||
|
boardId: string,
|
||||||
|
actor: { userId: string; role: string },
|
||||||
|
) {
|
||||||
|
if (managesAllBoards(actor.role)) return;
|
||||||
|
const members = await this.prisma.activityBoardMember.findMany({
|
||||||
|
where: { boardId, board: { organizationId } },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
|
||||||
|
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
||||||
@@ -131,7 +174,11 @@ export class ActivityStagesService {
|
|||||||
return board.id;
|
return board.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> {
|
async createBoard(
|
||||||
|
organizationId: string,
|
||||||
|
name: string,
|
||||||
|
memberIds?: string[],
|
||||||
|
): Promise<ActivityBoardDto> {
|
||||||
await this.ensureDefaultBoard(organizationId);
|
await this.ensureDefaultBoard(organizationId);
|
||||||
const max = await this.prisma.activityBoard.aggregate({
|
const max = await this.prisma.activityBoard.aggregate({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
@@ -147,7 +194,8 @@ export class ActivityStagesService {
|
|||||||
select: BOARD_SELECT,
|
select: BOARD_SELECT,
|
||||||
});
|
});
|
||||||
await this.ensureBoardStages(organizationId, board.id);
|
await this.ensureBoardStages(organizationId, board.id);
|
||||||
return { ...board };
|
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
|
||||||
|
return this.getBoardDto(organizationId, board.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw this.mapBoardError(e);
|
throw this.mapBoardError(e);
|
||||||
}
|
}
|
||||||
@@ -156,21 +204,51 @@ export class ActivityStagesService {
|
|||||||
async updateBoard(
|
async updateBoard(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
dto: { name?: string },
|
dto: { name?: string; memberIds?: string[] },
|
||||||
): Promise<ActivityBoardDto> {
|
): Promise<ActivityBoardDto> {
|
||||||
await this.getBoard(organizationId, id);
|
await this.getBoard(organizationId, id);
|
||||||
try {
|
try {
|
||||||
const board = await this.prisma.activityBoard.update({
|
if (dto.name != null) {
|
||||||
|
await this.prisma.activityBoard.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {},
|
data: { name: this.cleanBoardName(dto.name) },
|
||||||
select: BOARD_SELECT,
|
|
||||||
});
|
});
|
||||||
return { ...board };
|
}
|
||||||
|
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
|
||||||
|
return this.getBoardDto(organizationId, id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw this.mapBoardError(e);
|
throw this.mapBoardError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) {
|
||||||
|
const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
if (ids.length) {
|
||||||
|
const found = await this.prisma.membership.findMany({
|
||||||
|
where: { organizationId, userId: { in: ids }, isActive: true },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (found.length !== ids.length) {
|
||||||
|
throw new BadRequestException('بعضی از اعضا در این محیط نیستند');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.prisma.activityBoardMember.deleteMany({ where: { boardId } });
|
||||||
|
if (ids.length) {
|
||||||
|
await this.prisma.activityBoardMember.createMany({
|
||||||
|
data: ids.map((userId) => ({ boardId, userId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBoardDto(organizationId: string, id: string): Promise<ActivityBoardDto> {
|
||||||
|
const board = await this.prisma.activityBoard.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
});
|
||||||
|
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
|
||||||
|
return toBoardDto(board);
|
||||||
|
}
|
||||||
|
|
||||||
/** فعالیتهای گروه حذفشده به گروه پیشفرض (ستون اولِ هموضعیت) منتقل میشوند */
|
/** فعالیتهای گروه حذفشده به گروه پیشفرض (ستون اولِ هموضعیت) منتقل میشوند */
|
||||||
async removeBoard(organizationId: string, id: string): Promise<void> {
|
async removeBoard(organizationId: string, id: string): Promise<void> {
|
||||||
const board = await this.getBoard(organizationId, id);
|
const board = await this.getBoard(organizationId, id);
|
||||||
@@ -213,8 +291,14 @@ export class ActivityStagesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
|
async list(
|
||||||
return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r));
|
organizationId: string,
|
||||||
|
boardId?: string | null,
|
||||||
|
actor?: { userId: string; role: string },
|
||||||
|
): Promise<ActivityStageDto[]> {
|
||||||
|
const resolved = await this.resolveBoardId(organizationId, boardId);
|
||||||
|
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
|
||||||
|
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ export class CreateActivityBoardDto {
|
|||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
memberIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateActivityBoardDto {
|
export class UpdateActivityBoardDto {
|
||||||
@@ -139,6 +144,11 @@ export class UpdateActivityBoardDto {
|
|||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
memberIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListActivityStagesQueryDto {
|
export class ListActivityStagesQueryDto {
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ const board = computed<string>(() => {
|
|||||||
});
|
});
|
||||||
const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null);
|
const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null);
|
||||||
const boardMenuOpen = ref(false);
|
const boardMenuOpen = ref(false);
|
||||||
const boardModal = ref<{ mode: 'create' | 'edit'; name: string } | null>(null);
|
const boardModal = ref<{ mode: 'create' | 'edit'; name: string; memberIds: string[] } | null>(null);
|
||||||
|
const boardMemberQuery = ref('');
|
||||||
const boardSaving = ref(false);
|
const boardSaving = ref(false);
|
||||||
const boardDeleteOpen = ref(false);
|
const boardDeleteOpen = ref(false);
|
||||||
|
|
||||||
@@ -410,13 +411,76 @@ function setBoard(next: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeBoardUsers = computed(() => users.value.filter((user) => user.isActive !== false));
|
||||||
|
|
||||||
|
const boardMembersOpenToAll = computed(() => {
|
||||||
|
const ids = activeBoardUsers.value.map((user) => user.id);
|
||||||
|
const selected = boardModal.value?.memberIds ?? [];
|
||||||
|
return ids.length > 0 && ids.every((id) => selected.includes(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredBoardUsers = computed(() => {
|
||||||
|
const query = boardMemberQuery.value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک');
|
||||||
|
const selected = new Set(boardModal.value?.memberIds ?? []);
|
||||||
|
const list = activeBoardUsers.value.filter((user) => {
|
||||||
|
if (!query) return true;
|
||||||
|
return user.fullName.replace(/ي/g, 'ی').replace(/ك/g, 'ک').includes(query);
|
||||||
|
});
|
||||||
|
return list.slice().sort((a, b) => Number(selected.has(b.id)) - Number(selected.has(a.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function showExistingBoardMembers() {
|
||||||
|
const modal = boardModal.value;
|
||||||
|
if (!modal || modal.mode !== 'edit') return;
|
||||||
|
const saved = currentBoard.value?.memberIds ?? [];
|
||||||
|
modal.memberIds = saved.length
|
||||||
|
? [...saved]
|
||||||
|
: activeBoardUsers.value.map((user) => user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBoardUsers() {
|
||||||
|
if (!users.value.length) {
|
||||||
|
try {
|
||||||
|
const { data: rows } = await api.get<OrgUserDto[]>('/users');
|
||||||
|
users.value = rows.filter((user) => user.isActive !== false);
|
||||||
|
} catch {
|
||||||
|
users.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showExistingBoardMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberIdsToSave(selected: string[]) {
|
||||||
|
const all = activeBoardUsers.value.map((user) => user.id);
|
||||||
|
if (all.length && selected.length === all.length && all.every((id) => selected.includes(id))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateBoard() {
|
function openCreateBoard() {
|
||||||
boardModal.value = { mode: 'create', name: '' };
|
boardMemberQuery.value = '';
|
||||||
|
boardModal.value = { mode: 'create', name: '', memberIds: [] };
|
||||||
|
void ensureBoardUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditBoard() {
|
function openEditBoard() {
|
||||||
if (!currentBoard.value) return;
|
if (!currentBoard.value) return;
|
||||||
boardModal.value = { mode: 'edit', name: currentBoard.value.name };
|
boardMemberQuery.value = '';
|
||||||
|
boardModal.value = {
|
||||||
|
mode: 'edit',
|
||||||
|
name: currentBoard.value.name,
|
||||||
|
memberIds: [...(currentBoard.value.memberIds ?? [])],
|
||||||
|
};
|
||||||
|
void ensureBoardUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBoardMember(userId: string) {
|
||||||
|
const modal = boardModal.value;
|
||||||
|
if (!modal) return;
|
||||||
|
modal.memberIds = modal.memberIds.includes(userId)
|
||||||
|
? modal.memberIds.filter((id) => id !== userId)
|
||||||
|
: [...modal.memberIds, userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveBoard() {
|
async function saveBoard() {
|
||||||
@@ -427,12 +491,18 @@ async function saveBoard() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
if (modal.mode === 'create') {
|
if (modal.mode === 'create') {
|
||||||
const { data: created } = await api.post<ActivityBoardDto>('/activities/boards', { name });
|
const { data: created } = await api.post<ActivityBoardDto>('/activities/boards', {
|
||||||
|
name,
|
||||||
|
memberIds: memberIdsToSave(modal.memberIds),
|
||||||
|
});
|
||||||
await loadBoards();
|
await loadBoards();
|
||||||
boardModal.value = null;
|
boardModal.value = null;
|
||||||
setBoard(created.id);
|
setBoard(created.id);
|
||||||
} else if (currentBoard.value) {
|
} else if (currentBoard.value) {
|
||||||
await api.patch(`/activities/boards/${currentBoard.value.id}`, { name });
|
await api.patch(`/activities/boards/${currentBoard.value.id}`, {
|
||||||
|
name,
|
||||||
|
memberIds: memberIdsToSave(modal.memberIds),
|
||||||
|
});
|
||||||
await loadBoards();
|
await loadBoards();
|
||||||
boardModal.value = null;
|
boardModal.value = null;
|
||||||
}
|
}
|
||||||
@@ -1620,6 +1690,34 @@ onUnmounted(() => {
|
|||||||
<p v-if="boardModal.mode === 'create'" class="board-form-hint">
|
<p v-if="boardModal.mode === 'create'" class="board-form-hint">
|
||||||
گروه جدید با ستونهای پیشفرض ساخته میشود و بعد میتوانید ستونهایش را تغییر دهید.
|
گروه جدید با ستونهای پیشفرض ساخته میشود و بعد میتوانید ستونهایش را تغییر دهید.
|
||||||
</p>
|
</p>
|
||||||
|
<label class="board-form-label" for="activity-board-member-search">اعضا</label>
|
||||||
|
<UiInput
|
||||||
|
id="activity-board-member-search"
|
||||||
|
v-model="boardMemberQuery"
|
||||||
|
placeholder="جستجوی نام عضو…"
|
||||||
|
/>
|
||||||
|
<p class="board-form-hint">
|
||||||
|
{{
|
||||||
|
boardMembersOpenToAll
|
||||||
|
? `اعضای فعلی همین ${toFaDigits(boardModal.memberIds.length)} نفر هستند و گروه برای همهٔ محیط باز است. با برداشتن تیک، فقط افراد انتخابشده آن را میبینند.`
|
||||||
|
: boardModal.memberIds.length
|
||||||
|
? `${toFaDigits(boardModal.memberIds.length)} نفر انتخاب شده. فقط همین افراد این گروه را میبینند.`
|
||||||
|
: 'اگر کسی را انتخاب نکنید، این گروه برای همهٔ اعضای محیط دیده میشود.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<ul class="board-members">
|
||||||
|
<li v-if="!filteredBoardUsers.length" class="board-form-hint">عضوی پیدا نشد.</li>
|
||||||
|
<li v-for="user in filteredBoardUsers" :key="user.id">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="boardModal.memberIds.includes(user.id)"
|
||||||
|
@change="toggleBoardMember(user.id)"
|
||||||
|
/>
|
||||||
|
<span>{{ user.fullName }}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<UiButton
|
<UiButton
|
||||||
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
|
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1890,6 +1988,31 @@ h1 {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-members {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-members label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-members label:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
.board-delete {
|
.board-delete {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|||||||
@@ -1584,14 +1584,18 @@ export interface ActivityBoardDto {
|
|||||||
name: string;
|
name: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
|
/** خالی = گروه برای همهٔ اعضای محیط باز است */
|
||||||
|
memberIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateActivityBoardRequest {
|
export interface CreateActivityBoardRequest {
|
||||||
name: string;
|
name: string;
|
||||||
|
memberIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateActivityBoardRequest {
|
export interface UpdateActivityBoardRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
memberIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityStageDto {
|
export interface ActivityStageDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user