feat: group activities into boards with their own stages, like sales pipelines

Existing stages and activities move into a default board per environment; new activities pick a board in the form.
This commit is contained in:
2026-10-20 17:23:27 +03:30
parent 9127fa334b
commit e6aa4c64f0
11 changed files with 769 additions and 58 deletions
@@ -0,0 +1,57 @@
-- CreateTable
CREATE TABLE "ActivityBoard" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityBoard_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ActivityBoard_organizationId_name_key" ON "ActivityBoard"("organizationId", "name");
-- CreateIndex
CREATE INDEX "ActivityBoard_organizationId_position_idx" ON "ActivityBoard"("organizationId", "position");
-- AddForeignKey
ALTER TABLE "ActivityBoard" ADD CONSTRAINT "ActivityBoard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill: one default board per organization that already has activity stages
INSERT INTO "ActivityBoard" ("id", "organizationId", "name", "isDefault", "position", "createdAt", "updatedAt")
SELECT 'actb_' || md5(s."organizationId" || clock_timestamp()::text), s."organizationId", 'کارهای اصلی', true, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (SELECT DISTINCT "organizationId" FROM "ActivityStage") AS s;
-- AlterTable: stages belong to a board
ALTER TABLE "ActivityStage" ADD COLUMN "boardId" TEXT;
UPDATE "ActivityStage" AS st
SET "boardId" = b."id"
FROM "ActivityBoard" AS b
WHERE b."organizationId" = st."organizationId" AND b."isDefault" = true;
ALTER TABLE "ActivityStage" ALTER COLUMN "boardId" SET NOT NULL;
-- Stage names are unique per board (not per organization)
DROP INDEX "ActivityStage_organizationId_name_key";
-- CreateIndex
CREATE UNIQUE INDEX "ActivityStage_boardId_name_key" ON "ActivityStage"("boardId", "name");
-- CreateIndex
CREATE INDEX "ActivityStage_boardId_position_idx" ON "ActivityStage"("boardId", "position");
-- AddForeignKey
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable: activities optionally point at a board (null = default board)
ALTER TABLE "Activity" ADD COLUMN "boardId" TEXT;
-- CreateIndex
CREATE INDEX "Activity_boardId_idx" ON "Activity"("boardId");
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+26 -1
View File
@@ -224,6 +224,7 @@ model Organization {
reservationAppointments ReservationAppointment[] reservationAppointments ReservationAppointment[]
leads Lead[] leads Lead[]
leadStages LeadStage[] leadStages LeadStage[]
activityBoards ActivityBoard[]
activityStages ActivityStage[] activityStages ActivityStage[]
activityTypeDefs ActivityTypeDef[] activityTypeDefs ActivityTypeDef[]
leadEvents LeadEvent[] leadEvents LeadEvent[]
@@ -686,10 +687,29 @@ model Deal {
} }
/// مراحل/ستون‌های فعالیت (تب و کانبان) — kind وضعیت فعالیت داخل این مرحله را تعیین می‌کند /// مراحل/ستون‌های فعالیت (تب و کانبان) — kind وضعیت فعالیت داخل این مرحله را تعیین می‌کند
/// گروه فعالیت (مثل مسیر فروش برای معاملات): هر گروه ستون‌های خودش را دارد
model ActivityBoard {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isDefault Boolean @default(false)
position Int @default(0)
stages ActivityStage[]
activities Activity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId, position])
}
model ActivityStage { model ActivityStage {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
boardId String
board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
name String name String
position Int @default(0) position Int @default(0)
color String @default("green") color String @default("green")
@@ -698,8 +718,9 @@ model ActivityStage {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([organizationId, name]) @@unique([boardId, name])
@@index([organizationId, position]) @@index([organizationId, position])
@@index([boardId, position])
} }
/// نوع فعالیت قابل تعریف توسط سازمان با فیلدهای اختصاصی /// نوع فعالیت قابل تعریف توسط سازمان با فیلدهای اختصاصی
@@ -764,6 +785,9 @@ model Activity {
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
notes Note[] notes Note[]
routineTasks RoutineTask[] routineTasks RoutineTask[]
/// گروه فعالیت؛ null = گروه پیش‌فرض محیط
boardId String?
board ActivityBoard? @relation(fields: [boardId], references: [id], onDelete: SetNull)
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String? stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
@@ -781,6 +805,7 @@ model Activity {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([stageId]) @@index([stageId])
@@index([boardId])
@@index([typeDefId]) @@index([typeDefId])
@@index([leadId]) @@index([leadId])
@@index([organizationId, status, dueAt]) @@index([organizationId, status, dueAt])
@@ -27,14 +27,17 @@ import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service'; import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service'; import { ActivityTypesService } from './activity-types.service';
import { import {
CreateActivityBoardDto,
CreateActivityDto, CreateActivityDto,
CreateActivityStageDto, CreateActivityStageDto,
CreateActivityTypeDto, CreateActivityTypeDto,
ImportActivityRowsDto, ImportActivityRowsDto,
ListActivitiesQueryDto, ListActivitiesQueryDto,
ListActivityStagesQueryDto,
MoveActivityStageDto, MoveActivityStageDto,
ReorderActivityStagesDto, ReorderActivityStagesDto,
ReorderActivityTypesDto, ReorderActivityTypesDto,
UpdateActivityBoardDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto, UpdateActivityTypeDto,
@@ -65,9 +68,40 @@ export class ActivitiesController {
return this.activitiesService.summary(user.organizationId, user, scope); return this.activitiesService.summary(user.organizationId, user, scope);
} }
@Get('boards')
listBoards(@CurrentUser() user: Actor) {
return this.stagesService.listBoards(user.organizationId);
}
@Post('boards')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
return this.stagesService.createBoard(user.organizationId, dto.name);
}
@Patch('boards/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateBoard(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateActivityBoardDto,
) {
return this.stagesService.updateBoard(user.organizationId, id, dto);
}
@Delete('boards/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeBoard(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.stagesService.removeBoard(user.organizationId, id);
return { ok: true };
}
@Get('stages') @Get('stages')
listStages(@CurrentUser() user: Actor) { listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
return this.stagesService.list(user.organizationId); return this.stagesService.list(user.organizationId, query.boardId);
} }
@Post('stages') @Post('stages')
@@ -157,6 +191,7 @@ export class ActivitiesController {
typeDefId: query.typeDefId, typeDefId: query.typeDefId,
status: query.status, status: query.status,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
boardId: query.boardId,
}, },
); );
} }
@@ -189,6 +224,7 @@ export class ActivitiesController {
typeDefId: query.typeDefId, typeDefId: query.typeDefId,
status: query.status, status: query.status,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
boardId: query.boardId,
}, },
query.stageId ? { stageId: query.stageId } : undefined, query.stageId ? { stageId: query.stageId } : undefined,
); );
+44 -7
View File
@@ -30,14 +30,14 @@ import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates'; import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { activityPriorWhere } from '../common/prior-archive'; import { activityPriorWhere } from '../common/prior-archive';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service'; import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
import { import {
ActivityTypesService, ActivityTypesService,
type SystemTypes, type SystemTypes,
type TypeRow, type TypeRow,
} from './activity-types.service'; } from './activity-types.service';
type DtoContext = { stages?: StageDefaults; types?: SystemTypes }; type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>; type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
@@ -49,6 +49,8 @@ type ListFacets = {
typeDefId?: string; typeDefId?: string;
status?: ActivityDto['status']; status?: ActivityDto['status'];
assigneeId?: string; assigneeId?: string;
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string;
}; };
type ActivityRow = { type ActivityRow = {
@@ -77,14 +79,15 @@ type ActivityRow = {
assignee: { id: string; fullName: string } | null; assignee: { id: string; fullName: string } | null;
assignees: Array<{ id: string; fullName: string }>; assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null; createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | null; boardId?: string | null;
stage?: { id: string; kind: ActivityDto['status']; boardId: string } | null;
typeDef?: { id: string; name: string; icon: string; color: string } | null; typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null; fields?: Prisma.JsonValue | null;
participantIds?: string[]; participantIds?: string[];
}; };
const include = { const include = {
stage: { select: { id: true, kind: true } }, stage: { select: { id: true, kind: true, boardId: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } }, typeDef: { select: { id: true, name: true, icon: true, color: true } },
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } }, company: { select: { id: true, name: true } },
@@ -109,7 +112,7 @@ export class ActivitiesService {
private async ctx(organizationId: string): Promise<DtoContext> { private async ctx(organizationId: string): Promise<DtoContext> {
const [stages, types] = await Promise.all([ const [stages, types] = await Promise.all([
this.stages.defaults(organizationId), this.stages.boardContext(organizationId),
this.types.system(organizationId), this.types.system(organizationId),
]); ]);
return { stages, types }; return { stages, types };
@@ -255,13 +258,20 @@ export class ActivitiesService {
stageScope, stageScope,
typeRows, typeRows,
); );
const boardCtx = await this.stages.boardContext(organizationId);
if (facets?.boardId) {
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
(where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
);
}
if (stageScope && stageScope.stageId !== 'all') { if (stageScope && stageScope.stageId !== 'all') {
(where.AND as Prisma.ActivityWhereInput[]).push( (where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId), await this.stages.whereForStage(organizationId, stageScope.stageId),
); );
} }
const ctx: DtoContext = { const ctx: DtoContext = {
stages: await this.stages.defaults(organizationId), stages: boardCtx,
types: this.types.systemOf(typeRows), types: this.types.systemOf(typeRows),
}; };
const take = Math.min(Math.max(limit, 1), 200); const take = Math.min(Math.max(limit, 1), 200);
@@ -293,7 +303,9 @@ export class ActivitiesService {
desk?: 'mine' | 'team', desk?: 'mine' | 'team',
facets?: ListFacets, facets?: ListFacets,
): Promise<ActivityStageCountsDto> { ): Promise<ActivityStageCountsDto> {
const rows = await this.stages.rows(organizationId); const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
const rows = await this.stages.rows(organizationId, boardId);
const defaults = this.stages.defaultsOf(rows); const defaults = this.stages.defaultsOf(rows);
const kindOf = new Map(rows.map((r) => [r.id, r.kind])); const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
const where = this.buildWhere( const where = this.buildWhere(
@@ -308,6 +320,9 @@ export class ActivitiesService {
{ stageId: 'all' }, { stageId: 'all' },
await this.types.rows(organizationId), await this.types.rows(organizationId),
); );
(where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, defaultBoard.id),
);
const groups = await this.prisma.activity.groupBy({ const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'], by: ['status', 'stageId'],
where, where,
@@ -341,6 +356,7 @@ export class ActivitiesService {
where: { id }, where: { id },
data: { data: {
stageId: stage.id, stageId: stage.id,
boardId: stage.boardId,
status: stage.kind, status: stage.kind,
completedAt: completedAt:
stage.kind === 'done' stage.kind === 'done'
@@ -401,6 +417,12 @@ export class ActivitiesService {
const initialStage = initialStageId const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId) ? await this.stages.get(organizationId, initialStageId)
: null; : null;
const requestedBoardId = emptyToNull(dto.boardId ?? null);
const boardId = initialStage
? initialStage.boardId
: requestedBoardId
? await this.stages.resolveBoardId(organizationId, requestedBoardId)
: null;
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,
@@ -414,6 +436,7 @@ export class ActivitiesService {
const row = await this.prisma.activity.create({ const row = await this.prisma.activity.create({
data: { data: {
organizationId, organizationId,
boardId,
...(initialStage ...(initialStage
? { ? {
stageId: initialStage.id, stageId: initialStage.id,
@@ -513,6 +536,19 @@ export class ActivitiesService {
}); });
const data: Prisma.ActivityUncheckedUpdateInput = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) {
const nextBoardId = await this.stages.resolveBoardId(organizationId, dto.boardId);
const current = await this.prisma.activity.findUnique({
where: { id },
select: { boardId: true },
});
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود
data.boardId = nextBoardId;
data.stageId = null;
}
}
let addedParticipants: string[] = []; let addedParticipants: string[] = [];
const typeChanged = 'typeDefId' in dto || dto.type !== undefined; const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
if (typeChanged || dto.fields !== undefined) { if (typeChanged || dto.fields !== undefined) {
@@ -922,6 +958,7 @@ export class ActivitiesService {
? (row.fields as unknown as ActivityFieldValues) ? (row.fields as unknown as ActivityFieldValues)
: {}; : {};
return { return {
boardId: this.stages.effectiveBoardId(row, ctx?.stages),
stageId: this.stages.effectiveStageId(row, ctx?.stages), stageId: this.stages.effectiveStageId(row, ctx?.stages),
typeDef: row.typeDef typeDef: row.typeDef
? this.types.toSummary(row.typeDef) ? this.types.toSummary(row.typeDef)
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type { import type {
ActivityBoardDto,
ActivityStageColor, ActivityStageColor,
ActivityStageDto, ActivityStageDto,
ActivityStatus, ActivityStatus,
@@ -8,6 +9,8 @@ import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_BOARD_NAME = 'کارهای اصلی';
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [ const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
{ name: 'در صف', color: 'orange', kind: 'open' }, { name: 'در صف', color: 'orange', kind: 'open' },
{ name: 'در حال انجام', color: 'blue', kind: 'open' }, { name: 'در حال انجام', color: 'blue', kind: 'open' },
@@ -19,24 +22,84 @@ const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
type StageRow = { type StageRow = {
id: string; id: string;
boardId: string;
name: string; name: string;
position: number; position: number;
color: string; color: string;
kind: ActivityStatus; kind: ActivityStatus;
}; };
type BoardRow = {
id: string;
name: string;
isDefault: boolean;
position: number;
};
const STAGE_SELECT = {
id: true,
boardId: true,
name: true,
position: true,
color: true,
kind: true,
} satisfies Prisma.ActivityStageSelect;
const BOARD_SELECT = {
id: true,
name: true,
isDefault: true,
position: true,
} satisfies Prisma.ActivityBoardSelect;
export type StageDefaults = Record<ActivityStatus, string | null>; export type StageDefaults = Record<ActivityStatus, string | null>;
/** پیش‌فرض‌های هر گروه برای محاسبهٔ «ستون مؤثر» فعالیت‌ها */
export type BoardStageContext = {
defaultBoardId: string;
defaults: Map<string, StageDefaults>;
};
@Injectable() @Injectable()
export class ActivityStagesService { export class ActivityStagesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async ensureDefaults(organizationId: string): Promise<void> { // —— گروه‌ها ——
const count = await this.prisma.activityStage.count({ where: { organizationId } });
/** گروه پیش‌فرض محیط را (با ستون‌های اولیه) تضمین می‌کند */
async ensureDefaultBoard(organizationId: string): Promise<BoardRow> {
let board = await this.prisma.activityBoard.findFirst({
where: { organizationId, isDefault: true },
select: BOARD_SELECT,
});
if (!board) {
board = await this.prisma.activityBoard
.create({
data: { organizationId, name: DEFAULT_BOARD_NAME, isDefault: true, position: 0 },
select: BOARD_SELECT,
})
.catch(async (e) => {
// ساخت هم‌زمان در دو درخواست
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return this.prisma.activityBoard.findFirstOrThrow({
where: { organizationId, isDefault: true },
select: BOARD_SELECT,
});
}
throw e;
});
}
await this.ensureBoardStages(organizationId, board.id);
return board;
}
private async ensureBoardStages(organizationId: string, boardId: string) {
const count = await this.prisma.activityStage.count({ where: { boardId } });
if (count > 0) return; if (count > 0) return;
await this.prisma.activityStage.createMany({ await this.prisma.activityStage.createMany({
data: DEFAULT_STAGES.map((s, i) => ({ data: DEFAULT_STAGES.map((s, i) => ({
organizationId, organizationId,
boardId,
name: s.name, name: s.name,
color: s.color, color: s.color,
kind: s.kind, kind: s.kind,
@@ -46,17 +109,112 @@ export class ActivityStagesService {
}); });
} }
async rows(organizationId: string): Promise<StageRow[]> { async listBoards(organizationId: string): Promise<ActivityBoardDto[]> {
await this.ensureDefaults(organizationId); await this.ensureDefaultBoard(organizationId);
return this.prisma.activityStage.findMany({ const rows = await this.prisma.activityBoard.findMany({
where: { organizationId }, where: { organizationId },
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
select: BOARD_SELECT,
});
return rows.map((b) => ({ ...b }));
}
/** شناسهٔ گروه معتبر؛ خالی = گروه پیش‌فرض */
async resolveBoardId(organizationId: string, boardId?: string | null): Promise<string> {
const id = boardId?.trim();
if (!id) return (await this.ensureDefaultBoard(organizationId)).id;
const board = await this.prisma.activityBoard.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!board) throw new BadRequestException('گروه فعالیت انتخاب‌شده معتبر نیست');
return board.id;
}
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> {
await this.ensureDefaultBoard(organizationId);
const max = await this.prisma.activityBoard.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
const board = await this.prisma.activityBoard.create({
data: {
organizationId,
name: this.cleanBoardName(name),
position: (max._max.position ?? 0) + 1,
},
select: BOARD_SELECT,
});
await this.ensureBoardStages(organizationId, board.id);
return { ...board };
} catch (e) {
throw this.mapBoardError(e);
}
}
async updateBoard(
organizationId: string,
id: string,
dto: { name?: string },
): Promise<ActivityBoardDto> {
await this.getBoard(organizationId, id);
try {
const board = await this.prisma.activityBoard.update({
where: { id },
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {},
select: BOARD_SELECT,
});
return { ...board };
} catch (e) {
throw this.mapBoardError(e);
}
}
/** فعالیت‌های گروه حذف‌شده به گروه پیش‌فرض (ستون اولِ هم‌وضعیت) منتقل می‌شوند */
async removeBoard(organizationId: string, id: string): Promise<void> {
const board = await this.getBoard(organizationId, id);
if (board.isDefault) {
throw new BadRequestException('گروه پیش‌فرض قابل حذف نیست');
}
await this.prisma.$transaction([
this.prisma.activity.updateMany({
where: { organizationId, boardId: id },
data: { boardId: null, stageId: null },
}),
this.prisma.activityBoard.delete({ where: { id } }),
]);
}
private async getBoard(organizationId: string, id: string): Promise<BoardRow> {
const board = await this.prisma.activityBoard.findFirst({
where: { id, organizationId },
select: BOARD_SELECT,
});
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
return board;
}
/** شرط Prisma برای فعالیت‌های یک گروه (فعالیت بدون گروه = گروه پیش‌فرض) */
boardWhere(boardId: string, defaultBoardId: string): Prisma.ActivityWhereInput {
return boardId === defaultBoardId
? { OR: [{ boardId }, { boardId: null }] }
: { boardId };
}
// —— ستون‌ها ——
async rows(organizationId: string, boardId?: string | null): Promise<StageRow[]> {
const resolved = await this.resolveBoardId(organizationId, boardId);
return this.prisma.activityStage.findMany({
where: { organizationId, boardId: resolved },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select: { id: true, name: true, position: true, color: true, kind: true }, select: STAGE_SELECT,
}); });
} }
async list(organizationId: string): Promise<ActivityStageDto[]> { async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
return (await this.rows(organizationId)).map((r) => this.toDto(r)); return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r));
} }
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */ /** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
@@ -68,8 +226,23 @@ export class ActivityStagesService {
return out; return out;
} }
async defaults(organizationId: string): Promise<StageDefaults> { /** پیش‌فرض‌های ستون برای همهٔ گروه‌های محیط */
return this.defaultsOf(await this.rows(organizationId)); async boardContext(organizationId: string): Promise<BoardStageContext> {
const defaultBoard = await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.activityStage.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select: STAGE_SELECT,
});
const byBoard = new Map<string, StageRow[]>();
for (const r of rows) {
const list = byBoard.get(r.boardId) ?? [];
list.push(r);
byBoard.set(r.boardId, list);
}
const defaults = new Map<string, StageDefaults>();
for (const [boardId, list] of byBoard) defaults.set(boardId, this.defaultsOf(list));
return { defaultBoardId: defaultBoard.id, defaults };
} }
/** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */ /** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */
@@ -77,33 +250,54 @@ export class ActivityStagesService {
organizationId: string, organizationId: string,
stageId: string, stageId: string,
): Promise<Prisma.ActivityWhereInput> { ): Promise<Prisma.ActivityWhereInput> {
const rows = await this.rows(organizationId); const stage = await this.get(organizationId, stageId);
const stage = rows.find((r) => r.id === stageId); const defaultBoard = await this.ensureDefaultBoard(organizationId);
if (!stage) throw new BadRequestException('مرحلهٔ انتخاب‌شده معتبر نیست'); const boardRows = await this.rows(organizationId, stage.boardId);
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id; const inBoard = this.boardWhere(stage.boardId, defaultBoard.id);
if (!isDefault) return { status: stage.kind, stageId: stage.id }; const isDefault = this.defaultsOf(boardRows)[stage.kind] === stage.id;
if (!isDefault) {
return { AND: [inBoard, { status: stage.kind, stageId: stage.id }] };
}
return { return {
status: stage.kind, AND: [
OR: [ inBoard,
{ stageId: stage.id }, { status: stage.kind },
{ stageId: null }, {
{ stage: { kind: { not: stage.kind } } }, OR: [
{ stageId: stage.id },
{ stageId: null },
{ stage: { kind: { not: stage.kind } } },
{ stage: { boardId: { not: stage.boardId } } },
],
},
], ],
}; };
} }
effectiveBoardId(row: { boardId?: string | null }, ctx?: BoardStageContext): string | null {
return row.boardId ?? ctx?.defaultBoardId ?? null;
}
effectiveStageId( effectiveStageId(
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null }, row: {
defaults?: StageDefaults, status: ActivityStatus;
boardId?: string | null;
stage?: { id: string; kind: ActivityStatus; boardId: string } | null;
},
ctx?: BoardStageContext,
): string | null { ): string | null {
if (row.stage && row.stage.kind === row.status) return row.stage.id; const boardId = this.effectiveBoardId(row, ctx);
return defaults?.[row.status] ?? null; if (row.stage && row.stage.kind === row.status && (!boardId || row.stage.boardId === boardId)) {
return row.stage.id;
}
if (!ctx || !boardId) return null;
return ctx.defaults.get(boardId)?.[row.status] ?? null;
} }
async get(organizationId: string, id: string): Promise<StageRow> { async get(organizationId: string, id: string): Promise<StageRow> {
const row = await this.prisma.activityStage.findFirst({ const row = await this.prisma.activityStage.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { id: true, name: true, position: true, color: true, kind: true }, select: STAGE_SELECT,
}); });
if (!row) throw new NotFoundException('مرحله پیدا نشد'); if (!row) throw new NotFoundException('مرحله پیدا نشد');
return row; return row;
@@ -111,22 +305,25 @@ export class ActivityStagesService {
async create( async create(
organizationId: string, organizationId: string,
dto: { name: string; color?: string; kind?: ActivityStatus }, dto: { boardId?: string; name: string; color?: string; kind?: ActivityStatus },
): Promise<ActivityStageDto> { ): Promise<ActivityStageDto> {
await this.ensureDefaults(organizationId); const boardId = await this.resolveBoardId(organizationId, dto.boardId);
await this.ensureBoardStages(organizationId, boardId);
const max = await this.prisma.activityStage.aggregate({ const max = await this.prisma.activityStage.aggregate({
where: { organizationId }, where: { boardId },
_max: { position: true }, _max: { position: true },
}); });
try { try {
const row = await this.prisma.activityStage.create({ const row = await this.prisma.activityStage.create({
data: { data: {
organizationId, organizationId,
boardId,
name: this.cleanName(dto.name), name: this.cleanName(dto.name),
color: this.cleanColor(dto.color), color: this.cleanColor(dto.color),
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open', kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
position: (max._max.position ?? -1) + 1, position: (max._max.position ?? -1) + 1,
}, },
select: STAGE_SELECT,
}); });
return this.toDto(row); return this.toDto(row);
} catch (e) { } catch (e) {
@@ -142,7 +339,7 @@ export class ActivityStagesService {
const stage = await this.get(organizationId, id); const stage = await this.get(organizationId, id);
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind; const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
if (nextKind !== stage.kind) { if (nextKind !== stage.kind) {
await this.assertNotLastOfKind(organizationId, stage); await this.assertNotLastOfKind(stage);
} }
try { try {
const row = await this.prisma.$transaction(async (tx) => { const row = await this.prisma.$transaction(async (tx) => {
@@ -160,6 +357,7 @@ export class ActivityStagesService {
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
kind: nextKind, kind: nextKind,
}, },
select: STAGE_SELECT,
}); });
}); });
return this.toDto(row); return this.toDto(row);
@@ -168,34 +366,45 @@ export class ActivityStagesService {
} }
} }
/** ترتیب ستون‌های یک گروه؛ همهٔ شناسه‌ها باید از همان گروه باشند */
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> { async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
if (!stageIds.length) return [];
const rows = await this.prisma.activityStage.findMany({
where: { organizationId, id: { in: stageIds } },
select: { id: true, boardId: true },
});
const boardIds = new Set(rows.map((r) => r.boardId));
if (rows.length !== new Set(stageIds).size || boardIds.size !== 1) {
throw new BadRequestException('ستون‌های ارسالی باید همه از یک گروه باشند');
}
const [boardId] = [...boardIds];
await this.prisma.$transaction( await this.prisma.$transaction(
stageIds.map((id, position) => stageIds.map((id, position) =>
this.prisma.activityStage.updateMany({ this.prisma.activityStage.updateMany({
where: { id, organizationId }, where: { id, organizationId, boardId },
data: { position }, data: { position },
}), }),
), ),
); );
return this.list(organizationId); return this.list(organizationId, boardId);
} }
async remove(organizationId: string, id: string): Promise<void> { async remove(organizationId: string, id: string): Promise<void> {
const stage = await this.get(organizationId, id); const stage = await this.get(organizationId, id);
await this.assertNotLastOfKind(organizationId, stage); await this.assertNotLastOfKind(stage);
// onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان منتقل می‌شوند // onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان در همان گروه منتقل می‌شوند
await this.prisma.activityStage.delete({ where: { id } }); await this.prisma.activityStage.delete({ where: { id } });
} }
private async assertNotLastOfKind(organizationId: string, stage: StageRow) { private async assertNotLastOfKind(stage: StageRow) {
const sameKind = await this.prisma.activityStage.count({ const sameKind = await this.prisma.activityStage.count({
where: { organizationId, kind: stage.kind }, where: { boardId: stage.boardId, kind: stage.kind },
}); });
if (sameKind <= 1) { if (sameKind <= 1) {
const label = const label =
stage.kind === 'done' ? 'انجام‌شده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز'; stage.kind === 'done' ? 'انجام‌شده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
throw new BadRequestException( throw new BadRequestException(
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`, `حداقل یک ستون از نوع «${label}» در هر گروه لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
); );
} }
} }
@@ -207,6 +416,13 @@ export class ActivityStagesService {
return name; return name;
} }
private cleanBoardName(raw: string): string {
const name = raw?.trim() ?? '';
if (name.length < 2) throw new BadRequestException('نام گروه حداقل ۲ کاراکتر است');
if (name.length > 80) throw new BadRequestException('نام گروه حداکثر ۸۰ کاراکتر است');
return name;
}
private cleanColor(raw?: string): ActivityStageColor { private cleanColor(raw?: string): ActivityStageColor {
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor) return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
? (raw as ActivityStageColor) ? (raw as ActivityStageColor)
@@ -215,7 +431,14 @@ export class ActivityStagesService {
private mapError(e: unknown) { private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('ستونی با این نام وجود دارد'); return new BadRequestException('ستونی با این نام در این گروه وجود دارد');
}
return e;
}
private mapBoardError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('گروهی با این نام وجود دارد');
} }
return e; return e;
} }
@@ -223,6 +446,7 @@ export class ActivityStagesService {
private toDto(row: StageRow): ActivityStageDto { private toDto(row: StageRow): ActivityStageDto {
return { return {
id: row.id, id: row.id,
boardId: row.boardId,
name: row.name, name: row.name,
position: row.position, position: row.position,
color: this.cleanColor(row.color), color: this.cleanColor(row.color),
+42
View File
@@ -79,6 +79,11 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
stageId?: string; stageId?: string;
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها (در شمارش ستون‌ها = گروه پیش‌فرض) */
@IsOptional()
@IsString()
boardId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
personId?: string; personId?: string;
@@ -121,7 +126,33 @@ export class ListActivitiesQueryDto {
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const; const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
export class CreateActivityBoardDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
}
export class UpdateActivityBoardDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
name?: string;
}
export class ListActivityStagesQueryDto {
@IsOptional()
@IsString()
boardId?: string;
}
export class CreateActivityStageDto { export class CreateActivityStageDto {
/** خالی = گروه پیش‌فرض */
@IsOptional()
@IsString()
boardId?: string;
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(80) @MaxLength(80)
@@ -229,6 +260,11 @@ export class CreateActivityDto {
@MaxLength(200) @MaxLength(200)
title!: string; title!: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
boardId?: string | null;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -319,6 +355,12 @@ export class UpdateActivityDto {
@MaxLength(200) @MaxLength(200)
title?: string; title?: string;
/** null/خالی = گروه پیش‌فرض */
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
boardId?: string | null;
@IsOptional() @IsOptional()
@IsIn(ACTIVITY_TYPES) @IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number]; type?: (typeof ACTIVITY_TYPES)[number];
@@ -380,7 +380,7 @@ async function onAddStage(name: string) {
if (stageBusy.value) return; if (stageBusy.value) return;
stageBusy.value = true; stageBusy.value = true;
try { try {
await api.post('/activities/stages', { name, kind: 'open' }); await api.post('/activities/stages', { name, kind: 'open', boardId: props.params.boardId });
emit('stagesChanged'); emit('stagesChanged');
} catch (e) { } catch (e) {
emit('error', apiMessage(e, 'افزودن ستون ناموفق بود.')); emit('error', apiMessage(e, 'افزودن ستون ناموفق بود.'));
@@ -20,6 +20,9 @@ import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
stages: ActivityStageDto[]; stages: ActivityStageDto[];
/** ستون‌های جدید در همین گروه ساخته می‌شوند */
boardId?: string;
boardName?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -34,7 +37,7 @@ type Draft = { name: string; color: ActivityStageColor; kind: ActivityStatus };
const drafts = ref<Record<string, Draft>>({}); const drafts = ref<Record<string, Draft>>({});
const busyId = ref(''); const busyId = ref('');
const error = ref(''); const error = ref('');
const createForm = reactive<Required<CreateActivityStageRequest>>({ const createForm = reactive<Required<Omit<CreateActivityStageRequest, 'boardId'>>>({
name: '', name: '',
color: 'blue', color: 'blue',
kind: 'open', kind: 'open',
@@ -140,7 +143,11 @@ async function create() {
busyId.value = 'create'; busyId.value = 'create';
error.value = ''; error.value = '';
try { try {
await api.post('/activities/stages', { ...createForm, name: createForm.name.trim() }); await api.post('/activities/stages', {
...createForm,
name: createForm.name.trim(),
boardId: props.boardId,
});
createForm.name = ''; createForm.name = '';
emit('changed'); emit('changed');
} catch (e) { } catch (e) {
@@ -152,7 +159,11 @@ async function create() {
</script> </script>
<template> <template>
<UiModal :open="open" title="مدیریت ستون‌ها و تب‌های فعالیت" @close="emit('close')"> <UiModal
:open="open"
:title="boardName ? `مدیریت ستون‌های گروه «${boardName}»` : 'مدیریت ستون‌ها و تب‌های فعالیت'"
@close="emit('close')"
>
<div class="manager"> <div class="manager">
<p class="hint"> <p class="hint">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت
+237 -6
View File
@@ -14,9 +14,11 @@ import {
UiBusyOverlay, UiBusyOverlay,
UiButton, UiButton,
UiDropdownMenu, UiDropdownMenu,
UiInput,
UiModal, UiModal,
UiPageHeader, UiPageHeader,
UiPagination, UiPagination,
UiSelect,
} from '@/components/ui'; } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import UiSegmented from '@/components/ui/UiSegmented.vue'; import UiSegmented from '@/components/ui/UiSegmented.vue';
@@ -39,6 +41,7 @@ const activityClientReadOptions = {
}; };
import ActivityFormView from '@/views/ActivityFormView.vue'; import ActivityFormView from '@/views/ActivityFormView.vue';
import type { import type {
ActivityBoardDto,
ActivityDto, ActivityDto,
ActivityFilter, ActivityFilter,
ActivityStageCountsDto, ActivityStageCountsDto,
@@ -158,6 +161,19 @@ const stages = ref<ActivityStageDto[]>([]);
const stagesManagerOpen = ref(false); const stagesManagerOpen = ref(false);
const typesManagerOpen = ref(false); const typesManagerOpen = ref(false);
/** گروه‌های فعالیت (مثل مسیر فروش)؛ هر گروه ستون‌های خودش را دارد */
const boards = ref<ActivityBoardDto[]>([]);
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const board = computed<string>(() => {
const value = typeof route.query.board === 'string' ? route.query.board : '';
if (value && boards.value.some((b) => b.id === value)) return value;
return defaultBoardId.value;
});
const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null);
const boardModal = ref<{ mode: 'create' | 'edit'; name: string } | null>(null);
const boardSaving = ref(false);
const boardDeleteOpen = ref(false);
async function onTypesChanged() { async function onTypesChanged() {
await activityTypes.load(true); await activityTypes.load(true);
kanbanReload.value += 1; kanbanReload.value += 1;
@@ -276,6 +292,7 @@ const filterLabel = computed(() => {
/** فیلترهای مشترک لیست، شمارش‌ها و کانبان */ /** فیلترهای مشترک لیست، شمارش‌ها و کانبان */
const sharedParams = computed<Record<string, string | undefined>>(() => ({ const sharedParams = computed<Record<string, string | undefined>>(() => ({
boardId: board.value || undefined,
filter: filter.value, filter: filter.value,
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined, q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
desk: showDeskTabs.value ? desk.value : undefined, desk: showDeskTabs.value ? desk.value : undefined,
@@ -361,15 +378,96 @@ function subtitle(item: ActivityDto) {
return (item.description ?? '').split('\n')[0].trim(); return (item.description ?? '').split('\n')[0].trim();
} }
async function loadBoards() {
try {
const { data: rows } = await api.get<ActivityBoardDto[]>('/activities/boards');
boards.value = rows;
} catch {
error.value = 'بارگذاری گروه‌های فعالیت ناموفق بود.';
}
}
async function loadStages() { async function loadStages() {
try { try {
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages'); const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages', {
params: { boardId: board.value || undefined },
});
stages.value = rows; stages.value = rows;
} catch { } catch {
error.value = 'بارگذاری ستون‌های فعالیت ناموفق بود.'; error.value = 'بارگذاری ستون‌های فعالیت ناموفق بود.';
} }
} }
function setBoard(next: string) {
void router.replace({
name: 'activities',
query: {
...route.query,
board: next && next !== defaultBoardId.value ? next : undefined,
stage: undefined,
page: undefined,
},
});
}
const boardModel = computed<string>({
get: () => board.value,
set: (v) => setBoard(v),
});
function openCreateBoard() {
boardModal.value = { mode: 'create', name: '' };
}
function openEditBoard() {
if (!currentBoard.value) return;
boardModal.value = { mode: 'edit', name: currentBoard.value.name };
}
async function saveBoard() {
const modal = boardModal.value;
const name = modal?.name.trim() ?? '';
if (!modal || name.length < 2 || boardSaving.value) return;
boardSaving.value = true;
error.value = '';
try {
if (modal.mode === 'create') {
const { data: created } = await api.post<ActivityBoardDto>('/activities/boards', { name });
await loadBoards();
boardModal.value = null;
setBoard(created.id);
} else if (currentBoard.value) {
await api.patch(`/activities/boards/${currentBoard.value.id}`, { name });
await loadBoards();
boardModal.value = null;
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیرهٔ گروه ناموفق بود.')
: 'ذخیرهٔ گروه ناموفق بود.';
} finally {
boardSaving.value = false;
}
}
async function deleteBoard() {
const target = currentBoard.value;
if (!target || target.isDefault || boardSaving.value) return;
boardSaving.value = true;
error.value = '';
try {
await api.delete(`/activities/boards/${target.id}`);
boardDeleteOpen.value = false;
boardModal.value = null;
await loadBoards();
setBoard(defaultBoardId.value);
} catch {
error.value = 'حذف گروه ناموفق بود.';
} finally {
boardSaving.value = false;
}
}
/** شمارش تب‌های زمانی (با ستون انتخابی در نمای تب) و شمارش ستون‌ها (با زمان انتخابی) */ /** شمارش تب‌های زمانی (با ستون انتخابی در نمای تب) و شمارش ستون‌ها (با زمان انتخابی) */
async function loadCounts() { async function loadCounts() {
const base = sharedParams.value; const base = sharedParams.value;
@@ -775,6 +873,17 @@ watch(
}, },
); );
/** گروه عوض شد → ستون‌های همان گروه، بعد لیست و شمارش‌ها */
watch(board, (next, prev) => {
if (!prev || next === prev) return;
clearSelect();
void loadStages().then(() => {
kanbanReload.value += 1;
void load();
void loadCounts();
});
});
/** لینک‌های قدیمی ?filter=done → ستون انجام‌شده */ /** لینک‌های قدیمی ?filter=done → ستون انجام‌شده */
function migrateLegacyDoneFilter() { function migrateLegacyDoneFilter() {
if (route.query.filter !== 'done') return false; if (route.query.filter !== 'done') return false;
@@ -801,11 +910,13 @@ onMounted(() => {
mobileQuery.addEventListener('change', syncMobile); mobileQuery.addEventListener('change', syncMobile);
void loadUsers(); void loadUsers();
void activityTypes.load(true); void activityTypes.load(true);
void loadStages().then(() => { void loadBoards()
if (migrateLegacyDoneFilter()) return; .then(() => loadStages())
void load(); .then(() => {
void loadCounts(); if (migrateLegacyDoneFilter()) return;
}); void load();
void loadCounts();
});
}); });
watch(viewMode, () => { watch(viewMode, () => {
@@ -887,6 +998,32 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div class="view-row-end"> <div class="view-row-end">
<div v-if="boards.length" class="board-picker" @click.stop>
<UiSelect v-model="boardModel" size="sm" aria-label="گروه فعالیت">
<option v-for="b in boards" :key="b.id" :value="b.id">{{ b.name }}</option>
</UiSelect>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
aria-label="ویرایش گروه"
title="ویرایش گروه"
@click="openEditBoard"
>
<UiIcon name="pencil" :size="14" />
</UiButton>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="openCreateBoard"
>
<UiIcon name="plus" :size="14" />
گروه جدید
</UiButton>
</div>
<UiButton <UiButton
v-if="canManageStages" v-if="canManageStages"
variant="ghost" variant="ghost"
@@ -1428,6 +1565,7 @@ onUnmounted(() => {
:initial-type="activityModal.type" :initial-type="activityModal.type"
:initial-title="activityModal.title" :initial-title="activityModal.title"
:initial-stage-id="activityModal.stageId" :initial-stage-id="activityModal.stageId"
:initial-board-id="board || undefined"
@saved="onActivitySaved" @saved="onActivitySaved"
@cancel="activityModal = null" @cancel="activityModal = null"
/> />
@@ -1451,9 +1589,68 @@ onUnmounted(() => {
v-if="canManageStages" v-if="canManageStages"
:open="stagesManagerOpen" :open="stagesManagerOpen"
:stages="stages" :stages="stages"
:board-id="board || undefined"
:board-name="boards.length > 1 ? currentBoard?.name : undefined"
@close="stagesManagerOpen = false" @close="stagesManagerOpen = false"
@changed="onStagesChanged(); kanbanReload += 1" @changed="onStagesChanged(); kanbanReload += 1"
/> />
<UiModal
:open="Boolean(boardModal)"
:title="boardModal?.mode === 'create' ? 'گروه فعالیت جدید' : 'ویرایش گروه فعالیت'"
@close="boardModal = null"
>
<form v-if="boardModal" class="board-form" @submit.prevent="saveBoard">
<label class="board-form-label" for="activity-board-name">نام گروه</label>
<UiInput
id="activity-board-name"
v-model="boardModal.name"
:maxlength="80"
placeholder="مثلاً پشتیبانی، نصب، پیگیری فروش…"
/>
<p v-if="boardModal.mode === 'create'" class="board-form-hint">
گروه جدید با ستون‌های پیش‌فرض ساخته می‌شود و بعد می‌توانید ستون‌هایش را تغییر دهید.
</p>
<UiButton
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
variant="ghost"
size="sm"
type="button"
class="board-delete"
@click="boardDeleteOpen = true"
>
<UiIcon name="trash" :size="14" />
حذف این گروه
</UiButton>
</form>
<template #footer>
<UiButton
type="button"
:disabled="boardSaving || (boardModal?.name.trim().length ?? 0) < 2"
@click="saveBoard"
>
{{ boardSaving ? 'در حال ذخیره…' : boardModal?.mode === 'create' ? 'ساخت گروه' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="boardModal = null">انصراف</UiButton>
</template>
</UiModal>
<UiModal
:open="boardDeleteOpen"
title="حذف گروه فعالیت"
@close="boardDeleteOpen = false"
>
<p class="delete-copy">
گروه «{{ currentBoard?.name }}» و ستون‌هایش حذف شود؟ فعالیت‌های این گروه حذف نمی‌شوند و به
گروه اصلی منتقل می‌شوند.
</p>
<template #footer>
<UiButton type="button" :disabled="boardSaving" @click="deleteBoard">
{{ boardSaving ? 'در حال حذف…' : 'حذف گروه' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="boardDeleteOpen = false">انصراف</UiButton>
</template>
</UiModal>
<ActivityTypesManager <ActivityTypesManager
v-if="canManageStages" v-if="canManageStages"
:open="typesManagerOpen" :open="typesManagerOpen"
@@ -1620,6 +1817,40 @@ h1 {
margin-inline-start: auto; margin-inline-start: auto;
} }
.board-picker {
display: flex;
align-items: center;
gap: 0.35rem;
}
.board-picker .ui-select {
width: auto;
min-width: 10rem;
}
.board-form {
display: grid;
gap: 0.5rem;
}
.board-form-label {
font-size: 0.85rem;
font-weight: 700;
color: #334155;
}
.board-form-hint {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
line-height: 1.6;
}
.board-delete {
justify-self: start;
color: var(--danger);
}
.stage-dot { .stage-dot {
display: inline-block; display: inline-block;
width: 0.5rem; width: 0.5rem;
+24 -1
View File
@@ -25,11 +25,13 @@ import {
UiInput, UiInput,
UiModal, UiModal,
UiPageHeader, UiPageHeader,
UiSelect,
UiTextarea, UiTextarea,
} from '@/components/ui'; } from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue'; import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type { import type {
ActivityBoardDto,
ActivityDto, ActivityDto,
ActivityFieldValue, ActivityFieldValue,
ActivityFieldValues, ActivityFieldValues,
@@ -52,6 +54,8 @@ const props = withDefaults(
initialType?: ActivityType; initialType?: ActivityType;
initialTitle?: string; initialTitle?: string;
initialStageId?: string; initialStageId?: string;
/** گروه فعالیت پیش‌فرض برای فعالیت جدید (گروهی که کاربر در آن است) */
initialBoardId?: string;
}>(), }>(),
{ embedded: false }, { embedded: false },
); );
@@ -74,6 +78,9 @@ const error = ref('');
const people = ref<PersonDto[]>([]); const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]); const deals = ref<DealDto[]>([]);
const users = ref<OrgUserDto[]>([]); const users = ref<OrgUserDto[]>([]);
const boards = ref<ActivityBoardDto[]>([]);
const boardId = ref('');
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const activityTypes = useActivityTypes(); const activityTypes = useActivityTypes();
const title = ref(''); const title = ref('');
@@ -326,19 +333,25 @@ async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const [peopleRes, boardRes, usersRes] = await Promise.all([ const [peopleRes, boardRes, usersRes, activityBoardsRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }), api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'), api.get<PipelineBoardDto>('/pipelines/board'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })), api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api
.get<ActivityBoardDto[]>('/activities/boards')
.catch(() => ({ data: [] as ActivityBoardDto[] })),
activityTypes.load(true), activityTypes.load(true),
]); ]);
people.value = peopleRes.data.items; people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals); deals.value = boardRes.data.stages.flatMap((s) => s.deals);
users.value = usersRes.data; users.value = usersRes.data;
boards.value = activityBoardsRes.data;
boardId.value = props.initialBoardId || defaultBoardId.value;
if (!isNew.value && id.value) { if (!isNew.value && id.value) {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`); const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
title.value = data.title; title.value = data.title;
boardId.value = data.boardId || defaultBoardId.value;
type.value = data.type; type.value = data.type;
typeDefId.value = typeDefId.value =
data.typeDef?.id ?? activityTypes.systemOf.value[data.type]?.id ?? ''; data.typeDef?.id ?? activityTypes.systemOf.value[data.type]?.id ?? '';
@@ -421,6 +434,7 @@ function payload(): CreateActivityRequest {
const keys = new Set(typeFields.value.map((f) => f.key)); const keys = new Set(typeFields.value.map((f) => f.key));
return { return {
title: title.value, title: title.value,
boardId: boardId.value || null,
type: type.value || 'task', type: type.value || 'task',
typeDefId: typeDefId.value || null, typeDefId: typeDefId.value || null,
fields: Object.fromEntries( fields: Object.fromEntries(
@@ -631,6 +645,15 @@ watch([id, isNew], () => void load(), { immediate: true });
</div> </div>
</div> </div>
<div v-if="boards.length > 1" class="card">
<div class="field">
<label for="activity-board">گروه فعالیت</label>
<UiSelect id="activity-board" v-model="boardId">
<option v-for="b in boards" :key="b.id" :value="b.id">{{ b.name }}</option>
</UiSelect>
</div>
</div>
<div v-if="typeFields.length" class="card type-fields"> <div v-if="typeFields.length" class="card type-fields">
<p class="type-fields-title"> <p class="type-fields-title">
<span class="type-ico sm" :style="typeColorStyle(typeMeta!.color)"> <span class="type-ico sm" :style="typeColorStyle(typeMeta!.color)">
+25
View File
@@ -1279,8 +1279,25 @@ export const ACTIVITY_STAGE_KIND_LABELS: Record<ActivityStatus, string> = {
canceled: "لغو شده", canceled: "لغو شده",
}; };
/** گروه فعالیت (مثل مسیر فروش): هر گروه ستون‌های خودش را دارد */
export interface ActivityBoardDto {
id: string;
name: string;
isDefault: boolean;
position: number;
}
export interface CreateActivityBoardRequest {
name: string;
}
export interface UpdateActivityBoardRequest {
name?: string;
}
export interface ActivityStageDto { export interface ActivityStageDto {
id: string; id: string;
boardId: string;
name: string; name: string;
position: number; position: number;
color: ActivityStageColor; color: ActivityStageColor;
@@ -1288,6 +1305,8 @@ export interface ActivityStageDto {
} }
export interface CreateActivityStageRequest { export interface CreateActivityStageRequest {
/** خالی = گروه پیش‌فرض */
boardId?: string;
name: string; name: string;
color?: ActivityStageColor; color?: ActivityStageColor;
kind?: ActivityStatus; kind?: ActivityStatus;
@@ -1530,6 +1549,8 @@ export interface ActivityDto {
/** همه مسئولان انجام فعالیت */ /** همه مسئولان انجام فعالیت */
assignees: UserSummaryDto[]; assignees: UserSummaryDto[];
createdBy: UserSummaryDto | null; createdBy: UserSummaryDto | null;
/** گروه مؤثر فعالیت (گروه پیش‌فرض اگر گروهی انتخاب نشده باشد) */
boardId?: string | null;
/** مرحلهٔ مؤثر (ستون کانبان) */ /** مرحلهٔ مؤثر (ستون کانبان) */
stageId?: string | null; stageId?: string | null;
/** نوع مؤثر (سفارشی یا نوع اصلیِ همان type) */ /** نوع مؤثر (سفارشی یا نوع اصلیِ همان type) */
@@ -1546,6 +1567,8 @@ export interface ActivityDto {
export interface CreateActivityRequest { export interface CreateActivityRequest {
title: string; title: string;
/** گروه فعالیت؛ خالی = گروه پیش‌فرض. اگر stageId بیاید، گروه از همان مرحله گرفته می‌شود */
boardId?: string | null;
/** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته می‌شود */ /** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته می‌شود */
stageId?: string | null; stageId?: string | null;
type?: ActivityType; type?: ActivityType;
@@ -1571,6 +1594,8 @@ export interface CreateActivityRequest {
export interface UpdateActivityRequest { export interface UpdateActivityRequest {
title?: string; title?: string;
/** انتقال به گروه دیگر؛ فعالیت به ستون اولِ هم‌وضعیت آن گروه می‌رود */
boardId?: string | null;
type?: ActivityType; type?: ActivityType;
typeDefId?: string | null; typeDefId?: string | null;
fields?: ActivityFieldValues; fields?: ActivityFieldValues;